From 3f7aafe6c304243e1525bc84a7e653b9a38c0ebf Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:00:03 -0400 Subject: [PATCH 01/65] feat(mcpl): stamp each accepted inbound item with its source envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every inbound item the host accepts now carries `metadata.inboundSource` (src/mcpl/inbound-source.ts), decided once at the framework's ingestion boundary: which conversation the item belongs to and when the host accepted it. Speech routing (shelf-355), the visible provenance header (shelf-356) and per-channel receipt clocks (shelf-354, room-220) all read this one value instead of re-deriving identity from the lane an item arrived on. Kinds: `channel` (MCPL server id, RFC-006 endpoint binding, canonical channel id — the registered composite id, also on the push lane — thread, message, producer eventId when supplied, reply edge from metadata.replyTo, the channel's registry label at receipt), `unscoped` (a push naming no channel: never a guessed channel), and `surface` (console/API input: module messages added in response to an `external-message` or `api:message` event that names no channel). Each carries `acceptedAt` (host acceptance, epoch ms) and, for MCPL items, the adapter's `sourceTimestamp` verbatim; neither is a version key. Acceptance time is stamped where the host actually admits an item: ChannelRegistry.handleIncoming and PushHandler for ordinary items, and pushCoalescer acceptance for RFC-006 work. A coalesced occurrence computes its envelope at acceptance (handleCoalescedIncoming/handleCoalescedPush) and stores it on the occurrence's event, so deferral, fan-out, replay and materialization deliver it unchanged (a materialized delivery only adds `materialized: true`; deferred work is marked `deferred: true`). A label is therefore the label when accepted, and a later rename never rewrites it. The field is written after adapter metadata is spread, so an adapter cannot supply or spoof it. It is not named `source` because adapter origins already carry a free-form `source` string (discord-mcpl sends `source: 'discord'`), which stays untouched. An optional InboundAcceptanceObserver (private framework field, installed by the receipt lane) hears each acceptance exactly once in-process: where an ordinary item's envelope is created in handleMcplChannelIncoming / handleMcplPushEvent, at a coalescer admission via a new CoalescerHost `accepted` hook (after the receipt is recorded, never for a deduplicated producer retry), and once per console/API event. Deliveries that reuse a frozen envelope are not acceptances. A throwing observer never affects delivery; it is reported on stderr and as an `inbound:observer-failed` trace. Interface agreed with Oscar-1005 and Hugo-1004 in room-220 (#41234, #41242, #41249, #41270, #41286, #41292). Tests (test/inbound-source.test.ts, over a real MCPL child fixture test/fixtures/speech-route-mcpl-server.mjs and the RFC-006 WebSocket fixture): incoming message stamp with label, reply edge, timestamps and an adapter spoof attempt overwritten; DM push with derived composite id and people-first label (origin.source preserved); unscoped push; console surface; label frozen across a rename; observer failure isolation and trace; coalesced acceptance observed once (retry not an acceptance, replacement carries its own frozen envelope); deferred work observed before render with the materialized delivery keeping that envelope; channel-scoped coalesced message. The trunk-channel-routing registry stubs gain getChannelLabel. npm test: 1303 pass / 0 fail (baseline 64c480b: 1298 pass / 0 fail). Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-source-envelope.added.md | 12 + src/framework.ts | 211 ++++++++++++++- src/index.ts | 9 + src/mcpl/channel-registry.ts | 11 + src/mcpl/inbound-source.ts | 132 ++++++++++ src/mcpl/index.ts | 9 + src/mcpl/push-coalescer.ts | 9 + src/mcpl/push-handler.ts | 7 + src/types/events.ts | 4 + src/types/trace.ts | 10 + test/fixtures/speech-route-mcpl-server.mjs | 225 ++++++++++++++++ test/inbound-source.test.ts | 261 +++++++++++++++++++ test/trunk-channel-routing.test.ts | 5 + 13 files changed, 899 insertions(+), 6 deletions(-) create mode 100644 changelog.d/inbound-source-envelope.added.md create mode 100644 src/mcpl/inbound-source.ts create mode 100644 test/fixtures/speech-route-mcpl-server.mjs create mode 100644 test/inbound-source.test.ts diff --git a/changelog.d/inbound-source-envelope.added.md b/changelog.d/inbound-source-envelope.added.md new file mode 100644 index 000000000..30db418ea --- /dev/null +++ b/changelog.d/inbound-source-envelope.added.md @@ -0,0 +1,12 @@ +- **Inbound source envelope.** Every inbound item the host accepts now carries + `metadata.inboundSource`, stamped once where it was accepted: the + conversation it belongs to (`channel`: MCPL server, endpoint binding, + canonical channel id, thread, message, reply edge and the channel's label at + receipt; `unscoped` for a push that names no channel; `surface` for console + or API input from a module), the host acceptance time and the adapter's own + timestamp. It covers `channels/incoming` and `push/event` on both the + ordinary and the RFC-006 coalesced paths; a coalesced occurrence freezes its + envelope at acceptance, so deferral, fan-out and replay deliver it unchanged + and a later rename never rewrites it. Adapter-supplied metadata cannot set + the field (an adapter's own `origin.source` string is untouched). + `readInboundSource`, `conversationKey` and `INBOUND_SOURCE_KEY` are exported. diff --git a/src/framework.ts b/src/framework.ts index c527c488f..3934b1f9d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -83,6 +83,7 @@ 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, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; +import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -441,6 +442,11 @@ interface CoalescedChannelEvent { assemblingFor?: string; /** RFC-006 §3.2: deliver into this agent only (replacement / notice audience). */ deliverTo?: string; + /** Host acceptance time (epoch ms), stamped where the message was admitted. */ + acceptedAt?: number; + /** Framework-owned source envelope, frozen at a coalesced occurrence's + * acceptance so its later delivery cannot restamp it (inbound-source.ts). */ + inboundSource?: InboundSource; } type CoalescedDelivery = | { lane: 'channel'; event: CoalescedChannelEvent } @@ -1012,6 +1018,9 @@ export class AgentFramework { private processLoggingPersist: boolean; private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); + /** Told once per inbound acceptance (mcpl/inbound-source.ts); unset unless + * a receipt consumer installs one. */ + private inboundAcceptanceObserver?: InboundAcceptanceObserver; /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved * eagerly in startAgentStream (home → addressed trigger → global default) @@ -6949,9 +6958,15 @@ export class AgentFramework { } } - // Apply responses + // Apply responses. Console/API input is conversation from a non-channel + // surface: one acceptance per event, whose envelope every message the + // modules add for it carries. + const surface = this.surfaceSourceFor(event); + if (surface && responses.some(({ response }) => (response.addMessages?.length ?? 0) > 0)) { + this.noteInboundAccepted(surface); + } for (const { moduleName, response } of responses) { - await this.applyProcessResponse(response, event, moduleName); + await this.applyProcessResponse(response, event, moduleName, surface); } // Handle tool calls specially @@ -6973,12 +6988,15 @@ export class AgentFramework { private async applyProcessResponse( response: EventResponse, event: ProcessEvent, - moduleName: string + moduleName: string, + /** The event's surface envelope, when it is console/API input. */ + surface?: InboundSource, ): Promise { // Add messages if (response.addMessages) { for (const msg of response.addMessages) { - const id = this.addMessage(msg.participant, msg.content, msg.metadata); + const metadata = surface ? { ...msg.metadata, [INBOUND_SOURCE_KEY]: surface } : msg.metadata; + const id = this.addMessage(msg.participant, msg.content, metadata); this.emitTrace({ type: 'message:added', messageId: id, source: event.type }); } } @@ -7065,6 +7083,8 @@ export class AgentFramework { eventId?: string; assemblingFor?: string; deliverTo?: string; + acceptedAt?: number; + inboundSource?: InboundSource; }): Promise { const metadata: Record = { ...event.metadata, @@ -7078,6 +7098,27 @@ export class AgentFramework { }; if (event.threadId) metadata.threadId = event.threadId; if (event.tags) metadata.tags = event.tags; + // The inbound source envelope (mcpl/inbound-source.ts): frozen at the + // occurrence's acceptance when the coalescer delivers, else stamped now + // from this event. Written after the adapter's metadata, so an adapter + // cannot supply it. + let source: InboundSource; + if (event.inboundSource) { + source = event.inboundSource; + } else { + source = this.channelSource({ + serverId: event.serverId, + channelId: event.channelId, + threadId: event.threadId, + messageId: event.messageId, + eventId: event.eventId, + replyTo: event.metadata?.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: event.timestamp, + }); + this.noteInboundAccepted(source); + } + metadata[INBOUND_SOURCE_KEY] = source; // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never @@ -7587,6 +7628,113 @@ export class AgentFramework { return createHash('sha256').update(JSON.stringify([serverId, config?.url ?? null, config?.command ?? null, config?.args ?? null])).digest('hex').slice(0, 16); } + /** + * Report one inbound acceptance to the observer, if one is installed + * (InboundAcceptanceObserver). Called where an envelope is first created — + * never for a delivery that reuses a frozen one. A failing observer is + * reported loudly (stderr + trace) and never affects delivery. + */ + private noteInboundAccepted(source: InboundSource): void { + if (!this.inboundAcceptanceObserver) return; + try { + this.inboundAcceptanceObserver.inboundAccepted(source); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + console.error(`[inbound] acceptance observer failed (${source.kind}): ${error}`); + this.emitTrace({ type: 'inbound:observer-failed', kind: source.kind, error }); + } + } + + /** + * The inbound source envelope (mcpl/inbound-source.ts) of an item that + * belongs to a registered channel. The one place the framework decides + * which conversation an inbound item came from; every lane calls it at the + * point the host accepted the item, or reuses the envelope frozen then. + * `acceptedAt` falls back to now only for an event no admission path + * stamped (a direct framework caller), which is the closest observation. + */ + private channelSource(fields: { + serverId: string; + channelId: string; + threadId?: unknown; + messageId?: unknown; + eventId?: unknown; + replyTo?: unknown; + labelFallback?: string; + acceptedAt?: number; + sourceTimestamp?: unknown; + deferred?: boolean; + }): InboundChannelSource { + const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined); + const label = this.channelRegistry?.getChannelLabel(fields.serverId, fields.channelId) ?? str(fields.labelFallback); + const threadId = str(fields.threadId); + const messageId = str(fields.messageId); + const eventId = str(fields.eventId); + const replyTo = str(fields.replyTo); + const sourceTimestamp = str(fields.sourceTimestamp); + return { + kind: 'channel', + serverId: fields.serverId, + binding: this.coalescingBinding(fields.serverId), + channelId: fields.channelId, + ...(threadId ? { threadId } : {}), + ...(messageId ? { messageId } : {}), + ...(eventId ? { eventId } : {}), + ...(label ? { label } : {}), + ...(replyTo ? { replyTo } : {}), + acceptedAt: fields.acceptedAt ?? Date.now(), + ...(sourceTimestamp ? { sourceTimestamp } : {}), + ...(fields.deferred ? { deferred: true as const } : {}), + }; + } + + /** Source envelope of a push event: its derived channel, else unscoped. */ + private pushSource(event: McplPushEvent, deferred = false): InboundSource { + const origin = (event.origin ?? {}) as Record; + const channel = this.derivePushEventChannel(event.origin); + if (channel) { + return this.channelSource({ + serverId: event.serverId, + channelId: channel.channelId, + threadId: origin.threadId, + messageId: origin.messageId, + eventId: event.eventId, + replyTo: origin.replyTo, + labelFallback: channel.label, + acceptedAt: event.acceptedAt, + sourceTimestamp: event.timestamp, + deferred, + }); + } + const unscoped: InboundUnscopedSource = { + kind: 'unscoped', + serverId: event.serverId, + binding: this.coalescingBinding(event.serverId), + ...(typeof event.eventId === 'string' && event.eventId ? { eventId: event.eventId } : {}), + acceptedAt: event.acceptedAt ?? Date.now(), + ...(typeof event.timestamp === 'string' && event.timestamp ? { sourceTimestamp: event.timestamp } : {}), + ...(deferred ? { deferred: true as const } : {}), + }; + return unscoped; + } + + /** + * Source envelope for conversational input from a non-channel surface: a + * module's messages in response to an `external-message` (console, TUI) or + * `api:message` event. An event whose metadata names a channel is not a + * surface conversation and gets none (never a guessed identity). + */ + private surfaceSourceFor(event: ProcessEvent): InboundSource | undefined { + if (event.type !== 'external-message' && event.type !== 'api:message') return undefined; + const metadata = (event as { metadata?: Record }).metadata; + if (typeof metadata?.channelId === 'string' && metadata.channelId) return undefined; + const declared = (event as { source?: unknown }).source; + const surface = typeof declared === 'string' && declared + ? declared + : event.type === 'api:message' ? 'api' : 'external'; + return { kind: 'surface', surface, acceptedAt: Date.now() }; + } + private initializePushCoalescer(): void { const coalescer = new PushCoalescer({ isUnread: (p) => this.isUnreadPlacement(p), @@ -7610,6 +7758,12 @@ export class AgentFramework { this.coalescingRecentReceipts.push(record); this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); }, + // The acceptance of coalesced work (deferred included) is observed + // here, once; its later delivery reuses the frozen envelope. + accepted: (occ) => { + const source = occ.event.event.inboundSource; + if (source) this.noteInboundAccepted(source); + }, wasPublished: (subject, eventId) => { // Boot-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. @@ -7710,6 +7864,18 @@ export class AgentFramework { if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required'); validateCoalescedContent(message.content); const c = message.coalesce; + // The inbound source envelope, frozen at acceptance: deferral, fan-out + // and replay deliver it unchanged (mcpl/inbound-source.ts). + const inboundSource = this.channelSource({ + serverId, + channelId: message.channelId, + threadId: message.threadId, + messageId: message.messageId, + eventId: message.eventId, + replyTo: message.metadata?.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: message.timestamp, + }); const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7723,7 +7889,7 @@ export class AgentFramework { tags: event.tags, content: message.content, identity: { messageId: message.messageId, author: message.author, threadId: message.threadId }, - event: { lane: 'channel', event: { ...event, eventId: message.eventId } }, + event: { lane: 'channel', event: { ...event, eventId: message.eventId, inboundSource } }, }); return { messageId: message.messageId, accepted: true, coalesce: result.coalesce }; }); @@ -7740,6 +7906,21 @@ 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); + // The inbound source envelope, frozen at acceptance (see the channel + // lane). A channel-scoped push belongs to the channel its subject names. + const inboundSource: InboundSource = c.channelId + ? this.channelSource({ + serverId, + channelId: c.channelId, + threadId: origin.threadId, + messageId: origin.messageId, + eventId: params.eventId, + replyTo: origin.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: params.timestamp, + deferred: !!c.deferred, + }) + : this.pushSource(event, !!c.deferred); return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7761,7 +7942,7 @@ export class AgentFramework { ...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}), }, } : {}), - event: { lane: 'push', event }, + event: { lane: 'push', event: { ...event, inboundSource } }, }); }); } @@ -7835,6 +8016,12 @@ export class AgentFramework { ): Promise { const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; + // The envelope frozen at acceptance travels unchanged; this delivery only + // adds the fact that it carries the rendered body. + const frozenSource = (source: InboundSource | undefined): { inboundSource?: InboundSource } => + source + ? { inboundSource: materialized && source.kind !== 'surface' ? { ...source, materialized: true } : source } + : {}; if (occ.event.lane === 'channel') { const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); @@ -7867,6 +8054,7 @@ export class AgentFramework { eventId: occ.eventId, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}), + ...frozenSource(push.inboundSource), }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); @@ -7882,6 +8070,7 @@ export class AgentFramework { // assembled; the wake that started it is not repeated. ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}), ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}), + ...frozenSource(occ.event.event.inboundSource), }; const placement = this.handleMcplPushEvent(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); @@ -8049,6 +8238,16 @@ export class AgentFramework { if (triggerChannel) { metadata.channelId = triggerChannel.channelId; } + // The inbound source envelope (mcpl/inbound-source.ts), after the + // adapter's origin fields so an adapter cannot supply it. + let source: InboundSource; + if (event.inboundSource) { + source = event.inboundSource; + } else { + source = this.pushSource(event); + this.noteInboundAccepted(source); + } + metadata[INBOUND_SOURCE_KEY] = source; const content = [...event.content]; if (triggerChannel) { diff --git a/src/index.ts b/src/index.ts index bd07d3547..5cc2c2461 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,15 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior // MCPL channel registry (exposed for modules that need channel-level operations) export { ChannelRegistry } from './mcpl/index.js'; +// Where each accepted inbound item came from (`metadata.inboundSource`) +export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey } from './mcpl/index.js'; +export type { + InboundSource, + InboundChannelSource, + InboundUnscopedSource, + InboundSurfaceSource, +} from './mcpl/index.js'; + // MCPL server config (exposed for hosts that manage servers at runtime via // connectMcplServer / disconnectMcplServer / restartMcplServer) export type { McplServerConfig } from './mcpl/index.js'; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 9e31ef7e1..d999a6867 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -236,6 +236,9 @@ interface McplChannelIncomingEvent { tags?: string[]; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the message is admitted. + * The framework's inbound source envelope reads it; nothing re-stamps it. */ + acceptedAt?: number; } // ============================================================================ @@ -981,6 +984,7 @@ export class ChannelRegistry { metadata: message.metadata, ...(message.tags ? { tags: message.tags } : {}), triggerInference, + acceptedAt: Date.now(), }; if (coalesced) { @@ -1229,6 +1233,13 @@ export class ChannelRegistry { return this.findChannelEntry(channelId)?.descriptor; } + /** The registered label of `channelId` on exactly `serverId` (no + * cross-server first match), or undefined when it has none. */ + getChannelLabel(serverId: string, channelId: string): string | undefined { + const label = this.channels.get(`${serverId}:${channelId}`)?.descriptor.label; + return typeof label === 'string' && label.length > 0 ? label : undefined; + } + isChannelOpen(channelId: string): boolean { return this.findChannelEntry(channelId)?.open === true; } diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts new file mode 100644 index 000000000..b7c2fea7d --- /dev/null +++ b/src/mcpl/inbound-source.ts @@ -0,0 +1,132 @@ +/** + * Where an inbound item came from, decided once at Agent Framework's + * ingestion boundary and stored on the message as `metadata.inboundSource`. + * + * Every later consumer reads this one value instead of re-deriving identity + * from the lane it happened to arrive on: speech routing compares + * conversations with it, the visible provenance header renders from it, and + * delivery bookkeeping keys on it. It is frozen when the host ACCEPTS the + * item. A coalesced occurrence (RFC-006) carries the value stamped at its + * acceptance through deferral, fan-out and replay, so a later rename, + * reconnect or re-render never rewrites where an old item says it came from. + * + * The field is framework-owned. It is written after any adapter-supplied + * metadata is spread, so an adapter cannot supply or spoof it (adapter + * `origin` objects already use a free-form `source` string, which is why this + * is not called `source`). + * + * Facts only, no policy: whether an item is a body, an edit or a deletion + * stays in its tags (`chat:edited`, `chat:deleted`); `deferred` and + * `materialized` record what RFC-006 did with it. Nothing here is a version + * key: `eventId` is present only when the producer supplied one. + */ + +/** An item that belongs to a registered channel. */ +export interface InboundChannelSource { + kind: 'channel'; + /** MCPL server (connection) id the item arrived through, as the host names it. */ + serverId: string; + /** RFC-006 endpoint binding of that connection at acceptance. A recipe that + * later points the same server id at a different endpoint gets a + * different binding, so old items never acquire the new endpoint's identity. */ + binding: string; + /** The registered canonical channel id (on the push lane: the composite id + * the host derived from `origin`, never the adapter's raw platform id). */ + channelId: string; + /** Thread within the channel, when the adapter supplied one. */ + threadId?: string; + /** The platform message this item is (or, for an edit/delete, refers to). */ + messageId?: string; + /** The producer's occurrence id, when it supplied one. */ + eventId?: string; + /** The channel's human label when the host accepted the item. */ + label?: string; + /** Message this one replies to, when the adapter supplied a reply edge. */ + replyTo?: string; + /** Host acceptance time (epoch ms). */ + acceptedAt: number; + /** The adapter's own timestamp for the item, verbatim. */ + sourceTimestamp?: string; + /** RFC-006: accepted as a deferred notification (body rendered later). */ + deferred?: true; + /** RFC-006: this delivery carries the materialized (rendered) body. */ + materialized?: true; +} + +/** An MCPL push that names no channel (heartbeats, timers, feature-set events). */ +export interface InboundUnscopedSource { + kind: 'unscoped'; + serverId: string; + binding: string; + eventId?: string; + acceptedAt: number; + sourceTimestamp?: string; + deferred?: true; + materialized?: true; +} + +/** Conversational input from a non-channel surface (console, TUI, API). */ +export interface InboundSurfaceSource { + kind: 'surface'; + /** The surface's name as its module reported it (e.g. `tui`, `api`). */ + surface: string; + acceptedAt: number; +} + +export type InboundSource = InboundChannelSource | InboundUnscopedSource | InboundSurfaceSource; + +/** + * Told once per inbound acceptance, with that acceptance's envelope: when an + * ordinary item reaches the framework, and when the RFC-006 coalescer admits + * an occurrence (deferred work included). Deliveries that reuse a frozen + * envelope (replay, fan-out, materialization, corrections) are not + * acceptances and are not reported. Observation only: the framework contains + * and reports a throw, which never affects delivery. + */ +export interface InboundAcceptanceObserver { + inboundAccepted(source: InboundSource): void; +} + +/** Metadata key the framework stamps. */ +export const INBOUND_SOURCE_KEY = 'inboundSource'; + +const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined); + +/** + * Read a stored item's source back from its metadata. Returns undefined for + * anything that is not a well-formed framework stamp (older messages, and any + * shape this version does not recognize) — never a guess. + */ +export function readInboundSource(metadata: unknown): InboundSource | undefined { + if (!metadata || typeof metadata !== 'object') return undefined; + const raw = (metadata as Record)[INBOUND_SOURCE_KEY]; + if (!raw || typeof raw !== 'object') return undefined; + const s = raw as Record; + if (typeof s.acceptedAt !== 'number') return undefined; + if (s.kind === 'channel') { + if (!str(s.serverId) || !str(s.binding) || !str(s.channelId)) return undefined; + return s as unknown as InboundChannelSource; + } + if (s.kind === 'unscoped') { + if (!str(s.serverId) || !str(s.binding)) return undefined; + return s as unknown as InboundUnscopedSource; + } + if (s.kind === 'surface') { + if (!str(s.surface)) return undefined; + return s as unknown as InboundSurfaceSource; + } + return undefined; +} + +/** + * The conversation an item belongs to, as a comparable key: server + channel + * + thread for a channel item, the surface for console input. Undefined for an + * unscoped push, which belongs to no conversation. + */ +export function conversationKey(source: InboundSource): string | undefined { + if (source.kind === 'channel') { + return `channel\u0000${source.serverId}\u0000${source.channelId}\u0000${source.threadId ?? ''}`; + } + if (source.kind === 'surface') return `surface\u0000${source.surface}`; + return undefined; +} diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index d82790c67..3b5fef3d5 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -157,6 +157,15 @@ export { InferenceRouter } from './inference-router.js'; // Channel registry (channel lifecycle, incoming messages, synthesized tools) export { ChannelRegistry } from './channel-registry.js'; +// Inbound source envelope: where each accepted item came from, stamped once +export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey } from './inbound-source.js'; +export type { + InboundSource, + InboundChannelSource, + InboundUnscopedSource, + InboundSurfaceSource, +} from './inbound-source.js'; + // Per-channel conversation routing (fork-per-channel agents) /** @deprecated Per-channel conversation routing is deprecated and will be removed (anima-research/agent-framework#235). */ export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './conversation-router.js'; diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 9536962f7..1e1b1be6c 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -170,6 +170,10 @@ export interface CoalescerHost { /** Persist one receipt now, before the acceptance is acknowledged. Throws * on failure, which fails the acceptance (the producer retries). */ recordReceipt?(record: CoalescingReceiptRecord): void; + /** One call per ADMITTED occurrence (never for a producer retry the receipt + * deduplicates), after its receipt is recorded. Observation only: a throw + * is contained here and never fails the acceptance. */ + accepted?(occurrence: CoalescedOccurrence): void; /** Make every write so far DURABLE (fsync, group-committed by the host). * Awaited before an acceptance is acknowledged and before push/render is * issued: a kill after either boundary must find the receipt, the @@ -396,6 +400,11 @@ export class PushCoalescer { throw new CoalesceError('eventId', 'host could not persist the acceptance; retry', -32000); } this.receipts.set(receiptKey, entry); + try { + this.host.accepted?.(occurrence); + } catch (error) { + this.host.audit({ kind: 'accepted-observer-failed', subject, eventId: occurrence.eventId, error: String(error) }); + } this.prune(); this.persist(); // Written but not yet durable: the in-memory receipt deduplicates a retry diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index d85df767a..d6f9b39f6 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -19,6 +19,7 @@ import type { FeatureSetManager } from './feature-set-manager.js'; import { McplFeatureSetError } from './feature-set-manager.js'; import { expandCoreTags } from './tags.js'; import { validateCoalescedContent } from './push-coalescer.js'; +import type { InboundSource } from './inbound-source.js'; // ============================================================================ // McplPushEvent (the ProcessEvent shape pushed to the queue) @@ -47,6 +48,11 @@ export interface McplPushEvent { coalescingSubject?: string; /** RFC-006 assembly: materialized for this agent's turn (store directly). */ assemblingFor?: string; + /** Host acceptance time (epoch ms), stamped where the push is admitted. */ + acceptedAt?: number; + /** Framework-owned source envelope, frozen at a coalesced occurrence's + * acceptance so its later delivery cannot restamp it (inbound-source.ts). */ + inboundSource?: InboundSource; } // ============================================================================ @@ -286,6 +292,7 @@ export class PushHandler { timestamp: params.timestamp, inferenceId, triggerInference, + acceptedAt: Date.now(), }; if (coalesced) { // RFC-006: the coalescer decides whether this occurrence replaces an diff --git a/src/types/events.ts b/src/types/events.ts index b5e8afb8e..1c91b9592 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -231,6 +231,8 @@ export interface McplPushEvent { inferenceId: string; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the push is admitted. */ + acceptedAt?: number; } /** @@ -249,4 +251,6 @@ export interface McplChannelIncomingEvent { metadata?: Record; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the message is admitted. */ + acceptedAt?: number; } diff --git a/src/types/trace.ts b/src/types/trace.ts index bf7b2715d..c5c3064f5 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -485,6 +485,16 @@ export type TraceEvent = type: 'host:quiesced_boot'; reason?: string; since?: number; + }) + + // Inbound acceptance observation (mcpl/inbound-source.ts): an installed + // InboundAcceptanceObserver threw. Delivery is unaffected; the observer's + // own coverage is interrupted for that acceptance. + | (TraceEventBase & { + type: 'inbound:observer-failed'; + /** Envelope kind of the acceptance the observer failed on. */ + kind: 'channel' | 'unscoped' | 'surface'; + error: string; }); /** diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs new file mode 100644 index 000000000..e4bffa711 --- /dev/null +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -0,0 +1,225 @@ +// MCPL fixture for speech routing, drafts and provenance tests. +// +// Registers the channels in CHANNELS (JSON array of {id,label}; two Discord +// guild channels by default) and, on command, sends inbound traffic over +// BOTH MCPL lanes. The TEST appends one JSON object per line to COMMAND_PATH: +// {"op":"incoming","channelId":…,"messageId":…,"mode":"ambient"|"addressed", +// "text":…, "threadId"?, "metadata"?, "eventId"?, "coalesce"?} +// {"op":"dm","eventId":…,"authorId":…,"authorName"?,"rawChannelId":…,"text":…} +// {"op":"push","eventId":…,"origin"?,"tags"?,"text":…,"featureSet"?} +// {"op":"rename","channelId":…,"label":…} (channels/changed) +// {"op":"publish-mode","mode":"delivered"|"not-delivered"|"no-receipt"|"error"|"hang"} +// {"op":"history","channelId":…,"history":[ChannelIncomingMessage…]} +// Host-side channels/publish and channels/open calls are recorded to +// STATUS_PATH as JSONL ({event:'publish', channelId, text, messageId}). +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; + +const statusPath = process.env.STATUS_PATH; +const commandPath = process.env.COMMAND_PATH; +const CHANNELS = process.env.CHANNELS + ? JSON.parse(process.env.CHANNELS) + : [ + { id: 'discord:g1:room', label: '#room (Guild One)' }, + { id: 'discord:g1:general', label: '#general (Guild One)' }, + ]; + +const log = (event, extra = {}) => { + if (!statusPath) return; + appendFileSync(statusPath, JSON.stringify({ event, ...extra }) + '\n'); +}; +const send = (message) => process.stdout.write(JSON.stringify(message) + '\n'); +const reply = (id, result) => send({ jsonrpc: '2.0', id, result }); +const replyError = (id, message) => send({ jsonrpc: '2.0', id, error: { code: -32000, message } }); + +let nextId = 500; +let processedCommands = 0; +let publishMode = 'delivered'; +let posted = 0; +const histories = new Map(); + +const textBlock = (text) => [{ type: 'text', text }]; + +function pollCommands() { + if (!commandPath || !existsSync(commandPath)) return; + const lines = readFileSync(commandPath, 'utf8').split('\n').filter(Boolean); + for (const line of lines.slice(processedCommands)) { + processedCommands++; + const c = JSON.parse(line); + if (c.op === 'incoming') { + log('incoming-sent', { channelId: c.channelId, messageId: c.messageId }); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/incoming', + params: { + messages: [{ + channelId: c.channelId, + messageId: c.messageId, + ...(c.threadId ? { threadId: c.threadId } : {}), + ...(c.eventId ? { eventId: c.eventId } : {}), + ...(c.coalesce ? { coalesce: c.coalesce } : {}), + author: { id: c.authorId ?? 'U-other', name: c.authorName ?? 'someone' }, + timestamp: c.timestamp ?? new Date().toISOString(), + content: textBlock(c.text ?? ''), + ...(c.metadata ? { metadata: c.metadata } : {}), + tags: c.mode === 'addressed' + ? ['chat:mention', 'chat:addressed', 'chat:from-human'] + : ['chat:ambient', 'chat:from-human'], + }], + }, + }); + } else if (c.op === 'dm') { + log('dm-sent', { eventId: c.eventId }); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'push/event', + params: { + featureSet: 'chat', + eventId: c.eventId, + timestamp: c.timestamp ?? new Date().toISOString(), + origin: { + source: 'discord', + channelId: c.rawChannelId, + guildId: null, + isDM: true, + authorId: c.authorId, + authorName: c.authorName ?? 'antra', + messageId: c.messageId ?? c.eventId, + ...(c.origin ?? {}), + }, + tags: ['chat:dm', 'chat:addressed', 'chat:private'], + payload: { content: textBlock(c.text ?? '') }, + }, + }); + } else if (c.op === 'push') { + log('push-sent', { eventId: c.eventId }); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'push/event', + params: { + featureSet: c.featureSet ?? 'chat', + eventId: c.eventId, + timestamp: c.timestamp ?? new Date().toISOString(), + ...(c.origin ? { origin: c.origin } : {}), + ...(c.tags ? { tags: c.tags } : {}), + payload: { content: textBlock(c.text ?? '') }, + }, + }); + } else if (c.op === 'rename') { + const channel = CHANNELS.find((x) => x.id === c.channelId); + if (channel) channel.label = c.label; + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { + updated: [{ id: c.channelId, type: 'discord', label: c.label, direction: 'bidirectional' }], + }, + }); + log('renamed', { channelId: c.channelId, label: c.label }); + } else if (c.op === 'publish-mode') { + publishMode = c.mode; + log('publish-mode', { mode: c.mode }); + } else if (c.op === 'history') { + histories.set(c.channelId, c.history); + } + } +} +const pollTimer = setInterval(pollCommands, 25); + +const rl = createInterface({ input: process.stdin }); +rl.on('close', () => { + clearInterval(pollTimer); + process.exit(0); +}); +rl.on('line', (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + if (msg.method === 'initialize') { + reply(msg.id, { + protocolVersion: '2024-11-05', + capabilities: { + experimental: { + mcpl: { + version: '0.5', + pushEvents: true, + channels: { register: true, lifecycle: true, incoming: true, publish: true }, + featureSets: { + chat: { description: 'chat connector', uses: ['pushEvents', 'channels.incoming', 'channels.publish'] }, + }, + }, + }, + }, + serverInfo: { name: 'speech-route-fixture', version: '0.0.0' }, + }); + return; + } + if (msg.method === 'featureSets/update') { + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { accepted: true }); + return; + } + if (msg.method === 'notifications/initialized') { + send({ + jsonrpc: '2.0', + id: 400, + method: 'channels/register', + params: { + channels: CHANNELS.map((c) => ({ + id: c.id, type: 'discord', label: c.label, direction: 'bidirectional', initiallyOpen: true, + })), + }, + }); + return; + } + if (msg.method === 'channels/open') { + const channelId = msg.params?.channelId; + const channel = CHANNELS.find((x) => x.id === channelId) ?? { id: channelId, label: channelId }; + const history = msg.params?.history ? histories.get(channelId) : undefined; + log('channel-opened', { channelId, history: !!history }); + reply(msg.id, { + channel: { id: channel.id, type: 'discord', label: channel.label, direction: 'bidirectional' }, + ...(history ? { history } : {}), + }); + return; + } + if (msg.method === 'channels/close') { + reply(msg.id, { closed: true }); + return; + } + if (msg.method === 'channels/publish') { + const text = (msg.params?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('\n'); + const channelId = msg.params?.channelId; + if (publishMode === 'hang') { + log('publish-hung', { channelId, text }); + return; // never answered: the host's request times out + } + if (publishMode === 'error') { + log('publish-error', { channelId, text }); + if (msg.id !== undefined && msg.id !== null) replyError(msg.id, 'connector exploded'); + return; + } + if (publishMode === 'not-delivered') { + log('publish-refused', { channelId, text }); + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: false }); + return; + } + const messageId = `posted-${++posted}`; + log('publish', { channelId, text, messageId }); + if (msg.id !== undefined && msg.id !== null) { + reply(msg.id, publishMode === 'no-receipt' ? {} : { delivered: true, messageId }); + } + return; + } + if (msg.method === 'tools/list') { + reply(msg.id, { tools: [] }); + return; + } + // Responses to our own requests (register, incoming, push) need no handling. +}); diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts new file mode 100644 index 000000000..806b54a9c --- /dev/null +++ b/test/inbound-source.test.ts @@ -0,0 +1,261 @@ +/** + * The inbound source envelope (src/mcpl/inbound-source.ts), end to end over a + * real MCPL child: every accepted item is stamped once, at the framework's + * ingestion boundary, with the conversation it came from. Both MCPL lanes + * (channels/incoming and push/event), unscoped pushes, and console input + * from a module are covered; an adapter cannot supply or spoof the stamp, and + * an acceptance observer hears each acceptance exactly once. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + AgentFramework, + INBOUND_SOURCE_KEY, + readInboundSource, + conversationKey, + type InboundSource, + type Module, + type ModuleContext, + type ProcessEvent, + type ProcessState, + type EventResponse, + type ToolDefinition, + type ToolCall, + type ToolResult, +} from '../src/index.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; +import { fixture, eventually, TS } from './helpers/coalescing-fixture.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); +const ROOM = 'discord:g1:room'; +const RAW_DM = '1548000000000000001'; + +async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error(`timed out waiting for: ${what}`); +} + +/** A console surface: the module that turns external-message into context. */ +class ConsoleModule implements Module { + readonly name = 'console'; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return []; } + async handleToolCall(_call: ToolCall): Promise { return { success: true }; } + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (event.type !== 'external-message') return {}; + const text = String((event as { content?: unknown }).content); + return { addMessages: [{ participant: 'Operator', content: [{ type: 'text', text }] }] }; + } +} + +describe('inbound source envelope', () => { + let tempDir: string; + let commandPath: string; + let framework: AgentFramework; + let observed: InboundSource[]; + let n = 0; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'inbound-source-')); + commandPath = join(tempDir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + observed = []; + framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [FIXTURE], + env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [new ConsoleModule()], + }); + (framework as unknown as { inboundAcceptanceObserver: unknown }).inboundAcceptanceObserver = { + inboundAccepted: (source: InboundSource) => observed.push(source), + }; + await framework.start(); + const registry = (framework as unknown as { channelRegistry: { listChannelsRaw(): unknown[] } }).channelRegistry; + await waitFor(() => registry.listChannelsRaw().length >= 2, 'channel registration'); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const command = (c: Record): void => appendFileSync(commandPath, JSON.stringify(c) + '\n'); + const stored = () => framework.getAgent('scout')!.getContextManager().getAllMessages(); + const storedWith = (pred: (m: Record) => boolean) => + stored().find((m) => pred((m.metadata ?? {}) as Record)); + + it('stamps a channels/incoming message with its registered channel, label, reply edge and acceptance time', async () => { + const before = Date.now(); + command({ + op: 'incoming', channelId: ROOM, messageId: 'm-1', mode: 'ambient', text: 'hello', + timestamp: '2026-10-07T01:02:03.000Z', + metadata: { + replyTo: 'm-0', + // An adapter cannot supply the framework's stamp. + inboundSource: { kind: 'channel', serverId: 'evil', binding: 'x', channelId: 'discord:g9:elsewhere', acceptedAt: 1 }, + }, + }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-1'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-1')!; + const source = readInboundSource(message.metadata); + assert.ok(source && source.kind === 'channel'); + assert.equal(source.serverId, 'discord'); + assert.match(source.binding, /^[0-9a-f]{16}$/); + assert.equal(source.channelId, ROOM); + assert.equal(source.messageId, 'm-1'); + assert.equal(source.label, '#room (Guild One)'); + assert.equal(source.replyTo, 'm-0'); + assert.equal(source.sourceTimestamp, '2026-10-07T01:02:03.000Z'); + assert.ok(source.acceptedAt >= before && source.acceptedAt <= Date.now()); + assert.deepEqual(observed, [source], 'observed exactly once, with the stored envelope'); + }); + + it('stamps a DM push with the derived composite channel and its people-first label', async () => { + command({ op: 'dm', eventId: `ev-${++n}`, authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst' }); + await waitFor(() => !!storedWith((m) => readInboundSource(m)?.kind === 'channel'), 'dm stored'); + const source = readInboundSource(storedWith((m) => readInboundSource(m)?.kind === 'channel')!.metadata); + assert.ok(source && source.kind === 'channel'); + assert.equal(source.channelId, `discord:dm:${RAW_DM}`); + assert.equal(source.label, 'DM: antra'); + assert.equal(source.messageId, `ev-${n}`); + assert.equal(source.eventId, `ev-${n}`); + // The adapter's own free-form origin.source stays where it was. + assert.equal((storedWith((m) => m.eventId === `ev-${n}`)!.metadata as Record).source, 'discord'); + assert.equal(observed.length, 1); + }); + + it('stamps a push that names no channel as unscoped, never a guessed channel', async () => { + command({ op: 'push', eventId: 'tick-1', origin: { source: 'timer' }, text: 'tick' }); + await waitFor(() => !!storedWith((m) => m.eventId === 'tick-1'), 'push stored'); + const source = readInboundSource(storedWith((m) => m.eventId === 'tick-1')!.metadata); + assert.ok(source && source.kind === 'unscoped'); + assert.equal(source.serverId, 'discord'); + assert.equal(source.eventId, 'tick-1'); + assert.equal(conversationKey(source), undefined); + }); + + it('stamps console input from a module as a surface conversation', async () => { + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'hi there', metadata: {} } as ProcessEvent); + await waitFor(() => stored().some((m) => m.participant === 'Operator'), 'console message stored'); + const message = stored().find((m) => m.participant === 'Operator')!; + const source = readInboundSource(message.metadata); + assert.deepEqual(source && { kind: source.kind, surface: (source as { surface: string }).surface }, { kind: 'surface', surface: 'tui' }); + assert.equal(conversationKey(source!), 'surface\u0000tui'); + assert.equal(observed.length, 1); + }); + + it('keeps a stored envelope when the channel is renamed later', async () => { + command({ op: 'incoming', channelId: ROOM, messageId: 'm-2', mode: 'ambient', text: 'before rename' }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-2'), 'first message stored'); + command({ op: 'rename', channelId: ROOM, label: '#lobby (Guild One)' }); + command({ op: 'incoming', channelId: ROOM, messageId: 'm-3', mode: 'ambient', text: 'after rename' }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-3'), 'second message stored'); + const first = readInboundSource(storedWith((m) => m.messageId === 'm-2')!.metadata); + const second = readInboundSource(storedWith((m) => m.messageId === 'm-3')!.metadata); + assert.equal(first?.kind === 'channel' && first.label, '#room (Guild One)'); + assert.equal(second?.kind === 'channel' && second.label, '#lobby (Guild One)'); + }); + + it('delivers normally when the acceptance observer throws, and reports the failure', async () => { + const traces: Array> = []; + framework.onTrace((e) => { if (e.type === 'inbound:observer-failed') traces.push(e as unknown as Record); }); + (framework as unknown as { inboundAcceptanceObserver: unknown }).inboundAcceptanceObserver = { + inboundAccepted: () => { throw new Error('ledger down'); }, + }; + command({ op: 'incoming', channelId: ROOM, messageId: 'm-4', mode: 'ambient', text: 'still here' }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-4'), 'message stored despite observer'); + assert.equal(traces.length, 1); + assert.equal(traces[0]!.kind, 'channel'); + assert.equal(traces[0]!.error, 'ledger down'); + }); +}); + +describe('readInboundSource', () => { + it('reads only well-formed framework stamps', () => { + const channel = { kind: 'channel', serverId: 's', binding: 'b', channelId: 'c', acceptedAt: 1 }; + assert.deepEqual(readInboundSource({ [INBOUND_SOURCE_KEY]: channel }), channel); + assert.equal(readInboundSource({ [INBOUND_SOURCE_KEY]: { ...channel, acceptedAt: 'soon' } }), undefined); + assert.equal(readInboundSource({ [INBOUND_SOURCE_KEY]: { ...channel, channelId: '' } }), undefined); + assert.equal(readInboundSource({ [INBOUND_SOURCE_KEY]: { kind: 'mystery', acceptedAt: 1 } }), undefined); + assert.equal(readInboundSource({ source: 'discord' }), undefined); + assert.equal(readInboundSource(undefined), undefined); + }); + + it('keys conversations by server, channel and thread', () => { + const base = { kind: 'channel' as const, serverId: 's', binding: 'b', channelId: 'c', acceptedAt: 1 }; + assert.notEqual(conversationKey(base), conversationKey({ ...base, serverId: 't' })); + assert.notEqual(conversationKey(base), conversationKey({ ...base, threadId: 'th' })); + assert.equal(conversationKey(base), conversationKey({ ...base, messageId: 'other', acceptedAt: 2 })); + }); +}); + +describe('inbound source envelope under RFC-006 coalescing', () => { + const install = (framework: AgentFramework): InboundSource[] => { + const observed: InboundSource[] = []; + (framework as unknown as { inboundAcceptanceObserver: unknown }).inboundAcceptanceObserver = { + inboundAccepted: (source: InboundSource) => observed.push(source), + }; + return observed; + }; + const sourceOf = (f: Awaited>, text: string): InboundSource | undefined => { + const message = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes(text)); + return message ? readInboundSource(message.metadata) : undefined; + }; + + it('observes each acceptance once; a replacement carries its own frozen envelope and a retry is not an acceptance', async (t) => { + const f = await fixture(); t.after(f.close); + const observed = install(f.framework); + await f.send('push/event', f.params('1', 'edit_original', { initial: true })); + await f.send('push/event', f.params('2', 'edit_latest')); + await f.send('push/event', f.params('2', 'edit_latest')); + assert.deepEqual(observed.map((s) => (s as { eventId?: string }).eventId), ['1', '2']); + const stored = sourceOf(f, 'edit_latest'); + assert.ok(stored && stored.kind === 'unscoped'); + assert.deepEqual(stored, observed[1], 'the delivered copy carries the envelope frozen at its acceptance'); + }); + + it('observes deferred work at acceptance, before any render; its rendered delivery keeps that envelope and adds materialized', async (t) => { + const f = await fixture(); t.after(f.close); + const observed = install(f.framework); + await f.send('push/event', f.params('1', 'fallback_unavailable', { deferred: true })); + assert.equal(observed.length, 1, 'observed at acceptance, nothing rendered yet'); + assert.equal(f.renders.length, 0); + const accepted = observed[0]!; + assert.equal((accepted as { deferred?: boolean }).deferred, true); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 1); + assert.equal(observed.length, 1, 'materialization is not a second acceptance'); + const stored = sourceOf(f, 'document_diff'); + assert.deepEqual(stored, { ...accepted, materialized: true }); + }); + + it('a channel-scoped coalesced message belongs to its channel, frozen at acceptance', async (t) => { + const f = await fixture(); t.after(f.close); + await f.register('chat'); + const observed = install(f.framework); + await f.send('channels/incoming', { messages: [f.channel('e1', 'first_words')] }); + await eventually(() => !!sourceOf(f, 'first_words'), 'coalesced message stored'); + const stored = sourceOf(f, 'first_words'); + assert.ok(stored && stored.kind === 'channel'); + assert.equal(stored.channelId, 'chat'); + assert.equal(stored.messageId, 'm'); + assert.equal(stored.eventId, 'e1'); + assert.equal(stored.sourceTimestamp, TS); + assert.deepEqual(observed, [stored]); + }); +}); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56c..c9f79c75f 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -146,6 +146,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -197,6 +198,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -262,6 +264,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: (id: string, serverId?: string) => { asked.push(`${serverId}/${id}`); return '@_reim0n'; }, stopAll: () => {}, @@ -280,6 +283,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i2.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => undefined, stopAll: () => {}, @@ -297,6 +301,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i3.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => undefined, stopAll: () => {}, }; From f7e14f6bc396cc3594f2d8d100544214903124e7 Mon Sep 17 00:00:00 2001 From: Basil-1007 Date: Wed, 7 Oct 2026 05:15:50 -0400 Subject: [PATCH 02/65] =?UTF-8?q?feat(chronicle):=20RecordJournal=20?= =?UTF-8?q?=E2=80=94=20typed=20records=20that=20don't=20follow=20branch=20?= =?UTF-8?q?switches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch state (snapshot, tree and log states) rewinds with a historical rollback; typed chronicle records do not. getRecordIdsByType enumerates them from every branch, in global order (ids come from one store-wide counter), and they survive deleteBranch, reopen and SIGKILL. Three AF journals need exactly that — the workspace's disk-agreement evidence (shelf-383), delivery receipts (shelf-354) and prose-draft outcomes (shelf-351) — so they share one helper instead of three: append typed records, checkpoint the caller's reduced state, replay the tail after the latest checkpoint at open. Durability is the subtle part. A record append reaches the OS before it returns, while branch-state commits are buffered until store.sync(): a probe lost a tree commit made between two appends that both survived a SIGKILL. So the API names two barriers and implies neither from the returned {id, sequence}: - afterCommittedState: sync BEFORE appending, for an entry asserting state committed just before it (it can then never survive without that state); - durable: sync AFTER appending, for an entry that must be on stable storage before the caller acts (an attempt before its outbound request, an intent before its disk effect). One writer per record type: a checkpoint covers entries up to the last one this instance loaded or appended. Entry meaning, reducers, dedup and checkpoint cadence stay with each caller. Tests: empty load; append refs and replay order; entries from a rolled-back branch and main loading from either, through deleteBranch and reopen; checkpoint + tail with interleaved other record types and a tree commit, the newest checkpoint winning; the barrier call order; and a SIGKILL child that commits tree state then appends with afterCommittedState — the entry never survives without the state. (The same kill without the barrier leaves the entry and loses the state.) Agreed in room-220 #41647-#41713 with Oscar-1005, Petra-1012, Hugo-1004. Co-Authored-By: Claude Opus 5.5 --- src/record-journal.ts | 166 ++++++++++++++++++++++++++ test/fixtures/record-journal-kill.mjs | 18 +++ test/record-journal.test.ts | 161 +++++++++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 src/record-journal.ts create mode 100644 test/fixtures/record-journal-kill.mjs create mode 100644 test/record-journal.test.ts diff --git a/src/record-journal.ts b/src/record-journal.ts new file mode 100644 index 000000000..b3affd27e --- /dev/null +++ b/src/record-journal.ts @@ -0,0 +1,166 @@ +/** + * An append-only journal of typed Chronicle records that does not follow + * branch switches. + * + * Chronicle's branch state — snapshot, tree and log states — rewinds with a + * historical rollback (createBranchAt + switchBranch). Typed records do not: + * `getRecordIdsByType` enumerates them from every branch, in global order, and + * they survive `deleteBranch`, reopen and a hard kill. A journal is for state + * that must not rewind with the conversation but should live and die with its + * store: what a workspace's disk last agreed with, what reached a recipient, + * what became of a suppressed draft. + * + * Durability. A record append reaches the OS before `append` returns, so it + * survives a killed process, while branch-state commits are buffered until + * `store.sync()`: a record appended after a state commit can outlive that + * commit. Two barriers, each named where it's needed and neither implied by + * the `{id, sequence}` an append returns: + * - `afterCommittedState: true` syncs BEFORE appending — for an entry that + * asserts state committed just before it, so the entry can never survive + * without that state; + * - `durable: true` syncs AFTER appending — for an entry that must be on + * stable storage before the caller acts on it (an attempt before its + * outbound request, an intent before its disk effect). + * + * Checkpoints bound the replay at open: `checkpoint(snapshot)` records the + * caller's reduced state together with the last entry it covers, and `load()` + * returns the latest checkpoint's snapshot plus only the entries appended + * after it. A journal that never checkpoints replays everything. What entries + * mean, how they reduce, and when to checkpoint stay with the caller. + * + * One writer per record type: a checkpoint covers every entry up to the last + * one THIS journal loaded or appended, so entries another instance appended + * to the same type in between would be covered without being reduced. + */ + +import type { JsStore } from '@animalabs/chronicle'; + +/** Where an appended record landed. Not a durability receipt — see the barriers. */ +export interface JournalRecordRef { + /** Chronicle record id: global, increasing in append order. */ + id: string; + /** The record's sequence on the branch current when it was appended. */ + sequence: number; +} + +export interface JournalEntry extends JournalRecordRef { + entry: Entry; +} + +export interface JournalLoad { + /** The latest checkpoint's snapshot, or null when none was written. */ + snapshot: Snapshot | null; + /** Every entry appended after that checkpoint, in append order. */ + entries: Array>; +} + +export interface AppendOptions { + /** `store.sync()` before appending: the entry asserts state committed just before it. */ + afterCommittedState?: boolean; + /** `store.sync()` after appending: the entry must be on stable storage before the caller acts. */ + durable?: boolean; +} + +interface CheckpointPayload { + /** Id of the last entry the snapshot covers ("0" when it covers none). */ + through: string; + snapshot: Snapshot; +} + +/** Record ids are decimal strings from one store-wide counter. */ +function idValue(id: string): bigint { + if (!/^\d+$/.test(id)) throw new Error(`RecordJournal: unexpected chronicle record id "${id}"`); + return BigInt(id); +} + +function decode(store: JsStore, id: string): { value: T; sequence: number } { + const record = store.getRecord(id); + if (!record) throw new Error(`RecordJournal: record ${id} is listed but cannot be read`); + return { value: JSON.parse(Buffer.from(record.payload).toString('utf-8')) as T, sequence: record.sequence }; +} + +export class RecordJournal { + private readonly store: JsStore; + /** Record type of the entries. */ + readonly type: string; + /** Record type of the checkpoints. */ + readonly checkpointType: string; + /** Highest entry id this journal has loaded or appended (0 when none). */ + private lastEntryId = 0n; + private sinceCheckpoint = 0; + + constructor(store: JsStore, opts: { type: string; checkpointType?: string }) { + if (!opts.type) throw new Error('RecordJournal: a record type is required'); + this.store = store; + this.type = opts.type; + this.checkpointType = opts.checkpointType ?? `${opts.type}/checkpoint`; + if (this.checkpointType === this.type) { + throw new Error('RecordJournal: entries and checkpoints need distinct record types'); + } + } + + /** Entries appended since the last checkpoint (or since load, when there was none). */ + get entriesSinceCheckpoint(): number { + return this.sinceCheckpoint; + } + + /** + * The latest checkpoint and every entry after it, whichever branch each was + * appended on. Also positions the journal, so later checkpoints cover what + * was loaded. + */ + load(): JournalLoad { + let snapshot: Snapshot | null = null; + let through = 0n; + let latestCheckpoint: bigint | null = null; + let latestCheckpointId: string | null = null; + for (const id of this.store.getRecordIdsByType(this.checkpointType)) { + const value = idValue(id); + if (latestCheckpoint === null || value > latestCheckpoint) { + latestCheckpoint = value; + latestCheckpointId = id; + } + } + if (latestCheckpointId !== null) { + const payload = decode>(this.store, latestCheckpointId).value; + snapshot = payload.snapshot; + through = idValue(payload.through); + } + + const tail = this.store.getRecordIdsByType(this.type) + .map((id) => ({ id, value: idValue(id) })) + .filter(({ value }) => value > through) + .sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0)); + const entries = tail.map(({ id }) => { + const { value, sequence } = decode(this.store, id); + return { id, sequence, entry: value }; + }); + + this.lastEntryId = tail.length > 0 ? tail[tail.length - 1]!.value : through; + this.sinceCheckpoint = entries.length; + return { snapshot, entries }; + } + + /** Append one entry. The returned ref says where it landed, not that it's durable. */ + append(entry: Entry, opts: AppendOptions = {}): JournalRecordRef { + if (opts.afterCommittedState) this.store.sync(); + const record = this.store.appendJson(this.type, entry); + if (opts.durable) this.store.sync(); + const value = idValue(record.id); + if (value > this.lastEntryId) this.lastEntryId = value; + this.sinceCheckpoint++; + return { id: record.id, sequence: record.sequence }; + } + + /** + * Record the caller's reduced state as covering every entry this journal has + * loaded or appended. Like an entry, it's durable only with `durable`. + */ + checkpoint(snapshot: Snapshot, opts: { durable?: boolean } = {}): JournalRecordRef { + const payload: CheckpointPayload = { through: this.lastEntryId.toString(), snapshot }; + const record = this.store.appendJson(this.checkpointType, payload); + if (opts.durable) this.store.sync(); + this.sinceCheckpoint = 0; + return { id: record.id, sequence: record.sequence }; + } +} diff --git a/test/fixtures/record-journal-kill.mjs b/test/fixtures/record-journal-kill.mjs new file mode 100644 index 000000000..e0c04d802 --- /dev/null +++ b/test/fixtures/record-journal-kill.mjs @@ -0,0 +1,18 @@ +// Child process for record-journal.test.ts: commit branch state, append a +// journal entry that asserts it, then die without sync or close. +// node record-journal-kill.mjs +// mode "asserting": append with afterCommittedState (the barrier under test). +import { JsStore } from '@animalabs/chronicle'; +import { RecordJournal } from '../../src/record-journal.js'; + +const [storePath, mode] = process.argv.slice(2); +const store = JsStore.openOrCreate({ path: storePath }); +try { + store.registerState({ id: 'journal-test/tree', strategy: 'tree' }); +} catch { + // registered by the parent's setup +} +const journal = new RecordJournal(store, { type: 'journal-test/entry' }); +store.treeSet('journal-test/tree', 'committed.txt', { blobHash: 'c'.repeat(64), size: 1, mode: 0o644 }); +journal.append({ asserts: 'committed.txt' }, { afterCommittedState: mode === 'asserting' }); +process.kill(process.pid, 'SIGKILL'); diff --git a/test/record-journal.test.ts b/test/record-journal.test.ts new file mode 100644 index 000000000..f74513559 --- /dev/null +++ b/test/record-journal.test.ts @@ -0,0 +1,161 @@ +/** + * RecordJournal — typed chronicle records that don't follow branch switches, + * replayed from the latest checkpoint, with the two durability barriers named. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { RecordJournal } from '../src/record-journal.js'; + +const KILL_FIXTURE = join(import.meta.dirname, 'fixtures/record-journal-kill.mjs'); + +function withStore(fn: (store: JsStore, path: string) => T): T { + const dir = mkdtempSync(join(tmpdir(), 'record-journal-')); + const path = join(dir, 'store'); + const store = JsStore.openOrCreate({ path }); + try { + return fn(store, path); + } finally { + if (!store.isClosed()) store.close(); + rmSync(dir, { recursive: true, force: true }); + } +} + +type Entry = { n: string }; +const entriesOf = (journal: RecordJournal): string[] => journal.load().entries.map((e) => e.entry.n); + +describe('RecordJournal', () => { + it('an empty journal loads nothing', () => { + withStore((store) => { + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + assert.deepStrictEqual(journal.load(), { snapshot: null, entries: [] }); + assert.strictEqual(journal.entriesSinceCheckpoint, 0); + }); + }); + + it('append returns where the record landed; a fresh load replays entries in append order', () => { + withStore((store) => { + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + const a = journal.append({ n: 'a' }); + const b = journal.append({ n: 'b' }); + assert.match(a.id, /^\d+$/); + assert.ok(BigInt(b.id) > BigInt(a.id), 'ids increase in append order'); + assert.strictEqual(typeof a.sequence, 'number'); + const loaded = new RecordJournal(store, { type: 'journal-test/entry' }).load(); + assert.deepStrictEqual(loaded.entries, [ + { id: a.id, sequence: a.sequence, entry: { n: 'a' } }, + { id: b.id, sequence: b.sequence, entry: { n: 'b' } }, + ]); + }); + }); + + it('entries from every branch load from any branch, in global order, through deleteBranch and reopen', () => { + withStore((store, path) => { + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + journal.append({ n: 'main-0' }); + const fork = store.currentSequence(); + journal.append({ n: 'main-1' }); + const main = store.currentBranch().name; + store.createBranchAt('rollback', main, fork); + store.switchBranch('rollback'); + journal.append({ n: 'rollback-2' }); + assert.deepStrictEqual(entriesOf(journal), ['main-0', 'main-1', 'rollback-2'], + 'the rolled-back branch still sees what main appended after the fork'); + store.switchBranch(main); + journal.append({ n: 'main-3' }); + store.deleteBranch('rollback'); + assert.deepStrictEqual(entriesOf(journal), ['main-0', 'main-1', 'rollback-2', 'main-3']); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + assert.deepStrictEqual(entriesOf(new RecordJournal(reopened, { type: 'journal-test/entry' })), + ['main-0', 'main-1', 'rollback-2', 'main-3']); + } finally { + reopened.close(); + } + }); + }); + + it('load returns the latest checkpoint and only the entries after it; other records do not disturb it', () => { + withStore((store) => { + store.registerState({ id: 'journal-test/tree', strategy: 'tree' }); + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + const other = new RecordJournal(store, { type: 'journal-test/other' }); + journal.append({ n: 'a' }); + other.append({ n: 'x' }); + journal.append({ n: 'b' }); + journal.checkpoint({ seen: ['a', 'b'] }); + assert.strictEqual(journal.entriesSinceCheckpoint, 0); + store.treeSet('journal-test/tree', 'f.txt', { blobHash: 'f'.repeat(64), size: 1, mode: 0o644 }); + journal.append({ n: 'c' }); + other.append({ n: 'y' }); + assert.strictEqual(journal.entriesSinceCheckpoint, 1); + + const reader = new RecordJournal(store, { type: 'journal-test/entry' }); + const first = reader.load(); + assert.deepStrictEqual(first.snapshot, { seen: ['a', 'b'] }); + assert.deepStrictEqual(first.entries.map((e) => e.entry.n), ['c']); + assert.strictEqual(reader.entriesSinceCheckpoint, 1); + + // A checkpoint after load covers what was loaded; the newest one wins. + reader.checkpoint({ seen: ['a', 'b', 'c'] }); + const second = new RecordJournal(store, { type: 'journal-test/entry' }).load(); + assert.deepStrictEqual(second, { snapshot: { seen: ['a', 'b', 'c'] }, entries: [] }); + assert.deepStrictEqual(entriesOf(new RecordJournal(store, { type: 'journal-test/other' })), ['x', 'y']); + }); + }); + + it('names its two barriers: afterCommittedState syncs before the append, durable after it, neither by default', () => { + withStore((store) => { + const calls: string[] = []; + const recording = { + getRecordIdsByType: (type: string) => store.getRecordIdsByType(type), + getRecord: (id: string) => store.getRecord(id), + appendJson: (type: string, data: unknown) => { calls.push('append'); return store.appendJson(type, data); }, + sync: () => { calls.push('sync'); store.sync(); }, + } as unknown as JsStore; + const journal = new RecordJournal(recording, { type: 'journal-test/entry' }); + journal.append({ n: 'plain' }); + assert.deepStrictEqual(calls.splice(0), ['append'], 'a plain append does not sync'); + journal.append({ n: 'asserting' }, { afterCommittedState: true }); + assert.deepStrictEqual(calls.splice(0), ['sync', 'append']); + journal.append({ n: 'durable' }, { durable: true }); + assert.deepStrictEqual(calls.splice(0), ['append', 'sync']); + journal.append({ n: 'both' }, { afterCommittedState: true, durable: true }); + assert.deepStrictEqual(calls.splice(0), ['sync', 'append', 'sync']); + journal.checkpoint({ n: 1 }, { durable: true }); + assert.deepStrictEqual(calls.splice(0), ['append', 'sync']); + }); + }); + + it('after a hard kill, an afterCommittedState entry never survives without the state it asserts', () => { + withStore((store, path) => { + store.registerState({ id: 'journal-test/tree', strategy: 'tree' }); + store.sync(); + store.close(); + const child = spawnSync(process.execPath, [KILL_FIXTURE, path, 'asserting'], { encoding: 'utf-8' }); + assert.strictEqual(child.signal, 'SIGKILL', `the child died by SIGKILL (stderr: ${child.stderr})`); + const reopened = JsStore.openOrCreate({ path }); + try { + try { reopened.registerState({ id: 'journal-test/tree', strategy: 'tree' }); } catch { /* exists */ } + const entries = new RecordJournal<{ asserts: string }, unknown>(reopened, { type: 'journal-test/entry' }).load().entries; + assert.strictEqual(entries.length, 1, 'the appended entry survived the kill'); + assert.notStrictEqual(reopened.treeGet('journal-test/tree', 'committed.txt'), null, + 'the state it asserts survived with it'); + } finally { + reopened.close(); + } + }); + }); + + it('refuses a checkpoint type equal to the entry type', () => { + withStore((store) => { + assert.throws(() => new RecordJournal(store, { type: 't', checkpointType: 't' }), /distinct record types/); + }); + }); +}); From 2153e3d3627d3a40657d01f5e38be956d44689e5 Mon Sep 17 00:00:00 2001 From: Basil-1007 Date: Wed, 7 Oct 2026 05:21:31 -0400 Subject: [PATCH 03/65] fix(chronicle): a RecordJournal write that fails after reaching the store must be reconciled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hugo-1004's review of f7e14f6 found a replay-loss path: append(A, {durable:true}) hands A to the store, then sync() throws; the caller sees failure and never reduces A. A later successful append(B) advanced lastEntryId past A, and a checkpoint({seen:[B]}) then covered A without A ever entering the reducer — load() returned that snapshot with an empty tail. Now any failure after a record reaches the store (the appendJson itself, or the durable barrier after it, for entries and checkpoints alike) marks the journal unreconciled: append and checkpoint throw until load() has replayed the tail, the possibly-written record included, for the caller to rebuild from. A failure before the record reaches the store (the afterCommittedState sync) wrote nothing and leaves the journal usable. load() documents that the caller rebuilds its reduced state from exactly the snapshot and tail it returns. needsReconcile exposes the state. Regression test (fails on f7e14f6, which let append(B) and the covering checkpoint through): a durable barrier failing after A, writes refused until load(), load() replaying A, the journal usable again; and a pre-append barrier failure leaving it usable. Co-Authored-By: Claude Opus 5.5 --- src/record-journal.ts | 67 ++++++++++++++++++++++++++++++------- test/record-journal.test.ts | 42 +++++++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/record-journal.ts b/src/record-journal.ts index b3affd27e..0565e5422 100644 --- a/src/record-journal.ts +++ b/src/record-journal.ts @@ -31,6 +31,17 @@ * One writer per record type: a checkpoint covers every entry up to the last * one THIS journal loaded or appended, so entries another instance appended * to the same type in between would be covered without being reduced. + * + * An ambiguous write must be reconciled before the journal writes again. Once + * `append` or `checkpoint` has handed a record to the store, any failure from + * there on (the append itself, or a barrier after it) leaves it unknown + * whether that record exists, and the caller didn't reduce it. A later + * checkpoint would then cover it without its ever entering the reducer. So + * such a failure marks the journal unreconciled: every further `append` and + * `checkpoint` throws until `load()` has replayed the tail — the possibly + * written record included — for the caller to reduce. A failure BEFORE the + * record reaches the store (the `afterCommittedState` sync) wrote nothing and + * leaves the journal usable. */ import type { JsStore } from '@animalabs/chronicle'; @@ -88,6 +99,8 @@ export class RecordJournal { /** Highest entry id this journal has loaded or appended (0 when none). */ private lastEntryId = 0n; private sinceCheckpoint = 0; + /** Set by a failure after a record reached the store; cleared by load(). */ + private unreconciled: Error | null = null; constructor(store: JsStore, opts: { type: string; checkpointType?: string }) { if (!opts.type) throw new Error('RecordJournal: a record type is required'); @@ -106,8 +119,9 @@ export class RecordJournal { /** * The latest checkpoint and every entry after it, whichever branch each was - * appended on. Also positions the journal, so later checkpoints cover what - * was loaded. + * appended on. The caller rebuilds its reduced state from exactly these — + * at open, and to reconcile after an ambiguous write. Also positions the + * journal, so later checkpoints cover what was loaded. */ load(): JournalLoad { let snapshot: Snapshot | null = null; @@ -138,18 +152,24 @@ export class RecordJournal { this.lastEntryId = tail.length > 0 ? tail[tail.length - 1]!.value : through; this.sinceCheckpoint = entries.length; + this.unreconciled = null; return { snapshot, entries }; } + /** True after an ambiguous write, until load() has replayed it. */ + get needsReconcile(): boolean { + return this.unreconciled !== null; + } + /** Append one entry. The returned ref says where it landed, not that it's durable. */ append(entry: Entry, opts: AppendOptions = {}): JournalRecordRef { + this.assertReconciled(); if (opts.afterCommittedState) this.store.sync(); - const record = this.store.appendJson(this.type, entry); - if (opts.durable) this.store.sync(); - const value = idValue(record.id); - if (value > this.lastEntryId) this.lastEntryId = value; - this.sinceCheckpoint++; - return { id: record.id, sequence: record.sequence }; + return this.write(this.type, entry, opts.durable === true, (ref) => { + const value = idValue(ref.id); + if (value > this.lastEntryId) this.lastEntryId = value; + this.sinceCheckpoint++; + }); } /** @@ -157,10 +177,33 @@ export class RecordJournal { * loaded or appended. Like an entry, it's durable only with `durable`. */ checkpoint(snapshot: Snapshot, opts: { durable?: boolean } = {}): JournalRecordRef { + this.assertReconciled(); const payload: CheckpointPayload = { through: this.lastEntryId.toString(), snapshot }; - const record = this.store.appendJson(this.checkpointType, payload); - if (opts.durable) this.store.sync(); - this.sinceCheckpoint = 0; - return { id: record.id, sequence: record.sequence }; + return this.write(this.checkpointType, payload, opts.durable === true, () => { + this.sinceCheckpoint = 0; + }); + } + + /** Hand one record to the store; any failure from here on is ambiguous. */ + private write(type: string, payload: unknown, durable: boolean, onWritten: (ref: JournalRecordRef) => void): JournalRecordRef { + try { + const record = this.store.appendJson(type, payload); + const ref = { id: record.id, sequence: record.sequence }; + onWritten(ref); + if (durable) this.store.sync(); + return ref; + } catch (err) { + this.unreconciled = err instanceof Error ? err : new Error(String(err)); + throw err; + } + } + + private assertReconciled(): void { + if (this.unreconciled) { + throw new Error( + `RecordJournal(${this.type}): an earlier write failed after reaching the store ` + + `(${this.unreconciled.message}); load() and reduce its tail before writing again`, + ); + } } } diff --git a/test/record-journal.test.ts b/test/record-journal.test.ts index f74513559..3618ae077 100644 --- a/test/record-journal.test.ts +++ b/test/record-journal.test.ts @@ -153,6 +153,48 @@ describe('RecordJournal', () => { }); }); + it('a write that fails after reaching the store blocks further writes until load() replays it', () => { + withStore((store) => { + let failNextSync = false; + const flaky = { + getRecordIdsByType: (type: string) => store.getRecordIdsByType(type), + getRecord: (id: string) => store.getRecord(id), + appendJson: (type: string, data: unknown) => store.appendJson(type, data), + sync: () => { + if (failNextSync) { failNextSync = false; throw new Error('disk full'); } + store.sync(); + }, + } as unknown as JsStore; + const journal = new RecordJournal(flaky, { type: 'journal-test/entry' }); + + // The durable barrier fails after A reached the store: the caller never reduces A. + failNextSync = true; + assert.throws(() => journal.append({ n: 'A' }, { durable: true }), /disk full/); + assert.strictEqual(journal.needsReconcile, true); + // Without reconciliation, a later write and checkpoint would cover A unreduced. + assert.throws(() => journal.append({ n: 'B' }), /load\(\) and reduce its tail before writing again/); + assert.throws(() => journal.checkpoint({ seen: ['B'] }), /load\(\) and reduce its tail/); + + const reconciled = journal.load(); + assert.deepStrictEqual(reconciled.entries.map((e) => e.entry.n), ['A'], 'load replays the ambiguous record'); + assert.strictEqual(journal.needsReconcile, false); + journal.append({ n: 'B' }); + journal.checkpoint({ seen: ['A', 'B'] }); + assert.deepStrictEqual(new RecordJournal(store, { type: 'journal-test/entry' }).load(), + { snapshot: { seen: ['A', 'B'] }, entries: [] }); + + // A failure BEFORE the record reaches the store wrote nothing and leaves the journal usable. + failNextSync = true; + assert.throws(() => journal.append({ n: 'C' }, { afterCommittedState: true }), /disk full/); + assert.strictEqual(journal.needsReconcile, false); + journal.append({ n: 'D' }); + assert.deepStrictEqual( + new RecordJournal(store, { type: 'journal-test/entry' }).load().entries.map((e) => e.entry.n), + ['D'], + ); + }); + }); + it('refuses a checkpoint type equal to the entry type', () => { withStore((store) => { assert.throws(() => new RecordJournal(store, { type: 't', checkpointType: 't' }), /distinct record types/); From eb2a588b1f28a81ea0f7e7fbe5a35edd0cbe9412 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:32:48 -0400 Subject: [PATCH 04/65] fix(mcpl): freeze ordinary inbound envelopes at admission; add lane facts Review findings on 3f7aafe from Hugo-1004 (room-220 #41587) and a request from Oscar-1005 (#41589). Freeze at admission. An ordinary (uncoalesced) channels/incoming message or push/event used to get only `acceptedAt` at admission; its envelope was built later, in handleMcplChannelIncoming/handleMcplPushEvent, from the registry's CURRENT label and endpoint binding, and the acceptance was observed there. An item accepted (and acknowledged) before a rename or rebind could therefore acquire the new label or binding while it waited in the queue, and a receipt clock read stale until the queue drained. ChannelRegistry.handleIncoming and PushHandler now call a framework-supplied `acceptInbound` at admission, before queueing and before the acknowledgement: the framework builds the envelope and observes the acceptance there, and the event carries the frozen envelope to its handler, as coalesced work already did. The handlers only fall back to building (and observing) an envelope for a direct caller that bypassed admission. Lane facts. Channel and unscoped envelopes now carry `lane` ('channels/incoming' | 'push/event') and `coalesced: true` for RFC-006 admission. What an eventId is worth depends on the admitting lane's contract (push/event dedups by eventId; coalesced admission guarantees stable retries and distinct versions; an uncoalesced channels/incoming eventId is adapter-supplied with no guarantee), and its mere presence cannot say which. Facts only: the receipt policy that uses them stays with the receipt lane. Strict reader. readInboundSource validates every field it returns: identity strings non-empty, acceptedAt finite, deferred/materialized/coalesced exactly true when present, a known lane, and refuses arrays. Tests: both ordinary lanes hold an admitted item between admission and processing (by intercepting the framework's queue), assert the observer ran at admission with the original label, rename the channel, release, and assert the stored envelope equals the admission envelope; lane/coalesced facts asserted on ordinary and coalesced items; the reader refuses each malformed field. Suite (machine load average ~40 during the run): all files except code-execution 1274/1275 with code-execution separately 36/36. The one failure is mcpl-coalescing's wall-clock bound (#196 vector 34c, 300 fsynced coalesced acceptances < 30 s): 40.0 s here, and 40.4 s for the same test on an untouched 64c480b build under the same load, so it is load, not this change (it passed at 24.1 s on 3f7aafe at lower load). The full-suite PyRunner "rejects concurrent execs" test hung twice under this load in the combined run and passes alone. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-source-envelope.added.md | 7 ++ src/framework.ts | 69 ++++++++++--- src/mcpl/channel-registry.ts | 21 +++- src/mcpl/inbound-source.ts | 48 ++++++--- src/mcpl/push-handler.ts | 9 ++ test/inbound-source.test.ts | 100 +++++++++++++++++-- 6 files changed, 220 insertions(+), 34 deletions(-) diff --git a/changelog.d/inbound-source-envelope.added.md b/changelog.d/inbound-source-envelope.added.md index 30db418ea..9afd3c00c 100644 --- a/changelog.d/inbound-source-envelope.added.md +++ b/changelog.d/inbound-source-envelope.added.md @@ -10,3 +10,10 @@ and a later rename never rewrites it. Adapter-supplied metadata cannot set the field (an adapter's own `origin.source` string is untouched). `readInboundSource`, `conversationKey` and `INBOUND_SOURCE_KEY` are exported. +- **Inbound source envelope: admission facts.** An ordinary `channels/incoming` + message or `push/event` now has its envelope frozen at admission, before it + is queued or acknowledged, so a rename or rebind while it waits cannot + change it. Every channel and unscoped envelope names the admission `lane` + (`channels/incoming` or `push/event`) and whether it came through RFC-006 + coalesced admission (`coalesced: true`): facts a consumer needs to know what + an `eventId` is worth. `readInboundSource` validates every field it returns. diff --git a/src/framework.ts b/src/framework.ts index 3934b1f9d..b6f771746 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -83,7 +83,7 @@ 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, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; -import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; +import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -7106,16 +7106,9 @@ export class AgentFramework { if (event.inboundSource) { source = event.inboundSource; } else { - source = this.channelSource({ - serverId: event.serverId, - channelId: event.channelId, - threadId: event.threadId, - messageId: event.messageId, - eventId: event.eventId, - replyTo: event.metadata?.replyTo, - acceptedAt: event.acceptedAt, - sourceTimestamp: event.timestamp, - }); + // Only a caller that bypassed admission (none in the framework's own + // lanes) arrives without a frozen envelope. + source = this.channelEventSource(event, 'channels/incoming'); this.noteInboundAccepted(source); } metadata[INBOUND_SOURCE_KEY] = source; @@ -7654,6 +7647,8 @@ export class AgentFramework { * stamped (a direct framework caller), which is the closest observation. */ private channelSource(fields: { + lane: InboundLane; + coalesced?: boolean; serverId: string; channelId: string; threadId?: unknown; @@ -7674,6 +7669,8 @@ export class AgentFramework { const sourceTimestamp = str(fields.sourceTimestamp); return { kind: 'channel', + lane: fields.lane, + ...(fields.coalesced ? { coalesced: true as const } : {}), serverId: fields.serverId, binding: this.coalescingBinding(fields.serverId), channelId: fields.channelId, @@ -7688,12 +7685,38 @@ export class AgentFramework { }; } + /** Source envelope of a channels/incoming event (lane facts as given). */ + private channelEventSource( + event: { + serverId: string; channelId: string; threadId?: string; messageId?: string; eventId?: string; + metadata?: Record; acceptedAt?: number; timestamp?: string; + }, + lane: InboundLane, + coalesced = false, + ): InboundChannelSource { + return this.channelSource({ + lane, + coalesced, + serverId: event.serverId, + channelId: event.channelId, + threadId: event.threadId, + messageId: event.messageId, + eventId: event.eventId, + replyTo: event.metadata?.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: event.timestamp, + }); + } + /** Source envelope of a push event: its derived channel, else unscoped. */ - private pushSource(event: McplPushEvent, deferred = false): InboundSource { + private pushSource(event: McplPushEvent, coalesced: { deferred: boolean } | undefined = undefined): InboundSource { const origin = (event.origin ?? {}) as Record; + const deferred = coalesced?.deferred ?? false; const channel = this.derivePushEventChannel(event.origin); if (channel) { return this.channelSource({ + lane: 'push/event', + coalesced: !!coalesced, serverId: event.serverId, channelId: channel.channelId, threadId: origin.threadId, @@ -7708,6 +7731,8 @@ export class AgentFramework { } const unscoped: InboundUnscopedSource = { kind: 'unscoped', + lane: 'push/event', + ...(coalesced ? { coalesced: true as const } : {}), serverId: event.serverId, binding: this.coalescingBinding(event.serverId), ...(typeof event.eventId === 'string' && event.eventId ? { eventId: event.eventId } : {}), @@ -7867,6 +7892,8 @@ export class AgentFramework { // The inbound source envelope, frozen at acceptance: deferral, fan-out // and replay deliver it unchanged (mcpl/inbound-source.ts). const inboundSource = this.channelSource({ + lane: 'channels/incoming', + coalesced: true, serverId, channelId: message.channelId, threadId: message.threadId, @@ -7910,6 +7937,8 @@ export class AgentFramework { // lane). A channel-scoped push belongs to the channel its subject names. const inboundSource: InboundSource = c.channelId ? this.channelSource({ + lane: 'push/event', + coalesced: true, serverId, channelId: c.channelId, threadId: origin.threadId, @@ -7920,7 +7949,7 @@ export class AgentFramework { sourceTimestamp: params.timestamp, deferred: !!c.deferred, }) - : this.pushSource(event, !!c.deferred); + : this.pushSource(event, { deferred: !!c.deferred }); return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -13183,6 +13212,13 @@ export class AgentFramework { (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event), + // An ordinary push: freeze its envelope and observe the acceptance at + // admission, before it is queued. + (event) => { + const source = this.pushSource(event); + this.noteInboundAccepted(source); + return source; + }, ); // Server-initiated inference router (Step 6) @@ -13214,6 +13250,13 @@ export class AgentFramework { store: this.store, handleCoalescedIncoming: (serverId, message, event) => this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent), + // An ordinary channels/incoming message: freeze its envelope and + // observe the acceptance at admission, before it is queued. + acceptInbound: (event) => { + const source = this.channelEventSource(event, 'channels/incoming'); + this.noteInboundAccepted(source); + return source; + }, sendTypingFn: (serverId, channelId, metadata, op) => { const server = this.mcplServerRegistry!.getServer(serverId); if (server) { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index d999a6867..34912447b 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -39,6 +39,7 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js import { expandCoreTags } from './tags.js'; import { validateCoalescedContent } from './push-coalescer.js'; import { CapabilityGrant } from './capability-grant.js'; +import type { InboundSource } from './inbound-source.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -236,9 +237,10 @@ interface McplChannelIncomingEvent { tags?: string[]; triggerInference?: boolean; targetAgents?: string[]; - /** Host acceptance time (epoch ms), stamped where the message is admitted. - * The framework's inbound source envelope reads it; nothing re-stamps it. */ + /** Host acceptance time (epoch ms), stamped where the message is admitted. */ acceptedAt?: number; + /** Source envelope frozen at admission (mcpl/inbound-source.ts). */ + inboundSource?: InboundSource; } // ============================================================================ @@ -457,6 +459,13 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // ============================================================================ interface ChannelRegistryOptions { + /** + * An ordinary (uncoalesced) `channels/incoming` message has just been + * admitted, before it is queued or acknowledged: return its source + * envelope, frozen now (mcpl/inbound-source.ts). The framework observes the + * acceptance here; coalesced work is stamped and observed by its own path. + */ + acceptInbound?: (event: McplChannelIncomingEvent) => InboundSource | undefined; /** * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with * the event the ordinary path would have queued. The handler decides @@ -660,6 +669,7 @@ export class ChannelRegistry { private legacyPolicies = new Map(); private migratedLegacyPolicies = new Set(); private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming']; + private acceptInbound?: ChannelRegistryOptions['acceptInbound']; constructor( serverRegistry: McplServerRegistry, @@ -676,6 +686,7 @@ export class ChannelRegistry { }, ) { this.handleCoalescedIncoming = options?.handleCoalescedIncoming; + this.acceptInbound = options?.acceptInbound; this.serverRegistry = serverRegistry; this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -1007,6 +1018,12 @@ export class ChannelRegistry { continue; } + // The source envelope is frozen here, at admission, before the message + // is queued or acknowledged: a rename or rebind while it waits in the + // queue cannot change where it says it came from. + const inboundSource = this.acceptInbound?.(event); + if (inboundSource) event.inboundSource = inboundSource; + // Push to the processing queue // Cast through unknown because McplChannelIncomingEvent matches the // CustomEvent `${string}:${string}` type pattern but lacks an index signature. diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index b7c2fea7d..29c2e65e0 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -21,9 +21,20 @@ * key: `eventId` is present only when the producer supplied one. */ +/** The MCPL admission lane that accepted an item. Its contract decides what + * an `eventId` is worth: `push/event` deduplicates by eventId, and RFC-006 + * coalesced admission (`coalesced: true`) guarantees stable retries and + * distinct versions; a `channels/incoming` eventId outside coalescing is + * adapter-supplied with no such guarantee. */ +export type InboundLane = 'channels/incoming' | 'push/event'; + /** An item that belongs to a registered channel. */ export interface InboundChannelSource { kind: 'channel'; + /** The admission lane that accepted it (see InboundLane). */ + lane: InboundLane; + /** Accepted through RFC-006 coalesced admission. */ + coalesced?: true; /** MCPL server (connection) id the item arrived through, as the host names it. */ serverId: string; /** RFC-006 endpoint binding of that connection at acceptance. A recipe that @@ -56,6 +67,8 @@ export interface InboundChannelSource { /** An MCPL push that names no channel (heartbeats, timers, feature-set events). */ export interface InboundUnscopedSource { kind: 'unscoped'; + lane: 'push/event'; + coalesced?: true; serverId: string; binding: string; eventId?: string; @@ -90,30 +103,39 @@ export interface InboundAcceptanceObserver { /** Metadata key the framework stamps. */ export const INBOUND_SOURCE_KEY = 'inboundSource'; -const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined); +const isText = (v: unknown): v is string => typeof v === 'string' && v.length > 0; +const optionalText = (v: unknown): boolean => v === undefined || isText(v); +const optionalTrue = (v: unknown): boolean => v === undefined || v === true; /** - * Read a stored item's source back from its metadata. Returns undefined for - * anything that is not a well-formed framework stamp (older messages, and any - * shape this version does not recognize) — never a guess. + * Read a stored item's source back from its metadata, validating every field + * a consumer relies on: identity strings are non-empty strings, `acceptedAt` + * is a finite number, flags are exactly `true` when present, and the lane is + * one this version knows. Anything else (older messages, a shape this + * version does not recognize, a damaged import) is undefined — never a guess. */ export function readInboundSource(metadata: unknown): InboundSource | undefined { if (!metadata || typeof metadata !== 'object') return undefined; const raw = (metadata as Record)[INBOUND_SOURCE_KEY]; - if (!raw || typeof raw !== 'object') return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const s = raw as Record; - if (typeof s.acceptedAt !== 'number') return undefined; + if (typeof s.acceptedAt !== 'number' || !Number.isFinite(s.acceptedAt)) return undefined; + if (s.kind === 'surface') { + return isText(s.surface) ? (s as unknown as InboundSurfaceSource) : undefined; + } + const effectFlags = optionalTrue(s.coalesced) && optionalTrue(s.deferred) && optionalTrue(s.materialized); + if (!isText(s.serverId) || !isText(s.binding) || !effectFlags) return undefined; + if (!optionalText(s.eventId) || !optionalText(s.sourceTimestamp)) return undefined; if (s.kind === 'channel') { - if (!str(s.serverId) || !str(s.binding) || !str(s.channelId)) return undefined; + if (s.lane !== 'channels/incoming' && s.lane !== 'push/event') return undefined; + if (!isText(s.channelId)) return undefined; + for (const field of ['threadId', 'messageId', 'label', 'replyTo'] as const) { + if (!optionalText(s[field])) return undefined; + } return s as unknown as InboundChannelSource; } if (s.kind === 'unscoped') { - if (!str(s.serverId) || !str(s.binding)) return undefined; - return s as unknown as InboundUnscopedSource; - } - if (s.kind === 'surface') { - if (!str(s.surface)) return undefined; - return s as unknown as InboundSurfaceSource; + return s.lane === 'push/event' ? (s as unknown as InboundUnscopedSource) : undefined; } return undefined; } diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index d6f9b39f6..6a903ab00 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -183,6 +183,10 @@ export class PushHandler { emitTraceFn: (event: { type: string; [key: string]: unknown }) => void, shouldTriggerInference?: (content: string, metadata: Record) => boolean, private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent) => Promise, + /** An ordinary (uncoalesced) push was just admitted, before it is queued + * or acknowledged: return its source envelope, frozen now + * (mcpl/inbound-source.ts). Coalesced work is stamped by its own path. */ + private readonly acceptInbound?: (event: McplPushEvent) => InboundSource | undefined, ) { this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -310,6 +314,11 @@ export class PushHandler { } return; } + // The source envelope is frozen at admission, before queueing or the + // acknowledgement: nothing that changes while the push waits in the + // queue can rewrite where it came from. + const inboundSource = this.acceptInbound?.(pushEvent); + if (inboundSource) pushEvent.inboundSource = inboundSource; this.pushEventFn(pushEvent); // 7. Emit trace diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 806b54a9c..5516268dc 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -114,6 +114,8 @@ describe('inbound source envelope', () => { const source = readInboundSource(message.metadata); assert.ok(source && source.kind === 'channel'); assert.equal(source.serverId, 'discord'); + assert.equal(source.lane, 'channels/incoming'); + assert.equal(source.coalesced, undefined); assert.match(source.binding, /^[0-9a-f]{16}$/); assert.equal(source.channelId, ROOM); assert.equal(source.messageId, 'm-1'); @@ -170,6 +172,62 @@ describe('inbound source envelope', () => { assert.equal(second?.kind === 'channel' && second.label, '#lobby (Guild One)'); }); + /** + * Hold MCPL events between admission and processing (the framework's + * queue), so a test can change the world while an accepted item waits. + */ + const holdQueue = () => { + const held: ProcessEvent[] = []; + const original = framework.pushEvent.bind(framework); + (framework as unknown as { pushEvent: (e: ProcessEvent) => void }).pushEvent = (event: ProcessEvent) => { + if (event.type === 'mcpl:channel-incoming' || event.type === 'mcpl:push-event') held.push(event); + else original(event); + }; + return { + held, + release: () => { + (framework as unknown as { pushEvent: (e: ProcessEvent) => void }).pushEvent = original; + for (const event of held.splice(0)) original(event); + }, + }; + }; + const registryLabel = (channelId: string) => + (framework as unknown as { channelRegistry: { getChannelLabel(s: string, c: string): string | undefined } }) + .channelRegistry.getChannelLabel('discord', channelId); + + it('freezes an ordinary channels/incoming envelope at admission: observed before queueing, and a rename while queued does not change it', async () => { + const queue = holdQueue(); + command({ op: 'incoming', channelId: ROOM, messageId: 'm-10', mode: 'ambient', text: 'queued words' }); + await waitFor(() => queue.held.length === 1, 'message admitted and held in the queue'); + assert.equal(observed.length, 1, 'observed at admission, before processing'); + assert.equal(observed[0]!.kind === 'channel' && observed[0]!.label, '#room (Guild One)'); + assert.equal(observed[0]!.kind === 'channel' && observed[0]!.lane, 'channels/incoming'); + command({ op: 'rename', channelId: ROOM, label: '#lobby (Guild One)' }); + await waitFor(() => registryLabel(ROOM) === '#lobby (Guild One)', 'rename applied'); + queue.release(); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-10'), 'message stored'); + assert.deepEqual(readInboundSource(storedWith((m) => m.messageId === 'm-10')!.metadata), observed[0]); + assert.equal(observed.length, 1, 'processing is not a second acceptance'); + }); + + it('freezes an ordinary push envelope at admission: a rename while queued does not change it', async () => { + const queue = holdQueue(); + command({ + op: 'push', eventId: 'ev-room', text: 'pushed into the room', + origin: { source: 'discord', mcplChannelId: ROOM, messageId: 'pm-1', authorName: 'someone' }, + tags: ['chat:mention', 'chat:addressed'], + }); + await waitFor(() => queue.held.length === 1, 'push admitted and held in the queue'); + assert.equal(observed.length, 1); + assert.equal(observed[0]!.kind === 'channel' && observed[0]!.lane, 'push/event'); + assert.equal(observed[0]!.kind === 'channel' && observed[0]!.label, '#room (Guild One)'); + command({ op: 'rename', channelId: ROOM, label: '#lobby (Guild One)' }); + await waitFor(() => registryLabel(ROOM) === '#lobby (Guild One)', 'rename applied'); + queue.release(); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-room'), 'push stored'); + assert.deepEqual(readInboundSource(storedWith((m) => m.eventId === 'ev-room')!.metadata), observed[0]); + }); + it('delivers normally when the acceptance observer throws, and reports the failure', async () => { const traces: Array> = []; framework.onTrace((e) => { if (e.type === 'inbound:observer-failed') traces.push(e as unknown as Record); }); @@ -185,18 +243,44 @@ describe('inbound source envelope', () => { }); describe('readInboundSource', () => { + const channel = { kind: 'channel', lane: 'channels/incoming', serverId: 's', binding: 'b', channelId: 'c', acceptedAt: 1 }; + const read = (value: unknown) => readInboundSource({ [INBOUND_SOURCE_KEY]: value }); + it('reads only well-formed framework stamps', () => { - const channel = { kind: 'channel', serverId: 's', binding: 'b', channelId: 'c', acceptedAt: 1 }; - assert.deepEqual(readInboundSource({ [INBOUND_SOURCE_KEY]: channel }), channel); - assert.equal(readInboundSource({ [INBOUND_SOURCE_KEY]: { ...channel, acceptedAt: 'soon' } }), undefined); - assert.equal(readInboundSource({ [INBOUND_SOURCE_KEY]: { ...channel, channelId: '' } }), undefined); - assert.equal(readInboundSource({ [INBOUND_SOURCE_KEY]: { kind: 'mystery', acceptedAt: 1 } }), undefined); + assert.deepEqual(read(channel), channel); + assert.deepEqual(read({ ...channel, coalesced: true, deferred: true, materialized: true, eventId: 'e', label: '#c' }), + { ...channel, coalesced: true, deferred: true, materialized: true, eventId: 'e', label: '#c' }); + assert.deepEqual(read({ kind: 'unscoped', lane: 'push/event', serverId: 's', binding: 'b', acceptedAt: 1 }), + { kind: 'unscoped', lane: 'push/event', serverId: 's', binding: 'b', acceptedAt: 1 }); assert.equal(readInboundSource({ source: 'discord' }), undefined); assert.equal(readInboundSource(undefined), undefined); }); + it('refuses a stamp with any field its consumers rely on malformed', () => { + for (const bad of [ + { ...channel, acceptedAt: 'soon' }, + { ...channel, acceptedAt: Number.POSITIVE_INFINITY }, + { ...channel, acceptedAt: Number.NaN }, + { ...channel, channelId: '' }, + { ...channel, lane: 'carrier-pigeon' }, + { ...channel, lane: undefined }, + { ...channel, deferred: false }, + { ...channel, materialized: 'yes' }, + { ...channel, coalesced: 1 }, + { ...channel, messageId: 42 }, + { ...channel, label: '' }, + { ...channel, sourceTimestamp: {} }, + { kind: 'unscoped', lane: 'channels/incoming', serverId: 's', binding: 'b', acceptedAt: 1 }, + { kind: 'surface', surface: '', acceptedAt: 1 }, + { kind: 'mystery', acceptedAt: 1 }, + [channel], + ]) { + assert.equal(read(bad), undefined, JSON.stringify(bad)); + } + }); + it('keys conversations by server, channel and thread', () => { - const base = { kind: 'channel' as const, serverId: 's', binding: 'b', channelId: 'c', acceptedAt: 1 }; + const base = { kind: 'channel' as const, lane: 'channels/incoming' as const, serverId: 's', binding: 'b', channelId: 'c', acceptedAt: 1 }; assert.notEqual(conversationKey(base), conversationKey({ ...base, serverId: 't' })); assert.notEqual(conversationKey(base), conversationKey({ ...base, threadId: 'th' })); assert.equal(conversationKey(base), conversationKey({ ...base, messageId: 'other', acceptedAt: 2 })); @@ -226,6 +310,8 @@ describe('inbound source envelope under RFC-006 coalescing', () => { assert.deepEqual(observed.map((s) => (s as { eventId?: string }).eventId), ['1', '2']); const stored = sourceOf(f, 'edit_latest'); assert.ok(stored && stored.kind === 'unscoped'); + assert.equal(stored.lane, 'push/event'); + assert.equal(stored.coalesced, true); assert.deepEqual(stored, observed[1], 'the delivered copy carries the envelope frozen at its acceptance'); }); @@ -253,6 +339,8 @@ describe('inbound source envelope under RFC-006 coalescing', () => { const stored = sourceOf(f, 'first_words'); assert.ok(stored && stored.kind === 'channel'); assert.equal(stored.channelId, 'chat'); + assert.equal(stored.lane, 'channels/incoming'); + assert.equal(stored.coalesced, true); assert.equal(stored.messageId, 'm'); assert.equal(stored.eventId, 'e1'); assert.equal(stored.sourceTimestamp, TS); From 7144970b3370b7b6f020a1e29a80df5f8d1562e6 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:45:51 -0400 Subject: [PATCH 05/65] feat(mcpl): type MCPL request failures by what reached the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed request to an MCPL server used to reject with a plain Error, so the only way to tell "never written" from "written and unanswered" from "answered with an error" was to match message text. Callers that act in the world need that difference as a fact: a publish or a reaction that was never sent left the world as it was, while one that was handed to the transport and never answered may have landed. (A rollback reaction drain met exactly this: its pending reactions failed at shutdown, and only their message text said whether each had been written.) McplRequestError extends Error with `outcome`: - `not-sent`: sendRequest refused before writing (connection already closed); - `error-response`: a JSON-RPC error response; `code` and `data` kept verbatim (a connector can describe a partial multi-part post there); - `no-response`: the request was handed to the transport and no answer came (per-request timeout, a deliberate close while awaiting, or an unexpected disconnect). It does not prove the request reached the server — the transport can decline or drop a write — only that the host can no longer say it didn't. Messages are byte-identical to before, and every rejection is still an Error. Exported from the mcpl index and the package root. Tests (test/mcpl-request-outcome.test.ts, a real stdio server): an error response keeps code and data; an unanswered request and a process exit while awaiting are no-response; a request on a closed connection is not-sent; the class keeps Error identity. 12/12 with the existing request-timeout tests. Reviewed by Tessa-974 (room-203 #43780), whose wording correction for no-response this carries. Shared with room-225's lane 1 (Ada-1017). Co-Authored-By: Claude Opus 5.5 --- changelog.d/mcpl-request-outcome.added.md | 10 +++ src/index.ts | 2 +- src/mcpl/index.ts | 2 +- src/mcpl/server-connection.ts | 45 +++++++++-- test/mcpl-request-outcome.test.ts | 92 +++++++++++++++++++++++ 5 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 changelog.d/mcpl-request-outcome.added.md create mode 100644 test/mcpl-request-outcome.test.ts diff --git a/changelog.d/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md new file mode 100644 index 000000000..241ec226e --- /dev/null +++ b/changelog.d/mcpl-request-outcome.added.md @@ -0,0 +1,10 @@ +- **`McplRequestError`: why an MCPL request produced no result.** A request + to an MCPL server that fails now rejects with `McplRequestError` (exported), + whose `outcome` says which of three things happened: `not-sent` (refused + before anything was written: the connection was already closed), + `error-response` (the server answered with a JSON-RPC error; `code` and the + error's `data` are kept), or `no-response` (the request was handed to the + transport and no answer came back: a timeout, or the connection closed + while awaiting; it may or may not have reached the server). Only + `not-sent` proves the server never saw the request. Messages are unchanged, + and it is still an `Error`, so existing handling keeps working. diff --git a/src/index.ts b/src/index.ts index bd07d3547..e6bf2a6b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,7 @@ export type { McplServerConfig } from './mcpl/index.js'; // MCPL client connection (exposed so external tooling — server playtests, // health probes, protocol harnesses — can dial an MCPL server exactly the // way the framework host does, over stdio or WebSocket) -export { McplServerConnection } from './mcpl/index.js'; +export { McplServerConnection, McplRequestError } from './mcpl/index.js'; export type { McplHostCapabilities } from './mcpl/index.js'; // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index d82790c67..9033077ec 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -106,7 +106,7 @@ export { } from './errors.js'; // Server connection and registry -export { McplServerConnection } from './server-connection.js'; +export { McplServerConnection, McplRequestError } from './server-connection.js'; export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js'; // Feature set management (permission layer) diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index f29fea22d..18f82d096 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -62,6 +62,34 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; /** MCP protocol version used in the initialize handshake. */ const MCP_PROTOCOL_VERSION = '2024-11-05'; +/** + * Why a request to an MCPL server produced no result, as far as the host can + * know it. Messages are unchanged from plain errors; the `outcome` makes the + * difference between "never left the host" and "left, and no answer came + * back" a fact a caller can act on instead of a string to match: + * - `not-sent`: the request was refused before anything was written (the + * connection was already closed); + * - `error-response`: the server answered with a JSON-RPC error (it may have + * acted before failing; the error alone does not say); + * - `no-response`: the request was handed to the transport and no answer + * came back (timeout, or the connection closed while awaiting). It does + * not prove the request reached the server — the transport can decline or + * drop a write — only that the host can no longer say it didn't. + */ +export class McplRequestError extends Error { + constructor( + message: string, + readonly outcome: 'not-sent' | 'error-response' | 'no-response', + readonly code?: number, + /** The JSON-RPC error's `data`, verbatim, when the server supplied one + * (e.g. which parts of a multi-part send were posted). */ + readonly data?: unknown, + ) { + super(message); + this.name = 'McplRequestError'; + } +} + /** * Represents a pending JSON-RPC request awaiting a response. */ @@ -846,7 +874,7 @@ export class McplServerConnection extends EventEmitter { // Reject all pending requests for (const [id, pending] of this.pendingRequests) { if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`)); + pending.reject(new McplRequestError(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`, 'no-response')); } this.pendingRequests.clear(); this.orphanedRequests.clear(); @@ -973,7 +1001,7 @@ export class McplServerConnection extends EventEmitter { options: RequestOptions = {}, ): Promise { if (this.closed) { - return Promise.reject(new Error(`Cannot send request: connection to "${this.id}" is closed`)); + return Promise.reject(new McplRequestError(`Cannot send request: connection to "${this.id}" is closed`, 'not-sent')); } const id = this.nextRequestId++; @@ -994,13 +1022,14 @@ export class McplServerConnection extends EventEmitter { this.orphanedRequests.set(id, { method, ...(method === 'push/render' ? { renderParams: { featureSet: params.featureSet, key: params.key, eventId: params.eventId } } : {}) }); if (this.orphanedRequests.size > 4096) this.orphanedRequests.delete(this.orphanedRequests.keys().next().value!); } - reject(new Error( + reject(new McplRequestError( `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` + `within ${timeoutMs}ms — the server may be hung. The response ` + `outcome is unknown: the request was abandoned locally but was not ` + `cancelled, so the tool may still have completed server-side; verify ` + `state before retrying; a blind retry of a stateful/side-effecting ` + `tool may duplicate it.`, + 'no-response', )); }, timeoutMs); // Don't hold the event loop open for the watchdog alone. @@ -1215,7 +1244,12 @@ export class McplServerConnection extends EventEmitter { if (response.error) { pending.reject( - new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`), + new McplRequestError( + `MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`, + 'error-response', + typeof response.error.code === 'number' ? response.error.code : undefined, + (response.error as { data?: unknown }).data, + ), ); } else { pending.resolve(response.result); @@ -1267,8 +1301,9 @@ export class McplServerConnection extends EventEmitter { for (const [id, pending] of this.pendingRequests) { if (pending.timer) clearTimeout(pending.timer); pending.reject( - new Error( + new McplRequestError( `MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`, + 'no-response', ), ); } diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts new file mode 100644 index 000000000..1b0f45647 --- /dev/null +++ b/test/mcpl-request-outcome.test.ts @@ -0,0 +1,92 @@ +/** + * McplRequestError: a request that produced no result says which of three + * things happened, as a fact rather than a message to parse. + * not-sent refused before anything was written (connection closed) + * error-response the server answered with a JSON-RPC error (code, data kept) + * no-response the request was handed to the transport and no answer + * came back (timeout, or the connection closed while + * awaiting): it may or may not have reached the server + * Callers that act in the world (publishing speech, placing reactions) need + * the difference: only `not-sent` proves the server never saw the request. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { McplServerConnection, McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; + +// Stdio server: answers initialize; tools/call 'fail' gets a JSON-RPC error +// with data, 'stuck' gets nothing, 'exit' kills the process mid-request. +const SERVER = ` +let buf = ''; +process.stdin.on('data', (c) => { + buf += c.toString('utf8'); + let i; + while ((i = buf.indexOf('\\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + const out = (o) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, ...o }) + '\\n'); + if (m.method === 'initialize') out({ result: { capabilities: {} } }); + else if (m.method === 'tools/call' && m.params.name === 'fail') { + out({ error: { code: -32001, message: 'POSTED: part 1 of 2', data: { posted: ['111'] } } }); + } else if (m.method === 'tools/call' && m.params.name === 'exit') { + process.exit(3); + } + // 'stuck': deliberately no response. + } +}); +setInterval(() => {}, 1 << 30); +`; + +const HOST_CAPS: McplHostCapabilities = { version: '0.4', pushEvents: true, contextHooks: { beforeInference: true }, featureSets: true }; +const config = (overrides?: Partial): McplServerConfig => + ({ id: 'outcome', command: process.execPath, args: ['-e', SERVER], ...overrides }); + +let connection: McplServerConnection | null = null; +afterEach(async () => { + await connection?.close(); + connection = null; +}); + +const rejectsWith = async (promise: Promise, outcome: McplRequestError['outcome']): Promise => { + let caught: unknown; + await assert.rejects(promise, (err: unknown) => { caught = err; return true; }); + assert.ok(caught instanceof McplRequestError, `expected McplRequestError, got ${String(caught)}`); + assert.equal((caught as McplRequestError).outcome, outcome); + return caught as McplRequestError; +}; + +test('an error response is error-response, with its code and data kept', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'error-response'); + assert.equal(err.code, -32001); + assert.deepEqual(err.data, { posted: ['111'] }); + assert.match(err.message, /returned error for tools\/call: \[-32001\] POSTED: part 1 of 2/); +}); + +test('a request nobody answers is no-response, and the message is unchanged', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 200 }), HOST_CAPS); + const err = await rejectsWith(connection.sendToolsCall('stuck', {}), 'no-response'); + assert.match(err.message, /did not respond to tools\/call/); +}); + +test('a connection lost while awaiting is no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 10_000 }), HOST_CAPS); + await rejectsWith(connection.sendToolsCall('exit', {}), 'no-response'); +}); + +test('a request on a closed connection is not-sent', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + await connection.close(); + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'not-sent'); + assert.match(err.message, /Cannot send request: connection to "outcome" is closed/); + connection = null; +}); + +test('McplRequestError is an Error that keeps its message and name', () => { + const err = new McplRequestError('boom', 'not-sent'); + assert.ok(err instanceof Error); + assert.equal(err.name, 'McplRequestError'); + assert.equal(err.message, 'boom'); +}); From 2c591a7d9574a0ddfc3da57419ccae4138d4e45d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 06:01:35 -0400 Subject: [PATCH 06/65] feat(mcpl): one publish executor with structured delivery outcomes ChannelRegistry.publish(conversationId, text, {serverId?, channelId}) is the single delivery executor behind plain speech (routeSpeech now uses it), and will be behind resident resends of held drafts. It returns a PublishOutcome: - `delivered`: the connector returned `delivered: true` (with its messageId). A historical receipt, not a claim the message still exists. - `failed`: provably nothing posted. Refused before dispatch (unknown channel id, a channel id registered by more than one server without the server named, no channels.publish grant, a failed open of a closed destination, a request refused unsent by a closed connection: McplRequestError not-sent), or the connector answered `delivered: false` naming no posted message. - `unknown`: dispatched, and no valid confirmation came back: a JSON-RPC error response (which can follow a partial multi-part post, discord-mcpl #35), a timeout, a lost connection, `delivered: false` that names a posted message, or a missing/malformed receipt (#163's rule: a missing receipt is no longer counted as delivered). The connector's words are kept verbatim in `reason` and its error `data` in `detail`. Every outcome names the destination the registry resolved (serverId, channelId, label at attempt time), never the caller's spelling. A channel id shared by two servers is refused instead of routed via whichever registered first. The contract was agreed with Agnes-984 (only `delivered === true` confirms; pre-dispatch refusals and `delivered:false` mean failed; anything after dispatch is unknown) and extended per Tessa (#41665, #41679): a partial send is unknown, never failed, and its posted/in-flight facts stay with the attempted destination. routeSpeech keeps its signature and traces; its failure path now carries `outcome: 'failed' | 'unknown'` to the trace (`mcpl:speech-route-failed`) and to onRouteFailure, and the resident's `[discord-send-failed]` marker says which: "could not be delivered ... Nothing was posted" versus "was not confirmed ... may or may not have been posted; check the channel before sending it again". The marker metadata carries `outcome`. Tests: test/publish-outcome.test.ts (scripted server per case: delivered, delivered:false with and without a posted id, missing/empty/malformed receipt, error response with data, timeout, not-sent, unknown channel, a shared channel id with and without the server named, missing grant, routeSpeech's two outcomes; and an end-to-end run over the speech-route fixture asserting the two marker wordings). 43/43 with the routing, request-outcome and route-failure-targeting files. Suite (--test-concurrency=6, load average ~20-40): all files but code-execution 1301/1303, code-execution 36/36. The two failures are mcpl-coalescing timing tests that fail identically on an untouched 64c480b build in this environment: #196 vector 34c's wall-clock bound, and R17's 40 ms storage-outage backoff, which counted a fourth recovery wake; running the whole coalescing file on 64c480b reproduced the same R17 failure. Co-Authored-By: Claude Opus 5.5 --- changelog.d/publish-outcomes.fixed.md | 16 ++ src/framework.ts | 10 +- src/mcpl/channel-registry.ts | 299 ++++++++++++++++++-------- src/types/trace.ts | 3 + test/publish-outcome.test.ts | 213 ++++++++++++++++++ 5 files changed, 454 insertions(+), 87 deletions(-) create mode 100644 changelog.d/publish-outcomes.fixed.md create mode 100644 test/publish-outcome.test.ts diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md new file mode 100644 index 000000000..62eb206a7 --- /dev/null +++ b/changelog.d/publish-outcomes.fixed.md @@ -0,0 +1,16 @@ +- **Speech delivery reports what a publish actually established.** Plain + speech now goes through one publish executor (`ChannelRegistry.publish`, + returning a `PublishOutcome`: `delivered`, `failed` or `unknown`, with the + destination the registry resolved). Only a connector's `delivered: true` + confirms a post; a missing or malformed receipt is no longer counted as + delivered (#163's rule). `failed` means nothing was posted: refused before + dispatch, or `delivered: false` naming no posted message. An error response, + a timeout or a lost connection after dispatch is `unknown`, keeping the + connector's own words (a multi-part send may already be partly posted). + The resident's `[discord-send-failed]` marker now says which: "could not be + delivered … Nothing was posted" for a failure, and "was not confirmed … may + or may not have been posted; check the channel before sending it again" + for an uncertain outcome. The marker's metadata and the + `mcpl:speech-route-failed` trace carry `outcome`. A channel id registered + by more than one MCPL server is refused rather than routed through + whichever server registered it first. diff --git a/src/framework.ts b/src/framework.ts index b6f771746..931af6bff 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13286,7 +13286,7 @@ export class AgentFramework { // addMessage() alone does not request inference, so this never wakes // her (matching the `discord-send-failed-skip` gate intent: context // yes, wake no). - onRouteFailure: ({ channelId, reason, textLen }) => { + onRouteFailure: ({ channelId, reason, textLen, outcome }) => { try { // Render a human-readable channel name when we can — a bare // snowflake in the marker is unresolvable for the agent (the @@ -13305,9 +13305,13 @@ export class AgentFramework { 'user', [{ type: 'text', - text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). It was saved to your archive but the human did not receive it.`, + text: outcome === 'unknown' + // A request went out and no valid receipt came back: it may + // have been posted (in part or whole). Never claim it wasn't. + ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the channel before sending it again. It is saved in your archive.` + : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason }, + { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason, ...(outcome ? { outcome } : {}) }, ); } catch (err) { console.error('onRouteFailure: failed to record send-failure marker:', err); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 34912447b..8333c860a 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -40,6 +40,7 @@ import { expandCoreTags } from './tags.js'; import { validateCoalescedContent } from './push-coalescer.js'; import { CapabilityGrant } from './capability-grant.js'; import type { InboundSource } from './inbound-source.js'; +import { McplRequestError } from './server-connection.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -458,6 +459,41 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // Constructor Options // ============================================================================ +/** Where a publish was resolved to go — named by the registry, never by + * the caller's spelling. */ +export interface PublishDestination { + serverId: string; + channelId: string; + /** The channel's registered label when the attempt was made. */ + label?: string; +} + +/** + * What one channels/publish attempt established (ChannelRegistry.publish). + * - `delivered`: the connector returned `delivered: true`. A historical + * receipt (confirmed at `at`, to `destination`, message `messageId` when + * the connector named one), not a claim the message still exists. + * - `failed`: nothing was posted: the request never left the host + * (unresolvable or shared channel id, missing grant, failed open, closed + * connection), or the connector answered `delivered: false`. + * - `unknown`: the request was dispatched and no valid receipt came back (an + * error response, a timeout, a lost connection, or a missing or malformed + * receipt). It may or may not have been posted. + */ +export interface PublishOutcome { + status: 'delivered' | 'failed' | 'unknown'; + /** Absent only when no destination could be resolved. */ + destination?: PublishDestination; + messageId?: string; + /** Why it failed, or why its outcome is unknown — for a connector error, + * its message verbatim (which may say what was already posted). */ + reason?: string; + /** The connector error's structured `data`, verbatim, when it sent one. */ + detail?: unknown; + /** When the outcome was established (epoch ms). */ + at: number; +} + interface ChannelRegistryOptions { /** * An ordinary (uncoalesced) `channels/incoming` message has just been @@ -494,6 +530,9 @@ interface ChannelRegistryOptions { channelId: string | null; reason: string; textLen: number; + /** `failed`: nothing was posted; `unknown`: the request was dispatched + * and no valid receipt came back, so it may or may not have been. */ + outcome?: 'failed' | 'unknown'; }) => void; /** * Called when channels were opened WITHOUT the agent asking (subscription @@ -569,12 +608,7 @@ export class ChannelRegistry { op?: 'start' | 'stop', ) => void; private shouldTriggerInference?: (content: string, metadata: Record) => boolean; - private onRouteFailure?: (info: { - conversationId: string; - channelId: string | null; - reason: string; - textLen: number; - }) => void; + private onRouteFailure?: ChannelRegistryOptions['onRouteFailure']; private onChannelAutoOpened?: (info: { conversationId?: string; serverId: string; @@ -2968,6 +3002,161 @@ export class ChannelRegistry { return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; } + /** + * Publish `text` to one registered channel and report what the attempt + * established (PublishOutcome). The single delivery executor behind plain + * speech (routeSpeech), resident resends of held drafts, and channel tools. + * + * Resolution is exact: `serverId` plus `channelId` when the caller has the + * server, otherwise the channel id must be registered by exactly one server + * (a shared id is refused, never routed through whichever server came + * first). It is not possible to send into a closed channel: a closed but + * registered destination is opened first (`openSource` names why), and a + * failed open sends nothing. + * + * Only the connector's `delivered: true` confirms a post. Anything that + * stops before the request is written is `failed` (nothing left the host), + * as is the connector's own `delivered: false`. Once the request has been + * dispatched, an error response, a timeout, a lost connection, or a missing + * or malformed receipt is `unknown`: the post may or may not exist, and the + * caller must not treat it as safe to retry blindly. + */ + async publish( + conversationId: string, + text: string, + target: { serverId?: string; channelId: string }, + openSource: 'opened-by-delivery' | 'opened-by-reply' = 'opened-by-delivery', + ): Promise { + const at = (): number => Date.now(); + const matches = [...this.channels.values()].filter( + (e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId), + ); + if (matches.length === 0) { + return { + status: 'failed', + reason: target.serverId + ? `no registered channel "${target.channelId}" on server "${target.serverId}"` + : `no registered channel "${target.channelId}"`, + at: at(), + }; + } + if (matches.length > 1) { + return { + status: 'failed', + reason: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`, + at: at(), + }; + } + const entry = matches[0]!; + const destination: PublishDestination = { + serverId: entry.serverId, + channelId: entry.descriptor.id, + ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}), + }; + + if (!entry.open) { + try { + await this.openChannelNow(entry, openSource); + this.emitTraceFn({ + type: 'mcpl:channel-opened-by-send', + serverId: entry.serverId, + channelId: entry.descriptor.id, + }); + if (openSource === 'opened-by-delivery') { + try { + this.onChannelAutoOpened?.({ + conversationId, + serverId: entry.serverId, + source: 'opened-by-delivery', + channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }], + }); + } catch (err) { + console.error('onChannelAutoOpened (delivery) failed:', err); + } + } + } catch (err) { + return { + status: 'failed', + destination, + reason: `channel is closed and open failed: ${(err as Error).message}`, + at: at(), + }; + } + } + + const server = this.serverRegistry.getServer(entry.serverId); + if (!server) { + return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() }; + } + // §14.1: channels/publish requires channels.publish in the grant. The + // host not sending is the enforcement. + if (!CapabilityGrant.of(server).has('channels.publish')) { + return { + status: 'failed', + destination, + reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`, + at: at(), + }; + } + + let result: unknown; + try { + result = await server.sendChannelsPublish({ + conversationId, + channelId: entry.descriptor.id, + content: [{ type: 'text', text }], + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Only a request that never left the host proves nothing was posted. + // An error response may follow a partial post (a multi-part send whose + // later part failed), so it is unknown, with the connector's own words + // (and data) kept for the resident to judge. + const notSent = err instanceof McplRequestError && err.outcome === 'not-sent'; + const detail = err instanceof McplRequestError ? err.data : undefined; + return { + status: notSent ? 'failed' : 'unknown', + destination, + reason: notSent ? message : `${message} (delivery uncertain)`, + ...(detail !== undefined ? { detail } : {}), + at: at(), + }; + } + const receipt = (result ?? {}) as { delivered?: unknown; messageId?: unknown }; + const messageId = typeof receipt.messageId === 'string' && receipt.messageId ? receipt.messageId : undefined; + if (receipt.delivered === true) { + return { status: 'delivered', destination, ...(messageId ? { messageId } : {}), at: at() }; + } + if (receipt.delivered === false) { + // `delivered: false` proves nothing went out only when the receipt + // names no posted message; a named one means something did. + return messageId + ? { + status: 'unknown', + destination, + messageId, + reason: `server "${entry.serverId}" reported delivered:false but named posted message ${messageId} (delivery uncertain)`, + at: at(), + } + : { + status: 'failed', + destination, + reason: `server "${entry.serverId}" reported delivered:false`, + at: at(), + }; + } + // Final publish is a request, so success requires an explicit receipt; a + // missing or malformed one leaves delivery uncertain (anima-research/ + // agent-framework#163's rule). + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: `server "${entry.serverId}" returned no valid delivery receipt (delivery uncertain)`, + at: at(), + }; + } + async routeSpeech( conversationId: string, text: string, @@ -2981,18 +3170,20 @@ export class ChannelRegistry { locusChannelId: string | null, ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { // Surface a routing failure: emit a trace AND notify the host (which drops - // a `[discord-send-failed]` marker into chronicle) so the agent learns her - // reply never reached the human, instead of it vanishing silently. - const fail = (channelId: string | null, reason: string): null => { - console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`); + // a `[discord-send-failed]` marker into chronicle) so the agent learns its + // reply did not (or may not have) reached anyone, instead of it vanishing + // silently. + const fail = (channelId: string | null, reason: string, outcome: 'failed' | 'unknown' = 'failed'): null => { + console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${outcome === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, channelId: channelId ?? '', reason, textLen: text.length, + outcome, }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length }); + this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome }); return null; }; @@ -3002,95 +3193,35 @@ export class ChannelRegistry { if (locusChannelId === undefined) { return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)'); } - const channelId = locusChannelId; - if (!channelId) { + if (!locusChannelId) { // The turn froze with no locus (no home, no triggering channel, no // global inbound ever seen) — the agent was told its prose stays in // the archive; honor that. return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)'); } - const entry = this.findChannelEntry(channelId); - if (!entry) { - return fail(channelId, `no registered channel for locus "${channelId}"`); + const outcome = await this.publish(conversationId, text, { channelId: locusChannelId }); + const channelId = outcome.destination?.channelId ?? locusChannelId; + if (outcome.status !== 'delivered') { + return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome.status); } - // INVARIANT: it is not possible to send into a closed channel. Speech - // routed into a closed-but-registered locus (the canonical case: a DM - // that woke the agent — DMs register closed) OPENS the channel first, - // so typing indicators, reaction machinery, and inbound forwarding come - // alive with the reply. If the open fails, the speech does NOT go out: - // a half-alive delivery is worse than a loud marker. - if (!entry.open) { - try { - await this.openChannelNow(entry, 'opened-by-delivery'); - console.error( - `[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`, - ); - this.emitTraceFn({ - type: 'mcpl:channel-opened-by-send', - serverId: entry.serverId, - channelId, - }); - try { - this.onChannelAutoOpened?.({ - conversationId, - serverId: entry.serverId, - source: 'opened-by-delivery', - channels: [{ channelId, label: entry.descriptor.label }], - }); - } catch (err) { - console.error('onChannelAutoOpened (delivery) failed:', err); - } - } catch (err) { - return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`); - } - } - - const server = this.serverRegistry.getServer(entry.serverId); - if (!server) { - return fail(channelId, `server "${entry.serverId}" not found`); - } - // §14.1: channels/publish requires channels.publish in the grant. The - // host not sending is the enforcement — a send to an ungranted server - // would have it act on authority it was never told it has. - if (!CapabilityGrant.of(server).has('channels.publish')) { - return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`); - } - - const publishParams: ChannelsPublishParams = { - conversationId, - channelId, - content: [{ type: 'text', text }], - }; - const result = await server.sendChannelsPublish(publishParams); - const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true; - // Surface the posted message's id (ChannelsPublishResult.messageId) so - // trace consumers can act on the just-posted message — e.g. a TTS-relay - // tap editing it down to the words actually voiced on interruption. - // Previously this was silently dropped here. - const messageId = (result as { messageId?: string } | undefined)?.messageId; - - // The server accepted the publish RPC but reported the message was not - // actually delivered (e.g. missing Send Messages permission). Previously - // this returned `{ delivered: true }`, masking the failure. Surface it. - if (delivered === false) { - return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`); - } - - console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`); + console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${outcome.destination!.serverId}, delivered=true)`); this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId, - serverId: entry.serverId, + serverId: outcome.destination!.serverId, channelId, - delivered, + delivered: true, textLen: text.length, text, - ...(messageId !== undefined ? { messageId } : {}), + // Surface the posted message's id (ChannelsPublishResult.messageId) so + // trace consumers can act on the just-posted message — e.g. a TTS-relay + // tap editing it down to the words actually voiced on interruption. + ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}), }); - return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) }; + return { delivered: true, channelId, ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}) }; } private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise { diff --git a/src/types/trace.ts b/src/types/trace.ts index c5c3064f5..e14ab7088 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -369,6 +369,9 @@ export type TraceEvent = channelId: string; reason: string; textLen: number; + /** `failed`: nothing was posted; `unknown`: the request was dispatched + * and no valid receipt came back, so it may have been. */ + outcome?: 'failed' | 'unknown'; }) // Admin puppet: an operator executed a tool AS an agent and stored the diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts new file mode 100644 index 000000000..b9bb16d3a --- /dev/null +++ b/test/publish-outcome.test.ts @@ -0,0 +1,213 @@ +/** + * ChannelRegistry.publish(): what one channels/publish attempt establishes. + * + * Only the connector's `delivered: true` confirms a post. `failed` is reserved + * for proof that nothing went out (refused before dispatch, or a connector + * `delivered: false` naming no posted message). Anything after dispatch that + * is not a valid confirmation is `unknown`: an error response (which may + * follow a partial multi-part post), a timeout, a lost connection, or a + * missing/malformed receipt. The attempted destination is always named by + * the registry, never by the caller's spelling. + */ +import { test, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +type Publish = (params: { channelId: string }) => Promise; + +function registryWith(servers: Record, failures: unknown[] = []) { + const published: Array<{ serverId: string; channelId: string }> = []; + const mocks = new Map(); + for (const [id, s] of Object.entries(servers)) { + mocks.set(id, { + id, + grant: new CapabilityGrant(new Set(s.grant === false ? [] : ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (params: { channelId: string }) => { + published.push({ serverId: id, channelId: params.channelId }); + return s.publish(params); + }, + sendChannelsOpen: async () => ({}), + }); + } + const registry = new ChannelRegistry( + { getServer: (id: string) => mocks.get(id) } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { onRouteFailure: (info) => { failures.push(info); } }, + ); + const channels = (registry as unknown as { + channels: Map; + }).channels; + const seed = (serverId: string, id: string, label: string) => + channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open: true }); + return { registry, published, seed }; +} + +const ok: Publish = async () => ({ delivered: true, messageId: 'posted-1' }); + +describe('ChannelRegistry.publish outcomes', () => { + it('confirms only delivered:true, naming the destination from the registry', async () => { + const { registry, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'discord:g1:room', '#room (Guild)'); + const outcome = await registry.publish('agent', 'hello', { channelId: 'discord:g1:room' }); + assert.equal(outcome.status, 'delivered'); + assert.equal(outcome.messageId, 'posted-1'); + assert.deepEqual(outcome.destination, { serverId: 'discord', channelId: 'discord:g1:room', label: '#room (Guild)' }); + assert.equal(typeof outcome.at, 'number'); + }); + + it('treats delivered:false with no posted message as failed', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + }); + + it('treats delivered:false that names a posted message as unknown', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, messageId: 'part-1' }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.equal(outcome.messageId, 'part-1'); + }); + + for (const [what, receipt] of [['a missing receipt', undefined], ['an empty receipt', {}], ['a malformed delivered field', { delivered: 'yes' }]] as const) { + it(`treats ${what} as unknown, never as delivered (#163's rule)`, async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => receipt } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.match(outcome.reason ?? '', /delivery uncertain/); + }); + } + + it('treats an error response as unknown, keeping the connector\'s words and data (a partial post may precede it)', async () => { + const partial = 'Send partially completed. POSTED: part 1 of 2 (message id 111). IN FLIGHT: part 2 of 2.'; + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError(`MCPL server "discord" returned error for channels/publish: [-32000] ${partial}`, 'error-response', -32000, { posted: ['111'] }); } }, + }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.ok(outcome.reason?.includes(partial), 'the connector\'s account is kept verbatim'); + assert.deepEqual(outcome.detail, { posted: ['111'] }); + assert.equal(outcome.destination?.channelId, 'c'); + }); + + it('treats a timeout or a lost connection as unknown', async () => { + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError('MCPL server "discord" did not respond to channels/publish (id=7) within 10ms', 'no-response'); } }, + }); + seed('discord', 'c', '#c'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'unknown'); + }); + + it('treats a request refused before it was written as failed', async () => { + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError('Cannot send request: connection to "discord" is closed', 'not-sent'); } }, + }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason ?? '', /connection to "discord" is closed/); + }); + + it('fails without a destination for an unknown channel', async () => { + const { registry, published } = registryWith({ discord: { publish: ok } }); + const outcome = await registry.publish('agent', 'x', { channelId: 'nowhere' }); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.destination, undefined); + assert.equal(published.length, 0); + }); + + it('refuses a channel id two servers share unless the server is named, and then uses exactly that server', async () => { + const { registry, published, seed } = registryWith({ alpha: { publish: ok }, beta: { publish: ok } }); + seed('alpha', 'discord:g1:room', '#room (Alpha)'); + seed('beta', 'discord:g1:room', '#room (Beta)'); + const shared = await registry.publish('agent', 'x', { channelId: 'discord:g1:room' }); + assert.equal(shared.status, 'failed'); + assert.match(shared.reason ?? '', /more than one MCPL server/); + assert.equal(published.length, 0, 'never routed through whichever server came first'); + const exact = await registry.publish('agent', 'x', { serverId: 'beta', channelId: 'discord:g1:room' }); + assert.equal(exact.status, 'delivered'); + assert.deepEqual(published, [{ serverId: 'beta', channelId: 'discord:g1:room' }]); + assert.equal(exact.destination?.label, '#room (Beta)'); + }); + + it('fails before dispatch without channels.publish in the grant', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok, grant: false } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.equal(published.length, 0); + }); + + it('routeSpeech reports a failed and an uncertain delivery as different outcomes', async () => { + const failures: Array<{ outcome?: string }> = []; + const { registry, seed } = registryWith({ discord: { publish: async () => ({}) }, other: { publish: async () => ({ delivered: false }) } }, failures); + seed('discord', 'c', '#c'); + seed('other', 'd', '#d'); + assert.equal(await registry.routeSpeech('agent', 'x', 'c'), null); + assert.equal(await registry.routeSpeech('agent', 'x', 'd'), null); + assert.deepEqual(failures.map((f) => f.outcome), ['unknown', 'failed']); + }); +}); + +test('the resident\'s failure marker never claims an uncertain delivery did not happen', async () => { + const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-')); + const commandPath = join(dir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { STATUS_PATH: join(dir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + try { + await framework.start(); + const registry = (framework as unknown as { channelRegistry: { listChannelsRaw(): unknown[] } }).channelRegistry; + const until = async (cond: () => boolean, what: string) => { + const deadline = Date.now() + 15_000; + while (!cond()) { if (Date.now() > deadline) throw new Error(`timed out: ${what}`); await new Promise((r) => setTimeout(r, 20)); } + }; + await until(() => registry.listChannelsRaw().length >= 2, 'registration'); + const markers = () => framework.getAgent('scout')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'discord-send-failed') + .map((m) => (m.content[0] as { text: string }).text); + const turn = async (mode: string, n: number) => { + appendFileSync(commandPath, JSON.stringify({ op: 'publish-mode', mode }) + '\n'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `reply ${n}` }])); + appendFileSync(commandPath, JSON.stringify({ op: 'incoming', channelId: 'discord:g1:room', messageId: `m-${n}`, mode: 'addressed', text: 'hello?' }) + '\n'); + await until(() => markers().length === n, `marker ${n}`); + await framework.runUntilIdle(); + }; + await turn('no-receipt', 1); + await turn('not-delivered', 2); + const [uncertain, failed] = markers(); + assert.match(uncertain!, /was not confirmed/); + assert.match(uncertain!, /may or may not have been posted/); + assert.doesNotMatch(uncertain!, /did not receive|Nothing was posted/); + assert.match(failed!, /could not be delivered/); + assert.match(failed!, /Nothing was posted/); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); From 7a9106685c595e121711e6584443647d9b38d226 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 07:58:31 -0400 Subject: [PATCH 07/65] feat(drafts): hold suppressed plain speech as resendable drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shelf-351 (room-203). Suppressed plain speech used to survive only as a count in the turn receipt ("N segment(s) suppressed ... resend with a send tool"), and explicit mode's bounced text lived in one in-memory latest-wins clipboard: the suppression rule was right, the penalty was amnesia (a Connectome resident's report). Now the exact words are kept as drafts until the resident resends or dismisses them. What becomes a draft (src/prose-drafts.ts, reasons): - explicit-send: prose silenced by an explicit send in the same round or a sticky earlier one (channel_publish, send_message, reply_message, send_dm, say, whisper, and now a drafts resend), in locus and hybrid modes, mid-turn, trailing and text-only (a guard retry carrying the silence), native and XML tool modes. The turn tracks WHY it is silenced (silenceCause): an explicit send holds prose as drafts; skip_reply and silent turns keep it private. Hybrid prose is split into envelopes and a draft holds an envelope's body, not its routing syntax, with "written for >>>target" as a note. - no-destination: hybrid prose after a failed envelope (fail-closed until a new valid target), formerly counted as suppressed. - bounced: explicit/hybrid bounces. `{{unsent}}` keeps meaning the latest bounce while that draft is open; delivering through it journals an attempt on the draft; `>>skip_reply {{unsent}}` dismisses it; an unconfirmed latest bounce is never substituted silently. Re-bouncing `{{unsent}}` alone keeps the one draft. Never drafted (counted in the receipt): skip_reply, a same-round private think, silent turns, proseRouting=disabled narration. Storage: a RecordJournal (typed Chronicle records that do not follow branch switches), agreed in room-220 instead of the JSONL file first proposed to Tessa/Ada/June; the contract is the same. Entries held / attempt / outcome / dismissed / noticed, every one durable before its call returns; an attempt is journaled BEFORE its request leaves AF, so an attempt without an outcome reads as unconfirmed, never unsent. Undo, rollback, checkout and restarts never change drafts; nothing is ever sent from them on its own. A failed journal write reconciles (load) and reports the words it could not hold. Notices: a private notice naming new drafts at the next tool boundary when the live stream presents injections (native rounds; Membrane's supportsInjectedMessages when it ships, room-220 #41701/#41870); otherwise the turn-end [delivered] receipt names them ("held as drafts d-…"); drafts no notice reached (crash, aborted turn) are named at the next fresh turn's start. Notices name their resident: residents of one framework share a message slot (#197). The bounce notice now goes to the bouncing resident (forAgent) instead of the primary. The drafts tool (with MCPL channels): list | read | resend | dismiss, scoped to the caller; nullable unused fields (save_recent_image's pattern). resend publishes verbatim, in order, through ChannelRegistry.publish, stops at the first non-delivery, counts as an explicit send, marks the channel engaged, returns a delivered draft's historical receipt instead of resending, needs confirmDuplicate for an unconfirmed one, refuses dismissed drafts and a draft already in flight, and honours a conversation agent's home binding. Results are plain text blocks, so drafts read exactly as written. Registry: deliverSpeech (routeSpeech's PublishOutcome form; routeSpeech wraps it unchanged) and resolveDestination (exact resolution, shared ids refused). Text-only routing goes segment by segment when the content holds a membrane tool_attempt (Greta's 376, room-220 #43515). Tests: test/prose-drafts.test.ts, 18 (store: order/ids/isolation, reopen with an outcome-less attempt, branch independence, journal failure, latest-bounce; end to end over the speech-route fixture: three-segment reply held/named/injected/read/resent verbatim, XML mode, skip_reply and private think, disabled mode, resident scoping, failed then unknown then confirmed resend, June's rollback and restart cases and dismissal across a rollback, crash catch-up, explicit bounce across restart via {{unsent}}, resend silencing adjacent prose, parallel resend, refusals). Receipt expectations updated in present-while-acting and explicit-prose-routing; the explicit stub gains deliverSpeech/resolveDestination; the fixture answers tools/call at once. Suite (--test-concurrency=4, load ~20-30): 1315 pass, 1 fail (#196 vector 34c's wall-clock bound) and mcpl-coalescing.test.js cut off by the 900 s guard; that file alone afterwards: 66/66. code-execution alone: 36/36. Co-Authored-By: Claude Opus 5.5 --- changelog.d/prose-drafts.added.md | 27 + docs/explicit-prose-routing.md | 23 +- src/framework.ts | 971 ++++++++++++++++++--- src/mcpl/channel-registry.ts | 99 ++- src/prose-drafts.ts | 314 +++++++ src/types/trace.ts | 24 + test/explicit-prose-routing.test.ts | 18 +- test/fixtures/speech-route-mcpl-server.mjs | 5 + test/present-while-acting.test.ts | 13 +- test/prose-drafts.test.ts | 603 +++++++++++++ 10 files changed, 1950 insertions(+), 147 deletions(-) create mode 100644 changelog.d/prose-drafts.added.md create mode 100644 src/prose-drafts.ts create mode 100644 test/prose-drafts.test.ts diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md new file mode 100644 index 000000000..a115c52fe --- /dev/null +++ b/changelog.d/prose-drafts.added.md @@ -0,0 +1,27 @@ +- **Held prose drafts: suppressed speech is kept, not destroyed.** Plain + speech that is not sent is now held as a private draft with its exact + words, instead of surviving only as a count: speech suppressed by an + explicit send in the same round (locus and hybrid modes, mid-turn, trailing + and text-only, native and XML tool modes), hybrid prose left without a + destination after a failed envelope, and explicit/hybrid bounces (which + replace the in-memory latest-wins clipboard; `{{unsent}}` keeps meaning the + latest bounce). Deliberate privacy is never drafted: `skip_reply`, a + same-round private `think`, silent turns and `proseRouting: disabled` + narration stay private and are counted in the receipt. +- Drafts live in typed Chronicle records (`RecordJournal`) that do not follow + branch switches: undo, rollback and checkout never change them, and they + survive restarts. Nothing is ever sent from them on its own. +- A private notice names held drafts at the next tool boundary when the live + stream presents mid-turn messages, and the turn-end `[delivered]` receipt + names every draft held in the turn ("held as drafts d-…"); a draft no notice + reached (a crash, an aborted turn) is named at the next turn's start. +- New `drafts` tool (whenever MCPL channels are configured): `list`, `read`, + `resend` and `dismiss`, scoped to the calling resident. `resend` publishes + drafts verbatim, in order, to one destination through the normal publish + path, journaling each attempt durably before the request leaves; it counts + as an explicit send. A delivered draft returns its historical receipt + instead of sending again; a draft whose last attempt's outcome is unknown + needs `confirmDuplicate: true`. Unused fields may be passed as null. +- `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and + `ChannelRegistry.resolveDestination` are new; routeSpeech is unchanged. +- The bounce notice now goes to the bouncing resident rather than the primary. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 3f78c6ff4..1c428316d 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -30,15 +30,26 @@ A prose segment's FIRST line may carry a routing prefix: "keep going" ability tool turns have — wanted for robotics-like loops where an end-of-turn pause is undesirable. - `{{unsent}}` anywhere in the body is replaced at delivery with the - retained undelivered text (see below), enabling verbatim resend without retyping. + latest bounced text, held as a draft (see below), enabling verbatim resend + without retyping. ## The bounce (unprefixed prose) -Unprefixed prose with no sticky target is **never delivered**. It is retained -per-agent (latest-wins) and a system notice is appended telling the -agent how to resend: `>>#channel {{unsent}}`. The notice requests inference so -the resend can happen immediately — capped at 2 consecutive bounce-wakes per -agent (then notices append without waking, breaking any loop). +Unprefixed prose with no sticky target is **never delivered**. It is held as a +private draft (src/prose-drafts.ts), and a system notice naming the draft tells +the agent how to resend: `>>#channel {{unsent}}`, or the `drafts` tool by id. +The notice requests inference so the resend can happen immediately — capped at +2 consecutive bounce-wakes per agent (then notices append without waking, +breaking any loop). + +`{{unsent}}` means the latest bounce while that draft is open (latest-wins, as +the clipboard it replaced was), and `>>skip_reply {{unsent}}` dismisses it. +Earlier bounces stay in the agent's drafts, by id, until resent or dismissed; +drafts survive restarts and branch moves, and a delivery through `{{unsent}}` +is recorded on the draft like any resend. When the latest bounce's last +delivery attempt may already have been posted (an unknown outcome), +`{{unsent}}` is not substituted: the agent is told, and can resend it with the +`drafts` tool's `confirmDuplicate`. ## What explicit mode retires (for that agent) diff --git a/src/framework.ts b/src/framework.ts index 931af6bff..4fdab2234 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -81,7 +81,8 @@ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.j 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 { ChannelRegistry, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; +import { ProseDraftStore, draftState, type Draft, type DraftReason, type DraftSource } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -175,6 +176,38 @@ const isSilencingTool = (name: string): boolean => { return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare); }; +/** True when a model-issued call silences the round's auto-routed prose: a + * silencing tool by name, or the resident's `drafts` resend (an explicit + * send, though inspecting drafts is not). */ +const isSilencingCall = (call: { name: string; input?: unknown }): boolean => { + if (isSilencingTool(call.name)) return true; + return bareToolName(call.name) === 'drafts' + && (call.input as { action?: unknown } | null | undefined)?.action === 'resend'; +}; + +/** Hybrid prose envelopes: every line starting with `>>>` opens a new one. + * Leading whitespace before a `>>>` is dropped; blank envelopes are skipped. */ +const splitHybridEnvelopes = (rawText: string): string[] => { + const envelopes: string[] = []; + let current: string[] = []; + for (const line of rawText.split('\n')) { + if (line.trimStart().startsWith('>>>') && current.length > 0) { + envelopes.push(current.join('\n')); + current = []; + } + current.push(line); + } + if (current.length > 0) envelopes.push(current.join('\n')); + return envelopes.filter((e) => e.trim()).map((e) => e.replace(/^\s+(?=>>>)/, '')); +}; + +/** Why a round's calls silence its prose: `skip_reply` deliberately keeps it + * private; an explicit send holds it back as resendable drafts. */ +const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send' | 'private' | null => { + if (calls.some((c) => bareToolName(c.name) === 'skip_reply')) return 'private'; + return calls.some(isSilencingCall) ? 'send' : null; +}; + /** * True when an injected message is real conversational input — something the * agent might actually be replying to — rather than ambient machinery. @@ -313,9 +346,11 @@ const PROSE_ROUTING_HELP = ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' + ' immediately when this one ends, instead of pausing until the next event.\n' + ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' + - 'Text without a destination is not delivered: it is retained, and a notice will\n' + - 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' + - 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.'; + 'Text without a destination is not delivered: it is held as a draft, and a notice\n' + + 'names it — reply e.g. ">>#channel {{unsent}}" to deliver your latest held text\n' + + 'unchanged, or use the drafts tool to list, resend or dismiss any draft by id.\n' + + '">>skip_reply {{unsent}}" sets the latest one aside. Send tools (send_message,\n' + + 'send_dm, …) are unaffected.'; const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = { name: 'prose_help', @@ -1079,14 +1114,28 @@ export class AgentFramework { * first turn's locus is announced once to re-establish the baseline. */ private lastAnnouncedLocus: Map = new Map(); + // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- + /** Every resident's held drafts: suppressed, bounced or ambiguous plain + * speech, kept privately until resent or dismissed. */ + private proseDrafts: ProseDraftStore; + /** Drafts held during the current logical turn, for its `[delivered]` + * receipt. Cleared each fresh turn; restarts keep it. */ + private turnDrafts: Map = new Map(); + /** Plain-speech segments this turn kept private by the resident's own + * choice (skip_reply in the same round) — counted, never drafted. */ + private turnProsePrivate: Map = new Map(); + /** Segments that should have been held as drafts but could not be + * journaled (the words remain in the resident's history only). */ + private turnDraftFailures: Map = new Map(); + // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- - /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */ - private proseClipboards: Map = new Map(); /** Sticky per-TURN delivery target set by the turn's first `>>` prefix. * Cleared at every non-restart turn start; survives context restarts. */ private proseTargetPins: Map = new Map(); - /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */ - private proseHybridSuppressed: Set = new Set(); + /** Hybrid router is fail-closed after a `>>>skip_reply` envelope (the + * rest is private) or a malformed/unresolved/undelivered one (the rest is + * held as drafts, having no destination) until a new valid target. */ + private proseHybridSuppressed: Map = new Map(); /** Agents whose current turn requested `!` continuation — re-woken when the * turn completes instead of pausing until the next external event. */ private proseContinuations: Set = new Set(); @@ -1393,8 +1442,8 @@ export class AgentFramework { // Session-level token usage tracking (always-on) private usageTracker: UsageTracker; - /** Explicit-send suppression carried into a tool-result-guard retry. */ - private guardRetryTurnSilenced = new Map(); + /** Explicit-send or private suppression carried into a tool-result-guard retry. */ + private guardRetryTurnSilenced = new Map(); /** Presentation-only wall-clock zone; persistence remains UTC/epoch. */ private readonly timeZone: string; @@ -1417,6 +1466,9 @@ export class AgentFramework { this.operatorLog = operatorLog; this.store = store; this.ownsStore = ownsStore; + // Held prose drafts live in typed Chronicle records that do not follow + // branch switches (RecordJournal): replayed here, never re-sent. + this.proseDrafts = new ProseDraftStore(store); this.membrane = membrane; this.inferencePolicy = inferencePolicy; this.errorPolicy = errorPolicy; @@ -2450,6 +2502,9 @@ export class AgentFramework { // Copy: getChannelTools() returns the registry's shared definitions // array; pushing onto it would append another tune_out on every call. const channelTools = [...(this.channelRegistry?.getChannelTools() ?? [])]; + if (this.channelRegistry) { + channelTools.push(AgentFramework.DRAFTS_TOOL); + } if (this.tuneOutCoordinator) { channelTools.push(AgentFramework.TUNE_OUT_TOOL); } @@ -4595,6 +4650,51 @@ export class AgentFramework { inputSchema: { type: 'object' }, }; + /** Synthesized drafts tool — present whenever MCPL channels are + * configured. A resident's held drafts (src/prose-drafts.ts): plain speech + * that was not sent, kept privately until resent or dismissed. Nullable + * fields, cast like save_recent_image's: a caller whose provider presents + * every property as required can still say "not in use". */ + private static readonly DRAFTS_TOOL = { + name: 'drafts', + description: + 'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in the ' + + 'same round holds plain speech back, because a routing prefix bounced, or because it had no destination. ' + + 'Drafts are private to you and stay until you resend or dismiss them; nothing is ever sent from here on ' + + 'its own. list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + + 'resend: publish drafts verbatim, in the order given, to one destination ("#channel", "@person" or a ' + + 'channel id) — an explicit send, like send_message; it stops at the first draft not confirmed delivered. ' + + 'A draft already delivered returns its receipt instead of sending again. If an earlier attempt may ' + + 'already have been posted (its outcome is unknown), resend needs confirmDuplicate: true. dismiss: set ' + + 'drafts aside (your history keeps the words). A field you are not using may be omitted or passed as null.', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['list', 'read', 'resend', 'dismiss'], description: 'What to do.' }, + draftIds: { + type: ['array', 'null'], + items: { type: 'string' }, + description: 'read, resend, dismiss: draft ids such as "d-k7x3q", in order. null for list.', + }, + destination: { + type: ['string', 'null'], + description: 'resend: where to publish — "#channel", "@person" or a channel id. null otherwise.', + }, + confirmDuplicate: { + type: ['boolean', 'null'], + description: + 'resend: true to send a draft again although an earlier attempt may already have been posted. ' + + 'null or false otherwise.', + }, + offset: { + type: ['integer', 'null'], + description: 'list: how many of the newest drafts to skip. null, like omitting it, means 0.', + }, + }, + required: ['action'], + }, + } as unknown as import('./types/index.js').ToolDefinition; + /** Synthesized save_image tool — present when a workspace module is * registered. Lets the agent persist an image it has already seen in its * own context (Discord attachments arrive inlined as base64 and are @@ -8680,12 +8780,468 @@ export class AgentFramework { } } - /** Record prose segments suppressed by explicit-send silencing. */ + /** Record prose segments suppressed without drafts (proseRouting=disabled). */ private recordProseSuppression(agentName: string, count: number): void { if (count <= 0) return; this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count); } + /** + * Silenced plain speech: an explicit send holds it as resendable drafts; a + * deliberate silence (skip_reply, a silent turn, a same-round private + * think) keeps it private — counted for the receipt, never drafted. + */ + private holdSilencedProse( + agent: Agent, + segments: string[], + cause: 'send' | 'private' | null, + privateThink: boolean, + opts: { round?: number; notice: 'now' | 'later' }, + ): void { + if (segments.length === 0) return; + if (cause === 'private' || privateThink) { + this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + segments.length); + return; + } + if (agent.proseRouting !== 'hybrid') { + this.holdProseDrafts(agent, segments, 'explicit-send', opts); + return; + } + // Hybrid prose may carry `>>>` envelopes: a draft holds the words, not + // the routing syntax (a resend must not publish the prefix), and the + // destination the resident wrote stays with it as a note. A + // `>>>skip_reply` envelope was private by the resident's own choice. + let privateCount = 0; + const parts: Array<{ text: string; note?: string }> = []; + for (const segment of segments) { + for (const envelope of splitHybridEnvelopes(segment)) { + const parsed = parseHybridProsePrefix(envelope); + if (parsed.kind === 'private') privateCount++; + else if (parsed.kind === 'target') parts.push({ text: parsed.body, note: `written for >>>${parsed.target}` }); + else parts.push({ text: envelope }); + } + } + if (privateCount > 0) { + this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + privateCount); + } + this.holdProseDrafts(agent, parts, 'explicit-send', opts); + } + + /** + * Hold plain speech as private drafts (src/prose-drafts.ts) instead of + * letting it vanish: journaled durably and recorded for this turn's + * receipt. `notice: 'now'` also queues a private notice naming them, which + * the resident hears at its next tool boundary — only when the live stream + * presents mid-turn injections. Otherwise the turn-end receipt names them + * (or, after a crash, the next turn's catch-up). Drafts the journal cannot + * hold are reported in the receipt: their words remain in history only. + */ + private holdProseDrafts( + agent: Agent, + texts: Array, + reason: DraftReason, + opts: { round?: number; note?: string; notice: 'now' | 'later' }, + ): Draft[] { + const segments = texts + .map((t) => (typeof t === 'string' ? { text: t, note: opts.note } : { text: t.text, note: t.note ?? opts.note })) + .filter((t) => t.text.trim().length > 0); + if (segments.length === 0) return []; + const turn = this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0; + let branch = 'unknown'; + try { + branch = this.store.currentBranch().name; + } catch { + // provenance only + } + const { held, notHeld } = this.proseDrafts.hold( + agent.name, + segments.map(({ text, note }, segment) => ({ + text, + ...(note ? { note } : {}), + source: { branch, turn, round: opts.round ?? 0, segment } satisfies DraftSource, + })), + reason, + ); + if (notHeld) { + console.error(`[drafts] ${agent.name}: ${notHeld.count} segment(s) could not be held as drafts: ${notHeld.error}`); + const prior = this.turnDraftFailures.get(agent.name); + this.turnDraftFailures.set(agent.name, { count: (prior?.count ?? 0) + notHeld.count, error: notHeld.error }); + } + if (held.length === 0) return held; + const list = this.turnDrafts.get(agent.name) ?? []; + list.push(...held); + this.turnDrafts.set(agent.name, list); + console.error(`[drafts] ${agent.name}: held ${held.length} segment(s) as ${held.map((d) => d.id).join(', ')} (${reason})`); + this.emitTrace({ + type: 'prose:drafts-held', + agentName: agent.name, + reason, + draftIds: held.map((d) => d.id), + textLen: held.reduce((n, d) => n + d.text.length, 0), + }); + if (opts.notice === 'now') { + try { + this.addMessage( + 'user', + [{ type: 'text', text: this.draftsHeldNotice(agent.name, held, reason) }], + { system: true, kind: 'prose-drafts', draftIds: held.map((d) => d.id) } as MessageMetadata, + { forAgent: agent.name }, + ); + } catch (err) { + console.error('[drafts] held-draft notice failed:', err); + } + } + return held; + } + + private static readonly DRAFT_REASON_TEXT: Record = { + 'explicit-send': 'an explicit send in the same round holds plain speech back', + 'no-destination': 'it had no destination — after a routing envelope failed, plain speech waits for a new valid target', + ambiguous: 'more than one conversation was waiting for you, so unaddressed speech was held rather than guessed', + bounced: 'its routing prefix bounced', + }; + + private static readonly DRAFT_REASON_SHORT: Record = { + 'explicit-send': 'explicit send in the same round', + 'no-destination': 'no destination', + ambiguous: 'competing conversations', + bounced: 'routing prefix bounced', + }; + + private draftsHeldNotice(agentName: string, held: Draft[], reason: DraftReason): string { + const ids = held.map((d) => d.id); + const many = held.length > 1; + const idList = ids.map((id) => `"${id}"`).join(', '); + // Named: residents that share one message slot (#197) share this window. + return ( + `[drafts] ${agentName}: not sent — ${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` + + `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${AgentFramework.DRAFT_REASON_TEXT[reason]}. ` + + `Drafts are private to you and stay until you act: drafts(action: "resend", draftIds: [${idList}], ` + + `destination: "#channel") delivers ${many ? 'them' : 'it'} unchanged; drafts(action: "dismiss", ` + + `draftIds: [${idList}]) sets ${many ? 'them' : 'it'} aside.` + ); + } + + /** Compact local time for draft listings. */ + private draftTime(t: number): string { + return formatZonedDateTime(t, this.timeZone).replace(/:\d\d\.\d{3}/, ''); + } + + private static draftPreview(text: string, max = 80): string { + const flat = text.replace(/\s+/g, ' ').trim(); + return JSON.stringify(flat.length > max ? `${flat.slice(0, max)}…` : flat); + } + + private static destinationText(d: PublishDestination): string { + const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : ''; + return `${label}(${d.channelId})`; + } + + /** One line per draft: id, state, when, why, size, preview. */ + private draftLine(draft: Draft): string { + const state = draftState(draft); + const last = draft.attempts.at(-1); + let stateText: string = state; + if (state === 'unconfirmed' && last) { + stateText = `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(last.destination)} at ` + + `${this.draftTime(last.at)} may have been posted`; + } else if (state === 'held' && last) { + stateText = `held (last attempt failed: nothing was posted)`; + } + return `${draft.id} · ${stateText} · held ${this.draftTime(draft.heldAt)} · ` + + `${AgentFramework.DRAFT_REASON_SHORT[draft.reason]} · ${draft.text.length} chars · ` + + AgentFramework.draftPreview(draft.text); + } + + /** A delivered draft's historical receipt. */ + private draftReceipt(draft: Draft): string { + const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); + if (!delivered) return `${draft.id}: not delivered`; + return `${draft.id}: delivered — confirmed at ${this.draftTime(delivered.outcome!.at)} to ` + + `${AgentFramework.destinationText(delivered.destination)}` + + (delivered.outcome!.messageId ? `, message ${delivered.outcome!.messageId}` : '') + + ' (a record of that delivery, not a check that the message is still there)'; + } + + /** + * Turn start (or after a crash): name any open drafts no notice has named + * yet, privately, before the turn's compile. + */ + private noticeUnnoticedDrafts(agent: Agent): void { + const pending = this.proseDrafts.unnoticed(agent.name); + if (pending.length === 0) return; + const shown = pending.slice(0, 8).map((d) => + `${d.id} (held ${this.draftTime(d.heldAt)}, ${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ` + + `${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); + const more = pending.length > shown.length ? ` · and ${pending.length - shown.length} more` : ''; + const text = + `[drafts] ${agent.name}: ${pending.length === 1 ? 'a held draft' : `${pending.length} held drafts`} of yours ` + + `${pending.length === 1 ? 'has' : 'have'} not been named to you yet: ${shown.join(' · ')}${more}. ` + + 'They were not sent. drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; + try { + const mid = agent.getContextManager().addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'prose-drafts', draftIds: pending.map((d) => d.id) } as MessageMetadata, + ); + this.emitTrace({ type: 'message:added', messageId: mid, source: 'prose-drafts' }); + this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id)); + } catch (err) { + console.error('[drafts] catch-up notice failed:', err); + } + } + + /** Turn end: every draft held this turn has now been named (receipt or notice). */ + private settleTurnDrafts(agent: Agent): void { + const held = this.turnDrafts.get(agent.name); + this.turnDrafts.delete(agent.name); + this.turnDraftFailures.delete(agent.name); + this.turnProsePrivate.delete(agent.name); + if (!held?.length) return; + try { + this.proseDrafts.markNoticed(agent.name, held.map((d) => d.id)); + } catch (err) { + // Unmarked drafts are named again by the next turn's catch-up. + console.error('[drafts] could not record notice:', err); + } + } + + /** Page size for drafts(action: "list"). */ + private static readonly DRAFTS_PAGE = 10; + /** Drafts with a delivery attempt in flight, `${agent}\u0000${id}`: a + * parallel resend (or `{{unsent}}`) of the same draft is refused. */ + private draftsInFlight = new Set(); + + /** + * The resident's `drafts` tool: list, read, resend and dismiss its own held + * drafts. Private to the calling resident. `resend` is an explicit send: + * each draft is published verbatim, in order, through the normal publish + * executor, its attempt journaled durably before the request leaves. + */ + private async handleDraftsTool(agentName: string, rawInput: unknown): Promise { + const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record; + const refuse = (error: string): ToolResult => ({ success: false, error, isError: true }); + // Plain text, not a JSON-quoted string: draft text must read exactly as written. + const ok = (text: string): ToolResult => ({ success: true, data: [{ type: 'text', text }] }); + // null and omission mean the same thing: the field is not in use. + const present = (v: unknown): boolean => v !== undefined && v !== null; + const action = input.action; + if (action !== 'list' && action !== 'read' && action !== 'resend' && action !== 'dismiss') { + return refuse('action must be one of "list", "read", "resend" or "dismiss".'); + } + const unused = (field: string): ToolResult => + refuse(`${field} is not used by action "${action}" — pass null or omit it.`); + + let ids: string[] = []; + if (present(input.draftIds)) { + if (action === 'list') return unused('draftIds'); + if (!Array.isArray(input.draftIds) || input.draftIds.some((id) => typeof id !== 'string' || !id.trim())) { + return refuse('draftIds must be a list of draft ids such as "d-k7x3q", or null.'); + } + ids = (input.draftIds as string[]).map((id) => id.trim()); + if (new Set(ids).size !== ids.length) return refuse('draftIds names a draft more than once.'); + if (ids.length > 20) return refuse('draftIds names more than 20 drafts; act on them in smaller groups.'); + } + if (present(input.destination) && action !== 'resend') return unused('destination'); + if (present(input.confirmDuplicate) && action !== 'resend') return unused('confirmDuplicate'); + if (present(input.offset) && action !== 'list') return unused('offset'); + + if (action === 'list') { + let offset = 0; + if (present(input.offset)) { + const raw = input.offset; + const n = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : NaN; + if (!Number.isInteger(n) || n < 0) return refuse('offset must be a whole number of drafts to skip (0 or more), or null.'); + offset = n; + } + const open = this.proseDrafts.open(agentName); + if (open.length === 0) return ok('You have no open drafts.'); + const page = open.slice(offset, offset + AgentFramework.DRAFTS_PAGE); + const header = `${open.length} open draft${open.length === 1 ? '' : 's'}, newest first` + + (offset > 0 || page.length < open.length ? ` (showing ${page.length} from #${offset + 1})` : '') + ':'; + const more = offset + page.length < open.length + ? `\nMore: drafts(action: "list", offset: ${offset + page.length}).` + : ''; + return ok( + `${header}\n${page.map((d) => this.draftLine(d)).join('\n')}${more}\n` + + 'drafts(action: "read", draftIds: [...]) shows full text and every attempt.', + ); + } + + if (ids.length === 0) return refuse(`action "${action}" needs draftIds: the drafts to ${action}.`); + const drafts: Draft[] = []; + const missing: string[] = []; + for (const id of ids) { + const draft = this.proseDrafts.get(agentName, id); + if (draft) drafts.push(draft); + else missing.push(id); + } + if (missing.length > 0) { + return refuse(`No draft of yours with id ${missing.join(', ')}. drafts(action: "list") shows your open drafts.`); + } + + if (action === 'read') { + const blocks = drafts.map((d) => { + const lines = [ + `${d.id} — ${draftState(d)}; held ${this.draftTime(d.heldAt)} (${AgentFramework.DRAFT_REASON_TEXT[d.reason]})` + + (d.note ? `; ${d.note}` : ''), + ...d.attempts.map((a) => { + const o = a.outcome; + const status = !o + ? 'outcome never recorded — it may have been posted' + : o.status === 'delivered' + ? `delivered${o.messageId ? `, message ${o.messageId}` : ''}` + : o.status === 'unknown' + ? `UNKNOWN — may have been posted: ${o.reason ?? 'no valid receipt'}` + : `failed, nothing posted: ${o.reason ?? 'refused'}`; + return ` attempt ${this.draftTime(a.at)} → ${AgentFramework.destinationText(a.destination)}: ${status}` + + (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : ''); + }), + `Text (${d.text.length} chars, exactly as written):`, + d.text, + ]; + return lines.join('\n'); + }); + return ok(blocks.join('\n\n')); + } + + if (action === 'dismiss') { + const lines: string[] = []; + for (const d of drafts) { + const state = draftState(d); + if (state === 'delivered') { + lines.push(`${d.id}: already delivered — nothing to dismiss. ${this.draftReceipt(d)}`); + continue; + } + if (state === 'dismissed') { + lines.push(`${d.id}: already dismissed.`); + continue; + } + try { + this.proseDrafts.dismiss(agentName, d.id); + } catch (err) { + return refuse( + `${lines.length > 0 ? `${lines.join('\n')}\n` : ''}Could not record dismissing ${d.id}: ` + + `${err instanceof Error ? err.message : String(err)}. It is still open.`, + ); + } + lines.push(`${d.id}: dismissed (your history keeps the words).`); + } + this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: drafts.map((d) => d.id) }); + return ok(lines.join('\n')); + } + + // resend + const registry = this.channelRegistry; + if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.'); + const home = this.conversationAgentHomes.get(agentName); + let spec = typeof input.destination === 'string' ? input.destination.trim() : ''; + if (present(input.destination) && typeof input.destination !== 'string') { + return refuse('destination must be "#channel", "@person" or a channel id.'); + } + if (!spec && home) spec = home; + if (!spec) return refuse('resend needs a destination: "#channel", "@person" or a channel id.'); + let confirmDuplicate = false; + if (present(input.confirmDuplicate)) { + if (typeof input.confirmDuplicate !== 'boolean') return refuse('confirmDuplicate must be true, false or null.'); + confirmDuplicate = input.confirmDuplicate; + } + const resolved = registry.resolveProseTarget(spec); + if ('error' in resolved) { + const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : ''; + return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`); + } + const target = registry.resolveDestination({ channelId: resolved.channelId }); + if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`); + const destination = target.destination; + if (home && destination.channelId !== home) { + return refuse(`This conversation is bound to channel ${home}; resending to ${destination.channelId} is not allowed. Nothing was sent.`); + } + + // Refuse up front, before anything is published, rather than half-way. + for (const d of drafts) { + if (this.draftsInFlight.has(`${agentName}\u0000${d.id}`)) { + return refuse(`${d.id} is being sent right now; wait for that result before sending it again. Nothing was sent.`); + } + const state = draftState(d); + if (state === 'dismissed') { + return refuse(`${d.id} was dismissed, so it can't be resent. Nothing was sent.`); + } + if (state === 'unconfirmed' && !confirmDuplicate) { + const last = d.attempts.at(-1)!; + return refuse( + `${d.id}'s last attempt (to ${AgentFramework.destinationText(last.destination)} at ${this.draftTime(last.at)}) ` + + `may already have been posted: ${last.outcome?.reason ?? 'its outcome was never recorded'}. ` + + 'Check that channel; to send it again anyway, resend with confirmDuplicate: true. Nothing was sent.', + ); + } + } + + const lines: string[] = []; + let stopped = false; + for (const d of drafts) { + if (stopped) { + lines.push(`${d.id}: not attempted (an earlier draft was not confirmed delivered).`); + continue; + } + if (draftState(d) === 'delivered') { + lines.push(`${this.draftReceipt(d)} — not sent again.`); + continue; + } + let attemptId: string; + try { + attemptId = this.proseDrafts.beginAttempt(agentName, d.id, destination, 'resend', draftState(d) === 'unconfirmed'); + } catch (err) { + lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}).`); + stopped = true; + continue; + } + const flight = `${agentName}\u0000${d.id}`; + this.draftsInFlight.add(flight); + let outcome: PublishOutcome; + try { + outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId }); + } finally { + this.draftsInFlight.delete(flight); + } + let recorded = true; + try { + this.proseDrafts.recordOutcome(agentName, d.id, attemptId, outcome); + } catch (err) { + recorded = false; + console.error(`[drafts] ${agentName}: outcome of ${d.id} not recorded:`, err); + } + this.emitTrace({ + type: 'prose:draft-resent', + agentName, + draftId: d.id, + status: outcome.status, + serverId: outcome.destination?.serverId ?? destination.serverId, + channelId: outcome.destination?.channelId ?? destination.channelId, + ...(outcome.messageId ? { messageId: outcome.messageId } : {}), + }); + const where = AgentFramework.destinationText(outcome.destination ?? destination); + const unrecorded = recorded ? '' : ' (this outcome could not be recorded; the draft will read as unconfirmed)'; + if (outcome.status === 'delivered') { + lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); + let engaged = this.turnEngagedChannels.get(agentName); + if (!engaged) { + engaged = new Set(); + this.turnEngagedChannels.set(agentName, engaged); + } + engaged.add(destination.channelId); + continue; + } + stopped = true; + lines.push(outcome.status === 'unknown' + ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` + + `Check the channel before sending it again (that needs confirmDuplicate: true)${unrecorded}.` + : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted; the draft stays held${unrecorded}.`); + } + const allDelivered = !stopped; + return allDelivered ? ok(lines.join('\n')) : refuse(lines.join('\n')); + } + /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( agentName: string, @@ -8716,7 +9272,10 @@ export class AgentFramework { private appendProseDeliveryReceipt(agent: Agent): void { const list = this.turnProseDeliveries.get(agent.name); const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0; - if ((!list || list.length === 0) && suppressed === 0) return; + const drafts = this.turnDrafts.get(agent.name) ?? []; + const kept = this.turnProsePrivate.get(agent.name) ?? 0; + const notHeld = this.turnDraftFailures.get(agent.name); + if ((!list || list.length === 0) && suppressed === 0 && drafts.length === 0 && kept === 0 && !notHeld) return; this.turnProseDeliveries.delete(agent.name); this.turnProseSuppressed.delete(agent.name); const seen = new Set(); @@ -8731,12 +9290,27 @@ export class AgentFramework { : id, ); } - const suppressedNote = - suppressed > 0 - ? agent.proseRouting === 'disabled' - ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` - : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)` - : ''; + const notes: string[] = []; + if (drafts.length > 0) { + const ids = drafts.slice(0, 8).map((d) => d.id).join(', ') + (drafts.length > 8 ? `, and ${drafts.length - 8} more` : ''); + notes.push( + `${drafts.length} plain-speech segment(s) held as draft${drafts.length === 1 ? '' : 's'} ${ids} ` + + '(not sent — drafts can resend them unchanged, or dismiss them)', + ); + } + if (notHeld) { + notes.push( + `${notHeld.count} plain-speech segment(s) suppressed and NOT held as drafts (${notHeld.error}) — ` + + 'the words remain only in your history', + ); + } + if (kept > 0) notes.push(`${kept} plain-speech segment(s) kept private (skip_reply)`); + if (suppressed > 0) { + notes.push(agent.proseRouting === 'disabled' + ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` + : `${suppressed} plain-speech segment(s) suppressed`); + } + const suppressedNote = notes.join(' · '); const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` @@ -8854,7 +9428,7 @@ export class AgentFramework { * nothing is ever sent to a destination the model did not name (directly * or via the turn's sticky target). */ - private async deliverProse(agent: Agent, rawText: string): Promise { + private async deliverProse(agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }): Promise { // Multi-envelope: a single prose block may address SEVERAL destinations — // every line beginning with `>>` opens a new envelope, routed // independently (first live use: Tilde 2026-07-24, one block carrying a @@ -8878,38 +9452,94 @@ export class AgentFramework { for (const envelope of envelopes) { if (!envelope.trim()) continue; - await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, '')); + await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''), hold); } } + /** + * `{{unsent}}` in an envelope body: the latest bounce draft's words. An + * unconfirmed one (its last attempt may have been posted) is never + * substituted silently — the resident gets the facts and the explicit + * route (drafts resend with confirmDuplicate) instead. + */ + private takeUnsent(agent: Agent, prefix: '>>' | '>>>'): { draft?: Draft; refused?: true } { + const draft = this.proseDrafts.latestBounce(agent.name); + if (!draft || draftState(draft) !== 'unconfirmed') return { draft }; + const last = draft.attempts.at(-1)!; + const text = + `[prose-routing] {{unsent}} is draft ${draft.id}, and its last delivery attempt (to ` + + `${AgentFramework.destinationText(last.destination)} at ${this.draftTime(last.at)}) may already have been ` + + `posted: ${last.outcome?.reason ?? 'its outcome was never recorded'}. Nothing was sent. Check that channel; ` + + `to send it again anyway use drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true).`; + try { + this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (err) { + console.error('[drafts] unconfirmed-unsent notice failed:', err); + } + return { draft, refused: true }; + } + + /** + * Deliver an envelope that substituted `{{unsent}}`: the substituted + * draft's attempt is journaled durably before the request leaves, and its + * outcome after, exactly as a drafts resend would. + */ + private async deliverWithUnsent( + agent: Agent, + body: string, + channelId: string, + unsent: Draft | undefined, + ): Promise { + const registry = this.channelRegistry!; + let attemptId: string | undefined; + if (unsent) { + const target = registry.resolveDestination({ channelId }); + if (!('error' in target)) { + try { + attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false); + } catch (err) { + const reason = `the attempt for draft ${unsent.id} could not be recorded durably (${err instanceof Error ? err.message : String(err)})`; + console.error(`[drafts] ${agent.name}: ${reason} — not sending`); + return { status: 'failed', reason, at: Date.now() }; + } + } + } + const flight = unsent ? `${agent.name}\u0000${unsent.id}` : undefined; + if (flight) this.draftsInFlight.add(flight); + let outcome: PublishOutcome; + try { + outcome = await registry.deliverSpeech(agent.name, body, channelId); + } finally { + if (flight) this.draftsInFlight.delete(flight); + } + if (unsent && attemptId) { + try { + this.proseDrafts.recordOutcome(agent.name, unsent.id, attemptId, outcome); + } catch (err) { + console.error(`[drafts] ${agent.name}: outcome of ${unsent.id} not recorded:`, err); + } + } + return outcome; + } + /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */ private async deliverHybridProse( agent: Agent, rawText: string, locus: string | null, allowLocus: boolean, + hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, ): Promise { - const lines = rawText.split('\n'); - const envelopes: string[] = []; - let current: string[] = []; - for (const line of lines) { - if (line.trimStart().startsWith('>>>') && current.length > 0) { - envelopes.push(current.join('\n')); - current = []; - } - current.push(line); - } - if (current.length > 0) envelopes.push(current.join('\n')); - - for (const envelope of envelopes) { - if (!envelope.trim()) continue; - const normalized = envelope.replace(/^\s+(?=>>>)/, ''); + for (const envelope of splitHybridEnvelopes(rawText)) { + const normalized = envelope; const attempted = normalized.startsWith('>>>'); const parsed = parseHybridProsePrefix(normalized); if (parsed.continueTurn) this.proseContinuations.add(agent.name); if (parsed.kind === 'private') { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); + this.proseHybridSuppressed.set(agent.name, 'private'); + // `>>>skip_reply {{unsent}}`: the resident sets the bounced draft aside. + if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`); continue; } @@ -8917,13 +9547,19 @@ export class AgentFramework { const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!); if ('error' in resolved) { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>', hold); continue; } let body = parsed.body; const usedClipboard = body.includes('{{unsent}}'); - if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? ''); + const unsent = usedClipboard ? this.takeUnsent(agent, '>>>') : {}; + if (unsent.refused) { + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'failed'); + continue; + } + if (usedClipboard) body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); this.proseTargetPins.set(agent.name, resolved.channelId); this.proseHybridSuppressed.delete(agent.name); if (!body.trim()) { @@ -8931,36 +9567,45 @@ export class AgentFramework { continue; } try { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId); - this.recordProseDelivery(agent.name, outcome); - if (outcome?.delivered) { + const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); + this.recordProseDelivery(agent.name, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null); + if (outcome.status === 'delivered') { this.proseBounceStreaks.delete(agent.name); - if (usedClipboard) this.proseClipboards.delete(agent.name); } else { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + // An uncertain delivery is reported by the failure marker; it is + // not bounced, so its words are not re-held as a fresh draft that + // invites a duplicate. + if (outcome.status === 'failed') { + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', hold); + } } } catch (err) { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', hold); } continue; } if (attempted) { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>', hold); + continue; + } + const suppressedBy = this.proseHybridSuppressed.get(agent.name); + if (suppressedBy === 'private') { + this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + 1); continue; } - if (this.proseHybridSuppressed.has(agent.name)) { - this.recordProseSuppression(agent.name, 1); + if (suppressedBy === 'failed') { + this.holdProseDrafts(agent, [envelope], 'no-destination', hold); continue; } const sticky = this.proseTargetPins.get(agent.name); if (!allowLocus && !sticky) { - this.recordProseSuppression(agent.name, 1); + this.holdProseDrafts(agent, [envelope], 'no-destination', hold); continue; } const target = sticky ?? locus; @@ -8973,16 +9618,28 @@ export class AgentFramework { } } - private async deliverProseEnvelope(agent: Agent, rawText: string): Promise { + /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce draft aside. */ + private dismissLatestBounce(agent: Agent): void { + const draft = this.proseDrafts.latestBounce(agent.name); + if (!draft) return; + try { + this.proseDrafts.dismiss(agent.name, draft.id); + this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); + } catch (err) { + console.error(`[drafts] ${agent.name}: could not dismiss ${draft.id}:`, err); + } + } + + private async deliverProseEnvelope(agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' }): Promise { const name = agent.name; const parsed = parseProsePrefix(rawText); if (parsed.continueTurn) this.proseContinuations.add(name); if (parsed.kind === 'private') { // The text already lives in the assistant message (window/chronicle); - // "delivery" is deliberately a no-op. Consumes the retained text if the - // segment embedded it. - if (parsed.body.includes('{{unsent}}')) this.proseClipboards.delete(name); + // "delivery" is deliberately a no-op. `>>skip_reply {{unsent}}` sets + // the bounced draft aside. + if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`); return; } @@ -8992,7 +9649,7 @@ export class AgentFramework { if (parsed.kind === 'target') { const res = this.channelRegistry!.resolveProseTarget(parsed.target!); if ('error' in res) { - this.bounceProse(agent, parsed.body, res.error, res.candidates); + this.bounceProse(agent, parsed.body, res.error, res.candidates, '>>', hold); return; } targetChannel = res.channelId; @@ -9002,7 +9659,7 @@ export class AgentFramework { } else { const sticky = this.proseTargetPins.get(name); if (!sticky) { - this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn'); + this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn', undefined, '>>', hold); return; } targetChannel = sticky; @@ -9010,8 +9667,10 @@ export class AgentFramework { } const usedClipboard = body.includes('{{unsent}}'); + const unsent = usedClipboard ? this.takeUnsent(agent, '>>') : {}; + if (unsent.refused) return; if (usedClipboard) { - body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? ''); + body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); } if (!body.trim()) { console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`); @@ -9019,11 +9678,8 @@ export class AgentFramework { } try { - const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel); - if (result?.delivered) { - this.proseBounceStreaks.delete(name); - if (usedClipboard) this.proseClipboards.delete(name); - } + const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft); + if (outcome.status === 'delivered') this.proseBounceStreaks.delete(name); } catch (err) { console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); } @@ -9032,17 +9688,41 @@ export class AgentFramework { /** Cap on consecutive bounce-triggered wakes (notices still append after). */ private static readonly PROSE_BOUNCE_WAKE_CAP = 2; - private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[], prefix: '>>' | '>>>' = '>>'): void { + /** + * A routing envelope that could not be delivered: its words are held as a + * draft (the `{{unsent}}` source) and the resident is told, privately. + * Bouncing `{{unsent}}` itself again keeps the one draft rather than + * minting a copy; a body that adds words around it holds the expanded text. + */ + private bounceProse( + agent: Agent, + text: string, + reason: string, + candidates?: string[], + prefix: '>>' | '>>>' = '>>', + hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + ): void { const name = agent.name; - this.proseClipboards.set(name, text); + const previous = this.proseDrafts.latestBounce(name); + let draft: Draft | undefined; + if (previous && text.trim() === '{{unsent}}') { + draft = previous; + } else { + const expanded = text.includes('{{unsent}}') ? text.replaceAll('{{unsent}}', previous?.text ?? '') : text; + // The bounce notice below names the draft, so no separate held notice. + draft = this.holdProseDrafts(agent, [expanded], 'bounced', { round: hold.round, note: reason, notice: 'later' })[0]; + } const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; this.proseBounceStreaks.set(name, streak); const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : ''; + const kept = draft + ? `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, reply with a destination plus the ` + + `token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}", or use ` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"). ` + + `"${prefix}skip_reply {{unsent}}" sets it aside.` + : 'It could not be held as a draft; the words remain in your history.'; const notice = - `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` + - 'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' + - `destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` + - `"${prefix}skip_reply {{unsent}}" keeps it in context only.` + + `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}` + (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : ''); try { // Through framework.addMessage, NOT the context manager directly: while @@ -9051,11 +9731,12 @@ export class AgentFramework { // stream (hear-while-acting) — the model sees the error and can correct // the prefix within the same turn. A direct append here would land the // notice before the turn's assistant blocks (cache bust, invisible - // until the next compile). + // until the next compile). Addressed to the bouncing resident alone. const id = this.addMessage( 'user', [{ type: 'text', text: notice }], - { system: true, kind: 'prose-bounce' }, + { system: true, kind: 'prose-bounce', ...(draft ? { draftIds: [draft.id] } : {}) } as MessageMetadata, + { forAgent: name }, ); if (id) this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-bounce' }); else this.emitTrace({ type: 'message:added', messageId: 'deferred', source: 'prose-bounce' }); @@ -9334,6 +10015,9 @@ export class AgentFramework { if (!continuingTurn) { if (attempt === 0) this.maybePrimeProseMode(agent); + // Name any held drafts no notice reached (a crash, an aborted turn), + // privately, before this turn's compile. + if (attempt === 0) this.noticeUnnoticedDrafts(agent); const previousLogicalToolState = this.logicalTurnToolCalls.get(agent); if (attempt === 0) { // A true new turn gets a fresh generation and count. @@ -9348,6 +10032,9 @@ export class AgentFramework { this.turnEngagedChannels.delete(agent.name); this.turnProseDeliveries.delete(agent.name); this.turnProseSuppressed.delete(agent.name); + this.turnDrafts.delete(agent.name); + this.turnProsePrivate.delete(agent.name); + this.turnDraftFailures.delete(agent.name); this.proseHybridSuppressed.delete(agent.name); if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { @@ -9649,10 +10336,16 @@ export class AgentFramework { // Sticky explicit-send suppression: prose after send_message stays quiet // to prevent a redundant "sent it" postscript. Fresh injected input // clears it, because the following prose is a reply to a new message. - let turnSilenced = trigger?.suppressProse === true - || (trigger?.reason === 'tool_result_guard_retry' - && this.guardRetryTurnSilenced.get(agent.name) === true); + // Why it is silenced decides what happens to the held words: an explicit + // send ('send') holds them as resendable drafts; a deliberate silence + // ('private': skip_reply, a silent control-plane turn) keeps them private. + let silenceCause: 'send' | 'private' | null = trigger?.suppressProse === true + ? 'private' + : (trigger?.reason === 'tool_result_guard_retry' ? this.guardRetryTurnSilenced.get(agent.name) : undefined) ?? null; + let turnSilenced = silenceCause !== null; this.guardRetryTurnSilenced.delete(agent.name); + /** Provider rounds of this physical stream, for draft provenance. */ + let draftRound = 0; // Live routing is only trusted when the membrane provides verbatim // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64). @@ -9762,6 +10455,7 @@ export class AgentFramework { // earlier explicit delivery has completed its conversational job. // Routing is NOT touched: the turn locus stays frozen. turnSilenced = trigger?.suppressProse === true; + silenceCause = turnSilenced ? 'private' : null; }; try { @@ -9942,16 +10636,26 @@ export class AgentFramework { // from the round it occurs (see SILENCING_TOOLS). Only rounds // with verbatim roundContent are live-routed (see liveProseRouting // note above — the fallback preamble is cumulative in XML mode). + draftRound++; if (this.channelRegistry) { const roundToolNames = event.calls.map((c) => c.name); const hasSameRoundPrivateThink = roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; - if (roundToolNames.some(isSilencingTool)) { + const roundCause = silenceCauseOf(event.calls); + if (roundCause) { turnSilenced = true; + silenceCause = roundCause === 'private' || silenceCause === 'private' ? 'private' : 'send'; } if (roundContent && roundContent.length > 0) { liveProseRouting = true; + // Whether this round's tool-result batch (and anything queued + // with it) will be presented to the model before it speaks + // again: the producer says so when it can; otherwise native + // rounds (verbatim roundContent) carry injections. + const presentsInjections = + (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true; + const holdOpts = { round: draftRound, notice: presentsInjections ? 'now' as const : 'later' as const }; const roundSegments = splitProseSegments(assistantBlocks); if (roundSegments.length > 0) { if (turnProseRouting === 'disabled') { @@ -9961,12 +10665,12 @@ export class AgentFramework { this.recordProseSuppression(agent.name, roundSegments.length); } else if (turnProseRouting === 'hybrid') { if (turnSilenced) { - this.recordProseSuppression(agent.name, roundSegments.length); + this.holdSilencedProse(agent, roundSegments, silenceCause, hasSameRoundPrivateThink, holdOpts); } else if (!hasSameRoundPrivateThink) { const locus = resolveTurnLocus(); for (const seg of roundSegments) { turnSpeechChain = turnSpeechChain - .then(() => this.deliverHybridProse(agent, seg, locus, true)) + .then(() => this.deliverHybridProse(agent, seg, locus, true, holdOpts)) .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err)); } } @@ -9980,17 +10684,17 @@ export class AgentFramework { ); for (const seg of roundSegments) { turnSpeechChain = turnSpeechChain - .then(() => this.deliverProse(agent, seg)) + .then(() => this.deliverProse(agent, seg, holdOpts)) .catch((err) => console.error('mid-turn prose delivery failed:', err)); } } } else if (turnSilenced) { console.error( - `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`, + `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced: ${silenceCause})`, ); - // Visible in the turn-end receipt — silencing must never - // be a silent black hole (n=8: the flying-scene reply). - this.recordProseSuppression(agent.name, roundSegments.length); + // Never a silent black hole (n=8: the flying-scene reply): + // held as drafts the resident can resend, and named. + this.holdSilencedProse(agent, roundSegments, silenceCause, hasSameRoundPrivateThink, holdOpts); } else if (hasSameRoundPrivateThink) { console.error( `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`, @@ -10077,7 +10781,7 @@ export class AgentFramework { // Carry same-turn explicit-send suppression into the retry: // a text-only recovery after a successful send must not post // a postscript to a message already delivered. - if (turnSilenced) this.guardRetryTurnSilenced.set(agent.name, true); + if (turnSilenced) this.guardRetryTurnSilenced.set(agent.name, silenceCause ?? 'send'); // Restart inference inside this logical turn. No tool is // executed again and no settle/checkpoint/locus reset occurs. await this.startAgentStream(agent, { @@ -10426,31 +11130,44 @@ export class AgentFramework { .map((b) => (b as ContentBlock & { type: 'text' }).text) .join('\n') .trim(); - if (speechText) { + // A text-only turn is one message — unless an all-refused XML + // round (membrane `tool_attempt`, answered in-band with a + // `tool_notice`) separates its prose: nothing was called, but + // the words before the attempt and after its notice are two + // messages, routed as segments like a tool turn's. + const speechSegments = response.content.some((b) => (b.type as string) === 'tool_attempt') + ? splitProseSegments(response.content) + : speechText ? [speechText] : []; + const textOnlyHold = { round: draftRound + 1, notice: 'later' as const }; + if (speechSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); - this.recordProseSuppression(agent.name, 1); + this.recordProseSuppression(agent.name, speechSegments.length); } else if (turnSilenced && turnProseRouting !== 'explicit') { // Only reachable on a guard recovery that carried the // same-turn send suppression, or a suppressProse trigger // (a fresh text-only turn starts unsilenced). Explicit // mode is exempt, as mid-turn. - console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`); - this.recordProseSuppression(agent.name, 1); + console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced: ${silenceCause})`); + this.holdSilencedProse(agent, speechSegments, silenceCause, false, textOnlyHold); } else if (turnProseRouting === 'hybrid') { const locus = resolveTurnLocus(); console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`); - try { - await this.deliverHybridProse(agent, speechText, locus, true); - } catch (err) { - console.error('text-only hybrid prose delivery failed:', err); + for (const segment of speechSegments) { + try { + await this.deliverHybridProse(agent, segment, locus, true, textOnlyHold); + } catch (err) { + console.error('text-only hybrid prose delivery failed:', err); + } } } else if (turnProseRouting === 'explicit') { console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`); - try { - await this.deliverProse(agent, speechText); - } catch (err) { - console.error('text-only prose delivery failed:', err); + for (const segment of speechSegments) { + try { + await this.deliverProse(agent, segment, textOnlyHold); + } catch (err) { + console.error('text-only prose delivery failed:', err); + } } } else { // Route to the TURN-FROZEN locus, like every other speech @@ -10463,11 +11180,13 @@ export class AgentFramework { console.error( `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`, ); - try { - const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus); - this.recordProseDelivery(agent.name, outcome); - } catch (err) { - console.error('speech routing failed:', err); + for (const segment of speechSegments) { + try { + const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus); + this.recordProseDelivery(agent.name, outcome); + } catch (err) { + console.error('speech routing failed:', err); + } } } } @@ -10486,13 +11205,18 @@ export class AgentFramework { // injected message can reset. The legacy/fallback path has no // reliable round boundaries, so retain its historical turn-wide // scan to avoid double-posting. - const toolNames = response.content + const toolCalls = response.content .filter((b) => b.type === 'tool_use') - .map((b) => (b as unknown as { name?: string }).name) - .filter((n): n is string => typeof n === 'string'); + .map((b) => b as unknown as { name?: string; input?: unknown }) + .filter((c): c is { name: string; input?: unknown } => typeof c.name === 'string'); + const toolNames = toolCalls.map((c) => c.name); + const fallbackCause = liveProseRouting ? null : silenceCauseOf(toolCalls); const silenced = liveProseRouting ? turnSilenced - : turnSilenced || toolNames.some(isSilencingTool); + : turnSilenced || fallbackCause !== null; + const trailingCause: 'send' | 'private' | null = + silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); + const trailingHold = { round: draftRound + 1, notice: 'later' as const }; const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); @@ -10510,12 +11234,12 @@ export class AgentFramework { } } else if (turnProseRouting === 'hybrid') { if (silenced && segments.length > 0) { - this.recordProseSuppression(agent.name, segments.length); + this.holdSilencedProse(agent, segments, trailingCause, false, trailingHold); } else if (segments.length > 0) { const locus = resolveTurnLocus(); for (const seg of segments) { try { - await this.deliverHybridProse(agent, seg, locus, true); + await this.deliverHybridProse(agent, seg, locus, true, trailingHold); } catch (err) { console.error('trailing hybrid prose delivery failed:', err); } @@ -10528,7 +11252,7 @@ export class AgentFramework { ); for (const seg of segments) { try { - await this.deliverProse(agent, seg); + await this.deliverProse(agent, seg, trailingHold); } catch (err) { console.error('trailing prose delivery failed:', err); } @@ -10537,10 +11261,10 @@ export class AgentFramework { } else if (silenced || segments.length === 0) { console.error( `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` + - `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`, + `(${silenced ? `turn silenced: ${trailingCause}` : 'no trailing prose'})`, ); if (silenced && segments.length > 0) { - this.recordProseSuppression(agent.name, segments.length); + this.holdSilencedProse(agent, segments, trailingCause, false, trailingHold); } } else { // Reuse the locus pinned at the turn's first live-routed @@ -10577,6 +11301,10 @@ export class AgentFramework { if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent); } + // The turn's drafts have now been named (receipt, held notice or + // bounce notice); a crash before this leaves them for the next + // turn's catch-up. + this.settleTurnDrafts(agent); // Explicit-prose `!` continuation: a prose segment this turn asked // to keep going (`>>#x !` / `>>private !`) — start another turn @@ -10747,6 +11475,10 @@ export class AgentFramework { await turnSpeechChain; this.appendProseDeliveryReceipt(agent); } + if (cancelKind === 'turn_ended') { + await turnSpeechChain; + this.settleTurnDrafts(agent); + } return; } } @@ -12454,6 +13186,25 @@ export class AgentFramework { return; } + // drafts: the resident's own held drafts (list/read/resend/dismiss). + // Answered by the framework; resend publishes through the registry's + // publish executor, so the result arrives asynchronously. + if (enrichedCall.name === 'drafts' && this.channelRegistry) { + const startedAt = Date.now(); + this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input }); + void this.handleDraftsTool(agentName, enrichedCall.input) + .catch((err): ToolResult => ({ + success: false, + error: `drafts failed: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + })) + .then((result) => { + this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt }); + this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, agentName, moduleName: 'framework', result }); + }); + return; + } + // prose_help: on-demand routing reference for explicit-prose agents. // Answered inline — the grammar is a tool RESULT (model-requested), never // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce). diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 8333c860a..6aa477bf2 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3021,33 +3021,57 @@ export class ChannelRegistry { * or malformed receipt is `unknown`: the post may or may not exist, and the * caller must not treat it as safe to retry blindly. */ - async publish( - conversationId: string, - text: string, + /** + * Resolve a publish target to exactly one registered channel: `serverId` + * plus `channelId` when the caller has the server, otherwise a channel id + * registered by exactly one server. A shared id is refused, never resolved + * to whichever server registered it first. + */ + resolveDestination( target: { serverId?: string; channelId: string }, - openSource: 'opened-by-delivery' | 'opened-by-reply' = 'opened-by-delivery', - ): Promise { - const at = (): number => Date.now(); + ): { destination: PublishDestination } | { error: string } { + const entry = this.findExactEntry(target); + if ('error' in entry) return entry; + return { + destination: { + serverId: entry.entry.serverId, + channelId: entry.entry.descriptor.id, + ...(entry.entry.descriptor.label ? { label: entry.entry.descriptor.label } : {}), + }, + }; + } + + private findExactEntry( + target: { serverId?: string; channelId: string }, + ): { entry: ChannelEntry } | { error: string } { const matches = [...this.channels.values()].filter( (e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId), ); if (matches.length === 0) { return { - status: 'failed', - reason: target.serverId + error: target.serverId ? `no registered channel "${target.channelId}" on server "${target.serverId}"` : `no registered channel "${target.channelId}"`, - at: at(), }; } if (matches.length > 1) { return { - status: 'failed', - reason: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`, - at: at(), + error: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`, }; } - const entry = matches[0]!; + return { entry: matches[0]! }; + } + + async publish( + conversationId: string, + text: string, + target: { serverId?: string; channelId: string }, + openSource: 'opened-by-delivery' | 'opened-by-reply' = 'opened-by-delivery', + ): Promise { + const at = (): number => Date.now(); + const found = this.findExactEntry(target); + if ('error' in found) return { status: 'failed', reason: found.error, at: at() }; + const entry = found.entry; const destination: PublishDestination = { serverId: entry.serverId, channelId: entry.descriptor.id, @@ -3157,7 +3181,13 @@ export class ChannelRegistry { }; } - async routeSpeech( + /** + * Plain speech for one turn segment: publish `text` to the turn's frozen + * locus and report the PublishOutcome. A failure (or an uncertain outcome) + * is surfaced as a trace and through onRouteFailure, so the agent learns + * its words did not (or may not have) reached anyone. + */ + async deliverSpeech( conversationId: string, text: string, /** The turn's FROZEN locus, snapshotted once by the caller (resolveLocus @@ -3168,42 +3198,41 @@ export class ChannelRegistry { * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means * "this turn is pinned to no locus": fail loudly rather than guess. */ locusChannelId: string | null, - ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { - // Surface a routing failure: emit a trace AND notify the host (which drops - // a `[discord-send-failed]` marker into chronicle) so the agent learns its - // reply did not (or may not have) reached anyone, instead of it vanishing - // silently. - const fail = (channelId: string | null, reason: string, outcome: 'failed' | 'unknown' = 'failed'): null => { - console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${outcome === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); + ): Promise { + const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => { + const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; + console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, channelId: channelId ?? '', reason, textLen: text.length, - outcome, + outcome: status, }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome }); - return null; + this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status }); + return outcome; }; // Runtime backstop for JS callers the compiler can't see: an omitted // locus is a routing bug at the call site, never something to paper over // with a live re-resolution. if (locusChannelId === undefined) { - return fail(null, 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)'); + const reason = 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)'; + return fail(null, reason, { status: 'failed', reason, at: Date.now() }); } if (!locusChannelId) { // The turn froze with no locus (no home, no triggering channel, no // global inbound ever seen) — the agent was told its prose stays in // the archive; honor that. - return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)'); + const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)'; + return fail(null, reason, { status: 'failed', reason, at: Date.now() }); } const outcome = await this.publish(conversationId, text, { channelId: locusChannelId }); const channelId = outcome.destination?.channelId ?? locusChannelId; if (outcome.status !== 'delivered') { - return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome.status); + return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome); } console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${outcome.destination!.serverId}, delivered=true)`); @@ -3220,8 +3249,22 @@ export class ChannelRegistry { // tap editing it down to the words actually voiced on interruption. ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}), }); + return outcome; + } - return { delivered: true, channelId, ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}) }; + /** deliverSpeech, reduced to the confirmed delivery (null when there was none). */ + async routeSpeech( + conversationId: string, + text: string, + locusChannelId: string | null, + ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { + const outcome = await this.deliverSpeech(conversationId, text, locusChannelId); + if (outcome.status !== 'delivered') return null; + return { + delivered: true, + channelId: outcome.destination!.channelId, + ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}), + }; } private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise { diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts new file mode 100644 index 000000000..57e18706f --- /dev/null +++ b/src/prose-drafts.ts @@ -0,0 +1,314 @@ +/** + * Held prose drafts: a resident's plain speech that was not sent. + * + * Plain speech is held back for reasons that are right in themselves: an + * explicit send in the same round suppresses the round's prose (so a "sent + * it" postscript is not posted twice), a turn may have no destination, two + * conversations may compete for an unaddressed reply, and a malformed or + * unresolvable routing prefix bounces. Before drafts, the held words were + * kept only as a count (or, in explicit mode, as one in-memory latest-wins + * clipboard): the law was right, and the penalty was amnesia. A draft keeps + * the exact words, privately, until the resident resends or dismisses them. + * + * Durability is outside the conversation. Drafts live in a RecordJournal of + * typed Chronicle records, which do not follow branch switches: undo, + * rollback, suppression and branch moves never touch them. A draft written + * in a turn that is later undone stays usable (the words were genuinely + * written and held back), and a delivered draft stays delivered. Its + * originating branch is recorded as provenance only. + * + * A delivery attempt is journaled durably BEFORE its request leaves the + * host, and its outcome after: an attempt with no recorded outcome (a crash + * mid-send) reads as unknown, never as unsent. A delivered draft carries a + * historical receipt — confirmed at a time, to a destination, with the + * connector's message id — not a claim that the message still exists. + * Nothing here publishes anything: every send is the resident's explicit + * resend, and nothing replays at boot or on a branch move. + */ + +import { randomInt } from 'node:crypto'; +import type { JsStore } from '@animalabs/chronicle'; +import { RecordJournal } from './record-journal.js'; +import type { PublishDestination, PublishOutcome } from './mcpl/channel-registry.js'; + +/** Chronicle record type of the drafts journal. */ +export const PROSE_DRAFT_RECORD_TYPE = 'framework/prose-draft'; + +/** Why words were held instead of sent. */ +export type DraftReason = + /** An explicit send in the same round suppressed the round's plain speech. */ + | 'explicit-send' + /** The speech had no destination (no route this turn, or a failed envelope). */ + | 'no-destination' + /** Two conversations competed for an unaddressed reply. */ + | 'ambiguous' + /** An explicit/hybrid routing prefix was missing, malformed or unresolvable. */ + | 'bounced'; + +/** Where a draft's words were written. Provenance only: nothing routes by it. */ +export interface DraftSource { + /** Chronicle branch current when the words were held. */ + branch: string; + /** The resident's logical turn (process-local counter) and the round in it. */ + turn: number; + round: number; + /** Position of the segment within its round's speech. */ + segment: number; +} + +export interface DraftAttemptOutcome { + status: PublishOutcome['status']; + at: number; + messageId?: string; + reason?: string; + detail?: unknown; +} + +export interface DraftAttempt { + attemptId: string; + at: number; + destination: PublishDestination; + /** How the words went out: the drafts tool, or explicit mode's `{{unsent}}`. */ + via: 'resend' | 'unsent-token'; + /** The resident resent knowing an earlier attempt may have been posted. */ + confirmedDuplicate?: true; + /** Absent until recorded; an attempt without one reads as unknown. */ + outcome?: DraftAttemptOutcome; +} + +export interface Draft { + id: string; + agent: string; + /** The held words, byte for byte. */ + text: string; + reason: DraftReason; + /** Short context for the resident: the bounce reason, or the competing conversations. */ + note?: string; + heldAt: number; + source: DraftSource; + attempts: DraftAttempt[]; + dismissedAt?: number; + /** When an in-band notice named this draft. */ + noticedAt?: number; +} + +/** + * - `held`: never attempted, or the last attempt definitely failed — resend freely; + * - `unconfirmed`: the last attempt's outcome is unknown (or was never + * recorded): it may have been posted, so a resend needs explicit confirmation; + * - `delivered`: an attempt was confirmed — its receipt is historical; + * - `dismissed`: the resident set it aside. + */ +export type DraftState = 'held' | 'unconfirmed' | 'delivered' | 'dismissed'; + +export function draftState(draft: Draft): DraftState { + if (draft.attempts.some((a) => a.outcome?.status === 'delivered')) return 'delivered'; + if (draft.dismissedAt !== undefined) return 'dismissed'; + const last = draft.attempts.at(-1); + if (last && (!last.outcome || last.outcome.status === 'unknown')) return 'unconfirmed'; + return 'held'; +} + +type DraftEntry = + | { op: 'held'; draft: Omit } + | { op: 'attempt'; agent: string; id: string; attemptId: string; at: number; destination: PublishDestination; via: DraftAttempt['via']; confirmedDuplicate?: true } + | { op: 'outcome'; agent: string; id: string; attemptId: string; outcome: DraftAttemptOutcome } + | { op: 'dismissed'; agent: string; id: string; at: number } + | { op: 'noticed'; agent: string; ids: string[]; at: number }; + +/** Unambiguous lowercase alphabet for short ids (no i/l/o/0/1). */ +const ID_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; + +export class ProseDraftStore { + private readonly journal: RecordJournal; + /** agent → draft id → draft, in journal (hold) order. */ + private drafts = new Map>(); + + constructor(store: JsStore) { + this.journal = new RecordJournal(store, { type: PROSE_DRAFT_RECORD_TYPE }); + this.reload(); + } + + /** Rebuild the projection from the journal (at open, and to reconcile). */ + reload(): void { + const { entries } = this.journal.load(); + this.drafts = new Map(); + for (const { entry } of entries) this.reduce(entry); + } + + private reduce(entry: DraftEntry): void { + if (entry.op === 'held') { + this.agentDrafts(entry.draft.agent).set(entry.draft.id, { ...entry.draft, attempts: [] }); + return; + } + if (entry.op === 'noticed') { + for (const id of entry.ids) { + const draft = this.drafts.get(entry.agent)?.get(id); + if (draft && draft.noticedAt === undefined) draft.noticedAt = entry.at; + } + return; + } + const draft = this.drafts.get(entry.agent)?.get(entry.id); + if (!draft) return; + if (entry.op === 'dismissed') { + draft.dismissedAt ??= entry.at; + } else if (entry.op === 'attempt') { + draft.attempts.push({ + attemptId: entry.attemptId, + at: entry.at, + destination: entry.destination, + via: entry.via, + ...(entry.confirmedDuplicate ? { confirmedDuplicate: true as const } : {}), + }); + } else if (entry.op === 'outcome') { + const attempt = draft.attempts.find((a) => a.attemptId === entry.attemptId); + if (attempt) attempt.outcome = entry.outcome; + } + } + + /** + * Journal and apply one entry. A write that fails after reaching the store + * leaves the journal unreconciled: rebuild from it before rethrowing, so + * the projection never disagrees with what the journal holds. + */ + private write(entry: DraftEntry, durable: boolean): void { + try { + this.journal.append(entry, { durable }); + } catch (err) { + if (this.journal.needsReconcile) this.reload(); + throw err; + } + this.reduce(entry); + } + + private agentDrafts(agent: string): Map { + let map = this.drafts.get(agent); + if (!map) { + map = new Map(); + this.drafts.set(agent, map); + } + return map; + } + + private newId(agent: string): string { + const existing = this.agentDrafts(agent); + for (;;) { + let id = 'd-'; + for (let i = 0; i < 5; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; + if (!existing.has(id)) return id; + } + } + + /** + * Hold words as drafts, in order, each journaled durably before this + * returns. If the journal refuses a write (after reconciling), the drafts + * already written stand and the rest are reported as not held: their words + * remain in the resident's history only. + */ + hold( + agent: string, + segments: Array<{ text: string; source: DraftSource; note?: string }>, + reason: DraftReason, + note?: string, + ): { held: Draft[]; notHeld?: { count: number; error: string } } { + const held: Draft[] = []; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]!; + const segmentNote = segment.note ?? note; + const draft = { + id: this.newId(agent), + agent, + text: segment.text, + reason, + ...(segmentNote ? { note: segmentNote } : {}), + heldAt: Date.now(), + source: segment.source, + }; + try { + this.write({ op: 'held', draft }, true); + } catch (err) { + return { held, notHeld: { count: segments.length - i, error: err instanceof Error ? err.message : String(err) } }; + } + held.push(this.get(agent, draft.id)!); + } + return { held }; + } + + get(agent: string, id: string): Draft | undefined { + return this.drafts.get(agent)?.get(id); + } + + /** Held and unconfirmed drafts, newest first (journal order, which is + * exact even for segments held in the same millisecond). */ + open(agent: string): Draft[] { + return [...(this.drafts.get(agent)?.values() ?? [])] + .filter((d) => { const s = draftState(d); return s === 'held' || s === 'unconfirmed'; }) + .reverse(); + } + + /** Open drafts no in-band notice has named yet (a crash before the notice). */ + unnoticed(agent: string): Draft[] { + return this.open(agent).filter((d) => d.noticedAt === undefined).reverse(); + } + + markNoticed(agent: string, ids: string[]): void { + if (ids.length === 0) return; + this.write({ op: 'noticed', agent, ids, at: Date.now() }, true); + } + + dismiss(agent: string, id: string): void { + this.write({ op: 'dismissed', agent, id, at: Date.now() }, true); + } + + /** + * Journal a delivery attempt durably, BEFORE the request leaves the host. + * Throws (having reconciled) when it cannot be made durable: the caller + * must then not send. + */ + beginAttempt( + agent: string, + id: string, + destination: PublishDestination, + via: DraftAttempt['via'], + confirmedDuplicate: boolean, + ): string { + const attemptId = `a-${Date.now().toString(36)}-${randomInt(36 ** 4).toString(36)}`; + this.write({ + op: 'attempt', agent, id, attemptId, at: Date.now(), destination, via, + ...(confirmedDuplicate ? { confirmedDuplicate: true as const } : {}), + }, true); + return attemptId; + } + + /** Journal what an attempt established. */ + recordOutcome(agent: string, id: string, attemptId: string, outcome: PublishOutcome): void { + this.write({ + op: 'outcome', agent, id, attemptId, + outcome: { + status: outcome.status, + at: outcome.at, + ...(outcome.messageId ? { messageId: outcome.messageId } : {}), + ...(outcome.reason ? { reason: outcome.reason } : {}), + ...(outcome.detail !== undefined ? { detail: outcome.detail } : {}), + }, + }, true); + } + + /** + * Explicit and hybrid mode's `{{unsent}}`: the most recent bounce, while + * that draft is still open (held or unconfirmed — the caller must not + * resend an unconfirmed one without the resident's confirmation). + * Latest-wins, like the clipboard it replaces: once the latest bounce is + * delivered or dismissed there is nothing to substitute, even if older + * bounces are still held (they stay in the collection, by id). + */ + latestBounce(agent: string): Draft | undefined { + let latest: Draft | undefined; + for (const draft of this.drafts.get(agent)?.values() ?? []) { + if (draft.reason === 'bounced') latest = draft; + } + if (!latest) return undefined; + const state = draftState(latest); + return state === 'held' || state === 'unconfirmed' ? latest : undefined; + } +} diff --git a/src/types/trace.ts b/src/types/trace.ts index e14ab7088..a7465b550 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -498,6 +498,30 @@ export type TraceEvent = /** Envelope kind of the acceptance the observer failed on. */ kind: 'channel' | 'unscoped' | 'surface'; error: string; + }) + + // Held prose drafts (src/prose-drafts.ts): plain speech kept privately + // instead of sent; a resident's explicit resend; a deliberate dismissal. + | (TraceEventBase & { + type: 'prose:drafts-held'; + agentName: string; + reason: 'explicit-send' | 'no-destination' | 'ambiguous' | 'bounced'; + draftIds: string[]; + textLen: number; + }) + | (TraceEventBase & { + type: 'prose:draft-resent'; + agentName: string; + draftId: string; + status: 'delivered' | 'failed' | 'unknown'; + serverId: string; + channelId: string; + messageId?: string; + }) + | (TraceEventBase & { + type: 'prose:drafts-dismissed'; + agentName: string; + draftIds: string[]; }); /** diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 98a000067..588f08774 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -85,6 +85,14 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals if (behavior === 'throw') throw new Error('synthetic publication failure'); return { delivered: behavior === 'delivered', channelId: locus ?? '' }; }, + // The registry's outcome-returning form of routeSpeech, over the same plan. + deliverSpeech: async (agent: string, text: string, locus?: string | null) => { + const result = await (explicit.routeSpeech as (a: string, t: string, l?: string | null) => Promise<{ delivered: boolean; channelId: string }>)(agent, text, locus); + return result.delivered + ? { status: 'delivered', destination: { serverId: 'stub', channelId: result.channelId }, at: Date.now() } + : { status: 'failed', destination: { serverId: 'stub', channelId: result.channelId }, reason: 'stub reported delivered:false', at: Date.now() }; + }, + resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => null, isChannelOpen: () => true, @@ -443,7 +451,15 @@ describe('explicit prose routing', () => { const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.includes(authored)); - assert.ok(texts.some(t => t.startsWith('[delivered] nothing') && t.includes('suppressed'))); + assert.ok(texts.some(t => t.startsWith('[delivered] nothing') && /2 plain-speech segment\(s\) held as drafts d-[a-z2-9]{5}, d-[a-z2-9]{5}/.test(t))); + // The held words are the envelope's body, not its routing syntax; the + // destination the resident wrote stays with the draft as a note. + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ text: string; note?: string; reason: string }> } }) + .proseDrafts.open('assistant').reverse(); + assert.deepEqual(drafts.map(d => [d.text, d.note, d.reason]), [ + ['wrong-locus duplicate', 'written for >>>#cafe', 'explicit-send'], + ['tool completed', undefined, 'explicit-send'], + ]); await framework.stop(); }); diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs index e4bffa711..8cebbf70c 100644 --- a/test/fixtures/speech-route-mcpl-server.mjs +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -221,5 +221,10 @@ rl.on('line', (line) => { reply(msg.id, { tools: [] }); return; } + if (msg.method === 'tools/call') { + // No tools: answer at once (a rollback's awareness markers call one). + replyError(msg.id, `no tool "${msg.params?.name}" on this fixture`); + return; + } // Responses to our own requests (register, incoming, push) need no handling. }); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157bc..f2dfaafef 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -624,8 +624,13 @@ describe('present while acting', () => { .filter((b) => b.type === 'text') .map((b) => b.text ?? '') .filter((t) => t.startsWith('[delivered]')); + // The suppressed words are held as drafts, named in the receipt. + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ id: string; text: string }> } }) + .proseDrafts.open('assistant').reverse(); + assert.deepEqual(drafts.map((d) => d.text), ['the reply that silencing will eat', 'trailing prose, also suppressed (sticky)']); assert.deepEqual(receipts, [ - '[delivered] nothing — 2 plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)', + `[delivered] nothing — 2 plain-speech segment(s) held as drafts ${drafts.map((d) => d.id).join(', ')} ` + + '(not sent — drafts can resend them unchanged, or dismiss them)', ]); await framework.stop(); @@ -665,8 +670,12 @@ describe('present while acting', () => { .filter((b) => b.type === 'text') .map((b) => b.text ?? '') .filter((t) => t.startsWith('[delivered]')); + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ id: string; text: string }> } }) + .proseDrafts.open('assistant').reverse(); + assert.deepEqual(drafts.map((d) => d.text), ['adjacent prose that must NOT auto-publish', 'trailing prose, also suppressed (sticky)']); assert.deepEqual(receipts, [ - '[delivered] nothing — 2 plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)', + `[delivered] nothing — 2 plain-speech segment(s) held as drafts ${drafts.map((d) => d.id).join(', ')} ` + + '(not sent — drafts can resend them unchanged, or dismiss them)', ], `${verb}: suppression is visible in the receipt`); await framework.stop(); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts new file mode 100644 index 000000000..f4c0e1e3d --- /dev/null +++ b/test/prose-drafts.test.ts @@ -0,0 +1,603 @@ +/** + * Held prose drafts (shelf-351): plain speech that is not sent is kept as a + * private, explicitly resendable draft instead of vanishing. + * + * Unit tests drive ProseDraftStore over a real Chronicle store; end-to-end + * tests run real turns (MockMembrane) against a real MCPL child (the + * speech-route fixture), which records every channels/publish it receives. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import type { ContentBlock, NormalizedRequest, StreamEvent, YieldingStream } from '@animalabs/membrane'; +import { AgentFramework } from '../src/index.js'; +import { ProseDraftStore, draftState, PROSE_DRAFT_RECORD_TYPE } from '../src/prose-drafts.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const ROOM = 'discord:g1:room'; +const GENERAL = 'discord:g1:general'; +const source = { branch: 'main', turn: 1, round: 1, segment: 0 }; +const dest = (channelId = ROOM) => ({ serverId: 'discord', channelId, label: '#room (Guild One)' }); + +describe('ProseDraftStore', () => { + const open = () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-unit-')); + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + return { dir, store, drafts: new ProseDraftStore(store) }; + }; + + it('holds exact words in order, with stable ids, per resident', () => { + const { dir, store, drafts } = open(); + try { + const { held } = drafts.hold('a', [{ text: ' one\n', source }, { text: 'two ', source: { ...source, segment: 1 } }], 'explicit-send'); + assert.equal(held.length, 2); + assert.match(held[0]!.id, /^d-[a-z2-9]{5}$/); + assert.notEqual(held[0]!.id, held[1]!.id); + assert.deepEqual(drafts.open('a').map((d) => d.text), ['two ', ' one\n'], 'newest first, bytes exact'); + assert.deepEqual(drafts.open('b'), [], 'another resident sees none of them'); + assert.equal(drafts.get('b', held[0]!.id), undefined); + assert.equal(draftState(held[0]!), 'held'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('replays the journal at reopen: an attempt with no recorded outcome reads as unconfirmed', () => { + const { dir, store, drafts } = open(); + const path = join(dir, 'store'); + const { held } = drafts.hold('a', [{ text: 'words', source }, { text: 'more', source }], 'explicit-send'); + drafts.beginAttempt('a', held[0]!.id, dest(), 'resend', false); // the process "dies" here + const second = drafts.beginAttempt('a', held[1]!.id, dest(), 'resend', false); + drafts.recordOutcome('a', held[1]!.id, second, { status: 'delivered', messageId: 'm-9', at: 5, destination: dest() }); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + const again = new ProseDraftStore(reopened); + assert.equal(draftState(again.get('a', held[0]!.id)!), 'unconfirmed'); + assert.equal(draftState(again.get('a', held[1]!.id)!), 'delivered'); + assert.equal(again.get('a', held[1]!.id)!.attempts[0]!.outcome!.messageId, 'm-9'); + } finally { + reopened.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('is not moved by branch switches: undo, rollback and checkout leave every draft as it was', () => { + const { dir, store, drafts } = open(); + try { + const main = store.currentBranch().name; + const { held } = drafts.hold('a', [{ text: 'kept', source }, { text: 'set aside', source }], 'explicit-send'); + const attempt = drafts.beginAttempt('a', held[0]!.id, dest(), 'resend', false); + drafts.recordOutcome('a', held[0]!.id, attempt, { status: 'delivered', at: 7, destination: dest() }); + drafts.dismiss('a', held[1]!.id); + store.createBranch('elsewhere', main); + store.switchBranch('elsewhere'); + drafts.reload(); + assert.equal(draftState(drafts.get('a', held[0]!.id)!), 'delivered', 'a delivered draft stays delivered'); + assert.equal(draftState(drafts.get('a', held[1]!.id)!), 'dismissed', 'a dismissed draft stays dismissed'); + const later = drafts.hold('a', [{ text: 'held on another branch', source }], 'explicit-send').held[0]!; + store.switchBranch(main); + drafts.reload(); + assert.equal(draftState(drafts.get('a', later.id)!), 'held', 'a draft is usable from any branch'); + assert.equal(store.getRecordIdsByType(PROSE_DRAFT_RECORD_TYPE).length, 6); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports words it could not hold, and stays consistent with its journal', () => { + const { dir, store, drafts } = open(); + try { + const real = store.sync.bind(store); + let fail = true; + (store as unknown as { sync: () => void }).sync = () => { if (fail) throw new Error('EIO'); real(); }; + const result = drafts.hold('a', [{ text: 'x', source }, { text: 'y', source }], 'explicit-send'); + assert.equal(result.held.length, 0); + assert.deepEqual(result.notHeld, { count: 2, error: 'EIO' }); + // The first record reached the store before its barrier failed: the + // journal was reconciled, so the projection shows what it holds. + assert.deepEqual(drafts.open('a').map((d) => d.text), ['x']); + fail = false; + assert.equal(drafts.hold('a', [{ text: 'z', source }], 'explicit-send').held.length, 1); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('{{unsent}} is the latest bounce while it is open, latest-wins', () => { + const { dir, store, drafts } = open(); + try { + const first = drafts.hold('a', [{ text: 'first', source }], 'bounced').held[0]!; + const second = drafts.hold('a', [{ text: 'second', source }], 'bounced').held[0]!; + drafts.hold('a', [{ text: 'not a bounce', source }], 'explicit-send'); + assert.equal(drafts.latestBounce('a')?.id, second.id); + drafts.dismiss('a', second.id); + assert.equal(drafts.latestBounce('a'), undefined, 'an older bounce is not substituted'); + assert.equal(draftState(drafts.get('a', first.id)!), 'held', 'it stays in the collection by id'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// End to end: real turns against a real MCPL child +// --------------------------------------------------------------------------- + +type Publish = { event: string; channelId: string; text: string; messageId?: string }; + +/** A stream in XML tool mode: tool-calls carry no verbatim roundContent. */ +class XmlModeStream implements YieldingStream { + constructor(private readonly inner: YieldingStream) {} + provideToolResults(...args: Parameters): void { + this.inner.provideToolResults(...args); + } + cancel(): void { this.inner.cancel(); } + get isWaitingForTools(): boolean { return this.inner.isWaitingForTools; } + get pendingToolCallIds(): string[] { return this.inner.pendingToolCallIds; } + get toolDepth(): number { return this.inner.toolDepth; } + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const event of this.inner) { + if (event.type !== 'tool-calls') { + yield event; + continue; + } + const context = { ...(event as unknown as { context: Record }).context }; + delete context.roundContent; + yield { ...event, context } as unknown as StreamEvent; + } + } +} + +/** A membrane in XML tool mode (the inner mock's streams, wrapped). */ +function xmlMode(inner: MockMembrane): MockMembrane { + const streamYielding = inner.streamYielding.bind(inner); + (inner as unknown as { streamYielding: (r: NormalizedRequest, o?: unknown) => YieldingStream }).streamYielding = + (request, options) => new XmlModeStream(streamYielding(request, options)); + return inner; +} + +async function harness(opts: { + agents?: Array<{ name: string; proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; sameRoundThinkTextPolicy?: 'public' | 'private' }>; + xml?: boolean; +} = {}) { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-e2e-')); + const commandPath = join(dir, 'commands.jsonl'); + const statusPath = join(dir, 'status.jsonl'); + writeFileSync(commandPath, ''); + const membrane = opts.xml ? xmlMode(new MockMembrane()) : new MockMembrane(); + // A wake the test did not script (an explicit-mode bounce wake, say) gets + // an empty completion instead of a stream with no events. + const streamYielding = membrane.streamYielding.bind(membrane); + (membrane as unknown as { streamYielding: typeof streamYielding }).streamYielding = (request, options) => { + const internals = membrane as unknown as { responses: unknown[]; responseIndex: number }; + if (internals.responseIndex >= internals.responses.length) membrane.pushResponse(createMockResponse([])); + return streamYielding(request, options); + }; + const agents = (opts.agents ?? [{ name: 'scout' }]).map((a) => ({ + name: a.name, model: 'test-model', systemPrompt: `You are ${a.name}.`, + ...(a.proseRouting ? { proseRouting: a.proseRouting } : {}), + ...(a.sameRoundThinkTextPolicy ? { sameRoundThinkTextPolicy: a.sameRoundThinkTextPolicy } : {}), + })); + const create = async () => { + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents, + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + }], + modules: [], + }); + await framework.start(); + const registry = (framework as unknown as { channelRegistry: { listChannelsRaw(): unknown[] } }).channelRegistry; + await until(() => registry.listChannelsRaw().length >= 2, 'registration'); + return framework; + }; + const until = async (cond: () => boolean, what: string) => { + const deadline = Date.now() + 15_000; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out: ${what}`); + await new Promise((r) => setTimeout(r, 20)); + } + }; + let framework = await create(); + let incoming = 0; + const h = { + dir, + membrane, + get framework() { return framework; }, + until, + command: (c: Record) => appendFileSync(commandPath, JSON.stringify(c) + '\n'), + publishes: (): Publish[] => existsSync(statusPath) + ? readFileSync(statusPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l) as Publish) + .filter((e) => e.event === 'publish' || e.event === 'publish-refused' || e.event === 'publish-error') + : [], + /** One addressed message in #room starts a turn (or, with `request`, a + * direct inference request for just that resident); resolves when it settles. */ + turn: async (responses: Array>, agentName = 'scout', via: 'incoming' | 'request' = 'incoming') => { + for (const r of responses) membrane.pushResponse(r); + const before = membrane.calls.length; + if (via === 'incoming') { + h.command({ op: 'incoming', channelId: ROOM, messageId: `in-${++incoming}`, mode: 'addressed', text: `message ${incoming}` }); + } else { + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName, reason: 'test', source: 'test', timestamp: Date.now(), + }); + } + await until(() => membrane.calls.length > before, 'turn start'); + await framework.runUntilIdle(); + await until(() => !(framework as unknown as { activeTurnTokens: Map }).activeTurnTokens.has(agentName), 'turn end'); + await framework.runUntilIdle(); + }, + drafts: (agent = 'scout') => (framework as unknown as { proseDrafts: ProseDraftStore }).proseDrafts.open(agent), + store: () => (framework as unknown as { proseDrafts: ProseDraftStore }).proseDrafts, + texts: (agent = 'scout') => framework.getAgent(agent)!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text), + messages: (agent = 'scout') => framework.getAgent(agent)!.getContextManager().getAllMessages(), + /** Text of every tool result the last stream received, in call order + * within each round (results arrive in completion order). */ + toolResults: () => (membrane.lastStream?.receivedToolResults ?? []).flatMap((round, i) => { + const order = (membrane.lastStream as unknown as { responses: Array<{ toolCalls: Array<{ id: string }> }> }) + .responses[i]?.toolCalls.map((c) => c.id) ?? []; + const byId = (r: unknown) => order.indexOf((r as { toolUseId?: string }).toolUseId ?? ''); + return [...round].sort((a, b) => byId(a) - byId(b)).map((r) => { + const content = (r as { content?: unknown }).content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.map((b) => (b as { text?: string }).text ?? '').join(''); + return JSON.stringify(r); + }); + }), + restart: async () => { + await framework.stop(); + // A fresh fixture child reads the command file from the top: start it + // on an empty one so earlier inbound traffic is not replayed. + writeFileSync(commandPath, ''); + framework = await create(); + }, + close: async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }, + }; + return h; +} + +const text = (t: string) => ({ type: 'text', text: t }) as ContentBlock; +const call = (id: string, name: string, input: Record) => + ({ type: 'tool_use', id, name, input }) as ContentBlock; +const drafts = (id: string, input: Record) => + call(id, 'drafts', { action: null, draftIds: null, destination: null, confirmDuplicate: null, offset: null, ...input }); +const explicitSend = (id: string) => call(id, 'channel_publish', { channelId: GENERAL, content: 'an explicit note elsewhere' }); + +describe('held prose drafts, end to end', () => { + it('a suppressed three-segment reply is held, named, inspected and delivered without retyping', async () => { + const h = await harness(); + try { + const segments = ['First — the actual reply.', 'Second, with two spaces\nand a newline.', 'Third: trailing words.']; + await h.turn([ + createMockResponse([text(segments[0]!), explicitSend('s1')], 'tool_use'), + createMockResponse([text(segments[1]!), call('l1', 'channel_list', {})], 'tool_use'), + createMockResponse([text(segments[2]!)]), + ]); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere'], 'only the explicit send went out'); + const held = h.drafts().reverse(); + assert.deepEqual(held.map((d) => d.text), segments, 'exact words, in order'); + assert.ok(held.every((d) => d.reason === 'explicit-send')); + const ids = held.map((d) => d.id); + + // Safe in-band ordering: each mid-turn notice is stored after the tool + // result it rode with, and was injected into the live stream there. + const msgs = h.messages(); + const noticeIdx = msgs.findIndex((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-drafts'); + assert.ok(noticeIdx > 0, 'a held-draft notice was stored'); + assert.ok(msgs[noticeIdx - 1]!.content.some((b) => b.type === 'tool_result'), 'right after the tool result'); + const injected = (h.membrane.lastStream?.receivedToolResultOptions ?? []) + .flatMap((o) => o?.injectedMessages ?? []).map((m) => JSON.stringify(m.content)); + assert.ok(injected.some((t) => t.includes(ids[0]!)), 'the resident heard about the first draft mid-turn'); + const receipt = h.texts().find((t) => t.startsWith('[delivered]'))!; + assert.equal(receipt, `[delivered] nothing — 3 plain-speech segment(s) held as drafts ${ids.join(', ')} (not sent — drafts can resend them unchanged, or dismiss them)`); + + // Next turn: list (every field present, nulls), read, then resend all three. + await h.turn([ + createMockResponse([drafts('d1', { action: 'list' })], 'tool_use'), + createMockResponse([drafts('d2', { action: 'read', draftIds: [ids[1]] })], 'tool_use'), + createMockResponse([drafts('d3', { action: 'resend', draftIds: ids, destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + const [list, read, resend] = h.toolResults(); + assert.match(list!, /3 open drafts, newest first/); + assert.ok(read!.endsWith(`exactly as written):\n${segments[1]}`), `read shows the text in full: ${read}`); + assert.match(resend!, new RegExp(`${ids[0]}: delivered to #room \\(Guild One\\) \\(${ROOM}\\), message posted-2`)); + const sent = h.publishes().slice(1); + assert.deepEqual(sent.map((p) => [p.channelId, p.text]), segments.map((s) => [ROOM, s]), 'verbatim, in order'); + assert.ok(h.store().open('scout').length === 0, 'nothing left open'); + } finally { + await h.close(); + } + }); + + it('XML tool mode: the whole turn\'s prose is held at turn end and named in the receipt, never mid-turn', async () => { + const h = await harness({ xml: true }); + try { + // XML mode completes with the turn's cumulative content (its tool + // calls already ran: the completion carries none of its own). + await h.turn([ + createMockResponse([text('Narration before the send.'), explicitSend('s1')], 'tool_use'), + { ...createMockResponse([text('Narration before the send.'), explicitSend('s1'), text('And after it.')]), toolCalls: [] }, + ]); + const held = h.drafts().reverse(); + assert.deepEqual(held.map((d) => d.text), ['Narration before the send.', 'And after it.']); + assert.equal(h.messages().filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-drafts').length, 0, + 'no mid-turn notice: XML rounds present no injections'); + assert.ok(h.texts().some((t) => t.startsWith('[delivered] nothing — 2 plain-speech segment(s) held as drafts'))); + } finally { + await h.close(); + } + }); + + it('deliberate privacy is never drafted: skip_reply and a same-round private think', async () => { + const h = await harness({ agents: [{ name: 'scout', sameRoundThinkTextPolicy: 'private' }] }); + try { + await h.turn([ + createMockResponse([text('Thinking aloud, privately.'), call('k1', 'skip_reply', {})], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.drafts(), []); + assert.ok(h.texts().some((t) => t === '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply)')); + await h.turn([ + createMockResponse([text('Prose beside a private think.'), call('t1', 'think', { thought: 'hmm' }), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.drafts(), [], 'a same-round private think keeps its prose private, even beside a send'); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); + } finally { + await h.close(); + } + }); + + it('disabled mode stays draft-free: its narration is private by configuration', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'disabled' }] }); + try { + await h.turn([ + createMockResponse([text('Narration that never publishes.'), explicitSend('s1')], 'tool_use'), + createMockResponse([text('More of it.')]), + ]); + assert.deepEqual(h.drafts(), []); + assert.ok(h.texts().some((t) => t.startsWith('[delivered] nothing — 2 plain-speech segment(s) suppressed (proseRouting=disabled'))); + } finally { + await h.close(); + } + }); + + it('drafts belong to their resident: another resident can neither list nor resend them', async () => { + const h = await harness({ agents: [{ name: 'scout' }, { name: 'other' }] }); + try { + // Direct requests: a channel message would wake both residents. + await h.turn([ + createMockResponse([text('scout words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ], 'scout', 'request'); + const [mine] = h.drafts('scout'); + assert.ok(mine); + await h.turn([ + createMockResponse([drafts('o1', { action: 'list' })], 'tool_use'), + createMockResponse([drafts('o2', { action: 'resend', draftIds: [mine.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ], 'other', 'request'); + const [list, resend] = h.toolResults(); + assert.match(list!, /You have no open drafts/); + assert.match(resend!, new RegExp(`No draft of yours with id ${mine.id}`)); + assert.equal(h.publishes().filter((p) => p.text === 'scout words').length, 0); + // Residents of one framework share a message slot (#197), so the + // notice is visible there too; it names whose drafts it means. + assert.ok(h.texts('scout').some((t) => t.startsWith(`[drafts] scout: not sent — a plain-speech segment held as draft ${mine.id}`))); + } finally { + await h.close(); + } + }); + + it('failed and uncertain resends: a refusal stays resendable; an unknown outcome needs confirmDuplicate', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('fragile words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + const resend = (id: string, confirm?: boolean) => + drafts(id, { action: 'resend', draftIds: [draft!.id], destination: ROOM, ...(confirm ? { confirmDuplicate: true } : {}) }); + + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([resend('r1')], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /not sent to .* Nothing was posted; the draft stays held/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'held'); + + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([resend('r2')], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /NOT confirmed — it may or may not have been posted/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + + h.command({ op: 'publish-mode', mode: 'delivered' }); + await h.turn([createMockResponse([resend('r3')], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /may already have been posted.*confirmDuplicate: true\. Nothing was sent/); + const before = h.publishes().length; + await h.turn([createMockResponse([resend('r4', true)], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /delivered to/); + assert.equal(h.publishes().length, before + 1); + const attempts = h.store().get('scout', draft!.id)!.attempts; + assert.deepEqual(attempts.map((a) => [a.outcome?.status, a.confirmedDuplicate ?? false]), [ + ['failed', false], ['unknown', false], ['delivered', true], + ]); + } finally { + await h.close(); + } + }); + + it('June\'s cases: a rollback to before a confirmed resend returns the receipt, no new publish; a dismissal survives it', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('to deliver'), explicitSend('s1')], 'tool_use'), + createMockResponse([text('to dismiss')]), + ]); + const [deliver, dismiss] = h.drafts().reverse(); + const anchor = h.messages().at(-1)!.id; + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [deliver!.id], destination: ROOM })], 'tool_use'), + createMockResponse([drafts('x1', { action: 'dismiss', draftIds: [dismiss!.id] })], 'tool_use'), + createMockResponse([]), + ]); + const published = h.publishes().length; + await h.framework.rollbackToMessage('scout', { messageId: anchor }); + assert.ok(!h.texts().some((t) => t.includes('delivered to')), 'the resend turn is gone from the branch'); + await h.turn([ + createMockResponse([drafts('r2', { action: 'resend', draftIds: [deliver!.id], destination: ROOM })], 'tool_use'), + createMockResponse([drafts('r3', { action: 'resend', draftIds: [dismiss!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + const [receipt, refused] = h.toolResults(); + assert.match(receipt!, new RegExp(`${deliver!.id}: delivered — confirmed at .* to #room \\(Guild One\\) \\(${ROOM}\\), message posted-\\d+ .*not sent again`)); + assert.match(refused!, new RegExp(`${dismiss!.id} was dismissed`)); + assert.equal(h.publishes().length, published, 'no new publish'); + } finally { + await h.close(); + } + }); + + it('June\'s case: after a restart, an attempt that never recorded an outcome stays unknown and needs confirmDuplicate', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('half-sent words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + // The process "died" between journaling the attempt and its outcome. + h.store().beginAttempt('scout', draft!.id, dest(), 'resend', false); + await h.restart(); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + const before = h.publishes().length; + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.toolResults()[0]!, /its outcome was never recorded.*confirmDuplicate: true\. Nothing was sent/); + assert.equal(h.publishes().length, before, 'nothing replayed at boot or sent without confirmation'); + } finally { + await h.close(); + } + }); + + it('a draft held in a turn that crashed is named by the next turn\'s catch-up notice', async () => { + const h = await harness(); + try { + // Held directly, as if the turn that held it died before its receipt. + h.store().hold('scout', [{ text: 'orphaned words', source }], 'explicit-send'); + await h.turn([createMockResponse([])]); + const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; + assert.match(notice, /^\[drafts\] scout: a held draft of yours has not been named to you yet: d-[a-z2-9]{5} .*"orphaned words"/); + await h.turn([createMockResponse([])]); + assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'named once'); + } finally { + await h.close(); + } + }); + + it('explicit mode: a bounce is a draft that survives a restart, and {{unsent}} delivers and settles it', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); + try { + await h.turn([createMockResponse([text('words with nowhere to go')])]); + const [bounced] = h.drafts(); + assert.equal(bounced?.reason, 'bounced'); + assert.ok(h.texts().some((t) => t.includes(`It is held as draft ${bounced!.id}`))); + await h.restart(); + await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); + assert.deepEqual(h.publishes().map((p) => p.text), ['words with nowhere to go']); + const after = h.store().get('scout', bounced!.id)!; + assert.equal(draftState(after), 'delivered'); + assert.equal(after.attempts[0]!.via, 'unsent-token'); + } finally { + await h.close(); + } + }); + + it('resend is an explicit send: prose beside it is held, not auto-routed', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('earlier words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + await h.turn([ + createMockResponse([text('a postscript'), drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere', 'earlier words']); + assert.deepEqual(h.drafts().map((d) => d.text), ['a postscript']); + } finally { + await h.close(); + } + }); + + it('two resends of one draft in the same round: one is sent, the other is refused while it is in flight', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('once only'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + const resend = (id: string) => + drafts(id, { action: 'resend', draftIds: [draft!.id], destination: ROOM, confirmDuplicate: true }); + await h.turn([createMockResponse([resend('a'), resend('b')], 'tool_use'), createMockResponse([])]); + const [first, second] = h.toolResults(); + assert.match(first!, /delivered to/); + assert.match(second!, /is being sent right now; wait for that result/); + assert.equal(h.publishes().filter((p) => p.text === 'once only').length, 1); + } finally { + await h.close(); + } + }); + + it('refuses unusable calls before sending anything', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + await h.turn([ + createMockResponse([ + drafts('a', { action: 'resend', draftIds: [draft!.id] }), + drafts('b', { action: 'resend', draftIds: [draft!.id], destination: '#nowhere' }), + drafts('c', { action: 'list', draftIds: [draft!.id] }), + drafts('d', { action: 'read', draftIds: ['d-zzzzz'] }), + drafts('e', { action: 'shout' }), + ], 'tool_use'), + createMockResponse([]), + ]); + const [noDest, badDest, unused, unknown, badAction] = h.toolResults(); + assert.match(noDest!, /resend needs a destination/); + assert.match(badDest!, /Destination "#nowhere" did not resolve.*Nothing was sent/); + assert.match(unused!, /draftIds is not used by action "list"/); + assert.match(unknown!, /No draft of yours with id d-zzzzz/); + assert.match(badAction!, /action must be one of/); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); + } finally { + await h.close(); + } + }); +}); From 15ec398ac9ca454b21b67ea1ea533b96d8862fe9 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 08:12:34 -0400 Subject: [PATCH 08/65] fix(mcpl): refuse invalid publish selectors; a contradictory receipt is unknown Tessa-974's review of the publish executor (room-203 #43780, probe /tmp/tessa-974-publish-boundary-probe.mjs): 1. An explicitly empty serverId acted like omission and dispatched to the sole server registering the channel id. A supplied selector is now checked, never read as omission: resolveDestination/publish refuse an empty or non-string serverId and an empty or missing channelId (failed, nothing dispatched). Omission remains the only "not in use" form for this API. 2. `{delivered:false, messageId:123}` became `failed` once the malformed id was discarded, which does not support "nothing was posted". `delivered: false` is now `failed` only when the receipt names no message at all (messageId absent or null); a message id beside it, valid or malformed, is a contradictory receipt and reads `unknown` (a valid id is kept). 3. The publish() doc called it the executor behind channel tools, which is prospective: channel_publish (and publishForAgent, which shares its handler) move onto it with shelf-356. The doc says so, and it now sits on publish() itself (351 had left it above resolveDestination). Tests (publish-outcome.test.ts): delivered:false beside a numeric, empty and object message id is unknown with "contradictory receipt"; a null id is failed; empty/null/non-string serverId and empty/missing channelId are refused with nothing dispatched, while the exact selector still delivers. 43/43 with channel-registry-routing; 62/62 across prose-drafts, explicit-prose-routing, present-while-acting and mcpl-request-outcome. Co-Authored-By: Claude Opus 5.5 --- changelog.d/publish-outcomes.fixed.md | 11 ++-- src/mcpl/channel-registry.ts | 89 +++++++++++++++++---------- test/publish-outcome.test.ts | 36 +++++++++++ 3 files changed, 98 insertions(+), 38 deletions(-) diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md index 62eb206a7..659f0742b 100644 --- a/changelog.d/publish-outcomes.fixed.md +++ b/changelog.d/publish-outcomes.fixed.md @@ -4,13 +4,16 @@ destination the registry resolved). Only a connector's `delivered: true` confirms a post; a missing or malformed receipt is no longer counted as delivered (#163's rule). `failed` means nothing was posted: refused before - dispatch, or `delivered: false` naming no posted message. An error response, - a timeout or a lost connection after dispatch is `unknown`, keeping the - connector's own words (a multi-part send may already be partly posted). + dispatch, or `delivered: false` naming no message. An error response, a + timeout or a lost connection after dispatch is `unknown`, keeping the + connector's own words (a multi-part send may already be partly posted), and + so is a contradictory receipt (`delivered: false` beside a message id, + valid or not). The resident's `[discord-send-failed]` marker now says which: "could not be delivered … Nothing was posted" for a failure, and "was not confirmed … may or may not have been posted; check the channel before sending it again" for an uncertain outcome. The marker's metadata and the `mcpl:speech-route-failed` trace carry `outcome`. A channel id registered by more than one MCPL server is refused rather than routed through - whichever server registered it first. + whichever server registered it first, and a supplied but empty or + non-string server selector is refused rather than read as omitted. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 6aa477bf2..a17ffd3a2 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3002,25 +3002,6 @@ export class ChannelRegistry { return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; } - /** - * Publish `text` to one registered channel and report what the attempt - * established (PublishOutcome). The single delivery executor behind plain - * speech (routeSpeech), resident resends of held drafts, and channel tools. - * - * Resolution is exact: `serverId` plus `channelId` when the caller has the - * server, otherwise the channel id must be registered by exactly one server - * (a shared id is refused, never routed through whichever server came - * first). It is not possible to send into a closed channel: a closed but - * registered destination is opened first (`openSource` names why), and a - * failed open sends nothing. - * - * Only the connector's `delivered: true` confirms a post. Anything that - * stops before the request is written is `failed` (nothing left the host), - * as is the connector's own `delivered: false`. Once the request has been - * dispatched, an error response, a timeout, a lost connection, or a missing - * or malformed receipt is `unknown`: the post may or may not exist, and the - * caller must not treat it as safe to retry blindly. - */ /** * Resolve a publish target to exactly one registered channel: `serverId` * plus `channelId` when the caller has the server, otherwise a channel id @@ -3044,6 +3025,14 @@ export class ChannelRegistry { private findExactEntry( target: { serverId?: string; channelId: string }, ): { entry: ChannelEntry } | { error: string } { + // A supplied selector is checked, never read as omission: an empty or + // non-string serverId would otherwise widen to "any server". + if (typeof target.channelId !== 'string' || !target.channelId) { + return { error: 'the destination names no channel id' }; + } + if (target.serverId !== undefined && (typeof target.serverId !== 'string' || !target.serverId)) { + return { error: 'serverId must name a server (omit it to resolve the channel id alone)' }; + } const matches = [...this.channels.values()].filter( (e) => e.descriptor.id === target.channelId && (!target.serverId || e.serverId === target.serverId), ); @@ -3062,6 +3051,33 @@ export class ChannelRegistry { return { entry: matches[0]! }; } + /** + * Publish `text` to one registered channel and report what the attempt + * established (PublishOutcome). The delivery executor behind plain speech + * (deliverSpeech / routeSpeech) and resident resends of held drafts; + * channel_publish (and publishForAgent, which shares its handler) moves + * onto it with shelf-356 — until then that tool reads its own receipt. + * + * Resolution is exact (resolveDestination): `serverId` plus `channelId` + * when the caller has the server, otherwise the channel id must be + * registered by exactly one server (a shared id is refused, never routed + * through whichever server came first). An omitted `serverId` means "not + * in use"; a supplied one must be a non-empty server id, and an empty or + * non-string selector is refused rather than read as omission. It is not + * possible to send into a closed channel: a closed but registered + * destination is opened first (`openSource` names why), and a failed open + * sends nothing. + * + * Only the connector's `delivered: true` confirms a post. `failed` means + * nothing was posted: the attempt stopped before the request was written, + * or the connector answered `delivered: false` and named no message (no + * `messageId`, or null). Everything else is `unknown`: once the request + * has been handed to the transport, an error response, a timeout, a lost + * connection, or a missing, malformed or contradictory receipt (such as + * `delivered: false` beside a message id, valid or not) means the post may + * or may not exist, and the caller must not treat it as safe to retry + * blindly. + */ async publish( conversationId: string, text: string, @@ -3153,21 +3169,26 @@ export class ChannelRegistry { } if (receipt.delivered === false) { // `delivered: false` proves nothing went out only when the receipt - // names no posted message; a named one means something did. - return messageId - ? { - status: 'unknown', - destination, - messageId, - reason: `server "${entry.serverId}" reported delivered:false but named posted message ${messageId} (delivery uncertain)`, - at: at(), - } - : { - status: 'failed', - destination, - reason: `server "${entry.serverId}" reported delivered:false`, - at: at(), - }; + // names no message at all. A message id beside it — valid or + // malformed — contradicts it: something may have been posted. + if (receipt.messageId === undefined || receipt.messageId === null) { + return { + status: 'failed', + destination, + reason: `server "${entry.serverId}" reported delivered:false`, + at: at(), + }; + } + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: messageId + ? `server "${entry.serverId}" reported delivered:false but named posted message ${messageId} (delivery uncertain)` + : `server "${entry.serverId}" reported delivered:false beside a malformed message id ` + + `${JSON.stringify(receipt.messageId)} (contradictory receipt; delivery uncertain)`, + at: at(), + }; } // Final publish is a request, so success requires an explicit receipt; a // missing or malformed one leaves delivery uncertain (anima-research/ diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index b9bb16d3a..d9d2d79d3 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -73,6 +73,42 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(outcome.status, 'failed'); }); + for (const [what, messageId] of [['a numeric', 123], ['an empty', ''], ['an object', { id: 'x' }]] as const) { + it(`treats delivered:false beside ${what} message id as unknown: a contradictory receipt proves nothing`, async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, messageId }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.match(outcome.reason ?? '', /contradictory receipt/); + assert.equal(outcome.messageId, undefined); + }); + } + + it('treats delivered:false with a null message id as failed (nothing named)', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, messageId: null }) } }); + seed('discord', 'c', '#c'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'failed'); + }); + + it('refuses a supplied but empty or non-string selector instead of reading it as omitted', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'c', '#c'); + for (const target of [ + { serverId: '', channelId: 'c' }, + { serverId: null as unknown as string, channelId: 'c' }, + { serverId: 7 as unknown as string, channelId: 'c' }, + { channelId: '' }, + { channelId: undefined as unknown as string }, + ]) { + const outcome = await registry.publish('agent', 'x', target); + assert.equal(outcome.status, 'failed', JSON.stringify(target)); + assert.equal(outcome.destination, undefined); + assert.ok('error' in registry.resolveDestination(target), JSON.stringify(target)); + } + assert.equal(published.length, 0, 'nothing dispatched to the sole matching server'); + assert.equal((await registry.publish('agent', 'x', { serverId: 'discord', channelId: 'c' })).status, 'delivered', 'the exact selector still works'); + }); + it('treats delivered:false that names a posted message as unknown', async () => { const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, messageId: 'part-1' }) } }); seed('discord', 'c', '#c'); From 8e4146d33bca66cb907e4ecf68b0bcd844af64ac Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 08:50:13 -0400 Subject: [PATCH 09/65] fix(drafts): keep uncertainty across attempts; resends own and re-check drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tessa-974's review of 351 (room-203 #44135, #44214; probe /tmp/tessa-974-draft-state-probe.mjs): 1. Uncertainty was cleared by a later failure: unknown, then a confirmed retry that failed, read `held`, and a third resend went out without confirmation. A draft is now `unconfirmed` while ANY attempt has an unknown or unrecorded outcome and none was confirmed; uncertainAttempt() names that attempt, and refusals, the list line and a failed retry's receipt cite it (destination, time, reason) rather than "the last one". 2. The up-front batch check went stale across an await: a batch [A,B] waiting on A let a concurrent resend of B go out, then published B again; a dismissal of B meanwhile was ignored. A resend now claims every draft of its batch before its first await (a concurrent resend or {{unsent}} of any of them is refused while the batch owns it), and re-reads each draft from the current projection at its own dispatch boundary, so a dismissal or delivery since the batch began stops it there (also after a journal reconcile replaced the projection). 3. The crash catch-up said "They were not sent" of an unconfirmed draft. It and the list now render each draft's actual state: "held (not sent)", "held (every attempt failed: nothing was posted)" or "UNCONFIRMED — an attempt to … may have been posted". 4. bounceProse expanded `{{unsent}}` from an unconfirmed (or in-flight) latest bounce into a fresh held draft when the envelope's target failed, which then resent without confirmation. Such a draft is never copied: it stays held as itself, only the resident's other words become the new draft, and the bounce notice says so. Privacy boundary, agreed with Tessa (#44155, #44214): drafts are never published except by their resident's explicit resend (or {{unsent}}), only that resident can list or act on them, and notices go into the resident's own history, which residents sharing one message slot (#197) already share. The tool description, notices, module doc, explicit-routing doc and changelog now say that instead of "private". Tests (prose-drafts.test.ts, 22): unknown then failed stays unconfirmed and refuses an unconfirmed third resend; the catch-up renders held and unconfirmed drafts truthfully; an at-risk bounce is not copied into a new draft and {{unsent}} then means only the fresh words; and, through the real drafts handler with publish completions under the test's control, a concurrent resend of a queued draft is refused with B published once, and a draft dismissed while the batch waits never reaches publish. Suite (--test-concurrency=4, load ~7-12): 1329/1330 (#196 vector 34c's wall-clock bound at 30.7 s) + code-execution 36/36. Composition with Greta-1013's 376 companion (6ff476d, membrane#101 pack) merged cleanly: 30/30 incl. her xml-tool-call-notices and a composed text-only tool_attempt split check (room-220 #44367). Co-Authored-By: Claude Opus 5.5 --- changelog.d/prose-drafts.added.md | 14 +- docs/explicit-prose-routing.md | 3 +- src/framework.ts | 252 +++++++++++++++++++----------- src/prose-drafts.ts | 32 +++- test/prose-drafts.test.ts | 148 +++++++++++++++++- 5 files changed, 337 insertions(+), 112 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index a115c52fe..45af20a6a 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -1,6 +1,6 @@ - **Held prose drafts: suppressed speech is kept, not destroyed.** Plain - speech that is not sent is now held as a private draft with its exact - words, instead of surviving only as a count: speech suppressed by an + speech that is not sent is now held as a draft with its exact words, + instead of surviving only as a count: speech suppressed by an explicit send in the same round (locus and hybrid modes, mid-turn, trailing and text-only, native and XML tool modes), hybrid prose left without a destination after a failed envelope, and explicit/hybrid bounces (which @@ -15,13 +15,19 @@ stream presents mid-turn messages, and the turn-end `[delivered]` receipt names every draft held in the turn ("held as drafts d-…"); a draft no notice reached (a crash, an aborted turn) is named at the next turn's start. +- A draft is never published except by its resident's explicit resend (or + `{{unsent}}`), and only that resident can list or act on it. Its notices go + into the resident's own history, which residents sharing one message slot + already share, as they share the assistant turn the words came from. - New `drafts` tool (whenever MCPL channels are configured): `list`, `read`, `resend` and `dismiss`, scoped to the calling resident. `resend` publishes drafts verbatim, in order, to one destination through the normal publish path, journaling each attempt durably before the request leaves; it counts as an explicit send. A delivered draft returns its historical receipt - instead of sending again; a draft whose last attempt's outcome is unknown - needs `confirmDuplicate: true`. Unused fields may be passed as null. + instead of sending again; a draft with any attempt whose outcome is + unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend + owns its drafts until it finishes, re-checking each before it is sent. + Unused fields may be passed as null. - `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and `ChannelRegistry.resolveDestination` are new; routeSpeech is unchanged. - The bounce notice now goes to the bouncing resident rather than the primary. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 1c428316d..b719b400c 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -36,7 +36,8 @@ A prose segment's FIRST line may carry a routing prefix: ## The bounce (unprefixed prose) Unprefixed prose with no sticky target is **never delivered**. It is held as a -private draft (src/prose-drafts.ts), and a system notice naming the draft tells +draft (src/prose-drafts.ts) — never published except by the agent's own +resend — and a system notice naming the draft tells the agent how to resend: `>>#channel {{unsent}}`, or the `drafts` tool by id. The notice requests inference so the resend can happen immediately — capped at 2 consecutive bounce-wakes per agent (then notices append without waking, diff --git a/src/framework.ts b/src/framework.ts index 4fdab2234..e927a2cbf 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -82,7 +82,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, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { ProseDraftStore, draftState, type Draft, type DraftReason, type DraftSource } from './prose-drafts.js'; +import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -1116,7 +1116,8 @@ export class AgentFramework { // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- /** Every resident's held drafts: suppressed, bounced or ambiguous plain - * speech, kept privately until resent or dismissed. */ + * speech, never published except by their resident's resend, kept until + * resent or dismissed. */ private proseDrafts: ProseDraftStore; /** Drafts held during the current logical turn, for its `[delivered]` * receipt. Cleared each fresh turn; restarts keep it. */ @@ -4652,7 +4653,8 @@ export class AgentFramework { /** Synthesized drafts tool — present whenever MCPL channels are * configured. A resident's held drafts (src/prose-drafts.ts): plain speech - * that was not sent, kept privately until resent or dismissed. Nullable + * that was not sent, never published except by its resident's resend and + * kept until resent or dismissed. Nullable * fields, cast like save_recent_image's: a caller whose provider presents * every property as required can still say "not in use". */ private static readonly DRAFTS_TOOL = { @@ -4660,8 +4662,8 @@ export class AgentFramework { description: 'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in the ' + 'same round holds plain speech back, because a routing prefix bounced, or because it had no destination. ' + - 'Drafts are private to you and stay until you resend or dismiss them; nothing is ever sent from here on ' + - 'its own. list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + + 'Drafts are never published except by your own resend, only you can list or act on them, and they stay ' + + 'until you resend or dismiss them. list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + 'resend: publish drafts verbatim, in the order given, to one destination ("#channel", "@person" or a ' + 'channel id) — an explicit send, like send_message; it stops at the first draft not confirmed delivered. ' + 'A draft already delivered returns its receipt instead of sending again. If an earlier attempt may ' + @@ -8828,7 +8830,7 @@ export class AgentFramework { } /** - * Hold plain speech as private drafts (src/prose-drafts.ts) instead of + * Hold plain speech as drafts (src/prose-drafts.ts) instead of * letting it vanish: journaled durably and recorded for this turn's * receipt. `notice: 'now'` also queues a private notice naming them, which * the resident hears at its next tool boundary — only when the live stream @@ -8916,7 +8918,7 @@ export class AgentFramework { return ( `[drafts] ${agentName}: not sent — ${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` + `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${AgentFramework.DRAFT_REASON_TEXT[reason]}. ` + - `Drafts are private to you and stay until you act: drafts(action: "resend", draftIds: [${idList}], ` + + `Nothing publishes drafts but your own resend, and they stay until you act: drafts(action: "resend", draftIds: [${idList}], ` + `destination: "#channel") delivers ${many ? 'them' : 'it'} unchanged; drafts(action: "dismiss", ` + `draftIds: [${idList}]) sets ${many ? 'them' : 'it'} aside.` ); @@ -8939,20 +8941,26 @@ export class AgentFramework { /** One line per draft: id, state, when, why, size, preview. */ private draftLine(draft: Draft): string { - const state = draftState(draft); - const last = draft.attempts.at(-1); - let stateText: string = state; - if (state === 'unconfirmed' && last) { - stateText = `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(last.destination)} at ` + - `${this.draftTime(last.at)} may have been posted`; - } else if (state === 'held' && last) { - stateText = `held (last attempt failed: nothing was posted)`; - } + const stateText = this.draftStateText(draft); return `${draft.id} · ${stateText} · held ${this.draftTime(draft.heldAt)} · ` + `${AgentFramework.DRAFT_REASON_SHORT[draft.reason]} · ${draft.text.length} chars · ` + AgentFramework.draftPreview(draft.text); } + /** A draft's state in words that claim only what is known. */ + private draftStateText(draft: Draft): string { + const state = draftState(draft); + if (state === 'unconfirmed') { + const risky = uncertainAttempt(draft)!; + return `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + `${this.draftTime(risky.at)} may have been posted`; + } + if (state === 'held') { + return draft.attempts.length > 0 ? 'held (every attempt failed: nothing was posted)' : 'held (not sent)'; + } + return state; + } + /** A delivered draft's historical receipt. */ private draftReceipt(draft: Draft): string { const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); @@ -8971,13 +8979,13 @@ export class AgentFramework { const pending = this.proseDrafts.unnoticed(agent.name); if (pending.length === 0) return; const shown = pending.slice(0, 8).map((d) => - `${d.id} (held ${this.draftTime(d.heldAt)}, ${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ` + - `${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); + `${d.id} (${this.draftStateText(d)}; held ${this.draftTime(d.heldAt)}, ` + + `${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); const more = pending.length > shown.length ? ` · and ${pending.length - shown.length} more` : ''; const text = `[drafts] ${agent.name}: ${pending.length === 1 ? 'a held draft' : `${pending.length} held drafts`} of yours ` + `${pending.length === 1 ? 'has' : 'have'} not been named to you yet: ${shown.join(' · ')}${more}. ` + - 'They were not sent. drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; + 'drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; try { const mid = agent.getContextManager().addMessage( 'user', @@ -9161,87 +9169,114 @@ export class AgentFramework { // Refuse up front, before anything is published, rather than half-way. for (const d of drafts) { if (this.draftsInFlight.has(`${agentName}\u0000${d.id}`)) { - return refuse(`${d.id} is being sent right now; wait for that result before sending it again. Nothing was sent.`); - } - const state = draftState(d); - if (state === 'dismissed') { - return refuse(`${d.id} was dismissed, so it can't be resent. Nothing was sent.`); - } - if (state === 'unconfirmed' && !confirmDuplicate) { - const last = d.attempts.at(-1)!; - return refuse( - `${d.id}'s last attempt (to ${AgentFramework.destinationText(last.destination)} at ${this.draftTime(last.at)}) ` + - `may already have been posted: ${last.outcome?.reason ?? 'its outcome was never recorded'}. ` + - 'Check that channel; to send it again anyway, resend with confirmDuplicate: true. Nothing was sent.', - ); + return refuse(`${d.id} is being sent right now (or is queued in a resend in progress); wait for that result before sending it again. Nothing was sent.`); } + const refusal = this.resendRefusal(d, confirmDuplicate); + if (refusal) return refuse(`${refusal} Nothing was sent.`); } + // Claim every draft of the batch before the first await: a concurrent + // resend of any of them is refused while this batch owns it. Each draft + // is still re-read and re-checked at its own dispatch boundary below, so + // a dismissal (or a delivery) since the batch began is honoured. + const claims = drafts.map((d) => `${agentName}\u0000${d.id}`); + for (const claim of claims) this.draftsInFlight.add(claim); const lines: string[] = []; let stopped = false; - for (const d of drafts) { - if (stopped) { - lines.push(`${d.id}: not attempted (an earlier draft was not confirmed delivered).`); - continue; - } - if (draftState(d) === 'delivered') { - lines.push(`${this.draftReceipt(d)} — not sent again.`); - continue; - } - let attemptId: string; - try { - attemptId = this.proseDrafts.beginAttempt(agentName, d.id, destination, 'resend', draftState(d) === 'unconfirmed'); - } catch (err) { - lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}).`); - stopped = true; - continue; - } - const flight = `${agentName}\u0000${d.id}`; - this.draftsInFlight.add(flight); - let outcome: PublishOutcome; - try { - outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId }); - } finally { - this.draftsInFlight.delete(flight); - } - let recorded = true; - try { - this.proseDrafts.recordOutcome(agentName, d.id, attemptId, outcome); - } catch (err) { - recorded = false; - console.error(`[drafts] ${agentName}: outcome of ${d.id} not recorded:`, err); - } - this.emitTrace({ - type: 'prose:draft-resent', - agentName, - draftId: d.id, - status: outcome.status, - serverId: outcome.destination?.serverId ?? destination.serverId, - channelId: outcome.destination?.channelId ?? destination.channelId, - ...(outcome.messageId ? { messageId: outcome.messageId } : {}), - }); - const where = AgentFramework.destinationText(outcome.destination ?? destination); - const unrecorded = recorded ? '' : ' (this outcome could not be recorded; the draft will read as unconfirmed)'; - if (outcome.status === 'delivered') { - lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); - let engaged = this.turnEngagedChannels.get(agentName); - if (!engaged) { - engaged = new Set(); - this.turnEngagedChannels.set(agentName, engaged); + try { + for (const queued of drafts) { + if (stopped) { + lines.push(`${queued.id}: not attempted (an earlier draft was not confirmed delivered).`); + continue; } - engaged.add(destination.channelId); - continue; + // The current projection, not the batch's snapshot (it may have been + // rebuilt by a reconcile, or changed while an earlier draft was sent). + const d = this.proseDrafts.get(agentName, queued.id); + if (!d) { + lines.push(`${queued.id}: no longer found among your drafts — not sent.`); + stopped = true; + continue; + } + if (draftState(d) === 'delivered') { + lines.push(`${this.draftReceipt(d)} — not sent again.`); + continue; + } + const refusal = this.resendRefusal(d, confirmDuplicate); + if (refusal) { + lines.push(`${refusal} It changed while this resend was waiting — not sent.`); + stopped = true; + continue; + } + let attemptId: string; + try { + attemptId = this.proseDrafts.beginAttempt(agentName, d.id, destination, 'resend', draftState(d) === 'unconfirmed'); + } catch (err) { + lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}).`); + stopped = true; + continue; + } + const outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId }); + let recorded = true; + try { + this.proseDrafts.recordOutcome(agentName, d.id, attemptId, outcome); + } catch (err) { + recorded = false; + console.error(`[drafts] ${agentName}: outcome of ${d.id} not recorded:`, err); + } + this.emitTrace({ + type: 'prose:draft-resent', + agentName, + draftId: d.id, + status: outcome.status, + serverId: outcome.destination?.serverId ?? destination.serverId, + channelId: outcome.destination?.channelId ?? destination.channelId, + ...(outcome.messageId ? { messageId: outcome.messageId } : {}), + }); + const where = AgentFramework.destinationText(outcome.destination ?? destination); + const unrecorded = recorded ? '' : ' (this outcome could not be recorded; the draft will read as unconfirmed)'; + if (outcome.status === 'delivered') { + lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); + let engaged = this.turnEngagedChannels.get(agentName); + if (!engaged) { + engaged = new Set(); + this.turnEngagedChannels.set(agentName, engaged); + } + engaged.add(destination.channelId); + continue; + } + stopped = true; + const after = this.proseDrafts.get(agentName, d.id); + const stillUncertain = after ? uncertainAttempt(after) : undefined; + lines.push(outcome.status === 'unknown' + ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` + + `Check the channel before sending it again (that needs confirmDuplicate: true)${unrecorded}.` + : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` + + (stillUncertain + ? ` An earlier attempt (to ${AgentFramework.destinationText(stillUncertain.destination)} at ` + + `${this.draftTime(stillUncertain.at)}) may still have been posted, so the draft stays unconfirmed.` + : ' The draft stays held.')); } - stopped = true; - lines.push(outcome.status === 'unknown' - ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` + - `Check the channel before sending it again (that needs confirmDuplicate: true)${unrecorded}.` - : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted; the draft stays held${unrecorded}.`); + } finally { + for (const claim of claims) this.draftsInFlight.delete(claim); } const allDelivered = !stopped; return allDelivered ? ok(lines.join('\n')) : refuse(lines.join('\n')); } + /** Why a draft can't be resent now (dismissed, or possibly already posted + * without the resident's confirmation), or undefined when it can. */ + private resendRefusal(d: Draft, confirmDuplicate: boolean): string | undefined { + const state = draftState(d); + if (state === 'dismissed') return `${d.id} was dismissed, so it can't be resent.`; + if (state === 'unconfirmed' && !confirmDuplicate) { + const risky = uncertainAttempt(d)!; + return `${d.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ${this.draftTime(risky.at)} ` + + `may already have been posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}. ` + + 'Check that channel; to send it again anyway, resend with confirmDuplicate: true.'; + } + return undefined; + } + /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( agentName: string, @@ -9464,12 +9499,22 @@ export class AgentFramework { */ private takeUnsent(agent: Agent, prefix: '>>' | '>>>'): { draft?: Draft; refused?: true } { const draft = this.proseDrafts.latestBounce(agent.name); - if (!draft || draftState(draft) !== 'unconfirmed') return { draft }; - const last = draft.attempts.at(-1)!; + if (!draft) return {}; + if (this.draftsInFlight.has(`${agent.name}\u0000${draft.id}`)) { + const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. Nothing was sent; wait for that result.`; + try { + this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (err) { + console.error('[drafts] in-flight-unsent notice failed:', err); + } + return { draft, refused: true }; + } + if (draftState(draft) !== 'unconfirmed') return { draft }; + const risky = uncertainAttempt(draft)!; const text = - `[prose-routing] {{unsent}} is draft ${draft.id}, and its last delivery attempt (to ` + - `${AgentFramework.destinationText(last.destination)} at ${this.draftTime(last.at)}) may already have been ` + - `posted: ${last.outcome?.reason ?? 'its outcome was never recorded'}. Nothing was sent. Check that channel; ` + + `[prose-routing] {{unsent}} is draft ${draft.id}, and an attempt to deliver it (to ` + + `${AgentFramework.destinationText(risky.destination)} at ${this.draftTime(risky.at)}) may already have been ` + + `posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}. Nothing was sent. Check that channel; ` + `to send it again anyway use drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true).`; try { this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); @@ -9704,11 +9749,24 @@ export class AgentFramework { ): void { const name = agent.name; const previous = this.proseDrafts.latestBounce(name); + // A latest bounce that may already have been posted (unconfirmed, or in + // flight) is never copied into a fresh draft: that copy would resend + // without the confirmation its own uncertainty requires. It stays held as + // itself; only the resident's other words become a new draft. + const previousAtRisk = previous !== undefined + && (draftState(previous) === 'unconfirmed' || this.draftsInFlight.has(`${name}\u0000${previous.id}`)); let draft: Draft | undefined; + let keptRisky: Draft | undefined; if (previous && text.trim() === '{{unsent}}') { draft = previous; } else { - const expanded = text.includes('{{unsent}}') ? text.replaceAll('{{unsent}}', previous?.text ?? '') : text; + const usesUnsent = text.includes('{{unsent}}'); + if (usesUnsent && previousAtRisk) keptRisky = previous; + const expanded = !usesUnsent + ? text + : previousAtRisk + ? text.replaceAll('{{unsent}}', '').trim() + : text.replaceAll('{{unsent}}', previous?.text ?? ''); // The bounce notice below names the draft, so no separate held notice. draft = this.holdProseDrafts(agent, [expanded], 'bounced', { round: hold.round, note: reason, notice: 'later' })[0]; } @@ -9721,8 +9779,12 @@ export class AgentFramework { `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"). ` + `"${prefix}skip_reply {{unsent}}" sets it aside.` : 'It could not be held as a draft; the words remain in your history.'; + const risky = keptRisky + ? ` {{unsent}} referred to draft ${keptRisky.id}, which may already have been posted, so it was not copied: ` + + `it is still held as ${keptRisky.id}; resend it with the drafts tool and confirmDuplicate if you mean to.` + : ''; const notice = - `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}` + + `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}${risky}` + (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : ''); try { // Through framework.addMessage, NOT the context manager directly: while diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 57e18706f..2ef8a76d9 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -8,7 +8,14 @@ * unresolvable routing prefix bounces. Before drafts, the held words were * kept only as a count (or, in explicit mode, as one in-memory latest-wins * clipboard): the law was right, and the penalty was amnesia. A draft keeps - * the exact words, privately, until the resident resends or dismisses them. + * the exact words until the resident resends or dismisses them. + * + * The boundary is publication and agency, not visibility: a draft is never + * published except by its resident's explicit resend (or `{{unsent}}`), and + * only that resident can list or act on it. Its notices go into the + * resident's own history — which residents that share one message slot + * (#197) share, as they already share the assistant turn the words came + * from. * * Durability is outside the conversation. Drafts live in a RecordJournal of * typed Chronicle records, which do not follow branch switches: undo, @@ -93,9 +100,11 @@ export interface Draft { } /** - * - `held`: never attempted, or the last attempt definitely failed — resend freely; - * - `unconfirmed`: the last attempt's outcome is unknown (or was never - * recorded): it may have been posted, so a resend needs explicit confirmation; + * - `held`: never attempted, or every attempt definitely failed — resend freely; + * - `unconfirmed`: some attempt's outcome is unknown (or was never + * recorded), and none was confirmed: it may have been posted, so a resend + * needs explicit confirmation. A later attempt that fails does not resolve + * an earlier one that may have landed — the uncertainty stays; * - `delivered`: an attempt was confirmed — its receipt is historical; * - `dismissed`: the resident set it aside. */ @@ -104,9 +113,18 @@ export type DraftState = 'held' | 'unconfirmed' | 'delivered' | 'dismissed'; export function draftState(draft: Draft): DraftState { if (draft.attempts.some((a) => a.outcome?.status === 'delivered')) return 'delivered'; if (draft.dismissedAt !== undefined) return 'dismissed'; - const last = draft.attempts.at(-1); - if (last && (!last.outcome || last.outcome.status === 'unknown')) return 'unconfirmed'; - return 'held'; + return uncertainAttempt(draft) ? 'unconfirmed' : 'held'; +} + +/** The most recent attempt that may have been posted (unknown or unrecorded + * outcome), when no attempt was confirmed. */ +export function uncertainAttempt(draft: Draft): DraftAttempt | undefined { + if (draft.attempts.some((a) => a.outcome?.status === 'delivered')) return undefined; + for (let i = draft.attempts.length - 1; i >= 0; i--) { + const attempt = draft.attempts[i]!; + if (!attempt.outcome || attempt.outcome.status === 'unknown') return attempt; + } + return undefined; } type DraftEntry = diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index f4c0e1e3d..639064f72 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -420,7 +420,7 @@ describe('held prose drafts, end to end', () => { h.command({ op: 'publish-mode', mode: 'not-delivered' }); await h.turn([createMockResponse([resend('r1')], 'tool_use'), createMockResponse([])]); - assert.match(h.toolResults()[0]!, /not sent to .* Nothing was posted; the draft stays held/); + assert.match(h.toolResults()[0]!, /not sent to .* Nothing was posted by this attempt\. The draft stays held\./); assert.equal(draftState(h.store().get('scout', draft!.id)!), 'held'); h.command({ op: 'publish-mode', mode: 'no-receipt' }); @@ -444,6 +444,32 @@ describe('held prose drafts, end to end', () => { } }); + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('maybe already out'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + const resend = (id: string, confirm?: boolean) => + drafts(id, { action: 'resend', draftIds: [draft!.id], destination: ROOM, ...(confirm ? { confirmDuplicate: true } : {}) }); + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([resend('r1')], 'tool_use'), createMockResponse([])]); + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([resend('r2', true)], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /Nothing was posted by this attempt\. An earlier attempt \(to .*\) may still have been posted, so the draft stays unconfirmed/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + h.command({ op: 'publish-mode', mode: 'delivered' }); + const before = h.publishes().length; + await h.turn([createMockResponse([resend('r3')], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /may already have been posted: .*delivery uncertain.*confirmDuplicate: true\. Nothing was sent/); + assert.equal(h.publishes().length, before, 'no third publish without confirmation'); + } finally { + await h.close(); + } + }); + it('June\'s cases: a rollback to before a confirmed resend returns the receipt, no new publish; a dismissal survives it', async () => { const h = await harness(); try { @@ -499,14 +525,20 @@ describe('held prose drafts, end to end', () => { } }); - it('a draft held in a turn that crashed is named by the next turn\'s catch-up notice', async () => { + it('drafts held in a turn that crashed are named by the next turn\'s catch-up notice, each by its actual state', async () => { const h = await harness(); try { - // Held directly, as if the turn that held it died before its receipt. + // Held directly, as if the turn that held it died before its receipt; + // the second also died mid-send, its attempt never resolved. h.store().hold('scout', [{ text: 'orphaned words', source }], 'explicit-send'); + const midSend = h.store().hold('scout', [{ text: 'half-sent words', source }], 'explicit-send').held[0]!; + h.store().beginAttempt('scout', midSend.id, dest(), 'resend', false); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: a held draft of yours has not been named to you yet: d-[a-z2-9]{5} .*"orphaned words"/); + assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours have not been named to you yet: /); + assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); held .*"orphaned words"\)/); + assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); + assert.doesNotMatch(notice, /They were not sent/); await h.turn([createMockResponse([])]); assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'named once'); } finally { @@ -532,6 +564,31 @@ describe('held prose drafts, end to end', () => { } }); + it('explicit mode: a bounce that may already have been posted is never copied into a fresh draft', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); + try { + await h.turn([createMockResponse([text('risky words')])]); + const [risky] = h.drafts(); + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); + assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed'); + h.command({ op: 'publish-mode', mode: 'delivered' }); + // The destination does not resolve, so the envelope bounces: its other + // words are held, the possibly-posted text is not copied with them. + await h.turn([createMockResponse([text('>>#nowhere {{unsent}} and a postscript')])]); + const fresh = h.drafts().find((d) => d.id !== risky!.id)!; + assert.equal(fresh.text, 'and a postscript'); + assert.ok(h.texts().some((t) => t.includes(`{{unsent}} referred to draft ${risky!.id}, which may already have been posted, so it was not copied`))); + // And {{unsent}} itself now means the fresh draft, never the risky one. + const before = h.publishes().length; + await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); + assert.deepEqual(h.publishes().slice(before).map((p) => p.text), ['and a postscript']); + assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed', 'still needs its own confirmation'); + } finally { + await h.close(); + } + }); + it('resend is an explicit send: prose beside it is held, not auto-routed', async () => { const h = await harness(); try { @@ -564,7 +621,7 @@ describe('held prose drafts, end to end', () => { await h.turn([createMockResponse([resend('a'), resend('b')], 'tool_use'), createMockResponse([])]); const [first, second] = h.toolResults(); assert.match(first!, /delivered to/); - assert.match(second!, /is being sent right now; wait for that result/); + assert.match(second!, /is being sent right now \(or is queued in a resend in progress\); wait for that result/); assert.equal(h.publishes().filter((p) => p.text === 'once only').length, 1); } finally { await h.close(); @@ -601,3 +658,84 @@ describe('held prose drafts, end to end', () => { } }); }); + +// --------------------------------------------------------------------------- +// Concurrent resends through the actual drafts handler, with publish +// completions under the test's control. +// --------------------------------------------------------------------------- + +describe('drafts resend ownership', () => { + const setup = async () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-own-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + }); + const pending: Array<{ text: string; resolve: (o: unknown) => void }> = []; + const stub: Record = { + resolveProseTarget: (spec: string) => ({ channelId: spec }), + resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'discord', channelId } }), + publish: (_agent: string, text: string, target: { channelId: string }) => + new Promise((resolve) => pending.push({ + text, + resolve: (o) => resolve({ destination: { serverId: 'discord', channelId: target.channelId }, at: Date.now(), ...(o as object) }), + })), + getChannelTools: () => [], + }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(stub, { + get: (t, prop: string) => (prop in t ? t[prop] : () => undefined), + }); + const internals = framework as unknown as { + proseDrafts: ProseDraftStore; + handleDraftsTool(agent: string, input: unknown): Promise<{ success: boolean; data?: Array<{ text: string }>; error?: string }>; + }; + const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => + r.success ? r.data!.map((b) => b.text).join('') : r.error!; + const tick = () => new Promise((r) => setTimeout(r, 5)); + const [a, b] = internals.proseDrafts.hold('scout', [{ text: 'A words', source }, { text: 'B words', source }], 'explicit-send').held; + return { + dir, framework, internals, pending, say, tick, a: a!, b: b!, + close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, + }; + }; + + it('a batch owns its drafts: a concurrent resend of a queued draft is refused, and nothing is sent twice', async () => { + const t = await setup(); + try { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'the batch is waiting on A'); + const concurrent = t.say(await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.b.id], destination: 'chan' })); + assert.match(concurrent, new RegExp(`${t.b.id} is being sent right now \\(or is queued in a resend in progress\\)`)); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['B words']); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); + const result = t.say(await batch); + assert.match(result, /delivered to \(chan\), message m-a/); + assert.match(result, /delivered to \(chan\), message m-b/); + assert.equal(t.pending.length, 0, 'B was published exactly once'); + } finally { + await t.close(); + } + }); + + it('a draft dismissed while the batch waits is not published', async () => { + const t = await setup(); + try { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.tick(); + const dismissed = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); + assert.match(dismissed, new RegExp(`${t.b.id}: dismissed`)); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + const result = t.say(await batch); + assert.match(result, new RegExp(`${t.b.id} was dismissed, so it can't be resent\\. It changed while this resend was waiting — not sent\\.`)); + assert.equal(t.pending.length, 0, 'B never reached publish'); + assert.equal(draftState(t.internals.proseDrafts.get('scout', t.b.id)!), 'dismissed'); + } finally { + await t.close(); + } + }); +}); From 191ab09073eaf0924b44ac8ee1bad5feabb59654 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 09:17:37 -0400 Subject: [PATCH 10/65] fix(drafts): a re-bounce keeps the whole text and its risk; notices durable first Tessa-974's follow-up review of 397be39 (room-203 #44505, #44548, #44612): 1. Re-bounce of {{unsent}} from a possibly-posted draft. 397be39 stripped the token and held only the resident's other words, which changed the message being recovered (the notice still promised it "unchanged", and the next {{unsent}} could send just the prefix). Now the whole authored attempt is held, words of the risky draft included, and it carries that draft's duplication risk: Draft.inheritedRisk {draftId, destination?, at?, reason}. A draft with inherited risk reads `unconfirmed` until a confirmed delivery (a failed attempt does not clear it), so it can be resent only with confirmDuplicate, and {{unsent}} refuses it exactly as it refuses an unconfirmed draft. riskText() words both kinds of risk for refusals, the list, read, the {{unsent}} refusal and a failed retry's receipt; the bounce notice says the new draft includes the risky draft's words. 2. A noticed record could outlive its notice: the notice is conversation state, buffered until the store syncs, while a journal record reaches the OS at once, so a kill between them left drafts marked noticed with no notice in the history and no catch-up. markNoticed now appends with afterCommittedState (sync first, then append, then durable sync), and the turn's drafts are settled in driveStream's finally after the deferred flush, marking only drafts a stored message names (metadata.draftIds on the receipt, held-draft and bounce notices). A draft whose notice never reached the history stays unnoticed for the next turn's catch-up. Tests (prose-drafts.test.ts, 25): markNoticed's call order is sync, append, sync; inherited risk stays unconfirmed through a failed attempt until a delivery; settlement marks only the draft a stored notice names; the explicit re-bounce holds "risky words and a postscript" with the inherited risk, {{unsent}} refuses it, and a confirmed resend publishes the whole text while the original stays unconfirmed. Suite (--test-concurrency=4): 1333/1333 + code-execution 36/36. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 137 ++++++++++++++++++++++++++------------ src/prose-drafts.ts | 35 ++++++++-- test/prose-drafts.test.ts | 89 ++++++++++++++++++++++--- 3 files changed, 206 insertions(+), 55 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index e927a2cbf..dc3248a3b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -82,7 +82,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, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource } from './prose-drafts.js'; +import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -8840,12 +8840,14 @@ export class AgentFramework { */ private holdProseDrafts( agent: Agent, - texts: Array, + texts: Array, reason: DraftReason, opts: { round?: number; note?: string; notice: 'now' | 'later' }, ): Draft[] { const segments = texts - .map((t) => (typeof t === 'string' ? { text: t, note: opts.note } : { text: t.text, note: t.note ?? opts.note })) + .map((t) => (typeof t === 'string' + ? { text: t, note: opts.note } + : { text: t.text, note: t.note ?? opts.note, inheritedRisk: t.inheritedRisk })) .filter((t) => t.text.trim().length > 0); if (segments.length === 0) return []; const turn = this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0; @@ -8857,9 +8859,10 @@ export class AgentFramework { } const { held, notHeld } = this.proseDrafts.hold( agent.name, - segments.map(({ text, note }, segment) => ({ + segments.map(({ text, note, inheritedRisk }, segment) => ({ text, ...(note ? { note } : {}), + ...(inheritedRisk ? { inheritedRisk } : {}), source: { branch, turn, round: opts.round ?? 0, segment } satisfies DraftSource, })), reason, @@ -8951,9 +8954,12 @@ export class AgentFramework { private draftStateText(draft: Draft): string { const state = draftState(draft); if (state === 'unconfirmed') { - const risky = uncertainAttempt(draft)!; - return `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(risky.destination)} at ` + - `${this.draftTime(risky.at)} may have been posted`; + const risky = uncertainAttempt(draft); + if (risky) { + return `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + `${this.draftTime(risky.at)} may have been posted`; + } + return `UNCONFIRMED — it includes the words of ${draft.inheritedRisk!.draftId}, which may already have been posted`; } if (state === 'held') { return draft.attempts.length > 0 ? 'held (every attempt failed: nothing was posted)' : 'held (not sent)'; @@ -8961,6 +8967,21 @@ export class AgentFramework { return state; } + /** Why an unconfirmed draft may already have been posted, in full. */ + private riskText(draft: Draft): string { + const risky = uncertainAttempt(draft); + if (risky) { + return `${draft.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}`; + } + const inherited = draft.inheritedRisk!; + const where = inherited.destination + ? `its attempt to ${AgentFramework.destinationText(inherited.destination)}` + + (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '') + : 'it'; + return `${draft.id} includes the words of ${inherited.draftId}, and ${where} may already have been posted: ${inherited.reason}`; + } + /** A delivered draft's historical receipt. */ private draftReceipt(draft: Draft): string { const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); @@ -8999,21 +9020,42 @@ export class AgentFramework { } } - /** Turn end: every draft held this turn has now been named (receipt or notice). */ + /** + * Logical turn end, after the turn's deferred notices are flushed: record + * as noticed exactly the drafts a stored message names (the receipt, a + * held-draft notice, a bounce notice). One whose notice never reached the + * history stays unnoticed, for the next turn's catch-up. + */ private settleTurnDrafts(agent: Agent): void { const held = this.turnDrafts.get(agent.name); this.turnDrafts.delete(agent.name); this.turnDraftFailures.delete(agent.name); this.turnProsePrivate.delete(agent.name); if (!held?.length) return; + const named = this.draftsNamedInHistory(agent, held.map((d) => d.id)); + if (named.length === 0) return; try { - this.proseDrafts.markNoticed(agent.name, held.map((d) => d.id)); + this.proseDrafts.markNoticed(agent.name, named); } catch (err) { // Unmarked drafts are named again by the next turn's catch-up. console.error('[drafts] could not record notice:', err); } } + /** Which of these drafts a stored notice or receipt names (scanning the + * recent history, where this turn's notices are). */ + private draftsNamedInHistory(agent: Agent, ids: string[]): string[] { + const wanted = new Set(ids); + const named = new Set(); + const messages = agent.getContextManager().getAllMessages(); + for (let i = messages.length - 1, scanned = 0; i >= 0 && scanned < 500 && named.size < wanted.size; i--, scanned++) { + const draftIds = (messages[i]!.metadata as { draftIds?: unknown } | undefined)?.draftIds; + if (!Array.isArray(draftIds)) continue; + for (const id of draftIds) if (typeof id === 'string' && wanted.has(id)) named.add(id); + } + return ids.filter((id) => named.has(id)); + } + /** Page size for drafts(action: "list"). */ private static readonly DRAFTS_PAGE = 10; /** Drafts with a delivery attempt in flight, `${agent}\u0000${id}`: a @@ -9093,6 +9135,7 @@ export class AgentFramework { const lines = [ `${d.id} — ${draftState(d)}; held ${this.draftTime(d.heldAt)} (${AgentFramework.DRAFT_REASON_TEXT[d.reason]})` + (d.note ? `; ${d.note}` : ''), + ...(d.inheritedRisk && draftState(d) === 'unconfirmed' && !uncertainAttempt(d) ? [` ${this.riskText(d)}`] : []), ...d.attempts.map((a) => { const o = a.outcome; const status = !o @@ -9246,14 +9289,13 @@ export class AgentFramework { } stopped = true; const after = this.proseDrafts.get(agentName, d.id); - const stillUncertain = after ? uncertainAttempt(after) : undefined; + const stillAtRisk = after !== undefined && draftState(after) === 'unconfirmed'; lines.push(outcome.status === 'unknown' ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` + `Check the channel before sending it again (that needs confirmDuplicate: true)${unrecorded}.` : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` + - (stillUncertain - ? ` An earlier attempt (to ${AgentFramework.destinationText(stillUncertain.destination)} at ` + - `${this.draftTime(stillUncertain.at)}) may still have been posted, so the draft stays unconfirmed.` + (stillAtRisk + ? ` The draft stays unconfirmed: ${this.riskText(after!)}.` : ' The draft stays held.')); } } finally { @@ -9269,10 +9311,7 @@ export class AgentFramework { const state = draftState(d); if (state === 'dismissed') return `${d.id} was dismissed, so it can't be resent.`; if (state === 'unconfirmed' && !confirmDuplicate) { - const risky = uncertainAttempt(d)!; - return `${d.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ${this.draftTime(risky.at)} ` + - `may already have been posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}. ` + - 'Check that channel; to send it again anyway, resend with confirmDuplicate: true.'; + return `${this.riskText(d)}. Check that channel; to send it again anyway, resend with confirmDuplicate: true.`; } return undefined; } @@ -9354,7 +9393,11 @@ export class AgentFramework { const mid = agent.getContextManager().addMessage( 'user', [{ type: 'text', text }], - { system: true, kind: 'delivery-receipt' } as MessageMetadata, + { + system: true, + kind: 'delivery-receipt', + ...(drafts.length > 0 ? { draftIds: drafts.map((d) => d.id) } : {}), + } as MessageMetadata, ); this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' }); } catch (err) { @@ -9510,11 +9553,8 @@ export class AgentFramework { return { draft, refused: true }; } if (draftState(draft) !== 'unconfirmed') return { draft }; - const risky = uncertainAttempt(draft)!; const text = - `[prose-routing] {{unsent}} is draft ${draft.id}, and an attempt to deliver it (to ` + - `${AgentFramework.destinationText(risky.destination)} at ${this.draftTime(risky.at)}) may already have been ` + - `posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}. Nothing was sent. Check that channel; ` + + `[prose-routing] {{unsent}} is draft ${draft.id}: ${this.riskText(draft)}. Nothing was sent. Check that channel; ` + `to send it again anyway use drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true).`; try { this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); @@ -9756,19 +9796,32 @@ export class AgentFramework { const previousAtRisk = previous !== undefined && (draftState(previous) === 'unconfirmed' || this.draftsInFlight.has(`${name}\u0000${previous.id}`)); let draft: Draft | undefined; - let keptRisky: Draft | undefined; + let inherited: InheritedRisk | undefined; if (previous && text.trim() === '{{unsent}}') { draft = previous; } else { const usesUnsent = text.includes('{{unsent}}'); - if (usesUnsent && previousAtRisk) keptRisky = previous; - const expanded = !usesUnsent - ? text - : previousAtRisk - ? text.replaceAll('{{unsent}}', '').trim() - : text.replaceAll('{{unsent}}', previous?.text ?? ''); + const expanded = usesUnsent ? text.replaceAll('{{unsent}}', previous?.text ?? '') : text; + if (usesUnsent && previous && previousAtRisk) { + // The whole authored attempt is kept, words of the risky draft + // included, and it carries that draft's duplication risk: it can be + // resent only with confirmDuplicate, like the draft it copies. + const risky = uncertainAttempt(previous); + inherited = { + draftId: previous.id, + ...(risky ? { destination: risky.destination, at: risky.at } : {}), + reason: risky + ? risky.outcome?.reason ?? 'its outcome was never recorded' + : 'it was being sent when these words were held', + }; + } // The bounce notice below names the draft, so no separate held notice. - draft = this.holdProseDrafts(agent, [expanded], 'bounced', { round: hold.round, note: reason, notice: 'later' })[0]; + draft = this.holdProseDrafts( + agent, + [{ text: expanded, ...(inherited ? { inheritedRisk: inherited } : {}) }], + 'bounced', + { round: hold.round, note: reason, notice: 'later' }, + )[0]; } const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; this.proseBounceStreaks.set(name, streak); @@ -9779,9 +9832,9 @@ export class AgentFramework { `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"). ` + `"${prefix}skip_reply {{unsent}}" sets it aside.` : 'It could not be held as a draft; the words remain in your history.'; - const risky = keptRisky - ? ` {{unsent}} referred to draft ${keptRisky.id}, which may already have been posted, so it was not copied: ` + - `it is still held as ${keptRisky.id}; resend it with the drafts tool and confirmDuplicate if you mean to.` + const risky = inherited && draft + ? ` It includes the words of draft ${inherited.draftId}, which may already have been posted, so ${draft.id} ` + + 'can be resent only with the drafts tool and confirmDuplicate: true, after checking that channel.' : ''; const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}${risky}` + @@ -11363,10 +11416,6 @@ export class AgentFramework { if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent); } - // The turn's drafts have now been named (receipt, held notice or - // bounce notice); a crash before this leaves them for the next - // turn's catch-up. - this.settleTurnDrafts(agent); // Explicit-prose `!` continuation: a prose segment this turn asked // to keep going (`>>#x !` / `>>private !`) — start another turn @@ -11537,10 +11586,6 @@ export class AgentFramework { await turnSpeechChain; this.appendProseDeliveryReceipt(agent); } - if (cancelKind === 'turn_ended') { - await turnSpeechChain; - this.settleTurnDrafts(agent); - } return; } } @@ -11852,6 +11897,16 @@ export class AgentFramework { } this.ackDeferredWrites(); } + + // The logical turn has ended and its notices are written: record the + // drafts they name as noticed (the store is synced first). + if (frameReachedTerminal && ownsPhysicalStream) { + try { + this.settleTurnDrafts(agent); + } catch (err) { + console.error('[drafts] turn-end settlement failed:', err); + } + } } } diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 2ef8a76d9..b00409bc0 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -83,12 +83,29 @@ export interface DraftAttempt { outcome?: DraftAttemptOutcome; } +/** + * A draft held with words copied from another draft that may already have + * been posted (`{{unsent}}` re-bounced while that draft was unconfirmed or in + * flight). The new draft keeps the whole authored text and carries the same + * duplication risk: it reads `unconfirmed` until a confirmed delivery. + */ +export interface InheritedRisk { + /** The draft whose words were copied in. */ + draftId: string; + /** Its uncertain attempt, when there was one (absent: it was in flight). */ + destination?: PublishDestination; + at?: number; + reason: string; +} + export interface Draft { id: string; agent: string; /** The held words, byte for byte. */ text: string; reason: DraftReason; + /** Words copied in from a draft that may already have been posted. */ + inheritedRisk?: InheritedRisk; /** Short context for the resident: the bounce reason, or the competing conversations. */ note?: string; heldAt: number; @@ -113,7 +130,7 @@ export type DraftState = 'held' | 'unconfirmed' | 'delivered' | 'dismissed'; export function draftState(draft: Draft): DraftState { if (draft.attempts.some((a) => a.outcome?.status === 'delivered')) return 'delivered'; if (draft.dismissedAt !== undefined) return 'dismissed'; - return uncertainAttempt(draft) ? 'unconfirmed' : 'held'; + return uncertainAttempt(draft) || draft.inheritedRisk ? 'unconfirmed' : 'held'; } /** The most recent attempt that may have been posted (unknown or unrecorded @@ -189,9 +206,9 @@ export class ProseDraftStore { * leaves the journal unreconciled: rebuild from it before rethrowing, so * the projection never disagrees with what the journal holds. */ - private write(entry: DraftEntry, durable: boolean): void { + private write(entry: DraftEntry, durable: boolean, afterCommittedState = false): void { try { - this.journal.append(entry, { durable }); + this.journal.append(entry, { durable, afterCommittedState }); } catch (err) { if (this.journal.needsReconcile) this.reload(); throw err; @@ -225,7 +242,7 @@ export class ProseDraftStore { */ hold( agent: string, - segments: Array<{ text: string; source: DraftSource; note?: string }>, + segments: Array<{ text: string; source: DraftSource; note?: string; inheritedRisk?: InheritedRisk }>, reason: DraftReason, note?: string, ): { held: Draft[]; notHeld?: { count: number; error: string } } { @@ -239,6 +256,7 @@ export class ProseDraftStore { text: segment.text, reason, ...(segmentNote ? { note: segmentNote } : {}), + ...(segment.inheritedRisk ? { inheritedRisk: segment.inheritedRisk } : {}), heldAt: Date.now(), source: segment.source, }; @@ -269,9 +287,16 @@ export class ProseDraftStore { return this.open(agent).filter((d) => d.noticedAt === undefined).reverse(); } + /** + * Record that a notice naming these drafts is in the resident's history. + * The notice is conversation state, buffered until the store syncs, while + * this record reaches the OS at once: so the store is synced FIRST + * (afterCommittedState), and a notice that never became durable can't + * leave a record that suppresses the crash catch-up. + */ markNoticed(agent: string, ids: string[]): void { if (ids.length === 0) return; - this.write({ op: 'noticed', agent, ids, at: Date.now() }, true); + this.write({ op: 'noticed', agent, ids, at: Date.now() }, true, true); } dismiss(agent: string, id: string): void { diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 639064f72..fb0e34f4b 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -110,6 +110,41 @@ describe('ProseDraftStore', () => { } }); + it('records a notice only after the history holding it is synced (afterCommittedState)', () => { + const { dir, store, drafts } = open(); + try { + const { held } = drafts.hold('a', [{ text: 'x', source }], 'explicit-send'); + const calls: string[] = []; + const real = { sync: store.sync.bind(store), appendJson: store.appendJson.bind(store) }; + (store as unknown as { sync: () => void }).sync = () => { calls.push('sync'); real.sync(); }; + (store as unknown as { appendJson: (t: string, p: unknown) => unknown }).appendJson = (t, p) => { calls.push(`append:${t}`); return real.appendJson(t, p); }; + drafts.markNoticed('a', [held[0]!.id]); + assert.deepEqual(calls, ['sync', `append:${PROSE_DRAFT_RECORD_TYPE}`, 'sync']); + assert.ok(drafts.get('a', held[0]!.id)!.noticedAt); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('an inherited duplication risk keeps a draft unconfirmed until a confirmed delivery', () => { + const { dir, store, drafts } = open(); + try { + const risky = { draftId: 'd-zzzzz', destination: dest(), at: 1, reason: 'no valid receipt' }; + const [copy] = drafts.hold('a', [{ text: 'risky words and more', source, inheritedRisk: risky }], 'bounced').held; + assert.equal(draftState(copy!), 'unconfirmed'); + const failed = drafts.beginAttempt('a', copy!.id, dest(), 'resend', true); + drafts.recordOutcome('a', copy!.id, failed, { status: 'failed', at: 2, destination: dest() }); + assert.equal(draftState(drafts.get('a', copy!.id)!), 'unconfirmed', 'a failed attempt does not clear it'); + const ok = drafts.beginAttempt('a', copy!.id, dest(), 'resend', true); + drafts.recordOutcome('a', copy!.id, ok, { status: 'delivered', at: 3, destination: dest() }); + assert.equal(draftState(drafts.get('a', copy!.id)!), 'delivered'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + it('{{unsent}} is the latest bounce while it is open, latest-wins', () => { const { dir, store, drafts } = open(); try { @@ -458,7 +493,7 @@ describe('held prose drafts, end to end', () => { await h.turn([createMockResponse([resend('r1')], 'tool_use'), createMockResponse([])]); h.command({ op: 'publish-mode', mode: 'not-delivered' }); await h.turn([createMockResponse([resend('r2', true)], 'tool_use'), createMockResponse([])]); - assert.match(h.toolResults()[0]!, /Nothing was posted by this attempt\. An earlier attempt \(to .*\) may still have been posted, so the draft stays unconfirmed/); + assert.match(h.toolResults()[0]!, /Nothing was posted by this attempt\. The draft stays unconfirmed: d-[a-z2-9]{5}'s attempt to .* may already have been posted: .*delivery uncertain/); assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); h.command({ op: 'publish-mode', mode: 'delivered' }); const before = h.publishes().length; @@ -564,7 +599,7 @@ describe('held prose drafts, end to end', () => { } }); - it('explicit mode: a bounce that may already have been posted is never copied into a fresh draft', async () => { + it('explicit mode: re-bouncing {{unsent}} of a possibly-posted draft keeps the whole text and its risk', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); try { await h.turn([createMockResponse([text('risky words')])]); @@ -573,17 +608,22 @@ describe('held prose drafts, end to end', () => { await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed'); h.command({ op: 'publish-mode', mode: 'delivered' }); - // The destination does not resolve, so the envelope bounces: its other - // words are held, the possibly-posted text is not copied with them. + // The destination does not resolve, so the envelope bounces: the whole + // authored attempt is held, and it carries the copied draft's risk. await h.turn([createMockResponse([text('>>#nowhere {{unsent}} and a postscript')])]); const fresh = h.drafts().find((d) => d.id !== risky!.id)!; - assert.equal(fresh.text, 'and a postscript'); - assert.ok(h.texts().some((t) => t.includes(`{{unsent}} referred to draft ${risky!.id}, which may already have been posted, so it was not copied`))); - // And {{unsent}} itself now means the fresh draft, never the risky one. + assert.equal(fresh.text, 'risky words and a postscript', 'nothing substituted or truncated'); + assert.equal(fresh.inheritedRisk?.draftId, risky!.id); + assert.equal(draftState(fresh), 'unconfirmed'); + assert.ok(h.texts().some((t) => t.includes(`It includes the words of draft ${risky!.id}, which may already have been posted, so ${fresh.id} can be resent only with the drafts tool and confirmDuplicate: true`))); + // {{unsent}} now means the fresh draft, and it is refused unconfirmed. const before = h.publishes().length; await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); - assert.deepEqual(h.publishes().slice(before).map((p) => p.text), ['and a postscript']); - assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed', 'still needs its own confirmation'); + assert.equal(h.publishes().length, before, 'neither draft goes out without confirmation'); + assert.ok(h.texts().some((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${fresh.id}: ${fresh.id} includes the words of ${risky!.id}`))); + await h.turn([createMockResponse([drafts('r1', { action: 'resend', draftIds: [fresh.id], destination: ROOM, confirmDuplicate: true })], 'tool_use'), createMockResponse([])]); + assert.deepEqual(h.publishes().slice(before).map((p) => p.text), ['risky words and a postscript']); + assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed', 'the original still needs its own confirmation'); } finally { await h.close(); } @@ -664,6 +704,37 @@ describe('held prose drafts, end to end', () => { // completions under the test's control. // --------------------------------------------------------------------------- +describe('draft notices and turn-end settlement', () => { + it('marks as noticed only the drafts a stored message names', async () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-settle-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + }); + try { + const internals = framework as unknown as { + proseDrafts: ProseDraftStore; + turnDrafts: Map; + settleTurnDrafts(agent: unknown): void; + }; + const agent = framework.getAgent('scout')!; + const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named', source }, { text: 'lost', source }], 'explicit-send').held; + // Only the first draft's notice reached the history. + agent.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftIds: [named!.id] } as never); + internals.turnDrafts.set('scout', [named, lost]); + internals.settleTurnDrafts(agent); + assert.ok(internals.proseDrafts.get('scout', named!.id)!.noticedAt); + assert.equal(internals.proseDrafts.get('scout', lost!.id)!.noticedAt, undefined, 'left for the catch-up'); + assert.deepEqual(internals.proseDrafts.unnoticed('scout').map((d) => d.id), [lost!.id]); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('drafts resend ownership', () => { const setup = async () => { const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-own-')); From 6c5f054e8305c7e58bf63a31c60935d375d5a2dc Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 09:34:09 -0400 Subject: [PATCH 11/65] fix(drafts): owner-scoped notice identity; true risk evidence; idempotent receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tessa-974's review of 4f6974d (room-203 #44739, #44759; probe /tmp/tessa-974-draft-state-probe.mjs, logs 2987/96 and 2987/98): 1. Notice identity must include its resident. Draft ids are unique only per resident, and residents can share one history; settlement accepted any message's metadata.draftIds, so another resident's notice for the same id marked this resident's draft noticed. Every notice and receipt that names drafts now carries draftOwner (held-draft notice, catch-up, receipt, bounce notice), and settlement matches owner and id. 2. Inherited risk inherited again: re-bouncing a draft whose risk was itself inherited (no attempts of its own) recorded the false reason "it was being sent when these words were held" and lost the destination and time. InheritedRisk gains sourceDraftId; a chain carries the original uncertain attempt's destination, time and reason, and riskText says "d-3 includes the words of d-2, which itself includes the words of d-1, and d-1's attempt to … may already have been posted". The in-flight reason is used only when the copied draft really was in flight with no other risk. 3. A repeated resend of delivered drafts failed in destination resolution once the channel was gone. A request whose drafts are all delivered now returns their historical receipts before any destination is parsed or resolved; a mixed batch still needs a destination that resolves. Tests (prose-drafts.test.ts, 28): a same-id draft of another resident is not settled by that resident's notice; a two-step re-bounce keeps the original attempt as evidence; an all-delivered resend returns receipts after the destination stops resolving, while a mixed batch still refuses it. Suite (--test-concurrency=4): 1336/1336 + code-execution 36/36. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 62 +++++++++++++++++++---------- src/prose-drafts.ts | 6 ++- test/prose-drafts.test.ts | 84 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index dc3248a3b..c462c1c55 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8889,7 +8889,7 @@ export class AgentFramework { this.addMessage( 'user', [{ type: 'text', text: this.draftsHeldNotice(agent.name, held, reason) }], - { system: true, kind: 'prose-drafts', draftIds: held.map((d) => d.id) } as MessageMetadata, + { system: true, kind: 'prose-drafts', draftOwner: agent.name, draftIds: held.map((d) => d.id) } as MessageMetadata, { forAgent: agent.name }, ); } catch (err) { @@ -8975,11 +8975,13 @@ export class AgentFramework { `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}`; } const inherited = draft.inheritedRisk!; + const owner = inherited.sourceDraftId ?? inherited.draftId; + const via = owner !== inherited.draftId ? `, which itself includes the words of ${owner}` : ''; const where = inherited.destination - ? `its attempt to ${AgentFramework.destinationText(inherited.destination)}` + + ? `${owner}'s attempt to ${AgentFramework.destinationText(inherited.destination)}` + (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '') - : 'it'; - return `${draft.id} includes the words of ${inherited.draftId}, and ${where} may already have been posted: ${inherited.reason}`; + : owner; + return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`; } /** A delivered draft's historical receipt. */ @@ -9011,7 +9013,7 @@ export class AgentFramework { const mid = agent.getContextManager().addMessage( 'user', [{ type: 'text', text }], - { system: true, kind: 'prose-drafts', draftIds: pending.map((d) => d.id) } as MessageMetadata, + { system: true, kind: 'prose-drafts', draftOwner: agent.name, draftIds: pending.map((d) => d.id) } as MessageMetadata, ); this.emitTrace({ type: 'message:added', messageId: mid, source: 'prose-drafts' }); this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id)); @@ -9042,16 +9044,17 @@ export class AgentFramework { } } - /** Which of these drafts a stored notice or receipt names (scanning the - * recent history, where this turn's notices are). */ + /** Which of these drafts a stored notice or receipt OF THIS RESIDENT names + * (draft ids are unique only per resident, and residents can share one + * history), scanning the recent history, where this turn's notices are. */ private draftsNamedInHistory(agent: Agent, ids: string[]): string[] { const wanted = new Set(ids); const named = new Set(); const messages = agent.getContextManager().getAllMessages(); for (let i = messages.length - 1, scanned = 0; i >= 0 && scanned < 500 && named.size < wanted.size; i--, scanned++) { - const draftIds = (messages[i]!.metadata as { draftIds?: unknown } | undefined)?.draftIds; - if (!Array.isArray(draftIds)) continue; - for (const id of draftIds) if (typeof id === 'string' && wanted.has(id)) named.add(id); + const meta = messages[i]!.metadata as { draftIds?: unknown; draftOwner?: unknown } | undefined; + if (meta?.draftOwner !== agent.name || !Array.isArray(meta.draftIds)) continue; + for (const id of meta.draftIds) if (typeof id === 'string' && wanted.has(id)) named.add(id); } return ids.filter((id) => named.has(id)); } @@ -9183,6 +9186,13 @@ export class AgentFramework { } // resend + // Idempotence first: a request whose drafts were all delivered returns + // their historical receipts, whether or not the former destination is + // still registered or resolvable — nothing needs sending, so nothing + // needs authorizing. + if (drafts.every((d) => draftState(d) === 'delivered')) { + return ok(drafts.map((d) => `${this.draftReceipt(d)} — not sent again.`).join('\n')); + } const registry = this.channelRegistry; if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.'); const home = this.conversationAgentHomes.get(agentName); @@ -9396,7 +9406,7 @@ export class AgentFramework { { system: true, kind: 'delivery-receipt', - ...(drafts.length > 0 ? { draftIds: drafts.map((d) => d.id) } : {}), + ...(drafts.length > 0 ? { draftOwner: agent.name, draftIds: drafts.map((d) => d.id) } : {}), } as MessageMetadata, ); this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' }); @@ -9805,15 +9815,27 @@ export class AgentFramework { if (usesUnsent && previous && previousAtRisk) { // The whole authored attempt is kept, words of the risky draft // included, and it carries that draft's duplication risk: it can be - // resent only with confirmDuplicate, like the draft it copies. + // resent only with confirmDuplicate, like the draft it copies. The + // evidence is the risk's own: the copied draft's uncertain attempt, + // else the risk that draft itself inherited, else (truly) that it + // was in flight when copied. const risky = uncertainAttempt(previous); - inherited = { - draftId: previous.id, - ...(risky ? { destination: risky.destination, at: risky.at } : {}), - reason: risky - ? risky.outcome?.reason ?? 'its outcome was never recorded' - : 'it was being sent when these words were held', - }; + inherited = risky + ? { + draftId: previous.id, + destination: risky.destination, + at: risky.at, + reason: risky.outcome?.reason ?? 'its outcome was never recorded', + } + : previous.inheritedRisk + ? { + draftId: previous.id, + sourceDraftId: previous.inheritedRisk.sourceDraftId ?? previous.inheritedRisk.draftId, + ...(previous.inheritedRisk.destination ? { destination: previous.inheritedRisk.destination } : {}), + ...(previous.inheritedRisk.at !== undefined ? { at: previous.inheritedRisk.at } : {}), + reason: previous.inheritedRisk.reason, + } + : { draftId: previous.id, reason: 'it was being sent when these words were held' }; } // The bounce notice below names the draft, so no separate held notice. draft = this.holdProseDrafts( @@ -9850,7 +9872,7 @@ export class AgentFramework { const id = this.addMessage( 'user', [{ type: 'text', text: notice }], - { system: true, kind: 'prose-bounce', ...(draft ? { draftIds: [draft.id] } : {}) } as MessageMetadata, + { system: true, kind: 'prose-bounce', ...(draft ? { draftOwner: name, draftIds: [draft.id] } : {}) } as MessageMetadata, { forAgent: name }, ); if (id) this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-bounce' }); diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index b00409bc0..2735fd689 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -92,7 +92,11 @@ export interface DraftAttempt { export interface InheritedRisk { /** The draft whose words were copied in. */ draftId: string; - /** Its uncertain attempt, when there was one (absent: it was in flight). */ + /** The draft whose attempt is the uncertain one, when the copied draft + * itself carried inherited risk (a chain of re-bounces): the evidence is + * that original attempt's, never re-invented. */ + sourceDraftId?: string; + /** That uncertain attempt, when there was one (absent: it was in flight). */ destination?: PublishDestination; at?: number; reason: string; diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index fb0e34f4b..663706489 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -722,7 +722,7 @@ describe('draft notices and turn-end settlement', () => { const agent = framework.getAgent('scout')!; const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named', source }, { text: 'lost', source }], 'explicit-send').held; // Only the first draft's notice reached the history. - agent.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftIds: [named!.id] } as never); + agent.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); internals.turnDrafts.set('scout', [named, lost]); internals.settleTurnDrafts(agent); assert.ok(internals.proseDrafts.get('scout', named!.id)!.noticedAt); @@ -735,6 +735,66 @@ describe('draft notices and turn-end settlement', () => { }); }); +describe('draft identity and risk evidence', () => { + const setupFramework = async () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-ident-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'scout' }, { name: 'other', model: 'test-model', systemPrompt: 'other' }], + modules: [], + }); + const internals = framework as unknown as { + proseDrafts: ProseDraftStore; + turnDrafts: Map; + settleTurnDrafts(agent: unknown): void; + bounceProse(agent: unknown, text: string, reason: string): void; + riskText(draft: unknown): string; + }; + return { dir, framework, internals, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); } }; + }; + + it('a notice names drafts of its own resident only: the same id under another resident is not settled by it', async () => { + const t = await setupFramework(); + try { + (t.internals.proseDrafts as unknown as { newId: (agent: string) => string }).newId = () => 'd-same2'; + const [mine] = t.internals.proseDrafts.hold('scout', [{ text: 'scout words', source }], 'explicit-send').held; + t.internals.proseDrafts.hold('other', [{ text: 'other words', source }], 'explicit-send'); + // Only the OTHER resident's notice for "d-same2" is in the shared history. + t.framework.getAgent('other')!.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] other: …' }], + { system: true, kind: 'prose-drafts', draftOwner: 'other', draftIds: ['d-same2'] } as never); + t.internals.turnDrafts.set('scout', [mine]); + t.internals.settleTurnDrafts(t.framework.getAgent('scout')!); + assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.noticedAt, undefined); + } finally { + await t.close(); + } + }); + + it('a re-bounce of inherited risk keeps the original attempt as its evidence', async () => { + const t = await setupFramework(); + try { + const agent = t.framework.getAgent('scout')!; + const [original] = t.internals.proseDrafts.hold('scout', [{ text: 'first', source }], 'bounced').held; + t.internals.proseDrafts.beginAttempt('scout', original!.id, dest(), 'unsent-token', false); // never resolved + t.internals.bounceProse(agent, '{{unsent}} second', 'no such channel'); + const copy = t.internals.proseDrafts.open('scout').find((d) => d.text === 'first second')!; + assert.equal(copy.inheritedRisk?.draftId, original!.id); + t.internals.bounceProse(agent, '{{unsent}} third', 'no such channel'); + const again = t.internals.proseDrafts.open('scout').find((d) => d.text === 'first second third')!; + assert.equal(again.inheritedRisk?.draftId, copy.id); + assert.equal(again.inheritedRisk?.sourceDraftId, original!.id); + assert.deepEqual(again.inheritedRisk?.destination, dest()); + assert.equal(again.inheritedRisk?.reason, 'its outcome was never recorded'); + const text = t.internals.riskText(again); + assert.match(text, new RegExp(`${again.id} includes the words of ${copy.id}, which itself includes the words of ${original!.id}, and ${original!.id}'s attempt to #room \\(Guild One\\)`)); + assert.doesNotMatch(text, /being sent/); + } finally { + await t.close(); + } + }); +}); + describe('drafts resend ownership', () => { const setup = async () => { const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-own-')); @@ -772,6 +832,28 @@ describe('drafts resend ownership', () => { }; }; + it('an all-delivered resend returns its receipts even when the destination no longer resolves', async () => { + const t = await setup(); + try { + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.tick(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + assert.match(t.say(await sending), /delivered to \(chan\), message m-a/); + // The channel is gone: destination resolution would now fail. + const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.resolveProseTarget = () => ({ error: 'no channel matches' }); + registry.resolveDestination = () => ({ error: 'no registered channel' }); + const again = t.say(await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: '#gone' })); + assert.match(again, new RegExp(`${t.a.id}: delivered — confirmed at .* message m-a .*not sent again`)); + assert.equal(t.pending.length, 0); + // A mixed batch still needs a destination that resolves. + const mixed = t.say(await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: '#gone' })); + assert.match(mixed, /Destination "#gone" did not resolve/); + } finally { + await t.close(); + } + }); + it('a batch owns its drafts: a concurrent resend of a queued draft is refused, and nothing is sent twice', async () => { const t = await setup(); try { From e9099e5d9d3acc0710acce8712208025b71a5bd8 Mon Sep 17 00:00:00 2001 From: Basil-1369 Date: Wed, 7 Oct 2026 12:17:53 -0400 Subject: [PATCH 12/65] fix(drafts): held envelopes keep hybrid state, receipts read draft state, drafts keep exact runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers Greptile's first review of #247 (findings 1, 3, 4, 5; 2 is answered in the review thread as intended behavior) plus the two `{{unsent}}` cases Felix-1368 and Basil-1369 found beside finding 1, which Petra-1012 and Tessa-974 agreed belong to the same repair (room-203 #45831, #45845). - Hybrid envelopes beside a send (finding 1). holdSilencedProse now moves the router's state exactly as deliverHybridProse does, withholding only publication: `>>>skip_reply` clears the pin and keeps the rest private, across segments and later rounds, until a destination is named; a named destination that resolves becomes the sticky target again (clearing privacy alone would have sent a later unsilenced round's prose to the locus, which nobody named), and an unresolved or malformed one leaves no destination ('failed'). ` !` still asks to continue. The mid-turn hybrid hold is now chained on turnSpeechChain behind earlier rounds' envelopes, because their state transitions run there; a synchronous hold could read the state before a slow earlier delivery had applied it. - `{{unsent}}` beside a send. `>>>skip_reply {{unsent}}` still sets the latest bounce aside (a send suppresses speech, not that operation). A held target envelope holds the message it would have published, `{{unsent}}` expanded under the re-bounce rule, now one helper (unsentWords) shared with bounceProse: the whole authored text kept, inherited risk (with chained sourceDraftId evidence) when the latest bounce is unconfirmed or in flight; a bare `{{unsent}}` mints no copy and the bounce stays the one draft, named in the turn's receipt. Before, the draft held the literal token, which a resend would have published. bounceProse's stale comment (describing the rejected strip-the-token remedy) is replaced by the helper's doc. - Turn-end receipt (finding 3). Each of the turn's drafts is re-read from the store and reported by its state at turn end: still held ("held as drafts … (not sent …)", wording unchanged), delivered by the resident's resend (listed in the header where it landed, so the header no longer says "nothing" over it; a hybrid `{{unsent}}` delivery already listed as plain speech gets a note instead of a second entry), unconfirmed (riskText), or dismissed. - Exact runs (finding 4). prose-segments.ts has one walker: splitProseRuns returns runs exactly as written (whitespace-only runs dropped) and splitProseSegments is those runs trimmed. Routing still publishes trimmed segments (deliverProse needs `>>` at the start, and routed speech is unchanged); every silenced hold takes the raw runs, so Draft.text is the resident's words byte for byte, as its doc and `read` claim. - Refused-call boundaries (finding 5). The walker breaks at `tool_attempt` and `tool_notice`, so the text-only path's segment split actually happens on this branch alone. This is Greta-1013's hunk from fix/xml-tool-call-notices (a05a2e3); she agreed #247 owns it and will drop hers on rebase (via Felix, room-240 #45593). A turn-ending [text, tool_attempt] still yields just the text. - Finding 2 (XML think privacy) is not changed: same_round_think_text_policy is native-round-only by upstream design (think tool description; test "legacy/no-roundContent behavior remains unchanged" routes that prose publicly), so in XML mode a send holds it like any other speech. The doc comment and changelog now say so, and a test pins it. Tests: 7 end-to-end (exact runs and resend bytes; hybrid privacy across segments/rounds and the restored pin, plus a live >>>skip_reply binding a later silenced round; the {{unsent}} cases including inherited risk; the receipt's held/resent/dismissed/unconfirmed states; XML think beside a send; locus and hybrid refused-attempt splits) and 2 unit tests for the walker. On 6c5f054 the six behavior tests fail for exactly these defects (the XML-think test passes there: it pins existing behavior). Full suite 1381/1381 on this tree (node 24, concurrency 4, Petra's feat-prose-drafts node_modules: membrane 0.5.86, context-manager 0.13.0). Not tested: the chained-hold ordering under a slow earlier delivery (needs a publish that hangs while later rounds proceed); it is reasoned from the chain's ordering, not exercised. Co-Authored-By: Claude Opus 5.5 --- changelog.d/prose-drafts.added.md | 25 +++- src/framework.ts | 229 +++++++++++++++++++++--------- src/prose-segments.ts | 35 +++-- test/prose-drafts.test.ts | 162 +++++++++++++++++++++ test/prose-segments.test.ts | 23 ++- 5 files changed, 394 insertions(+), 80 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 45af20a6a..e128726b0 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -5,15 +5,29 @@ and text-only, native and XML tool modes), hybrid prose left without a destination after a failed envelope, and explicit/hybrid bounces (which replace the in-memory latest-wins clipboard; `{{unsent}}` keeps meaning the - latest bounce). Deliberate privacy is never drafted: `skip_reply`, a - same-round private `think`, silent turns and `proseRouting: disabled` - narration stay private and are counted in the receipt. + latest bounce). A draft held beside a send keeps its run exactly as + written, indentation and surrounding newlines included; routing still + publishes trimmed segments. Deliberate privacy is never drafted: + `skip_reply` (the tool, or a hybrid `>>>skip_reply` envelope, which keeps + the rest private until a destination is named), a same-round private + `think` in a native round (where that policy applies: XML tool mode routes + such prose as ordinary speech, so a send holds it as a draft), silent turns + and `proseRouting: disabled` narration stay private and are counted in the + receipt. +- In hybrid mode a send withholds publication, not what an envelope does: a + held `>>>` envelope moves the routing state as live routing would (a + destination it names becomes the sticky target), `>>>skip_reply {{unsent}}` + still sets the latest bounce aside, and the draft holds the message the + envelope would have published, with `{{unsent}}` expanded under the + re-bounce rule (a bare `{{unsent}}` leaves the bounce as the one draft). - Drafts live in typed Chronicle records (`RecordJournal`) that do not follow branch switches: undo, rollback and checkout never change them, and they survive restarts. Nothing is ever sent from them on its own. - A private notice names held drafts at the next tool boundary when the live stream presents mid-turn messages, and the turn-end `[delivered]` receipt - names every draft held in the turn ("held as drafts d-…"); a draft no notice + names every draft held in the turn by its state at turn end: still held + ("held as drafts d-… (not sent …)"), delivered by the resident's resend + (listed where it landed), unconfirmed, or dismissed. A draft no notice reached (a crash, an aborted turn) is named at the next turn's start. - A draft is never published except by its resident's explicit resend (or `{{unsent}}`), and only that resident can list or act on it. Its notices go @@ -28,6 +42,9 @@ unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend owns its drafts until it finishes, re-checking each before it is sent. Unused fields may be passed as null. +- Prose segments also break at XML tool mode's refused attempts and their + notices (`tool_attempt`, `tool_notice`): the words before an all-refused + call and after its notice are two messages, routed or held separately. - `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and `ChannelRegistry.resolveDestination` are new; routeSpeech is unchanged. - The bounce notice now goes to the bouncing resident rather than the primary. diff --git a/src/framework.ts b/src/framework.ts index c462c1c55..2ea22b5fd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -82,7 +82,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, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type InheritedRisk } from './prose-drafts.js'; +import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -109,7 +109,7 @@ import { scriptTimeLimits, validateCodeExecutionConfig, } from './code-execution/tool-definition.js'; -import { splitProseSegments } from './prose-segments.js'; +import { splitProseRuns } from './prose-segments.js'; import { cumulativeDelta } from './usage-accounting.js'; import { stampThinkingTokenEstimates } from './thinking-token-stamp.js'; @@ -8791,7 +8791,10 @@ export class AgentFramework { /** * Silenced plain speech: an explicit send holds it as resendable drafts; a * deliberate silence (skip_reply, a silent turn, a same-round private - * think) keeps it private — counted for the receipt, never drafted. + * think in a native round, where that policy applies) keeps it private — + * counted for the receipt, never drafted. `segments` are the runs exactly + * as written (splitProseRuns), so a draft keeps the resident's words byte + * for byte. */ private holdSilencedProse( agent: Agent, @@ -8809,24 +8812,71 @@ export class AgentFramework { this.holdProseDrafts(agent, segments, 'explicit-send', opts); return; } - // Hybrid prose may carry `>>>` envelopes: a draft holds the words, not - // the routing syntax (a resend must not publish the prefix), and the - // destination the resident wrote stays with it as a note. A - // `>>>skip_reply` envelope was private by the resident's own choice. + // Hybrid prose may carry `>>>` envelopes. A send suppresses speech, not + // what the envelopes do: they move the router's state exactly as + // deliverHybridProse's do. After `>>>skip_reply` the rest stays private + // (across segments and later rounds) until a destination is named; + // naming one sets the sticky target, or leaves none when it does not + // resolve; `>>>skip_reply {{unsent}}` sets the latest bounce aside, and + // ` !` asks to continue. Only publication is withheld: a draft holds + // what the envelope would have published (`{{unsent}}` under the + // re-bounce rule, unsentWords), not its routing syntax, and the + // destination the resident wrote stays with it as a note. let privateCount = 0; - const parts: Array<{ text: string; note?: string }> = []; + const parts: Array<{ text: string; note?: string; inheritedRisk?: InheritedRisk }> = []; + /** Latest bounces a bare `{{unsent}}` named: still the one draft of those words. */ + const kept: Draft[] = []; for (const segment of segments) { for (const envelope of splitHybridEnvelopes(segment)) { const parsed = parseHybridProsePrefix(envelope); - if (parsed.kind === 'private') privateCount++; - else if (parsed.kind === 'target') parts.push({ text: parsed.body, note: `written for >>>${parsed.target}` }); - else parts.push({ text: envelope }); + if (parsed.continueTurn) this.proseContinuations.add(agent.name); + if (parsed.kind === 'private') { + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'private'); + if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); + privateCount++; + } else if (parsed.kind === 'target') { + const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!); + if (resolved && !('error' in resolved)) { + this.proseTargetPins.set(agent.name, resolved.channelId); + this.proseHybridSuppressed.delete(agent.name); + } else { + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'failed'); + } + const words = this.unsentWords(agent, parsed.body); + if ('draft' in words) { + if (!kept.includes(words.draft)) kept.push(words.draft); + } else { + parts.push({ + text: words.text, + note: `written for >>>${parsed.target}`, + ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}), + }); + } + } else if (envelope.startsWith('>>>')) { + // Malformed: an attempted destination that names none. + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'failed'); + parts.push({ text: envelope }); + } else if (this.proseHybridSuppressed.get(agent.name) === 'private') { + privateCount++; + } else { + parts.push({ text: envelope }); + } } } if (privateCount > 0) { this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + privateCount); } this.holdProseDrafts(agent, parts, 'explicit-send', opts); + if (kept.length > 0) { + // The turn's receipt names them by their state at turn end; each was + // named when it bounced. + const list = this.turnDrafts.get(agent.name) ?? []; + for (const draft of kept) if (!list.some((d) => d.id === draft.id)) list.push(draft); + this.turnDrafts.set(agent.name, list); + } } /** @@ -9375,13 +9425,36 @@ export class AgentFramework { ); } const notes: string[] = []; - if (drafts.length > 0) { - const ids = drafts.slice(0, 8).map((d) => d.id).join(', ') + (drafts.length > 8 ? `, and ${drafts.length - 8} more` : ''); + // Each of the turn's drafts as it stands now: a mid-turn notice lets the + // resident resend or dismiss one before the turn ends, so "not sent" is + // said only of drafts still held. + const current = drafts.map((d) => this.proseDrafts.get(agent.name, d.id) ?? d); + const inState = (state: DraftState): Draft[] => current.filter((d) => draftState(d) === state); + const idList = (ds: Draft[]): string => + ds.slice(0, 8).map((d) => d.id).join(', ') + (ds.length > 8 ? `, and ${ds.length - 8} more` : ''); + const held = inState('held'); + if (held.length > 0) { notes.push( - `${drafts.length} plain-speech segment(s) held as draft${drafts.length === 1 ? '' : 's'} ${ids} ` + + `${held.length} plain-speech segment(s) held as draft${held.length === 1 ? '' : 's'} ${idList(held)} ` + '(not sent — drafts can resend them unchanged, or dismiss them)', ); } + for (const d of inState('unconfirmed')) { + notes.push(`draft ${d.id} is unconfirmed: ${this.riskText(d)} — check that channel before sending it again (resend needs confirmDuplicate: true)`); + } + const dismissed = inState('dismissed'); + if (dismissed.length > 0) notes.push(`draft${dismissed.length === 1 ? '' : 's'} ${idList(dismissed)} dismissed by you`); + for (const d of inState('delivered')) { + const attempt = d.attempts.find((a) => a.outcome?.status === 'delivered')!; + const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; + // A hybrid `{{unsent}}` envelope is already listed above, as the plain + // speech it was; a resend (or explicit mode's envelope) is not. + if (attempt.via === 'unsent-token' && seen.has(attempt.destination.channelId)) { + notes.push(`draft ${d.id} delivered ${how}`); + } else { + shown.push(`${AgentFramework.destinationText(attempt.destination)} (draft ${d.id}, ${how})`); + } + } if (notHeld) { notes.push( `${notHeld.count} plain-speech segment(s) suppressed and NOT held as drafts (${notHeld.error}) — ` + @@ -9783,11 +9856,52 @@ export class AgentFramework { /** Cap on consecutive bounce-triggered wakes (notices still append after). */ private static readonly PROSE_BOUNCE_WAKE_CAP = 2; + /** + * Words about to be held that may use `{{unsent}}` (a bounce, or an + * envelope an explicit send suppressed), under the one re-bounce rule. A + * bare `{{unsent}}` is the latest bounce itself, which stays the one draft + * of those words rather than being copied. Otherwise the whole authored + * text is kept, the token expanded to the latest bounce's words, nothing + * stripped or substituted. When that draft may already have been posted + * (unconfirmed, or in flight) the words carry its duplication risk: they + * can be resent only with confirmDuplicate, like the draft they copy. The + * evidence is the risk's own: the copied draft's uncertain attempt, else + * the risk that draft itself inherited, else (truly) that it was in flight + * when copied. + */ + private unsentWords(agent: Agent, text: string): { draft: Draft } | { text: string; inheritedRisk?: InheritedRisk } { + if (!text.includes('{{unsent}}')) return { text }; + const name = agent.name; + const previous = this.proseDrafts.latestBounce(name); + if (previous && text.trim() === '{{unsent}}') return { draft: previous }; + const expanded = text.replaceAll('{{unsent}}', previous?.text ?? ''); + if (!previous || !(draftState(previous) === 'unconfirmed' || this.draftsInFlight.has(`${name}\u0000${previous.id}`))) { + return { text: expanded }; + } + const risky = uncertainAttempt(previous); + const inheritedRisk: InheritedRisk = risky + ? { + draftId: previous.id, + destination: risky.destination, + at: risky.at, + reason: risky.outcome?.reason ?? 'its outcome was never recorded', + } + : previous.inheritedRisk + ? { + draftId: previous.id, + sourceDraftId: previous.inheritedRisk.sourceDraftId ?? previous.inheritedRisk.draftId, + ...(previous.inheritedRisk.destination ? { destination: previous.inheritedRisk.destination } : {}), + ...(previous.inheritedRisk.at !== undefined ? { at: previous.inheritedRisk.at } : {}), + reason: previous.inheritedRisk.reason, + } + : { draftId: previous.id, reason: 'it was being sent when these words were held' }; + return { text: expanded, inheritedRisk }; + } + /** * A routing envelope that could not be delivered: its words are held as a * draft (the `{{unsent}}` source) and the resident is told, privately. - * Bouncing `{{unsent}}` itself again keeps the one draft rather than - * minting a copy; a body that adds words around it holds the expanded text. + * `{{unsent}}` in it follows the re-bounce rule (unsentWords). */ private bounceProse( agent: Agent, @@ -9798,49 +9912,17 @@ export class AgentFramework { hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, ): void { const name = agent.name; - const previous = this.proseDrafts.latestBounce(name); - // A latest bounce that may already have been posted (unconfirmed, or in - // flight) is never copied into a fresh draft: that copy would resend - // without the confirmation its own uncertainty requires. It stays held as - // itself; only the resident's other words become a new draft. - const previousAtRisk = previous !== undefined - && (draftState(previous) === 'unconfirmed' || this.draftsInFlight.has(`${name}\u0000${previous.id}`)); + const words = this.unsentWords(agent, text); let draft: Draft | undefined; let inherited: InheritedRisk | undefined; - if (previous && text.trim() === '{{unsent}}') { - draft = previous; + if ('draft' in words) { + draft = words.draft; } else { - const usesUnsent = text.includes('{{unsent}}'); - const expanded = usesUnsent ? text.replaceAll('{{unsent}}', previous?.text ?? '') : text; - if (usesUnsent && previous && previousAtRisk) { - // The whole authored attempt is kept, words of the risky draft - // included, and it carries that draft's duplication risk: it can be - // resent only with confirmDuplicate, like the draft it copies. The - // evidence is the risk's own: the copied draft's uncertain attempt, - // else the risk that draft itself inherited, else (truly) that it - // was in flight when copied. - const risky = uncertainAttempt(previous); - inherited = risky - ? { - draftId: previous.id, - destination: risky.destination, - at: risky.at, - reason: risky.outcome?.reason ?? 'its outcome was never recorded', - } - : previous.inheritedRisk - ? { - draftId: previous.id, - sourceDraftId: previous.inheritedRisk.sourceDraftId ?? previous.inheritedRisk.draftId, - ...(previous.inheritedRisk.destination ? { destination: previous.inheritedRisk.destination } : {}), - ...(previous.inheritedRisk.at !== undefined ? { at: previous.inheritedRisk.at } : {}), - reason: previous.inheritedRisk.reason, - } - : { draftId: previous.id, reason: 'it was being sent when these words were held' }; - } + inherited = words.inheritedRisk; // The bounce notice below names the draft, so no separate held notice. draft = this.holdProseDrafts( agent, - [{ text: expanded, ...(inherited ? { inheritedRisk: inherited } : {}) }], + [{ text: words.text, ...(inherited ? { inheritedRisk: inherited } : {}) }], 'bounced', { round: hold.round, note: reason, notice: 'later' }, )[0]; @@ -10793,7 +10875,10 @@ export class AgentFramework { const presentsInjections = (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true; const holdOpts = { round: draftRound, notice: presentsInjections ? 'now' as const : 'later' as const }; - const roundSegments = splitProseSegments(assistantBlocks); + // Routing publishes trimmed segments; held drafts keep the + // runs exactly as written. + const roundRuns = splitProseRuns(assistantBlocks); + const roundSegments = roundRuns.map((run) => run.trim()); if (roundSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error( @@ -10802,7 +10887,12 @@ export class AgentFramework { this.recordProseSuppression(agent.name, roundSegments.length); } else if (turnProseRouting === 'hybrid') { if (turnSilenced) { - this.holdSilencedProse(agent, roundSegments, silenceCause, hasSameRoundPrivateThink, holdOpts); + // In order behind earlier rounds' envelopes: the router + // state they leave is where this round's starts. + const cause = silenceCause; + turnSpeechChain = turnSpeechChain + .then(() => this.holdSilencedProse(agent, roundRuns, cause, hasSameRoundPrivateThink, holdOpts)) + .catch((err) => console.error('mid-turn hybrid prose hold failed:', err)); } else if (!hasSameRoundPrivateThink) { const locus = resolveTurnLocus(); for (const seg of roundSegments) { @@ -10831,7 +10921,7 @@ export class AgentFramework { ); // Never a silent black hole (n=8: the flying-scene reply): // held as drafts the resident can resend, and named. - this.holdSilencedProse(agent, roundSegments, silenceCause, hasSameRoundPrivateThink, holdOpts); + this.holdSilencedProse(agent, roundRuns, silenceCause, hasSameRoundPrivateThink, holdOpts); } else if (hasSameRoundPrivateThink) { console.error( `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`, @@ -11263,18 +11353,20 @@ export class AgentFramework { // they are never routed here — which is precisely how the `think` // tool (and any explicit send tool) yields a silent turn. if (speechContent.length > 0 && this.channelRegistry) { - const speechText = speechContent + const speechRun = speechContent .map((b) => (b as ContentBlock & { type: 'text' }).text) - .join('\n') - .trim(); + .join('\n'); // A text-only turn is one message — unless an all-refused XML // round (membrane `tool_attempt`, answered in-band with a // `tool_notice`) separates its prose: nothing was called, but // the words before the attempt and after its notice are two - // messages, routed as segments like a tool turn's. - const speechSegments = response.content.some((b) => (b.type as string) === 'tool_attempt') - ? splitProseSegments(response.content) - : speechText ? [speechText] : []; + // messages, routed as segments like a tool turn's. Routing + // publishes trimmed segments; held drafts keep the runs exactly + // as written. + const speechRuns = response.content.some((b) => (b.type as string) === 'tool_attempt') + ? splitProseRuns(response.content) + : speechRun.trim() ? [speechRun] : []; + const speechSegments = speechRuns.map((run) => run.trim()); const textOnlyHold = { round: draftRound + 1, notice: 'later' as const }; if (speechSegments.length > 0) { if (turnProseRouting === 'disabled') { @@ -11286,7 +11378,7 @@ export class AgentFramework { // (a fresh text-only turn starts unsilenced). Explicit // mode is exempt, as mid-turn. console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced: ${silenceCause})`); - this.holdSilencedProse(agent, speechSegments, silenceCause, false, textOnlyHold); + this.holdSilencedProse(agent, speechRuns, silenceCause, false, textOnlyHold); } else if (turnProseRouting === 'hybrid') { const locus = resolveTurnLocus(); console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`); @@ -11355,7 +11447,10 @@ export class AgentFramework { silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); const trailingHold = { round: draftRound + 1, notice: 'later' as const }; - const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); + // Routing publishes trimmed segments; held drafts keep the runs + // exactly as written. + const runs = splitProseRuns(liveProseRouting ? terminalContent : response.content); + const segments = runs.map((run) => run.trim()); // Preserve in-channel ordering: everything enqueued live must // land before the trailing prose. Awaited even when silenced — @@ -11371,7 +11466,7 @@ export class AgentFramework { } } else if (turnProseRouting === 'hybrid') { if (silenced && segments.length > 0) { - this.holdSilencedProse(agent, segments, trailingCause, false, trailingHold); + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); } else if (segments.length > 0) { const locus = resolveTurnLocus(); for (const seg of segments) { @@ -11401,7 +11496,7 @@ export class AgentFramework { `(${silenced ? `turn silenced: ${trailingCause}` : 'no trailing prose'})`, ); if (silenced && segments.length > 0) { - this.holdSilencedProse(agent, segments, trailingCause, false, trailingHold); + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); } } else { // Reuse the locus pinned at the turn's first live-routed diff --git a/src/prose-segments.ts b/src/prose-segments.ts index 6d8f3f079..6e2b96f7e 100644 --- a/src/prose-segments.ts +++ b/src/prose-segments.ts @@ -10,26 +10,40 @@ * collapsing those distinct messages into one (item 4). This helper instead * yields each contiguous run of text — the segments a surface should deliver as * separate, ordered messages. Contiguous text blocks merge into one segment; - * `tool_use` / `tool_result` blocks are segment boundaries; empty or + * `tool_use` / `tool_result` blocks are segment boundaries, as are XML tool + * mode's `tool_attempt` / `tool_notice` (a refused attempt and the harness's + * notice end a round just as a call and its results do); empty or * whitespace-only runs are dropped. */ import type { ContentBlock } from '@animalabs/membrane'; -export function splitProseSegments(content: readonly ContentBlock[]): string[] { - const segments: string[] = []; +/** + * The runs exactly as written: contiguous text blocks joined with a newline, + * nothing trimmed. Whitespace-only runs are dropped. Held drafts keep these, + * so a draft's words (indentation included) are the resident's own, byte for + * byte. + */ +export function splitProseRuns(content: readonly ContentBlock[]): string[] { + const runs: string[] = []; let buf: string[] = []; const flush = (): void => { - const s = buf.join('\n').trim(); - if (s) segments.push(s); + const run = buf.join('\n'); + if (run.trim()) runs.push(run); buf = []; }; for (const block of content) { - if (block.type === 'text') { + const type = block.type as string; + if (type === 'text') { buf.push((block as ContentBlock & { type: 'text' }).text); - } else if (block.type === 'tool_use' || block.type === 'tool_result') { + } else if ( + type === 'tool_use' || + type === 'tool_result' || + type === 'tool_attempt' || + type === 'tool_notice' + ) { flush(); } // Other block types (thinking, redacted_thinking, image, …) are neither @@ -38,5 +52,10 @@ export function splitProseSegments(content: readonly ContentBlock[]): string[] { } flush(); - return segments; + return runs; +} + +/** The runs as speech is routed: each trimmed of surrounding whitespace. */ +export function splitProseSegments(content: readonly ContentBlock[]): string[] { + return splitProseRuns(content).map((run) => run.trim()); } diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 663706489..3672dd7cc 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -892,3 +892,165 @@ describe('drafts resend ownership', () => { } }); }); + +describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end state', () => { + /** Draft ids in hold order, so a later round can name them. */ + const fixIds = (h: Awaited>, ids: string[]) => { + let next = 0; + (h.store() as unknown as { newId: () => string }).newId = () => ids[next++]!; + }; + + it('a held draft keeps its run exactly as written, and a resend publishes those bytes', async () => { + const h = await harness(); + try { + const authored = ' indented code\n second line\n'; + await h.turn([ + createMockResponse([text(authored), explicitSend('s1')], 'tool_use'), + createMockResponse([text('\n\nAfter the send.\n')]), + ]); + const held = h.drafts().reverse(); + assert.deepEqual(held.map((d) => d.text), [authored, '\n\nAfter the send.\n']); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [held[0]!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.equal(h.publishes().at(-1)!.text, authored); + } finally { + await h.close(); + } + }); + + it('hybrid: after >>>skip_reply, prose a send holds back stays private across segments and rounds until a destination is named, which restores the sticky target', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + try { + await h.turn([ + createMockResponse([text('>>>skip_reply a private aside'), explicitSend('s1'), text('still private'), call('l1', 'channel_list', {})], 'tool_use'), + createMockResponse([text('private in a later round'), call('l2', 'channel_list', {})], 'tool_use'), + createMockResponse([text(`>>>${ROOM} words for the room`), call('l3', 'channel_list', {})], 'tool_use'), + createMockResponse([text('more for the room')]), + ]); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); + assert.deepEqual(h.drafts().reverse().map((d) => [d.text, d.note]), [ + ['words for the room', `written for >>>${ROOM}`], + ['more for the room', undefined], + ]); + assert.equal((h.framework as unknown as { proseTargetPins: Map }).proseTargetPins.get('scout'), ROOM, + 'the named destination is the sticky target again, as live routing would leave it'); + assert.match(h.texts().find((t) => t.startsWith('[delivered]'))!, / · 3 plain-speech segment\(s\) kept private \(skip_reply\)$/); + + // A live `>>>skip_reply` in an earlier round binds a later silenced one. + const before = h.drafts().length; + await h.turn([ + createMockResponse([text('>>>skip_reply thinking aloud'), call('l4', 'channel_list', {})], 'tool_use'), + createMockResponse([text('still thinking'), explicitSend('s2')], 'tool_use'), + createMockResponse([]), + ]); + assert.equal(h.drafts().length, before, 'nothing private was drafted'); + // "still thinking" is the one kept private (live routing does not count + // the `>>>skip_reply` envelope itself). + assert.equal(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1), '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply)'); + } finally { + await h.close(); + } + }); + + it('hybrid: beside a send, {{unsent}} holds the whole message it would have published, a bare one keeps the bounce as the one draft, and >>>skip_reply {{unsent}} still sets it aside', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + try { + await h.turn([createMockResponse([text('>>>#nowhere lost words')])]); + const [bounce] = h.drafts(); + assert.equal(bounce?.reason, 'bounced'); + + await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}} and a postscript`), explicitSend('s1')], 'tool_use'), createMockResponse([])]); + const copy = h.drafts().find((d) => d.text.endsWith('a postscript'))!; + assert.deepEqual([copy.text, copy.note, copy.reason, copy.inheritedRisk], ['lost words and a postscript', `written for >>>${ROOM}`, 'explicit-send', undefined]); + + const count = h.drafts().length; + await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}}`), explicitSend('s2')], 'tool_use'), createMockResponse([])]); + assert.equal(h.drafts().length, count, 'a bare {{unsent}} mints no copy'); + assert.equal(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1), + `[delivered] nothing — 1 plain-speech segment(s) held as draft ${bounce!.id} (not sent — drafts can resend them unchanged, or dismiss them)`); + + // Once the bounce may have been posted, held words copied from it carry its risk. + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}}`)])]); + assert.equal(draftState(h.store().get('scout', bounce!.id)!), 'unconfirmed'); + h.command({ op: 'publish-mode', mode: 'delivered' }); + await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}} again`), explicitSend('s3')], 'tool_use'), createMockResponse([])]); + const risky = h.drafts().find((d) => d.text === 'lost words again')!; + assert.equal(risky.inheritedRisk?.draftId, bounce!.id); + assert.equal(draftState(risky), 'unconfirmed'); + + await h.turn([createMockResponse([text('>>>skip_reply {{unsent}}'), explicitSend('s4')], 'tool_use'), createMockResponse([])]); + assert.equal(draftState(h.store().get('scout', bounce!.id)!), 'dismissed', 'a send withholds speech, not the dismissal'); + assert.ok(h.drafts().every((d) => !d.text.includes('{{unsent}}')), 'no draft holds the literal token'); + assert.deepEqual(h.publishes().map((p) => p.text).filter((t) => t !== 'an explicit note elsewhere'), ['lost words'], + 'only the live {{unsent}} went out'); + } finally { + await h.close(); + } + }); + + it('the turn-end receipt reports each draft as it stands: resent, dismissed, unconfirmed or still held', async () => { + const h = await harness(); + try { + const ids = ['d-rcpta', 'd-rcptb', 'd-rcptc', 'd-rcptd']; + fixIds(h, ids); + await h.turn([ + createMockResponse([text('resend me'), explicitSend('s1'), text('dismiss me'), call('l1', 'channel_list', {}), text('keep me'), call('l2', 'channel_list', {})], 'tool_use'), + createMockResponse([drafts('r1', { action: 'resend', draftIds: [ids[0]], destination: ROOM })], 'tool_use'), + createMockResponse([drafts('x1', { action: 'dismiss', draftIds: [ids[1]] })], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere', 'resend me']); + assert.equal(h.texts().find((t) => t.startsWith('[delivered]')), + `[delivered] plain speech → #room (Guild One) (${ROOM}) (draft ${ids[0]}, by your resend) · ` + + `1 plain-speech segment(s) held as draft ${ids[2]} (not sent — drafts can resend them unchanged, or dismiss them) · ` + + `draft ${ids[1]} dismissed by you`); + + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([ + createMockResponse([text('uncertain words'), explicitSend('s2')], 'tool_use'), + createMockResponse([drafts('r2', { action: 'resend', draftIds: [ids[3]], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( + `^\\[delivered\\] nothing — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + + 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); + } finally { + await h.close(); + } + }); + + it('XML tool mode: the same-round think policy is native-only, so prose beside think and a send is held like any other', async () => { + const h = await harness({ xml: true, agents: [{ name: 'scout', sameRoundThinkTextPolicy: 'private' }] }); + try { + const round = [text('Prose beside think.'), call('t1', 'think', { content: 'hmm' }), explicitSend('s1')]; + await h.turn([ + createMockResponse(round, 'tool_use'), + { ...createMockResponse(round), toolCalls: [] }, + ]); + assert.deepEqual(h.drafts().map((d) => [d.text, d.reason]), [['Prose beside think.', 'explicit-send']]); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); + } finally { + await h.close(); + } + }); + + for (const mode of ['locus', 'hybrid'] as const) { + it(`${mode}: prose around an all-refused XML attempt posts as two messages`, async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: mode }] }); + try { + await h.turn([createMockResponse([ + text('Before the attempt.'), + { type: 'tool_attempt', rawXml: '' } as unknown as ContentBlock, + { type: 'tool_notice', notices: [{ ordinal: 0, kind: 'refused', message: 'boundary' }] } as unknown as ContentBlock, + text('After its notice.'), + ])]); + assert.deepEqual(h.publishes().map((p) => p.text), ['Before the attempt.', 'After its notice.']); + } finally { + await h.close(); + } + }); + } +}); diff --git a/test/prose-segments.test.ts b/test/prose-segments.test.ts index 015b1594e..98ffda842 100644 --- a/test/prose-segments.test.ts +++ b/test/prose-segments.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { ContentBlock } from '@animalabs/membrane'; -import { splitProseSegments } from '../src/prose-segments.js'; +import { splitProseRuns, splitProseSegments } from '../src/prose-segments.js'; const textBlock = (text: string): ContentBlock => ({ type: 'text', text } as ContentBlock); const toolUse = (id: string): ContentBlock => @@ -60,3 +60,24 @@ test('a plain no-tool turn yields a single segment (unchanged behaviour)', () => test('a tool-only turn (no prose) yields no segments', () => { assert.deepEqual(splitProseSegments([toolUse('t1'), toolResult('t1')]), []); }); + +test('runs keep the words exactly as written; segments are those runs trimmed for routing', () => { + const content: ContentBlock[] = [ + textBlock(' indented code\n second line\n'), + toolUse('t1'), toolResult('t1'), + textBlock('\n\nAfter the tool. '), + toolUse('t2'), toolResult('t2'), + textBlock(' \n\t'), + ]; + assert.deepEqual(splitProseRuns(content), [' indented code\n second line\n', '\n\nAfter the tool. ']); + assert.deepEqual(splitProseSegments(content), ['indented code\n second line', 'After the tool.']); +}); + +test('a refused XML attempt and its notice are boundaries too, and a turn-ending attempt leaves only its prose', () => { + const attempt = { type: 'tool_attempt', rawXml: '' } as unknown as ContentBlock; + const notice = { type: 'tool_notice', notices: [{ ordinal: 0, kind: 'refused', message: 'boundary' }] } as unknown as ContentBlock; + assert.deepEqual(splitProseSegments([textBlock('Before the attempt.'), attempt, notice, textBlock('After its notice.')]), + ['Before the attempt.', 'After its notice.']); + assert.deepEqual(splitProseSegments([textBlock('Again.'), attempt]), ['Again.']); + assert.deepEqual(splitProseRuns([textBlock('Before. '), attempt, notice, textBlock(' After.')]), ['Before. ', ' After.']); +}); From 467a48ffaa5cca23995a4f3d84a5c7d2f4b22a30 Mon Sep 17 00:00:00 2001 From: Basil-1369 Date: Wed, 7 Oct 2026 12:54:35 -0400 Subject: [PATCH 13/65] fix(drafts): a held-draft notice gives a copied draft its risk; "nothing confirmed" beside an unconfirmed draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix-1368's review of e9099e5 (room-240 #46400, #46447): - The mid-turn held-draft notice said "not sent" and offered a plain resend for every draft it named. Since e9099e5 a held hybrid envelope can expand `{{unsent}}` from a latest bounce that may already have been posted, so the new draft is `unconfirmed` (inherited risk) from the moment it is held, and a plain resend would be refused. draftsHeldNotice now splits the batch by state: held drafts keep the existing "delivers it unchanged" wording (byte-identical when nothing is at risk), and each unconfirmed one gets its riskText and the confirmDuplicate resend it needs. Found by Felix's probe through a full native turn. - The turn-end receipt's header said "[delivered] nothing — draft d-x is unconfirmed: … may already have been posted". With an unconfirmed draft in the receipt and nothing confirmed delivered, the header now says "nothing confirmed", so its first clause doesn't contradict the evidence. - A comment no longer says explicit mode reaches the receipt: both receipt call sites exclude explicit mode (antra's original design, bdbc7e3), so the explicit-mode "nothing" I raised in room-240 is not reachable; Felix confirmed through a real turn. Tests, two adapted from Felix's probes: - a held round waits behind a slow earlier delivery for the state its `>>>skip_reply` leaves (this exercises e9099e5's chained hold; with the hold made synchronous again it fails: "the held round waits for the earlier private transition"); - the notice for a mixed batch: a free draft offered a plain resend, a copied, possibly-posted one its risk and confirmDuplicate; - XML mode: runs around a refused attempt held with their edge whitespace and resent byte for byte. The hybrid tests now end with the completion a real membrane yields (the whole turn's content, no calls), so the turn's speech chain is settled before its receipt as in production; MockMembrane's literal empty completion skipped that await. Full suite 1384/1384 on this tree (node 24, concurrency 4, Petra's feat-prose-drafts node_modules). Co-Authored-By: Claude Opus 5.5 --- changelog.d/prose-drafts.added.md | 4 +- src/framework.ts | 27 ++++++-- test/prose-drafts.test.ts | 111 +++++++++++++++++++++++++++--- 3 files changed, 124 insertions(+), 18 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index e128726b0..7e0a0a73c 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -24,7 +24,9 @@ branch switches: undo, rollback and checkout never change them, and they survive restarts. Nothing is ever sent from them on its own. - A private notice names held drafts at the next tool boundary when the live - stream presents mid-turn messages, and the turn-end `[delivered]` receipt + stream presents mid-turn messages (a draft holding words copied from one + that may already have been posted is named with that risk and the + `confirmDuplicate` its resend needs), and the turn-end `[delivered]` receipt names every draft held in the turn by its state at turn end: still held ("held as drafts d-… (not sent …)"), delivered by the resident's resend (listed where it landed), unconfirmed, or dismissed. A draft no notice diff --git a/src/framework.ts b/src/framework.ts index 2ea22b5fd..433896d59 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8966,14 +8966,25 @@ export class AgentFramework { private draftsHeldNotice(agentName: string, held: Draft[], reason: DraftReason): string { const ids = held.map((d) => d.id); const many = held.length > 1; - const idList = ids.map((id) => `"${id}"`).join(', '); + const quoted = (ds: Draft[]): string => ds.map((d) => `"${d.id}"`).join(', '); + // Words copied from a draft that may already have been posted resend + // only with the resident's confirmation; the rest resend freely. + const free = held.filter((d) => draftState(d) !== 'unconfirmed'); + const risky = held.filter((d) => draftState(d) === 'unconfirmed'); + const resend = [ + ...(free.length > 0 + ? [`drafts(action: "resend", draftIds: [${quoted(free)}], destination: "#channel") delivers ` + + `${free.length > 1 ? 'them' : 'it'} unchanged`] + : []), + ...risky.map((d) => `${this.riskText(d)}, so check that channel before resending ${d.id} ` + + `(drafts(action: "resend", draftIds: ["${d.id}"], destination: "#channel", confirmDuplicate: true))`), + ]; // Named: residents that share one message slot (#197) share this window. return ( `[drafts] ${agentName}: not sent — ${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` + `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${AgentFramework.DRAFT_REASON_TEXT[reason]}. ` + - `Nothing publishes drafts but your own resend, and they stay until you act: drafts(action: "resend", draftIds: [${idList}], ` + - `destination: "#channel") delivers ${many ? 'them' : 'it'} unchanged; drafts(action: "dismiss", ` + - `draftIds: [${idList}]) sets ${many ? 'them' : 'it'} aside.` + `Nothing publishes drafts but your own resend, and they stay until you act: ${resend.join('; ')}; ` + + `drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.` ); } @@ -9439,7 +9450,8 @@ export class AgentFramework { '(not sent — drafts can resend them unchanged, or dismiss them)', ); } - for (const d of inState('unconfirmed')) { + const unconfirmed = inState('unconfirmed'); + for (const d of unconfirmed) { notes.push(`draft ${d.id} is unconfirmed: ${this.riskText(d)} — check that channel before sending it again (resend needs confirmDuplicate: true)`); } const dismissed = inState('dismissed'); @@ -9448,7 +9460,7 @@ export class AgentFramework { const attempt = d.attempts.find((a) => a.outcome?.status === 'delivered')!; const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; // A hybrid `{{unsent}}` envelope is already listed above, as the plain - // speech it was; a resend (or explicit mode's envelope) is not. + // speech it was; a resend is not. if (attempt.via === 'unsent-token' && seen.has(attempt.destination.channelId)) { notes.push(`draft ${d.id} delivered ${how}`); } else { @@ -9468,10 +9480,11 @@ export class AgentFramework { : `${suppressed} plain-speech segment(s) suppressed`); } const suppressedNote = notes.join(' · '); + // Nothing is confirmed delivered, but an unconfirmed draft may have been. const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` - : `[delivered] nothing — ${suppressedNote}`; + : `[delivered] nothing${unconfirmed.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( 'user', diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 3672dd7cc..a25e60664 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -899,6 +899,13 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end let next = 0; (h.store() as unknown as { newId: () => string }).newId = () => ids[next++]!; }; + /** Tool rounds, then the completion a real membrane yields: the whole + * turn's content, with no calls of its own (so the turn's speech chain is + * settled before its receipt, as in production). */ + const rounds = (...rs: ContentBlock[][]) => [ + ...rs.map((r) => createMockResponse(r, 'tool_use')), + { ...createMockResponse(rs.flat()), toolCalls: [] }, + ]; it('a held draft keeps its run exactly as written, and a resend publishes those bytes', async () => { const h = await harness(); @@ -940,11 +947,10 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end // A live `>>>skip_reply` in an earlier round binds a later silenced one. const before = h.drafts().length; - await h.turn([ - createMockResponse([text('>>>skip_reply thinking aloud'), call('l4', 'channel_list', {})], 'tool_use'), - createMockResponse([text('still thinking'), explicitSend('s2')], 'tool_use'), - createMockResponse([]), - ]); + await h.turn(rounds( + [text('>>>skip_reply thinking aloud'), call('l4', 'channel_list', {})], + [text('still thinking'), explicitSend('s2')], + )); assert.equal(h.drafts().length, before, 'nothing private was drafted'); // "still thinking" is the one kept private (live routing does not count // the `>>>skip_reply` envelope itself). @@ -961,12 +967,12 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end const [bounce] = h.drafts(); assert.equal(bounce?.reason, 'bounced'); - await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}} and a postscript`), explicitSend('s1')], 'tool_use'), createMockResponse([])]); + await h.turn(rounds([text(`>>>${ROOM} {{unsent}} and a postscript`), explicitSend('s1')])); const copy = h.drafts().find((d) => d.text.endsWith('a postscript'))!; assert.deepEqual([copy.text, copy.note, copy.reason, copy.inheritedRisk], ['lost words and a postscript', `written for >>>${ROOM}`, 'explicit-send', undefined]); const count = h.drafts().length; - await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}}`), explicitSend('s2')], 'tool_use'), createMockResponse([])]); + await h.turn(rounds([text(`>>>${ROOM} {{unsent}}`), explicitSend('s2')])); assert.equal(h.drafts().length, count, 'a bare {{unsent}} mints no copy'); assert.equal(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1), `[delivered] nothing — 1 plain-speech segment(s) held as draft ${bounce!.id} (not sent — drafts can resend them unchanged, or dismiss them)`); @@ -976,12 +982,12 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}}`)])]); assert.equal(draftState(h.store().get('scout', bounce!.id)!), 'unconfirmed'); h.command({ op: 'publish-mode', mode: 'delivered' }); - await h.turn([createMockResponse([text(`>>>${ROOM} {{unsent}} again`), explicitSend('s3')], 'tool_use'), createMockResponse([])]); + await h.turn(rounds([text(`>>>${ROOM} {{unsent}} again`), explicitSend('s3')])); const risky = h.drafts().find((d) => d.text === 'lost words again')!; assert.equal(risky.inheritedRisk?.draftId, bounce!.id); assert.equal(draftState(risky), 'unconfirmed'); - await h.turn([createMockResponse([text('>>>skip_reply {{unsent}}'), explicitSend('s4')], 'tool_use'), createMockResponse([])]); + await h.turn(rounds([text('>>>skip_reply {{unsent}}'), explicitSend('s4')])); assert.equal(draftState(h.store().get('scout', bounce!.id)!), 'dismissed', 'a send withholds speech, not the dismissal'); assert.ok(h.drafts().every((d) => !d.text.includes('{{unsent}}')), 'no draft holds the literal token'); assert.deepEqual(h.publishes().map((p) => p.text).filter((t) => t !== 'an explicit note elsewhere'), ['lost words'], @@ -1015,13 +1021,98 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end createMockResponse([]), ]); assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( - `^\\[delivered\\] nothing — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + + `^\\[delivered\\] nothing confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); } finally { await h.close(); } }); + it('hybrid: a held round starts from the state earlier rounds leave, even while an earlier delivery is still in flight', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + let release: (() => void) | undefined; + try { + // Round 1's delivery blocks; its `>>>skip_reply` waits behind it on the + // speech chain. Round 2's held prose must not run ahead of it. + const registry = (h.framework as unknown as { channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise } }).channelRegistry; + const deliver = registry.deliverSpeech.bind(registry); + let entered = false; + registry.deliverSpeech = async (...args: unknown[]) => { + if (args[1] === 'slow prior') { + entered = true; + await new Promise((resolve) => { release = resolve; }); + } + return deliver(...args); + }; + const running = h.turn(rounds( + [text(`>>>${ROOM} slow prior\n>>>skip_reply private aside`), call('l1', 'channel_list', {})], + [text('later private prose'), explicitSend('s1')], + )); + await h.until(() => entered && (h.membrane.lastStream?.receivedToolResults.length ?? 0) >= 2, 'both rounds while the delivery is blocked'); + assert.deepEqual(h.drafts(), [], 'the held round waits for the earlier private transition'); + release!(); + await running; + assert.deepEqual(h.drafts(), [], 'the later prose stayed private'); + assert.deepEqual(h.publishes().map((p) => p.text).sort(), ['an explicit note elsewhere', 'slow prior']); + assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, /1 plain-speech segment\(s\) kept private/); + } finally { + release?.(); + await h.close(); + } + }); + + it('hybrid: the held-draft notice gives a copied, possibly-posted draft its risk and the confirmation it needs', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + try { + const original = h.store().hold('scout', [{ text: 'possibly sent', source }], 'bounced').held[0]!; + h.store().beginAttempt('scout', original.id, dest(), 'unsent-token', false); // its outcome never recorded + await h.turn(rounds([text(`plain words\n>>>${ROOM} {{unsent}} and a new line`), explicitSend('s1')])); + const free = h.drafts().find((d) => d.text === 'plain words')!; + const copy = h.drafts().find((d) => d.text === 'possibly sent and a new line')!; + assert.equal(draftState(free), 'held'); + assert.equal(draftState(copy), 'unconfirmed'); + const notice = h.messages().find((m) => (m.metadata as { kind?: string; draftIds?: string[] } | undefined)?.kind === 'prose-drafts' + && (m.metadata as { draftIds?: string[] }).draftIds?.includes(copy.id))!; + const words = notice.content.map((b) => (b as { text?: string }).text ?? '').join(''); + assert.ok(words.includes(`drafts(action: "resend", draftIds: ["${free.id}"], destination: "#channel") delivers it unchanged`), words); + assert.ok(words.includes(`${copy.id} includes the words of ${original.id}, and ${original.id}'s attempt to #room (Guild One) (${ROOM})`), words); + assert.ok(words.includes(`so check that channel before resending ${copy.id} (drafts(action: "resend", draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true))`), words); + assert.ok(!words.includes(`draftIds: ["${free.id}", "${copy.id}"], destination`), 'no plain resend offered for the copy'); + } finally { + await h.close(); + } + }); + + it('XML tool mode: runs around a refused attempt are held with their edge whitespace and resent exactly', async () => { + const h = await harness({ xml: true }); + try { + const before = ' before\n'; + const after = '\n after '; + const round = [text(before), explicitSend('s1')]; + await h.turn([ + createMockResponse(round, 'tool_use'), + { + ...createMockResponse([ + ...round, + { type: 'tool_attempt', rawXml: '' } as unknown as ContentBlock, + { type: 'tool_notice', notices: [] } as unknown as ContentBlock, + text(after), + ]), + toolCalls: [], + }, + ]); + const held = h.drafts().reverse(); + assert.deepEqual(held.map((d) => d.text), [before, after]); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: held.map((d) => d.id), destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.publishes().slice(-2).map((p) => p.text), [before, after]); + } finally { + await h.close(); + } + }); + it('XML tool mode: the same-round think policy is native-only, so prose beside think and a send is held like any other', async () => { const h = await harness({ xml: true, agents: [{ name: 'scout', sameRoundThinkTextPolicy: 'private' }] }); try { From a14e25a0f62754a33b6845c57f13b65a4be0eeea Mon Sep 17 00:00:00 2001 From: Basil-1369 Date: Wed, 7 Oct 2026 13:14:29 -0400 Subject: [PATCH 14/65] fix(drafts): "not sent" only over words not at risk; exact-text claims say what holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felix-1368's review of 467a48f (room-240 #46701): - draftsHeldNotice opened "[drafts] name: not sent —" even when its body now says a copied draft's words may already have been posted. A batch holding any such draft now opens with the held drafts alone ("[drafts] name: 2 plain-speech segments held as drafts …"); a batch with nothing at risk keeps the old prefix. Same reason as 467a48f's "nothing confirmed". - Only speech an explicit send suppressed is held from raw runs (e9099e5); bounces and holds with no destination still come from routing, which reads trimmed segments. Draft.text's doc said "byte for byte" and the drafts tool's `read` said "exactly as written" of every draft. The doc now says which is which, and `read` labels the text "exactly as a resend publishes it", which is true of every draft. (e9099e5's message said every silenced hold made Draft.text byte for byte "as its doc and read claim"; that held for send-suppressed drafts only.) Test: the mixed-batch notice test asserts the prefix; the three-segment test's `read` label assertion follows the new wording. Full suite 1384/1384 on this tree (tsc, then node --test --test-force-exit --test-concurrency=4 dist/test/*.test.js; node 24, Petra's feat-prose-drafts node_modules). git diff --check clean. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 8 +++++--- src/prose-drafts.ts | 5 ++++- test/prose-drafts.test.ts | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 433896d59..9263fb408 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8980,8 +8980,10 @@ export class AgentFramework { `(drafts(action: "resend", draftIds: ["${d.id}"], destination: "#channel", confirmDuplicate: true))`), ]; // Named: residents that share one message slot (#197) share this window. + // "Not sent" only when no copied words may already have been posted. return ( - `[drafts] ${agentName}: not sent — ${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` + + `[drafts] ${agentName}: ${risky.length > 0 ? '' : 'not sent — '}` + + `${many ? `${held.length} plain-speech segments` : 'a plain-speech segment'} ` + `held as draft${many ? 's' : ''} ${ids.join(', ')} — ${AgentFramework.DRAFT_REASON_TEXT[reason]}. ` + `Nothing publishes drafts but your own resend, and they stay until you act: ${resend.join('; ')}; ` + `drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.` @@ -9135,7 +9137,7 @@ export class AgentFramework { private async handleDraftsTool(agentName: string, rawInput: unknown): Promise { const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record; const refuse = (error: string): ToolResult => ({ success: false, error, isError: true }); - // Plain text, not a JSON-quoted string: draft text must read exactly as written. + // Plain text, not a JSON-quoted string: draft text must read exactly as held. const ok = (text: string): ToolResult => ({ success: true, data: [{ type: 'text', text }] }); // null and omission mean the same thing: the field is not in use. const present = (v: unknown): boolean => v !== undefined && v !== null; @@ -9212,7 +9214,7 @@ export class AgentFramework { return ` attempt ${this.draftTime(a.at)} → ${AgentFramework.destinationText(a.destination)}: ${status}` + (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : ''); }), - `Text (${d.text.length} chars, exactly as written):`, + `Text (${d.text.length} chars, exactly as a resend publishes it):`, d.text, ]; return lines.join('\n'); diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 2735fd689..7001db0fc 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -105,7 +105,10 @@ export interface InheritedRisk { export interface Draft { id: string; agent: string; - /** The held words, byte for byte. */ + /** The held words, byte for byte as a resend publishes them: a run an + * explicit send suppressed exactly as written; a bounce or a hold with no + * destination, its envelope as routing read it (surrounding whitespace + * trimmed). */ text: string; reason: DraftReason; /** Words copied in from a draft that may already have been posted. */ diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index a25e60664..bfea62ed2 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -352,7 +352,7 @@ describe('held prose drafts, end to end', () => { ]); const [list, read, resend] = h.toolResults(); assert.match(list!, /3 open drafts, newest first/); - assert.ok(read!.endsWith(`exactly as written):\n${segments[1]}`), `read shows the text in full: ${read}`); + assert.ok(read!.endsWith(`exactly as a resend publishes it):\n${segments[1]}`), `read shows the text in full: ${read}`); assert.match(resend!, new RegExp(`${ids[0]}: delivered to #room \\(Guild One\\) \\(${ROOM}\\), message posted-2`)); const sent = h.publishes().slice(1); assert.deepEqual(sent.map((p) => [p.channelId, p.text]), segments.map((s) => [ROOM, s]), 'verbatim, in order'); @@ -1074,6 +1074,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end const notice = h.messages().find((m) => (m.metadata as { kind?: string; draftIds?: string[] } | undefined)?.kind === 'prose-drafts' && (m.metadata as { draftIds?: string[] }).draftIds?.includes(copy.id))!; const words = notice.content.map((b) => (b as { text?: string }).text ?? '').join(''); + assert.ok(words.startsWith(`[drafts] scout: 2 plain-speech segments held as drafts ${free.id}, ${copy.id} — `), `no "not sent" over words that may be out: ${words}`); assert.ok(words.includes(`drafts(action: "resend", draftIds: ["${free.id}"], destination: "#channel") delivers it unchanged`), words); assert.ok(words.includes(`${copy.id} includes the words of ${original.id}, and ${original.id}'s attempt to #room (Guild One) (${ROOM})`), words); assert.ok(words.includes(`so check that channel before resending ${copy.id} (drafts(action: "resend", draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true))`), words); From 6c927dee34d3f913b20ab7592e87b93c480bcf8f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 15:12:41 -0400 Subject: [PATCH 15/65] feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ingestion stamp the delivery-receipt lane and the source-header work share (room-220 #46752–#47210; isolated as its own foundation at Tessa-974's request, #47586, so receipts need not wait on outgoing routing). mcpl/inbound-source.ts gains the one implementation: canonicalJson (keys sorted at every level, undefined dropped) and sourceBodyDigest(blocks) = SHA-256 hex of canonicalJson([blocks]) — the one-element framing an undecorated, unsharded stored copy has always hashed to, so stamped and older copies of the same body share a version (Hugo-1004 #46835). Both lanes stamp, outside the frozen admission envelope (they describe the delivery — a materialization, a correction — not the admission): - metadata.sourceBodyDigest: the body as delivered, after MCPL conversion and before any host decoration (on the push lane, the closed-channel invitation) or storage sharding; - metadata.storedBodyDigest: the same function over exactly the blocks handed to addMessage, decorations included (Oscar-1005 #47210). A copy that no longer hashes to it was changed after delivery — CM's editMessage keeps metadata — so a consumer can tell an intact copy from an edited one. A silent heartbeat stores nothing and gets neither field. Neither proves later presence or completeness. Tests (inbound-source.test.ts): a channels/incoming item's two digests agree and equal the legacy unsharded hash, sit outside the envelope, and an edit through the context manager leaves the stored digest unmatched; a DM push into a closed channel stores the invitation, so its stored digest covers it while the delivered digest is the adapter body alone. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ingestion-body-digests.added.md | 13 +++++++ src/framework.ts | 14 +++++++- src/mcpl/inbound-source.ts | 39 +++++++++++++++++++++ test/inbound-source.test.ts | 38 ++++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 changelog.d/ingestion-body-digests.added.md diff --git a/changelog.d/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md new file mode 100644 index 000000000..287bc8f7e --- /dev/null +++ b/changelog.d/ingestion-body-digests.added.md @@ -0,0 +1,13 @@ +- **Delivered-body digests on every inbound item.** Both MCPL lanes stamp two + fields at ingestion, outside the frozen source envelope: + - `metadata.sourceBodyDigest` is the version identity of the body as + delivered, taken after MCPL conversion and before any host decoration or + storage sharding. It is SHA-256 of `canonicalJson([blocks])`, the framing + an undecorated, unsharded stored copy already hashes to. + - `metadata.storedBodyDigest` uses the same function over exactly the + blocks handed to storage, decorations included. + + A copy that no longer hashes to its `storedBodyDigest` was changed after it + arrived (`editMessage` keeps metadata). `canonicalJson` and + `sourceBodyDigest` are exported from `mcpl/inbound-source.ts` as the one + implementation consumers share. diff --git a/src/framework.ts b/src/framework.ts index 9263fb408..395c224d8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -84,7 +84,7 @@ import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; -import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; +import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -7214,6 +7214,12 @@ export class AgentFramework { this.noteInboundAccepted(source); } metadata[INBOUND_SOURCE_KEY] = source; + // The delivered body's version identity, and the stored copy's own + // digest (mcpl/inbound-source.ts sourceBodyDigest; room-220 + // #46752–#47210). This lane stores the body as delivered, so the two + // agree until a decoration or a later edit sets them apart. + metadata.sourceBodyDigest = sourceBodyDigest(event.content); + metadata.storedBodyDigest = sourceBodyDigest(event.content); // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never @@ -8381,6 +8387,9 @@ export class AgentFramework { metadata[INBOUND_SOURCE_KEY] = source; const content = [...event.content]; + // The delivered body's version identity, before any host decoration + // (the closed-channel invitation below). + if (content.length > 0) metadata.sourceBodyDigest = sourceBodyDigest(content); if (triggerChannel) { const origin = (event.origin ?? {}) as Record; const invitation = this.buildClosedChannelInvitation({ @@ -8411,6 +8420,9 @@ export class AgentFramework { event.origin?.source === 'heartbeat' && event.origin?.silent === true && content.length === 0; + // The stored copy's own digest, over exactly what is stored (decorations + // included, before storage shards it). A silent heartbeat stores nothing. + if (!silentHeartbeat) metadata.storedBodyDigest = sourceBodyDigest(content); const placement: CoalescingPlacement = { agent: '' }; if (!silentHeartbeat) { diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index 29c2e65e0..0812c45f4 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -21,6 +21,8 @@ * key: `eventId` is present only when the producer supplied one. */ +import { createHash } from 'node:crypto'; + /** The MCPL admission lane that accepted an item. Its contract decides what * an `eventId` is worth: `push/event` deduplicates by eventId, and RFC-006 * coalesced admission (`coalesced: true`) guarantees stable retries and @@ -152,3 +154,40 @@ export function conversationKey(source: InboundSource): string | undefined { if (source.kind === 'surface') return `surface\u0000${source.surface}`; return undefined; } + +/** + * JSON with object keys sorted at every level and `undefined` values dropped, + * so a value hashes alike however its keys were ordered (content read back + * from the store has its keys in a different order than it was written). + */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +/** + * The version identity of a delivered body (agreed with the receipts lane, + * room-220 #46752–#47210): SHA-256 hex of `canonicalJson([blocks])` over the + * ContentBlock[] the framework stores for the body — after MCPL conversion, + * before any host decoration is added and before storage shards it. The + * one-element array keeps the framing an undecorated, unsharded stored copy + * has always hashed to, so a stamped copy and an older copy of the same body + * share a version. + * + * Ingestion stamps it as `metadata.sourceBodyDigest`, and the same function + * over exactly the blocks handed to storage (decorations included) as + * `metadata.storedBodyDigest`, on both MCPL lanes. Both live outside the + * frozen admission envelope: they describe the body actually delivered (a + * materialization, a correction), not the admission. Neither proves later + * presence or completeness; a copy that no longer hashes to its + * storedBodyDigest was changed after delivery (editMessage keeps metadata). + */ +export function sourceBodyDigest(blocks: readonly unknown[]): string { + return createHash('sha256').update(canonicalJson([blocks])).digest('hex'); +} diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 5516268dc..de9efada8 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -27,6 +27,8 @@ import { type ToolResult, } from '../src/index.js'; import { MockMembrane } from './helpers/mock-membrane.js'; +import { sourceBodyDigest, canonicalJson } from '../src/mcpl/inbound-source.js'; +import { createHash } from 'node:crypto'; import { fixture, eventually, TS } from './helpers/coalescing-fixture.js'; const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); @@ -160,6 +162,42 @@ describe('inbound source envelope', () => { assert.equal(observed.length, 1); }); + it('stamps the delivered body\'s digest and the stored copy\'s digest at ingestion', async () => { + command({ op: 'incoming', channelId: ROOM, messageId: 'm-d1', mode: 'ambient', text: 'digest me' }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-d1'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-d1')!; + const meta = message.metadata as Record; + // The unsharded framing receipts already hash: canonicalJson([blocks]). + const expected = createHash('sha256').update(canonicalJson([[{ type: 'text', text: 'digest me' }]])).digest('hex'); + assert.equal(meta.sourceBodyDigest, expected); + assert.equal(sourceBodyDigest([{ text: 'digest me', type: 'text' }]), expected, 'key order does not matter'); + // This lane stores the body as delivered: the stored copy hashes the same. + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content)); + assert.equal(meta.storedBodyDigest, meta.sourceBodyDigest); + // Outside the frozen admission envelope. + assert.equal((readInboundSource(message.metadata) as unknown as Record).sourceBodyDigest, undefined); + + // An edit through the supported path keeps the metadata, so only the + // stored digest can reveal that the copy no longer holds the delivery. + const cm = framework.getAgent('scout')!.getContextManager(); + cm.editMessage(message.id, [{ type: 'text', text: 'edited later' }]); + const edited = cm.getAllMessages().find((m) => m.id === message.id)!; + assert.equal((edited.metadata as Record).storedBodyDigest, meta.storedBodyDigest); + assert.notEqual(sourceBodyDigest(edited.content), meta.storedBodyDigest); + }); + + it('on the push lane, the delivered digest excludes host decoration and the stored digest covers it', async () => { + command({ op: 'dm', eventId: 'ev-dig', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst' }); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-dig'), 'dm stored'); + const message = storedWith((m) => m.eventId === 'ev-dig')!; + const meta = message.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'psst' }]), 'the adapter body alone'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored'); + // The DM's channel is closed, so the host appends its invitation. + assert.equal(message.content.length, 2); + assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the closed-channel invitation decorates the stored copy'); + }); + it('keeps a stored envelope when the channel is renamed later', async () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-2', mode: 'ambient', text: 'before rename' }); await waitFor(() => !!storedWith((m) => m.messageId === 'm-2'), 'first message stored'); From 711b400a9255d1edc90d23734807a9a7f2330bdf Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 16:28:02 -0400 Subject: [PATCH 16/65] fix(mcpl): take the stored-copy digest at each storage site, over the blocks stored there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tessa-974's recheck (room-220 #48282): on channels/incoming, storedBodyDigest was taken at ingestion, but the main storage path can still append the closed-channel invitation afterwards — when a message accepted while its channel was open is processed after the channel closed. The untouched stored copy (header + body + invitation) then failed its own witness, which the receipts lane would read as an edit. The stored digest is now taken where each path stores, over exactly the blocks it hands to storage, through one helper (withStoredDigest): - channels/incoming, main path: after the invitation is appended; - channels/incoming, an RFC-006 correction delivered to a conversation fork; - channels/incoming, conversation-router delivery; - push/event: at its addMessage (a silent heartbeat stores nothing). sourceBodyDigest stays at ingestion, from the undecorated delivered body. Deferred storage keeps the content and metadata it was given, so a deferred copy's witness still matches. Test (inbound-source.test.ts), reproducing Tessa's discriminator: an incoming message is accepted and held while its channel is open, the channel is closed, then the message is processed — it is stored with the invitation and its storedBodyDigest equals the hash of its stored blocks, while its sourceBodyDigest is the delivered body alone. Fails before this change. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 37 +++++++++++++++++++++++++------------ test/inbound-source.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 395c224d8..20acf6a1d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7214,12 +7214,11 @@ export class AgentFramework { this.noteInboundAccepted(source); } metadata[INBOUND_SOURCE_KEY] = source; - // The delivered body's version identity, and the stored copy's own - // digest (mcpl/inbound-source.ts sourceBodyDigest; room-220 - // #46752–#47210). This lane stores the body as delivered, so the two - // agree until a decoration or a later edit sets them apart. + // The delivered body's version identity (mcpl/inbound-source.ts + // sourceBodyDigest; room-220 #46752–#47210), from the body as delivered, + // before any host decoration. The stored copy's own digest is taken at + // each storage site below, over exactly the blocks stored there. metadata.sourceBodyDigest = sourceBodyDigest(event.content); - metadata.storedBodyDigest = sourceBodyDigest(event.content); // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never @@ -7232,7 +7231,7 @@ export class AgentFramework { if (!target) return undefined; if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) { metadata.triggered = event.triggerInference ?? false; - const id = target.getContextManager().addMessage('user', event.content, metadata); + const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content)); // 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) { @@ -7301,7 +7300,7 @@ export class AgentFramework { } const placement: CoalescingPlacement = { agent: '' }; - const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor }); + const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor }); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference && !divert) { @@ -7416,7 +7415,7 @@ export class AgentFramework { const trigger = decision.trigger && event.triggerInference !== false; messageMetadata.triggered = trigger; - const id = agent.getContextManager().addMessage('user', event.content, messageMetadata); + const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content)); router.touch(event.channelId); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); @@ -7793,6 +7792,23 @@ export class AgentFramework { }; } + /** + * An inbound item's metadata with its stored copy's own digest + * (`storedBodyDigest`): the delivered-body digest's function over exactly + * the blocks handed to storage here — every decoration included, before + * storage shards them. Taken at each storage site, never earlier, because + * a path can still decorate the body after ingestion stamped it (the + * closed-channel invitation; room-220 #48282). An edit through + * editMessage keeps metadata but not this hash. + */ + private static withStoredDigest( + metadata: Record, + content: readonly ContentBlock[], + ): Record { + metadata.storedBodyDigest = sourceBodyDigest(content); + return metadata; + } + /** Source envelope of a channels/incoming event (lane facts as given). */ private channelEventSource( event: { @@ -8420,13 +8436,10 @@ export class AgentFramework { event.origin?.source === 'heartbeat' && event.origin?.silent === true && content.length === 0; - // The stored copy's own digest, over exactly what is stored (decorations - // included, before storage shards it). A silent heartbeat stores nothing. - if (!silentHeartbeat) metadata.storedBodyDigest = sourceBodyDigest(content); const placement: CoalescingPlacement = { agent: '' }; if (!silentHeartbeat) { - const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor }); + const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor }); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' }); } else { console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`); diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index de9efada8..31e37ab95 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -186,6 +186,31 @@ describe('inbound source envelope', () => { assert.notEqual(sourceBodyDigest(edited.content), meta.storedBodyDigest); }); + it('a message accepted while its channel was open but stored after it closed: the stored digest covers the invitation', async () => { + // Tessa-974's discriminator (room-220 #48282): the invitation is + // appended after ingestion stamped the delivered digest, at storage + // time, when the channel has closed in between. + const registry = (framework as unknown as { + channelRegistry: { handleChannelToolCall(name: string, input: unknown, caller: unknown): Promise; isChannelOpen(id: string): boolean }; + }).channelRegistry; + const fw = framework as unknown as { pushEvent(e: { type: string }): void }; + const held: Array<{ type: string }> = []; + const push = fw.pushEvent.bind(framework); + fw.pushEvent = (e) => { if (e.type === 'mcpl:channel-incoming') held.push(e); else push(e); }; + command({ op: 'incoming', channelId: ROOM, messageId: 'm-closed', mode: 'addressed', text: 'hello while open' }); + await waitFor(() => held.length > 0, 'incoming accepted and held'); + await registry.handleChannelToolCall('channel_close', { channelId: ROOM }, { kind: 'agent', agentName: 'scout' }); + assert.equal(registry.isChannelOpen(ROOM), false); + fw.pushEvent = push; + for (const e of held) push(e); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-closed'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-closed')!; + const meta = message.metadata as Record; + assert.equal(meta.channelInvitation, true, 'stored with the closed-channel invitation'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'the untouched copy matches its own witness'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'hello while open' }]), 'the delivered body alone'); + }); + it('on the push lane, the delivered digest excludes host decoration and the stored digest covers it', async () => { command({ op: 'dm', eventId: 'ev-dig', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst' }); await waitFor(() => !!storedWith((m) => m.eventId === 'ev-dig'), 'dm stored'); From d36a5ad5d37b274055c1b1e527fde1df40334c1c Mon Sep 17 00:00:00 2001 From: Petra-1759 Date: Wed, 7 Oct 2026 18:54:27 -0400 Subject: [PATCH 17/65] fix(mcpl): every stored push gets the host's delivered-body digest; export sourceBodyDigest from the package root Greptile's review of #252 at 711b400 (review 5448991826): P1 (discussion r4212520929). handleMcplPushEvent stamped metadata.sourceBodyDigest only when the body had blocks, a guard meant for the silent heartbeat, which stores nothing. But PushHandler maps an ordinary empty payload to [], and handleMcplPushEvent stores it unless the heartbeat's exact marker holds. So an ordinary empty push, or an addressed empty push into a closed channel (stored as the host's invitation alone), was stored with a storedBodyDigest and no host-computed sourceBodyDigest; and since push metadata starts from the adapter's origin, an origin.sourceBodyDigest survived on the stored copy as if the host had computed it. The digest is now taken for every push, an empty body included, before any decoration, written over whatever the origin carried. A silent heartbeat still stores nothing; its metadata is never kept. channels/incoming already stamped unconditionally, and every storage site on both lanes already wrote storedBodyDigest over the adapter's value. P2 (discussion r4212520946). The helpers were reachable only from src/mcpl/inbound-source.ts, and the package's exports map exposes only the root, so a package consumer could not import them and would have to copy the hashing code. sourceBodyDigest is now re-exported from src/mcpl/index.ts and the package root. canonicalJson stays out of the package API: it is the digest's serializer, and keeping serialization and framing inside sourceBodyDigest saves consumers from duplicating either, which is the drift the finding is about (a consumer hashing canonicalJson(blocks) itself gets a different digest from sourceBodyDigest(blocks)). It keeps its module export for in-repo tests that pin the framing. Agreed with Petra-1012 (room-203 #49948) and June-1758 (room-262 #49931); wording per June-1758's review (#50126). The changelog fragment said both helpers were exported as the shared implementation and that both fields were stamped at ingestion. It now names the one public function and states the algorithm, says the stored digest is taken where each path stores (711b400), that an empty body has a digest, and that adapter-supplied values are replaced. Tests (inbound-source.test.ts, over the real MCPL child): - an ordinary empty unscoped push whose origin carries forged digests is stored with sourceBodyDigest = storedBodyDigest = SHA-256 of "[[]]"; - an empty DM into a closed channel is stored as the invitation alone, its sourceBodyDigest the empty body's and its storedBodyDigest the stored blocks'. Both fail before this change: sourceBodyDigest came back as the adapter's 'adapter-value'. The test file now imports sourceBodyDigest from the package root, so the public export is typechecked. The speech-route fixture's dm and push ops accept "content" (blocks, [] included) in place of "text", as its incoming op does on the receipts branch. Co-Authored-By: Claude Opus 5.5 --- changelog.d/ingestion-body-digests.added.md | 23 ++++++++----- src/framework.ts | 6 ++-- src/index.ts | 6 ++-- src/mcpl/inbound-source.ts | 7 ++++ src/mcpl/index.ts | 5 +-- test/fixtures/speech-route-mcpl-server.mjs | 10 +++--- test/inbound-source.test.ts | 37 ++++++++++++++++++++- 7 files changed, 74 insertions(+), 20 deletions(-) diff --git a/changelog.d/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md index 287bc8f7e..ea2ac3d73 100644 --- a/changelog.d/ingestion-body-digests.added.md +++ b/changelog.d/ingestion-body-digests.added.md @@ -1,13 +1,18 @@ -- **Delivered-body digests on every inbound item.** Both MCPL lanes stamp two - fields at ingestion, outside the frozen source envelope: +- **Delivered-body digests on every stored inbound item.** Both MCPL lanes + stamp two fields, outside the frozen source envelope: - `metadata.sourceBodyDigest` is the version identity of the body as - delivered, taken after MCPL conversion and before any host decoration or - storage sharding. It is SHA-256 of `canonicalJson([blocks])`, the framing - an undecorated, unsharded stored copy already hashes to. + delivered, taken at ingestion, after MCPL conversion and before any host + decoration or storage sharding. It is the SHA-256 (hex) of `[blocks]` + as canonical JSON (keys sorted at every level, `undefined` dropped), the + framing an undecorated, unsharded stored copy already hashes to. An + empty body has one too. - `metadata.storedBodyDigest` uses the same function over exactly the - blocks handed to storage, decorations included. + blocks handed to storage, decorations included, taken where each path + stores. + Both are always the host's own: values an adapter supplies are replaced. A copy that no longer hashes to its `storedBodyDigest` was changed after it - arrived (`editMessage` keeps metadata). `canonicalJson` and - `sourceBodyDigest` are exported from `mcpl/inbound-source.ts` as the one - implementation consumers share. + arrived (`editMessage` keeps metadata). `sourceBodyDigest(blocks)` is + exported from the package root as the one implementation consumers share; + it carries the framing, so check a copy with it rather than hashing your + own serialization. diff --git a/src/framework.ts b/src/framework.ts index 20acf6a1d..b2d8ec8f4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8404,8 +8404,10 @@ export class AgentFramework { const content = [...event.content]; // The delivered body's version identity, before any host decoration - // (the closed-channel invitation below). - if (content.length > 0) metadata.sourceBodyDigest = sourceBodyDigest(content); + // (the closed-channel invitation below). Every push gets one, an empty + // body included, written over any value the adapter's origin carried. + // A silent heartbeat stores nothing, so its metadata is never kept. + metadata.sourceBodyDigest = sourceBodyDigest(content); if (triggerChannel) { const origin = (event.origin ?? {}) as Record; const invitation = this.buildClosedChannelInvitation({ diff --git a/src/index.ts b/src/index.ts index 9c1cc4ac4..db533b6d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,8 +83,10 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior // MCPL channel registry (exposed for modules that need channel-level operations) export { ChannelRegistry } from './mcpl/index.js'; -// Where each accepted inbound item came from (`metadata.inboundSource`) -export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey } from './mcpl/index.js'; +// Where each accepted inbound item came from (`metadata.inboundSource`), and +// the digest its delivered and stored bodies are stamped with +// (`metadata.sourceBodyDigest`, `metadata.storedBodyDigest`) +export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey, sourceBodyDigest } from './mcpl/index.js'; export type { InboundSource, InboundChannelSource, diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index 0812c45f4..4f51a1860 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -159,6 +159,10 @@ export function conversationKey(source: InboundSource): string | undefined { * JSON with object keys sorted at every level and `undefined` values dropped, * so a value hashes alike however its keys were ordered (content read back * from the store has its keys in a different order than it was written). + * + * sourceBodyDigest's serializer, kept out of the package API: serialization + * and framing both stay inside sourceBodyDigest, so consumers don't + * duplicate either (`canonicalJson(blocks)` alone hashes differently). */ export function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; @@ -187,6 +191,9 @@ export function canonicalJson(value: unknown): string { * materialization, a correction), not the admission. Neither proves later * presence or completeness; a copy that no longer hashes to its * storedBodyDigest was changed after delivery (editMessage keeps metadata). + * + * Exported from the package root, so a consumer checks a copy against either + * field with this same function rather than a copy of it. */ export function sourceBodyDigest(blocks: readonly unknown[]): string { return createHash('sha256').update(canonicalJson([blocks])).digest('hex'); diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index 1b933017a..e7a54fb9b 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -157,8 +157,9 @@ export { InferenceRouter } from './inference-router.js'; // Channel registry (channel lifecycle, incoming messages, synthesized tools) export { ChannelRegistry } from './channel-registry.js'; -// Inbound source envelope: where each accepted item came from, stamped once -export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey } from './inbound-source.js'; +// Inbound source envelope: where each accepted item came from, stamped once; +// and the digest of a delivered or stored body +export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey, sourceBodyDigest } from './inbound-source.js'; export type { InboundSource, InboundChannelSource, diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs index 8cebbf70c..6d6a757d5 100644 --- a/test/fixtures/speech-route-mcpl-server.mjs +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -5,8 +5,10 @@ // BOTH MCPL lanes. The TEST appends one JSON object per line to COMMAND_PATH: // {"op":"incoming","channelId":…,"messageId":…,"mode":"ambient"|"addressed", // "text":…, "threadId"?, "metadata"?, "eventId"?, "coalesce"?} -// {"op":"dm","eventId":…,"authorId":…,"authorName"?,"rawChannelId":…,"text":…} -// {"op":"push","eventId":…,"origin"?,"tags"?,"text":…,"featureSet"?} +// {"op":"dm","eventId":…,"authorId":…,"authorName"?,"rawChannelId":…,"text":…, +// "content"? (content blocks, instead of text; [] included), "origin"?} +// {"op":"push","eventId":…,"origin"?,"tags"?,"text":…,"featureSet"?, +// "content"? (content blocks, instead of text; [] included)} // {"op":"rename","channelId":…,"label":…} (channels/changed) // {"op":"publish-mode","mode":"delivered"|"not-delivered"|"no-receipt"|"error"|"hang"} // {"op":"history","channelId":…,"history":[ChannelIncomingMessage…]} @@ -90,7 +92,7 @@ function pollCommands() { ...(c.origin ?? {}), }, tags: ['chat:dm', 'chat:addressed', 'chat:private'], - payload: { content: textBlock(c.text ?? '') }, + payload: { content: c.content ?? textBlock(c.text ?? '') }, }, }); } else if (c.op === 'push') { @@ -105,7 +107,7 @@ function pollCommands() { timestamp: c.timestamp ?? new Date().toISOString(), ...(c.origin ? { origin: c.origin } : {}), ...(c.tags ? { tags: c.tags } : {}), - payload: { content: textBlock(c.text ?? '') }, + payload: { content: c.content ?? textBlock(c.text ?? '') }, }, }); } else if (c.op === 'rename') { diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 31e37ab95..5d3f3dafb 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -16,6 +16,8 @@ import { INBOUND_SOURCE_KEY, readInboundSource, conversationKey, + // The package's public digest: consumers import it from the root. + sourceBodyDigest, type InboundSource, type Module, type ModuleContext, @@ -27,7 +29,7 @@ import { type ToolResult, } from '../src/index.js'; import { MockMembrane } from './helpers/mock-membrane.js'; -import { sourceBodyDigest, canonicalJson } from '../src/mcpl/inbound-source.js'; +import { canonicalJson } from '../src/mcpl/inbound-source.js'; import { createHash } from 'node:crypto'; import { fixture, eventually, TS } from './helpers/coalescing-fixture.js'; @@ -223,6 +225,39 @@ describe('inbound source envelope', () => { assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the closed-channel invitation decorates the stored copy'); }); + it('an ordinary empty push is stored with the host\'s digests of its empty body, never the adapter\'s', async () => { + // Only a silent heartbeat stores nothing (test/silent-heartbeat.test.ts); + // any other push is stored, an empty one included, so it is stamped like + // any other — over whatever its origin claimed. + command({ + op: 'push', eventId: 'empty-1', content: [], + origin: { source: 'timer', sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }, + }); + await waitFor(() => !!storedWith((m) => m.eventId === 'empty-1'), 'empty push stored'); + const message = storedWith((m) => m.eventId === 'empty-1')!; + const meta = message.metadata as Record; + assert.deepEqual(message.content, []); + // The empty body in the one-element framing: SHA-256 of `[[]]`. + const empty = createHash('sha256').update('[[]]').digest('hex'); + assert.equal(meta.sourceBodyDigest, empty, 'the empty delivered body'); + assert.equal(meta.storedBodyDigest, empty, 'stored undecorated'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'the untouched copy matches its own witness'); + }); + + it('an empty DM pushed into a closed channel: the delivered digest is the empty body, the stored digest covers the invitation', async () => { + command({ + op: 'dm', eventId: 'ev-empty', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, content: [], + origin: { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }, + }); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-empty'), 'empty dm stored'); + const message = storedWith((m) => m.eventId === 'ev-empty')!; + const meta = message.metadata as Record; + assert.equal(meta.channelInvitation, true, 'stored with the closed-channel invitation'); + assert.equal(message.content.length, 1, 'the invitation alone'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([]), 'the empty delivered body, not the adapter\'s value'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored, not the adapter\'s value'); + }); + it('keeps a stored envelope when the channel is renamed later', async () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-2', mode: 'ambient', text: 'before rename' }); await waitFor(() => !!storedWith((m) => m.messageId === 'm-2'), 'first message stored'); From abc8b4d7a7ee3c0afe1f9f14807884fb6afe97e3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 11:44:04 -0400 Subject: [PATCH 18/65] feat(routing): speech goes to a deliberate or triggering route, never the last inbound (shelf-355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unaddressed plain speech used to follow the process-global most recent inbound channel (ChannelRegistry.defaultPublishChannel), re-pinned itself mid-turn to whoever addressed the resident last, and could be retargeted by traffic the resident never answered (a resident's reports: unaddressed prose routed to the last speaker; an addressed mark that later speakers overwrite). Settled design: room-203 (Tessa-974, Basil; the hold boundary in commons #39414/#39415; the UI rule in #41399/#41492). Model (src/speech-routes.ts, pure): a SpeechRoute is a resolved server, channel, optional thread and reply edge (or a local surface), with an origin — trigger (inferred), home (a conversation fork's), open (channel_open). A TurnRoute is that route plus an optional hold naming competing conversations. inferTurnRoute(candidates, home): a fork home wins; else the addressed candidates if any, else all conversational ones; one conversation (server+channel+thread) infers a route, its newest considered message the reply edge; several start the turn held; none, or one whose channel couldn't be resolved, gives no route. suspendsRoute(): only an inferred, unheld route, only for a different conversation that addressed the resident or that the resident explicitly engaged this turn. Candidates: InferenceRequest.routeCandidates, from the frozen inbound source envelope on every conversational wake — channels/incoming (trunk, fork, router), push events naming a conversation (DMs, addressed-while-closed), coalesced wakes, local surfaces (console/TUI/API, addressed by nature), and a debounced gate batch, which now reports every conversation it holds (WakeProvenance.routeCandidates; an addressed event the host can't resolve competes as unroutable, so it can hold a turn but never be a route). The batch's candidates are merged at the turn (quiesced coalescing keeps an evicted request's candidates). Framework: turnRoutes replaces activeTriggerChannels, turnLocusPins and the locus announcement state. Decided once at a true new turn (attempt 0), announced on change (route, hold with each conversation's usable address, or no destination); continuations (context-budget restart, guard retry) and framework retries keep it, including a channel_open the resident made. speakUnaddressed() delivers each unaddressed segment along it: a channel route publishes (with its serverId, so a channel id shared across servers goes to the right one); a surface route shows it there, publishes nothing, and the receipt says so; a held turn holds an `ambiguous` draft naming the competing conversations; no route holds a `no-destination` draft. Live, trailing and text-only locus paths and hybrid unprefixed prose use it; a hybrid `>>>` pin still wins. The mid-turn addressed re-pin becomes the ambiguity hold: a presented conversational arrival that suspendsRoute() accepts holds the route for the rest of the turn, with a notice in the same injection batch; ambient chatter, reactions and same-conversation arrivals change nothing (the reply edge stays on the waking message). channel_open gains setSpeechTarget (default true): true sets a deliberate route for the rest of the logical turn (no borrowed reply edge; replaces an inferred route, a hold or a hybrid pin), false opens for reading and the result states the current route or its absence. Refusal reactions, rewind notices, the failure log, typing and the sleep notice use the agent's own route. Registry: no more latest-inbound tracking (defaultPublishChannel and getDefaultPublishChannel removed; markAccepted no longer retargets anything). resolveLocus returns only a fork home. speechRouteResolver replaces activeChannelResolver and feeds buildChannelContext, so defaultOutgoing/incoming are the route the agent's words will take. channel_publish goes through the publish executor: without channelId it uses the caller's route, and with none (held, surface, absent) it refuses with the reason; optional serverId; the receipt names serverId, channelId and channelLabel; failed and unknown outcomes are errors keeping their status and attempted destination (shelf-356's AF receipt requirement). publishForAgent shares it, attributed to its agent. routeSpeech/deliverSpeech accept {serverId, channelId}. Docs/changelog: speech-routes.changed.md, speech-routes.breaking.md (registry API), hybrid doc wording; mode primers describe the route. Tests: speech-routes.test.ts (inference and suspension rules, identity: servers, threads, surfaces, unroutable); speech-route-turns.test.ts (continuations and retries keep the route incl. channel_open; a new turn decides afresh; surface route end to end); batched-wake-locus (real MCPL child, both lanes: addressed+ambient, ambient-only one/two conversations, two addressed held, resend of the held draft to the DM, advertised context = delivered route); present-while-acting (hold on addressed/engaged arrivals with notice in the batch, ambient and same-conversation arrivals change nothing and keep the reply edge, fork home never held, hybrid >>> pin survives a hold, held-turn receipt, channel_open default and setSpeechTarget:false with and without a route, announce-on-change); trunk-channel-routing (routes per turn, heartbeat none); channel-registry- routing (no latest-inbound, route-fed context, channel_publish default / refusal / outcomes, same id on two servers); event-gate (batch candidates). Suite (--test-concurrency=4, run as a Jobs job): 1361/1361 + code-execution 36/36; after a final wording change the touched files re-run 64/64. Co-Authored-By: Claude Opus 5.5 --- changelog.d/speech-routes.breaking.md | 16 + changelog.d/speech-routes.changed.md | 48 ++ docs/hybrid-prose-routing.md | 10 +- src/framework.ts | 762 ++++++++++++++++---------- src/gate/event-gate.ts | 65 ++- src/gate/index.ts | 2 +- src/mcpl/channel-registry.ts | 242 ++++---- src/speech-routes.ts | 178 ++++++ src/types/agent.ts | 9 + test/batched-wake-locus.test.ts | 65 ++- test/channel-registry-routing.test.ts | 208 +++---- test/event-gate.test.ts | 29 +- test/present-while-acting.test.ts | 365 ++++++++---- test/speech-route-turns.test.ts | 126 +++++ test/speech-routes.test.ts | 150 +++++ test/trunk-channel-routing.test.ts | 58 +- 16 files changed, 1658 insertions(+), 675 deletions(-) create mode 100644 changelog.d/speech-routes.breaking.md create mode 100644 changelog.d/speech-routes.changed.md create mode 100644 src/speech-routes.ts create mode 100644 test/speech-route-turns.test.ts create mode 100644 test/speech-routes.test.ts diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md new file mode 100644 index 000000000..fe775818e --- /dev/null +++ b/changelog.d/speech-routes.breaking.md @@ -0,0 +1,16 @@ +- **Hosts and module authors using `ChannelRegistry` directly:** the registry + no longer tracks the most recent inbound channel. + - **Removed:** `getDefaultPublishChannel()`, and the + `activeChannelResolver` option. The new `speechRouteResolver(agentName)` + option replaces that option, and the framework supplies it. + - **Changed:** `resolveLocus(agent)` returns only a conversation fork's + home channel, or null. `buildChannelContext(agent)` advertises the + agent's own route and reply edge, and nothing for a held, surface-only or + absent route. + - **Migration:** read a route from the framework's turn rather than the + registry, and pass `channelId` explicitly when publishing outside a turn. + `routeSpeech`/`deliverSpeech` also accept `{ serverId, channelId }` to + publish to an exact server. + - **Unchanged:** fork homes, explicit sends naming a channel, and + `resolveProseTarget`/`resolveDestination`. No sibling package version + changes. diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md new file mode 100644 index 000000000..9a2681241 --- /dev/null +++ b/changelog.d/speech-routes.changed.md @@ -0,0 +1,48 @@ +- **Speech routes: unaddressed plain speech goes where the turn was + deliberately or actually addressed, never to the last channel that saw + traffic.** A turn's route is decided once at a true new turn: + - a conversation fork's home channel always wins; + - otherwise it is inferred from the whole wake. The addressed messages count + if there are any, else all of the conversational ones. A local surface + such as the console, the TUI or the API counts as addressed. The route is + inferred only when they name one conversation (server, channel and + thread), and its newest triggering message is the reply edge. + + Several competing conversations start the turn **held**: unaddressed speech + is kept as `ambiguous` drafts, and a `[routing]` notice names each + conversation's address. A turn with no conversation, such as a heartbeat, + a timer or a self-wake, has no route, and its speech is held as + `no-destination` drafts. Previously such speech went to the process-global + most-recent-inbound channel. That is also the fallback the batched-wake + routing fix kept for ambient-only batches, and this change replaces it. A surface + route shows speech on that surface and publishes it to no channel. +- Context-budget restarts, guard retries and framework retries keep the + turn's route. Only the next true new turn decides again. +- **Mid-turn ambiguity hold replaces the addressed re-pin.** When a presented + mid-turn arrival from another conversation addresses the resident, or + continues a conversation the resident explicitly sent into this turn, an + inferred route is held for the rest of the turn instead of moving to the + newcomer. A notice rides the same injection batch. Ambient chatter, + reactions and arrivals in the same conversation change nothing, and the + reply edge stays on the message that woke the turn. Deliberate routes are + never held: a fork's home, a `channel_open`, and a hybrid `>>>` target. +- **`channel_open` sets the speech route** for the rest of the turn by + default, replacing an inferred route, a hold, or a hybrid `>>>` target. The + new `setSpeechTarget: false` opens the channel for reading only, and the + result says where speech goes then. +- **`channel_publish` is an explicit send through the publish executor.** + Without `channelId` it goes to the caller's current route. With no route + (held, a local surface, or none) it is refused with the reason, and it no + longer uses the most recent inbound channel. A new optional `serverId` + disambiguates a channel id that several servers register. Its receipt + names `serverId`, `channelId` and `channelLabel`. A failed or unknown + outcome is an error that keeps its `status` and the attempted destination; + `unknown` warns that the message may already have been posted. +- The beforeInference channel context (`defaultOutgoing` and `incoming`), + refusal reactions, rewind notices and the failure log now use the agent's + own route and its reply edge, so what the agent is told matches where its + words go. +- A batched gate wake reports every conversation in its batch to the + framework (`WakeProvenance.routeCandidates`). An addressed event whose + registered channel the host can't resolve still competes, so the turn is + held, but it is never itself a route. diff --git a/docs/hybrid-prose-routing.md b/docs/hybrid-prose-routing.md index 0a1382089..726bdb2ad 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -1,10 +1,10 @@ # Hybrid prose routing (`>>>destination`) -Hybrid routing preserves ordinary frozen-locus speech while allowing a resident -to explicitly publish one or more prose envelopes elsewhere. +Hybrid routing preserves ordinary speech on the turn's speech route while +allowing a resident to explicitly publish one or more prose envelopes elsewhere. ```text -ordinary prose # current frozen locus +ordinary prose # the turn's speech route (held as a draft without one) >>>#cafe prose for the café # unique authorized channel by name >>> world:commons prose for the world # canonical channel id ``` @@ -23,7 +23,9 @@ Two views are intentionally preserved: At logical turn end, a `[delivered]` receipt names canonical destinations (or a bounce explains failure), so authored intent cannot impersonate delivery. Unprefixed prose after a successful envelope follows that explicit destination -for the remainder of the turn; a fresh turn begins at its ordinary locus. +for the remainder of the turn — a deliberate target, which a mid-turn ambiguity +hold never suspends, and which a later `channel_open` replaces; a fresh turn +begins at its own speech route. This is publication routing only. It creates no privacy meaning for `` or other prose tags, and grants no channel authority beyond the existing diff --git a/src/framework.ts b/src/framework.ts index b2d8ec8f4..a7aaedae6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -81,10 +81,21 @@ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.j 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, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; +import { ChannelRegistry, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { + conversationKey, + describeConversation, + inferTurnRoute, + routeConversation, + suspendsRoute, + type ConversationRef, + type RouteCandidate, + type SpeechRoute, + type TurnRoute, +} from './speech-routes.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; -import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; +import { INBOUND_SOURCE_KEY, readInboundSource, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -309,12 +320,14 @@ const isAddressedMessage = ( /** One-time primer appended when an agent's proseRouting mode changes. */ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): string { if (mode === 'locus') { - return '[prose-routing] Mode change: your plain text auto-routes to the ' + - 'conversational locus again. `>>` destination prefixes are no longer needed.'; + return '[prose-routing] Mode change: your plain text goes again to the conversation ' + + 'that woke your turn (or a channel you open); when that is unclear it is held as a ' + + 'draft. `>>` destination prefixes are no longer needed.'; } if (mode === 'hybrid') { - return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' + - 'current conversational locus. A leading `>>>destination` envelope instead routes ' + + return '[prose-routing] Hybrid routing enabled: unprefixed text still goes to the ' + + 'conversation that woke your turn (held as a draft when that is unclear). A leading ' + + '`>>>destination` envelope instead routes ' + 'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' + 'envelope remains in your memory but recipients see only its body; delivery or failure ' + 'is reported back to you.'; @@ -1006,16 +1019,6 @@ export class AgentFramework { private inferencePolicy: InferencePolicy; private errorPolicy: ErrorPolicy; private pendingRequests: InferenceRequest[] = []; - /** - * Per-agent channel that triggered the agent's CURRENT inference turn, if any - * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to - * route a single-trunk agent's plain-text speech back to the channel it is - * answering, instead of the process-global most-recent-inbound locus that a - * concurrent message elsewhere can hijack. Set at turn start (or cleared for a - * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next - * turn. Never read between turns (a given agent runs one turn at a time). - */ - private activeTriggerChannels: Map = new Map(); private running = false; private loopPromise: Promise | null = null; /** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from @@ -1057,31 +1060,27 @@ export class AgentFramework { * a receipt consumer installs one. */ private inboundAcceptanceObserver?: InboundAcceptanceObserver; - /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved - * eagerly in startAgentStream (home → addressed trigger → global default) - * and never moved until the next turn: mid-turn injected messages — ambient - * chatter, reactions, system markers — must not hijack where the agent's - * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in - * driveStream locals — so the pin survives a context-budget stream restart, - * which continues the same logical turn in a fresh driveStream. Cleared or - * re-set at the start of every non-restart turn. Since 2026-07-31 the pin - * is no longer fully frozen: an ADDRESSED conversational injection — or a - * conversational follow-up in a channel the agent itself explicitly sent - * into this turn (turnEngagedChannels) — re-pins it at the boundary (see - * the addressed re-pin block in the tool-result handler). Ambient chatter - * elsewhere, reactions, and system markers still cannot move it. */ - private turnLocusPins: Map = new Map(); + /** Per-agent speech route for the CURRENT logical turn (shelf-355, + * src/speech-routes.ts): where unaddressed plain speech goes. Decided at a + * true new turn — a fork's home, else inferred from the wake's candidates + * (addressed first; one conversation only, else held) — and changed during + * the turn only by the resident's own channel_open, or suspended by a + * competing addressed/engaged arrival (the ambiguity hold). Incoming + * traffic never selects a new destination. Lives here, not in driveStream, + * so context-budget restarts and retries keep it. */ + private turnRoutes: Map = new Map(); /** Channels the agent EXPLICITLY sent into during the CURRENT turn - * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin - * signal: a conversational reply in a channel the agent just engaged is - * addressed by context even without a mention (2026-07-31 n=7: q's - * #portables follow-up — no @mention — was answered in trailing prose - * that followed the stale pin into repligate's DM). No author-kind - * filter: agent-residents are full participants, and the bot flag - * tracks nothing that matters (antra). Reactions/system markers are - * excluded by isConversationalInjection; the engaged-this-turn scope - * keeps unrelated channels from moving the pin. Cleared at every fresh - * turn's start; budget restarts keep it. */ + * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second ambiguity + * signal: a conversational reply in a channel the agent just engaged + * competes for its unaddressed words even without a mention (2026-07-31 + * n=7: q's #portables follow-up — no @mention — was answered in trailing + * prose that followed the stale pin into repligate's DM), so it holds an + * inferred route (shelf-355). No author-kind filter: agent-residents are + * full participants, and the bot flag tracks nothing that matters + * (antra). Reactions/system markers are excluded by + * isConversationalInjection; the engaged-this-turn scope keeps unrelated + * channels from holding anything. Cleared at every fresh turn's start; + * budget restarts keep it. */ private turnEngagedChannels: Map> = new Map(); /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery * order (deduped at render). Feeds the `[delivered]` receipt appended at @@ -1108,11 +1107,14 @@ export class AgentFramework { * driveStream to clear sticky explicit-send suppression before handling * the next model round. Never affects routing: the turn locus is frozen. */ private midTurnInputSignals: Set = new Set(); - /** Last outbound locus announced to each agent as a durable `[routing]` - * window message. Announce-on-change only: steady state emits nothing - * (KV-safe, no per-turn chatter). In-memory — after a process restart the - * first turn's locus is announced once to re-establish the baseline. */ - private lastAnnouncedLocus: Map = new Map(); + /** The route last announced to each agent in a durable `[routing]` window + * message, as its identity key. Announce-on-change only (KV-safe, no + * per-turn chatter). In-memory: after a restart the first turn's route is + * announced once to re-establish the baseline. */ + private lastAnnouncedRoute: Map = new Map(); + /** Whether the agent's current live round presents mid-turn injections + * (native rounds do; XML rounds don't), for the ambiguity hold. */ + private roundPresentsInjections: Map = new Map(); // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- /** Every resident's held drafts: suppressed, bounced or ambiguous plain @@ -1744,6 +1746,25 @@ export class AgentFramework { ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId) ? { channelId: provenance.routeChannelId, addressed: true } : {}), + // The batch's conversations as speech-route candidates, under the + // same fork rule: a fork takes only its home's, and no other agent + // takes a channel a fork owns. + ...(provenance?.routeCandidates?.length + ? { + routeCandidates: provenance.routeCandidates + .filter((c) => c.kind === 'surface' || c.unroutable || framework.mayTakeGateRoute(agentName, c.channelId)) + .map((c): RouteCandidate => c.kind === 'surface' + ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } + : c.unroutable + ? { + conversation: { kind: 'channel', serverId: c.serverId ?? '', channelId: c.channelId }, + addressed: c.addressed, + at: c.at, + unroutable: true, + } + : framework.candidateForChannel(c.channelId, c.addressed, c.at, c.serverId, c.messageId)), + } + : {}), }); }, getAgentNames: () => [...framework.agents.keys()].filter( @@ -4855,14 +4876,14 @@ export class AgentFramework { }; /** - * Mark an inference refusal visibly: react on the message that holds the - * conversational locus (the most recent incoming channel message) with a - * category-specific emoji. Best-effort — failures are logged, never thrown, - * and non-Discord loci are silently skipped. + * Mark an inference refusal visibly: react on the message the agent's + * speech route answers (its reply edge) with a category-specific emoji. + * Best-effort — failures are logged, never thrown; a turn without a + * channel route (held, surface, none) and non-Discord routes are skipped. */ private async reactToRefusal(agentName: string, category: string): Promise { try { - const incoming = this.channelRegistry?.buildChannelContext()?.incoming; + const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming; if (!incoming) return; // incoming.channelId is the MCPL composite id ("discord::"); // the reaction tool wants the raw Discord channel (or thread) id — the @@ -5019,7 +5040,7 @@ export class AgentFramework { /** * Announce a refusal-rewind on the conversational surface (Discord), used * when the withheld turn was a *human* message so it isn't dropped silently. - * Best-effort; mirrors reactToRefusal's locus resolution. + * Best-effort; mirrors reactToRefusal: the agent's own route's reply edge. */ private async announceRewind( agentName: string, @@ -5027,7 +5048,7 @@ export class AgentFramework { category: string, ): Promise { try { - const incoming = this.channelRegistry?.buildChannelContext()?.incoming; + const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming; if (!incoming) return; const parts = incoming.channelId.split(':'); if (parts[0] !== 'discord') return; @@ -6849,74 +6870,61 @@ export class AgentFramework { : 0; const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity); - // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified): - // when someone ADDRESSES the agent mid-turn from another channel - // (mention / reply / DM — the same chat:addressed signal the - // turn-START batch policy prefers), the agent's subsequent plain - // prose almost always answers THEM. Six recorded misroutes - // delivered those answers to the stale frozen locus instead (the - // LabClaude answer into #hospital_commons mid-examination; the - // hospital scene into antra's DM). Re-pin the turn locus to the - // LAST addressed conversational injection, at segment granularity: - // prose routed in earlier rounds already went to the old pin, - // which was correct when it was delivered. Ambient chatter, - // reactions, and system markers still cannot move the pin — the - // 2026-07-21 Cairn hijack protection is unchanged; this extends - // the ratified addressed-outranks-ambient principle from turn - // start to boundaries. Locus mode only (explicit mode has no pin). - // Skipped on endTurn/overBudget: no further prose this stream, and - // a stale "for this turn" notice would be pure noise. + // Ambiguity hold (shelf-355; replaces the 2026-07-31 addressed + // re-pin). When an arrival PRESENTED in this batch is a + // conversational message from another conversation that addressed + // the resident (mention / reply / DM / its local surface), or that + // continues a conversation the resident explicitly sent into this + // turn, the resident's next unaddressed words could answer either + // conversation. Re-pinning to the newcomer guessed — and sent words + // still meant for the first conversation into the second. The + // inferred route is held instead: unaddressed speech becomes drafts + // until the turn ends or a deliberate target is set (`>>>target`, + // channel_open), and explicit sends keep naming their own + // destination. Deliberate routes (a fork's home, a channel_open) + // are never held; ambient chatter, reactions and system markers + // never hold anything; an arrival in the SAME conversation keeps + // the route and its reply edge. Only a presented batch counts: on a + // round that cannot present injections (XML tool mode) the arrival + // isn't in the turn, so it can't make the resident's words + // ambiguous. Skipped on endTurn/overBudget: no further prose this + // stream, and the notice would be noise. // - // The notice rides the SAME injection batch — stored right after - // the flushed messages and appended to midTurnInjections — so the - // window byte-matches the wire (short event-style notice: the - // classifier-safe D-class from the 2026-07-24 ablation). It also - // updates lastAnnouncedLocus so the next turn's announce-on-change - // diffs against what the agent was actually last told. + // The notice rides the SAME injection batch (stored right after the + // flushed messages and appended to midTurnInjections), so the + // window byte-matches the wire, and the next turn's + // announce-on-change diffs against what the resident was told. if ( (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') && - !shouldEndTurn && !overBudget && currentState.stream + !shouldEndTurn && !overBudget && currentState.stream && + this.roundPresentsInjections.get(agent.name) === true ) { - // Two signals qualify an injection to move the pin (n=6 + n=7): - // - chat:addressed — someone explicitly spoke TO the agent - // (mention / reply / DM); - // - a conversational follow-up in a channel the agent itself - // explicitly sent into THIS turn (turnEngagedChannels) — - // conversation the agent just engaged continues without - // re-mentioning it (2026-07-31: q's #portables reply, no @, - // answered in prose that followed the stale pin into - // repligate's DM). Deliberately NO author-kind filter: this - // fleet's participants include agent-residents whose speech - // is as conversational as anyone's — the Discord bot flag - // tracks nothing that matters here (antra, 2026-07-31). - // Reactions and system markers are already excluded by - // isConversationalInjection, and the engaged-this-turn scope - // keeps unrelated channels from moving the pin. + const turn = this.turnRoutes.get(agent.name); const engaged = this.turnEngagedChannels.get(agent.name); - const lastQualifying = [...midTurnInjections].reverse().find((inj) => { - const m = inj.metadata as Record | undefined; + const isEngaged = (c: ConversationRef): boolean => c.kind === 'channel' && engaged?.has(c.channelId) === true; + const competing = turn ? [...midTurnInjections].reverse().find((inj) => { if (!isConversationalInjection(inj.metadata)) return false; - if (typeof m?.channelId !== 'string') return false; - const tags = m.tags as string[] | undefined; - if (isAddressedMessage(tags, m)) return true; - return engaged?.has(m.channelId as string) === true; - }); - const newLocus = (lastQualifying?.metadata as Record | undefined) - ?.channelId as string | undefined; - if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) { - const prevPin = this.turnLocusPins.get(agent.name) ?? null; - this.turnLocusPins.set(agent.name, newLocus); - this.lastAnnouncedLocus.set(agent.name, newLocus); - const label = this.channelRegistry?.getDescriptor(newLocus)?.label; - const shown = label && label !== newLocus - ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})` - : newLocus; + const m = inj.metadata as Record | undefined; + const candidate = this.candidateFromSource( + readInboundSource(m), + isAddressedMessage(m?.tags as string[] | undefined, m), + ); + return candidate !== undefined && suspendsRoute(turn, candidate, isEngaged); + }) : undefined; + if (turn?.route && competing) { + const m = competing.metadata as Record | undefined; + const arrival = this.candidateFromSource(readInboundSource(m), true)!.conversation; + const current = routeConversation(turn.route); + const held: TurnRoute = { route: turn.route, hold: { conversations: [current, arrival], since: 'mid-turn' } }; + this.turnRoutes.set(agent.name, held); + this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); const noticeContent: ContentBlock[] = [{ type: 'text', text: - `[routing] The conversation moved to ${shown} — your plain ` + - 'speech now lands there for the rest of this turn. Other ' + - 'channels need an explicit send tool.', + `[routing] ${describeConversation(arrival)} addressed you mid-turn, so your unaddressed plain ` + + `speech could now answer either conversation. For the rest of this turn it is held as drafts ` + + `instead of going to ${describeConversation(current)}. Answer either one with an explicit send ` + + '(or a channel_open); held words can be resent from your drafts.', }]; const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata; try { @@ -6924,10 +6932,10 @@ export class AgentFramework { this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta }); console.error( - `[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`, + `[routing] ${agent.name}: mid-turn ambiguity hold — ${describeConversation(current)} vs ${describeConversation(arrival)}`, ); } catch (err) { - console.error('mid-turn locus re-pin: failed to record routing notice:', err); + console.error('mid-turn ambiguity hold: failed to record routing notice:', err); } } } @@ -7145,6 +7153,11 @@ export class AgentFramework { (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName) : response.requestInference; + // Console/API input is a conversation with a local surface, which + // counts as addressed: the turn's speech route can be that surface. + const surfaceCandidate = surface && response.addMessages?.length + ? this.candidateFromSource(surface, true) + : undefined; for (const agentName of targetAgents) { const agent = this.agents.get(agentName); if (agent && agent.canBeTriggeredBy(source)) { @@ -7153,6 +7166,7 @@ export class AgentFramework { reason: event.type, source, timestamp: Date.now(), + ...(surfaceCandidate ? { routeCandidates: [surfaceCandidate] } : {}), }); } } @@ -7239,11 +7253,14 @@ export class AgentFramework { } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference) { + const addressedHere = isAddressedMessage(event.tags, event.metadata); + const candidate = this.candidateFromSource(source, addressedHere); this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - addressed: isAddressedMessage(event.tags, event.metadata), + addressed: addressedHere, + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -7312,6 +7329,7 @@ export class AgentFramework { // tune-out's wake routing is the first setter.) const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName); + const candidate = this.candidateFromSource(source, addressed); for (const agentName of targetAgents) { if (!this.agents.has(agentName)) continue; this.pendingRequests.push({ @@ -7319,13 +7337,12 @@ export class AgentFramework { reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - // Route this turn's auto-published speech back to THIS channel, not - // the global most-recent-inbound locus (item-3 redux, trunk agents). channelId: event.channelId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - // Addressed messages outrank ambient chatter when a batched wake - // picks the turn's frozen speech locus. addressed, + // This message's conversation, as a speech-route candidate: the + // turn's route is inferred from every candidate of its batch. + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -7420,16 +7437,18 @@ export class AgentFramework { this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { + const addressedHere = isAddressedMessage(event.tags, event.metadata); + // A fork's home route always wins; the candidate is carried for parity. + const candidate = this.candidateFromSource(readInboundSource(messageMetadata), addressedHere); this.pendingRequests.push({ agentName: agent.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - // A fork's home channel wins in routeSpeech regardless, but carry the - // triggering channel too so the trunk/active path stays consistent. channelId: event.channelId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - addressed: isAddressedMessage(event.tags, event.metadata), + addressed: addressedHere, + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -8239,6 +8258,8 @@ export class AgentFramework { // 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); + const addressedHere = isAddressedMessage(occ.tags, origin); + const candidate = this.candidateFromSource(occ.event.event.inboundSource, addressedHere); for (const agentName of await this.coalescedAudience(occ)) { this.pendingRequests.push({ agentName, @@ -8247,7 +8268,8 @@ export class AgentFramework { timestamp: Date.now(), channelId, counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, - addressed: isAddressedMessage(occ.tags, origin), + addressed: addressedHere, + ...(candidate ? { routeCandidates: [candidate] } : {}), coalescingSubject: subject, }); } @@ -8452,6 +8474,11 @@ export class AgentFramework { const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter( (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); + const pushAddressed = isAddressedMessage(event.tags, event.origin); + // A push that names a conversation (a DM, an addressed-while-closed + // message) is a route candidate like its channels/incoming + // counterpart; an unscoped push names none. + const pushCandidate = silentHeartbeat ? undefined : this.candidateFromSource(source, pushAddressed); for (const agentName of targetAgents) { this.pendingRequests.push({ agentName, @@ -8459,10 +8486,8 @@ export class AgentFramework { source: event.serverId, timestamp: Date.now(), channelId: triggerChannel?.channelId, - // DMs and addressed-while-closed messages arrive as push events; - // they must outrank ambient chatter in a batched wake's locus - // selection just like their channels/incoming counterparts. - addressed: isAddressedMessage(event.tags, event.origin), + addressed: pushAddressed, + ...(pushCandidate ? { routeCandidates: [pushCandidate] } : {}), ...(silentHeartbeat ? { suppressProse: true, ephemeralSystemPrompt: @@ -8650,9 +8675,11 @@ export class AgentFramework { // must not absorb an unrelated wake that would vanish with it. const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`; const prev = newestByKey.get(key); - if (!prev || request.timestamp >= prev.timestamp) { - newestByKey.set(key, request); - } + // The kept request carries both requests' route candidates: a + // parked wake's conversation still competes for the turn's route. + const candidates = [...(prev?.routeCandidates ?? []), ...(request.routeCandidates ?? [])]; + const kept = !prev || request.timestamp >= prev.timestamp ? request : prev; + newestByKey.set(key, candidates.length > 0 ? { ...kept, routeCandidates: candidates } : kept); } this.pendingRequests.push(...newestByKey.values()); if (passThrough.length === 0) continue; @@ -8805,6 +8832,9 @@ export class AgentFramework { counterparty: budgetRestart ? undefined : provReq?.counterparty, wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId, wakeAt: budgetRestart ? undefined : provReq?.wakeAt, + // Every conversation this batch carries: a true new turn infers its + // speech route from all of them (shelf-355), never from one pick. + routeCandidates: budgetRestart ? undefined : requests.flatMap((r) => r.routeCandidates ?? []), }); } } @@ -8978,7 +9008,7 @@ export class AgentFramework { private static readonly DRAFT_REASON_TEXT: Record = { 'explicit-send': 'an explicit send in the same round holds plain speech back', - 'no-destination': 'it had no destination — after a routing envelope failed, plain speech waits for a new valid target', + 'no-destination': 'it had no destination — nothing this turn named a conversation to answer, or a failed routing envelope left plain speech waiting for a new valid target', ambiguous: 'more than one conversation was waiting for you, so unaddressed speech was held rather than guessed', bounced: 'its routing prefix bounced', }; @@ -9457,6 +9487,11 @@ export class AgentFramework { for (const id of list ?? []) { if (seen.has(id)) continue; seen.add(id); + if (id.startsWith('surface:')) { + // Speech on a surface route was shown there, never published. + shown.push(`${id.slice('surface:'.length)} (the local surface that messaged you; not published to any channel)`); + continue; + } const label = this.channelRegistry?.getDescriptor(id)?.label; shown.push( label && label !== id @@ -9531,43 +9566,181 @@ export class AgentFramework { } /** - * Announce-on-change locus notice (turn-frozen routing). Appends a durable - * `[routing]` window message when the turn's effective outbound locus - * differs from the last one announced — the agent must never have to guess - * where its plain prose lands. Durable append (never retracted) keeps the - * KV prefix stable; change-only keeps it out of steady-state turns. The - * first resolution after boot announces only a non-null locus ("prose has - * no destination" is the unremarkable boot default). Never triggers - * inference; never locus-eligible itself (system + no channel metadata). + * A route candidate from an accepted item's source envelope: its channel + * conversation (server, channel, thread; the message is the reply edge) or + * the local surface that messaged the resident (always addressed). */ - private announceLocusIfChanged(agentName: string, locus: string | null): void { - const hasBaseline = this.lastAnnouncedLocus.has(agentName); - const prev = this.lastAnnouncedLocus.get(agentName) ?? null; - if (hasBaseline ? prev === locus : locus === null) { - if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus); + private candidateFromSource( + source: InboundSource | undefined, + addressed: boolean, + ): RouteCandidate | undefined { + if (!source) return undefined; + if (source.kind === 'surface') { + return { conversation: { kind: 'surface', surface: source.surface }, addressed: true, at: source.acceptedAt }; + } + if (source.kind !== 'channel') return undefined; + return { + conversation: { + kind: 'channel', + serverId: source.serverId, + channelId: source.channelId, + ...(source.threadId ? { threadId: source.threadId } : {}), + ...(source.label ? { label: source.label } : {}), + }, + addressed, + ...(source.messageId ? { messageId: source.messageId } : {}), + at: source.acceptedAt, + }; + } + + /** A route candidate for a registered channel id (gate and coalesced wakes). */ + private candidateForChannel( + channelId: string, + addressed: boolean, + at: number, + serverId?: string, + messageId?: string, + ): RouteCandidate { + const server = serverId || this.channelRegistry?.getChannelServerId(channelId) || ''; + const label = this.channelRegistry?.getDescriptor(channelId)?.label; + return { + conversation: { kind: 'channel', serverId: server, channelId, ...(label ? { label } : {}) }, + addressed, + ...(messageId ? { messageId } : {}), + at, + }; + } + + /** A conversation fork's home as its standing route (it always wins). */ + private homeRoute(agentName: string): SpeechRoute | null { + const home = this.conversationAgentHomes.get(agentName) ?? this.channelRegistry?.resolveLocus(agentName) ?? null; + if (!home) return null; + const label = this.channelRegistry?.getDescriptor(home)?.label; + return { + kind: 'channel', + serverId: this.channelRegistry?.getChannelServerId(home) ?? '', + channelId: home, + ...(label ? { label } : {}), + origin: 'home', + }; + } + + /** The channel the agent's unaddressed speech goes to now, if it is a channel and not held. */ + private routeChannelId(agentName: string): string | null { + // Optional chaining: prototype-built harnesses leave the map unset. + const turn = this.turnRoutes?.get(agentName); + return turn && !turn.hold && turn.route?.kind === 'channel' ? turn.route.channelId : null; + } + + /** The agent's route as the registry needs it (context, channel_publish default). */ + private speechRouteView(agentName: string): SpeechRouteView { + const turn = this.turnRoutes.get(agentName); + if (!turn) return { kind: 'none' }; + if (turn.hold) return { kind: 'held', conversations: turn.hold.conversations.map(describeConversation) }; + const route = turn.route; + if (!route) return { kind: 'none' }; + if (route.kind === 'surface') return { kind: 'surface', surface: route.surface }; + return { + kind: 'channel', + serverId: route.serverId, + channelId: route.channelId, + ...(route.threadId ? { threadId: route.threadId } : {}), + ...(route.replyTo ? { replyTo: route.replyTo } : {}), + }; + } + + /** + * Deliver one segment of unaddressed plain speech along the turn's route: + * a channel route publishes it; a surface route shows it there (the + * surface already holds the turn's words) and publishes nothing; a held + * turn keeps it as an `ambiguous` draft naming the competing + * conversations; no route keeps it as a `no-destination` draft. + */ + private async speakUnaddressed( + agent: Agent, + text: string, + hold: { round?: number; notice: 'now' | 'later' }, + ): Promise { + const turn = this.turnRoutes.get(agent.name); + if (turn?.hold) { + this.holdProseDrafts(agent, [text], 'ambiguous', { + ...hold, + note: `competing conversations: ${turn.hold.conversations.map(describeConversation).join('; ')}`, + }); return; } - this.lastAnnouncedLocus.set(agentName, locus); + const route = turn?.route; + if (!route) { + this.holdProseDrafts(agent, [text], 'no-destination', hold); + return; + } + if (route.kind === 'surface') { + this.recordProseDelivery(agent.name, { delivered: true, channelId: `surface:${route.surface}` }); + return; + } + if (!this.channelRegistry) { + // No channel subsystem to publish through: keep the words. + this.holdProseDrafts(agent, [text], 'no-destination', hold); + return; + } + const target = route.serverId ? { serverId: route.serverId, channelId: route.channelId } : route.channelId; + const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target); + this.recordProseDelivery(agent.name, outcome); + } + + /** A turn route in words, for logs. */ + private static routeText(turn: TurnRoute): string { + if (turn.hold) return `held between ${turn.hold.conversations.map(describeConversation).join(' and ')}`; + return turn.route ? describeConversation(routeConversation(turn.route)) : 'none'; + } + + /** Identity of a turn route for announce-on-change. */ + private static routeKey(turn: TurnRoute): string { + if (turn.hold) return `held:${turn.hold.conversations.map(conversationKey).sort().join('|')}`; + if (!turn.route) return 'none'; + return conversationKey(routeConversation(turn.route)); + } + /** + * Announce-on-change route notice. Appends a durable `[routing]` window + * message when the turn's route differs from the last one announced — the + * agent must never have to guess where its plain speech goes, or why it is + * held. Durable append (never retracted) keeps the KV prefix stable; + * change-only keeps it out of steady-state turns. The first resolution + * after boot announces only a route that exists ("no destination" is the + * unremarkable boot default). Never triggers inference. + */ + private announceRouteIfChanged(agentName: string, turn: TurnRoute): void { + const key = AgentFramework.routeKey(turn); + const hasBaseline = this.lastAnnouncedRoute.has(agentName); + const prev = this.lastAnnouncedRoute.get(agentName); + if (hasBaseline ? prev === key : key === 'none') { + if (!hasBaseline) this.lastAnnouncedRoute.set(agentName, key); + return; + } + this.lastAnnouncedRoute.set(agentName, key); const agent = this.agents.get(agentName); if (!agent) return; let text: string; - if (locus === null) { + if (turn.hold) { + text = + `[routing] More than one conversation is waiting for you: ${turn.hold.conversations.map(describeConversation).join('; ')}. ` + + 'Your unaddressed plain speech is held as drafts this turn rather than guessed: answer one with an ' + + 'explicit send (or channel_open it), and resend held words with the drafts tool.'; + } else if (!turn.route) { text = - '[routing] Your plain speech currently has no channel — it stays in ' + - 'your archive. Use an explicit send tool to reach a channel.'; + '[routing] Your plain speech currently has no destination — it is held as drafts you can resend. ' + + 'Use an explicit send tool to reach a channel.'; + } else if (turn.route.kind === 'surface') { + text = + `[routing] Your plain speech now goes to ${describeConversation(routeConversation(turn.route))}: ` + + 'it is shown there and published to no channel. Channels need an explicit send tool.'; } else { - const label = this.channelRegistry?.getDescriptor(locus)?.label; - const shown = - label && label !== locus - ? `${label.startsWith('#') ? label : `#${label}`} (${locus})` - : locus; text = - `[routing] Your plain speech now lands in ${shown}. ` + + `[routing] Your plain speech now lands in ${describeConversation(routeConversation(turn.route))}. ` + 'Other channels need an explicit send tool.'; } - try { const id = agent.getContextManager().addMessage( 'user', @@ -9575,14 +9748,13 @@ export class AgentFramework { { system: true, kind: 'routing-notice' }, ); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); - console.error( - `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`, - ); + console.error(`[routing] ${agentName}: route -> ${AgentFramework.routeText(turn)} (announced in window)`); } catch (err) { - console.error('announceLocusIfChanged: failed to record routing notice:', err); + console.error('announceRouteIfChanged: failed to record routing notice:', err); } } + /** * Durable window notice for a channel the agent did not ask to open — * subscription-policy admission, or an open forced by the agent's own @@ -9736,8 +9908,6 @@ export class AgentFramework { private async deliverHybridProse( agent: Agent, rawText: string, - locus: string | null, - allowLocus: boolean, hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, ): Promise { for (const envelope of splitHybridEnvelopes(rawText)) { @@ -9813,15 +9983,16 @@ export class AgentFramework { this.holdProseDrafts(agent, [envelope], 'no-destination', hold); continue; } + // A `>>>target` set this turn is deliberate and wins; otherwise the + // envelope is unaddressed speech, which follows the turn's route. const sticky = this.proseTargetPins.get(agent.name); - if (!allowLocus && !sticky) { - this.holdProseDrafts(agent, [envelope], 'no-destination', hold); - continue; - } - const target = sticky ?? locus; try { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target); - this.recordProseDelivery(agent.name, outcome); + if (sticky) { + const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, sticky); + this.recordProseDelivery(agent.name, outcome); + } else { + await this.speakUnaddressed(agent, envelope, hold); + } } catch (err) { console.error('hybrid locus delivery failed:', err); } @@ -10236,39 +10407,12 @@ export class AgentFramework { this.redoStacks.delete(agent.name); // new work invalidates redo } - // Establish this turn's outbound routing locus (item-3 redux). Set it to the - // triggering channel for a channel/DM-triggered turn; clear it otherwise so - // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and - // instead falls back to the global default. A given agent runs one turn at a - // time, so a set here is only read during THIS turn's routeSpeech / - // buildChannelContext; retries re-run with the same trigger, re-setting it. - // A context-budget restart continues the SAME logical turn in a fresh - // stream — its trigger carries no channelId, and deleting the trigger - // channel here mid-logical-turn is exactly the hole that sent Sol's DM - // reply to a stale guild channel (2026-07-22): any live locus resolution - // after the restart fell through to the hours-old defaultPublishChannel. - // Keep the turn's trigger channel across restarts; only real new turns - // reset it. - if (!continuingTurn) { - if (trigger?.channelId) { - this.activeTriggerChannels.set(agent.name, trigger.channelId); - } else { - this.activeTriggerChannels.delete(agent.name); - } - } - - // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved - // once — home channel for forks, else the triggering channel, else the - // global default — and never moved for the rest of the turn: mid-turn - // injections must not redirect the agent's plain prose (ambient chatter / - // reactions hijacked it before, 2026-07-21). A context-budget restart - // continues the same logical turn, so it keeps the existing pin. The - // eager snapshot also protects heartbeat turns from a moving - // defaultPublishChannel mid-turn. If the effective locus differs from the - // last one announced, drop a durable `[routing]` notice into the window - // BEFORE this turn compiles, so the agent always knows where its voice - // goes (announce-on-change only — no per-turn chatter, append-only for - // KV stability). + // This turn's speech route (shelf-355) is decided below, once, at a true + // new turn: a fork's home, else inferred from the wake's candidates. A + // context-budget restart or a framework retry continues the SAME logical + // turn and keeps it — including a channel_open the resident made — so a + // late or repeated stream never re-resolves where its words go (the + // 2026-07-22 Sol DM misroute was exactly such a re-resolution). if (continuingTurn) { const previousLogicalToolState = this.logicalTurnToolCalls.get(agent); this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); @@ -10299,27 +10443,32 @@ export class AgentFramework { this.proseHybridSuppressed.delete(agent.name); if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { - // Explicit and disabled prose routing have no inferred locus. + // Explicit and disabled prose routing have no inferred route. // Explicit mode uses a turn-scoped `>>` target; disabled mode has no - // prose target at all. Neither mode freezes or announces a locus. + // prose target at all. Neither mode infers or announces a route. this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); - this.turnLocusPins.delete(agent.name); - } else { - const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null; - if (locus !== null) this.turnLocusPins.set(agent.name, locus); - else this.turnLocusPins.delete(agent.name); + this.turnRoutes.delete(agent.name); + } else if (attempt === 0) { + // A true new turn: the route comes from a fork's home, else from the + // wake's candidates — addressed ones first, one conversation only, + // else held (src/speech-routes.ts). Never from whichever channel + // last saw traffic. A silent control-plane wake has none. + const turn: TurnRoute = trigger?.suppressProse + ? { route: null } + : inferTurnRoute(trigger?.routeCandidates ?? [], this.homeRoute(agent.name)); + this.turnRoutes.set(agent.name, turn); this.midTurnInputSignals.delete(agent.name); - if (attempt === 0) this.announceLocusIfChanged(agent.name, locus); + if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); } } - // A context-budget restart normally preserves the turn locus, but a - // silent control-plane wake must remain locusless even if an explicit tool - // opened/pinned a channel mid-turn. + // A context-budget restart normally preserves the turn route, but a + // silent control-plane wake must remain routeless even if an explicit + // tool opened a channel mid-turn. if (trigger?.suppressProse) { - this.turnLocusPins.delete(agent.name); + this.turnRoutes.set(agent.name, { route: null }); this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); } @@ -10328,9 +10477,9 @@ export class AgentFramework { this.emitTrace({ type: 'inference:started', agentName: agent.name, - // The turn-frozen locus was pinned just above (kept across a - // context-budget restart, which skips the re-pin but re-emits this). - channelId: this.turnLocusPins.get(agent.name), + // The turn's route channel, decided just above (kept across a + // context-budget restart, which skips the decision but re-emits this). + channelId: this.routeChannelId(agent.name) ?? undefined, ...(trigger?.suppressProse ? { silent: true } : {}), }); // A budget restart continues the same logical inference window. The @@ -10352,7 +10501,7 @@ export class AgentFramework { const earlyTypingChannel = turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : this.turnLocusPins.get(agent.name) ?? null; + : this.routeChannelId(agent.name); if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel); try { @@ -10567,15 +10716,13 @@ export class AgentFramework { let generationLost = false; // ---- Present-while-acting turn state --------------------------------- - // Output locus for the WHOLE logical turn: frozen in startAgentStream - // (home → addressed trigger → global default) before this stream began, - // and never moved until the next turn. Mid-turn injections do not touch - // it — an ambient message or a reaction arriving at a tool boundary must - // not redirect the agent's task narration (2026-07-21 Cairn misroute). - // The pin lives in `turnLocusPins` so a context-budget restart (same - // logical turn, fresh driveStream) keeps it. - const resolveTurnLocus = (): string | null => - this.turnLocusPins.get(agent.name) ?? null; + // The turn's speech route (turnRoutes) was decided in startAgentStream + // before this stream began and lives there, so a context-budget restart + // (same logical turn, fresh driveStream) keeps it. Unaddressed speech goes + // through speakUnaddressed, which reads it at delivery: an arrival can + // HOLD an inferred route mid-turn (the ambiguity hold), but nothing that + // arrives can point it somewhere else. + const routeLabel = (): string => AgentFramework.routeText(this.turnRoutes.get(agent.name) ?? { route: null }); // Ordered delivery chain for live-routed prose. Links are enqueued // WITHOUT awaiting in the stream-event loop — an awaited network post @@ -10585,12 +10732,9 @@ export class AgentFramework { // is preserved end-to-end. Each link catches its own error: one failed // post must not silence the rest of the turn. let turnSpeechChain: Promise = Promise.resolve(); - const enqueueSpeech = (text: string, locus: string | null): void => { + const enqueueSpeech = (text: string, hold: { round?: number; notice: 'now' | 'later' }): void => { turnSpeechChain = turnSpeechChain - .then(async () => { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); - this.recordProseDelivery(agent.name, outcome); - }) + .then(() => this.speakUnaddressed(agent, text, hold)) .catch((err) => console.error('mid-turn speech routing failed:', err)); }; @@ -10633,8 +10777,8 @@ export class AgentFramework { ? null : turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : resolveTurnLocus(); - if (typingChannel) this.channelRegistry!.startTyping(typingChannel); + : this.routeChannelId(agent.name); + if (typingChannel) this.channelRegistry?.startTyping(typingChannel); // MCPL Spec 14.3 outgoing streaming: route text deltas to their // destination server AS THEY GENERATE (voice synthesis, live message @@ -10898,6 +11042,15 @@ export class AgentFramework { // with verbatim roundContent are live-routed (see liveProseRouting // note above — the fallback preamble is cumulative in XML mode). draftRound++; + // Whether this round's tool-result batch (and anything queued with + // it) reaches the model before it speaks again: the producer says + // so when it can; otherwise native rounds (verbatim roundContent) + // carry injections and XML rounds don't. + this.roundPresentsInjections.set( + agent.name, + (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages + ?? (roundContent !== undefined && roundContent.length > 0), + ); if (this.channelRegistry) { const roundToolNames = event.calls.map((c) => c.name); const hasSameRoundPrivateThink = @@ -10936,10 +11089,9 @@ export class AgentFramework { .then(() => this.holdSilencedProse(agent, roundRuns, cause, hasSameRoundPrivateThink, holdOpts)) .catch((err) => console.error('mid-turn hybrid prose hold failed:', err)); } else if (!hasSameRoundPrivateThink) { - const locus = resolveTurnLocus(); for (const seg of roundSegments) { turnSpeechChain = turnSpeechChain - .then(() => this.deliverHybridProse(agent, seg, locus, true, holdOpts)) + .then(() => this.deliverHybridProse(agent, seg, holdOpts)) .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err)); } } @@ -10969,12 +11121,11 @@ export class AgentFramework { `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`, ); } else { - const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`, + `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${routeLabel()}`, ); for (const seg of roundSegments) { - enqueueSpeech(seg, locus); + enqueueSpeech(seg, holdOpts); } } } @@ -11422,11 +11573,10 @@ export class AgentFramework { console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced: ${silenceCause})`); this.holdSilencedProse(agent, speechRuns, silenceCause, false, textOnlyHold); } else if (turnProseRouting === 'hybrid') { - const locus = resolveTurnLocus(); console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`); for (const segment of speechSegments) { try { - await this.deliverHybridProse(agent, segment, locus, true, textOnlyHold); + await this.deliverHybridProse(agent, segment, textOnlyHold); } catch (err) { console.error('text-only hybrid prose delivery failed:', err); } @@ -11447,14 +11597,12 @@ export class AgentFramework { // a post-restart cleared one) and land the reply in a stale // channel — the 2026-07-22 Sol DM-to-guild misroute. The // frozen pin is immune to both races. - const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`, + `[routing] ${agent.name}: text-only turn -> routing speech -> ${routeLabel()}`, ); for (const segment of speechSegments) { try { - const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus); - this.recordProseDelivery(agent.name, outcome); + await this.speakUnaddressed(agent, segment, textOnlyHold); } catch (err) { console.error('speech routing failed:', err); } @@ -11510,10 +11658,9 @@ export class AgentFramework { if (silenced && segments.length > 0) { this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); } else if (segments.length > 0) { - const locus = resolveTurnLocus(); for (const seg of segments) { try { - await this.deliverHybridProse(agent, seg, locus, true, trailingHold); + await this.deliverHybridProse(agent, seg, trailingHold); } catch (err) { console.error('trailing hybrid prose delivery failed:', err); } @@ -11548,15 +11695,13 @@ export class AgentFramework { // could otherwise overwrite the per-agent triggering channel // between segments — and a turn that narrated live into one // channel must not land its postscript in another. - const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`, + `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${routeLabel()}`, ); // Deliver sequentially (await each) so the segments land in order. for (const seg of segments) { try { - const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus); - this.recordProseDelivery(agent.name, outcome); + await this.speakUnaddressed(agent, seg, trailingHold); } catch (err) { console.error('speech routing failed:', err); } @@ -13966,7 +14111,7 @@ export class AgentFramework { kind: 'refusal', category, streak, - locus: this.channelRegistry?.buildChannelContext()?.incoming?.channelId ?? null, + locus: this.routeChannelId(agentName), tokens: tokens ?? null, }); if (streak >= 2) { @@ -14291,22 +14436,19 @@ export class AgentFramework { } }, shouldTriggerInference: triggerFilter, - // Route a conversation fork's plain-text speech to its HOME channel, not - // the process-global most-recent-inbound locus (item 3). The trunk agent - // has no home entry, so this returns undefined and routeSpeech falls back - // to defaultPublishChannel. `conversationAgentHomes` is the permanent - // spawn-time binding; `channelForAgent` is the router's live binding as a - // belt-and-suspenders fallback. + // A conversation fork's HOME channel: its standing speech route, which + // always wins (item 3). The trunk agent has no home entry; its route + // comes from each turn. `conversationAgentHomes` is the permanent + // spawn-time binding; `channelForAgent` is the router's live binding as + // a belt-and-suspenders fallback. homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName) ?? this.conversationRouter?.channelForAgent(agentName), - // Route a single TRUNK agent's plain-text speech to the channel that - // triggered its CURRENT turn (item-3 redux). connectome-host runs every - // agent as a trunk (it never exposes conversation forks), so without this - // a reply falls back to the process-global most-recent-inbound locus and - // a concurrent message in another channel hijacks it. Empty for - // heartbeat / no-trigger turns → correct global fallback. - activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName), + // The agent's own speech route for the live turn (shelf-355): the + // beforeInference channel context and a channel_publish without a + // channel use it, so what the agent is told and where its words land + // never diverge. No latest-inbound fallback exists. + speechRouteResolver: (agentName) => this.speechRouteView(agentName), // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the // agent sees, on her next turn, that her reply never reached the human. @@ -15881,9 +16023,9 @@ export class AgentFramework { this.channelRegistry .openIfClosedForSend(target, serverId) .then(({ status, channelId, label }) => { - // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31 - // n=7): a human follow-up in a channel the agent explicitly - // sent into this turn counts as conversationally addressed. + // Turn-scoped engagement record (2026-07-31 n=7; the + // ambiguity hold): a follow-up in a channel the agent + // explicitly sent into this turn competes for its speech. // Recorded here — not eagerly — because injection metadata // carries the CANONICAL channel id (descriptor form), while // send args often carry the raw provider id; this callback @@ -16016,40 +16158,57 @@ export class AgentFramework { .then((result) => { const durationMs = Date.now() - startTime; this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs }); - // A successful channel_open plants the agent's feet in the opened - // channel. The agent's own deliberate open is the strongest "my next - // words go here" signal in the system — stronger than any injection — - // so it MOVES the current turn's prose pin (turnLocusPins), not just - // the next turn's trigger state. (The original 2026-07-21 Aria fix - // set only activeTriggerChannels, which resolveLocus reads at the - // NEXT turn's freeze — the turn-frozen refactor had silently - // regressed the "reply right after opening" case; found while - // mapping the routing logic 2026-07-31, antra-ratified fix.) The - // announcement rides THIS TOOL RESULT — model-requested content, - // distance zero, the safest role there is — instead of a separate - // window notice. lastAnnouncedLocus tracks it so announce-on-change - // stays coherent. Locus-mode agents only: explicit mode has no pin. + // channel_open is a deliberate speech choice (shelf-355): unless the + // resident says setSpeechTarget: false, its unaddressed plain speech + // goes to the opened channel for the rest of this logical turn — a + // true new turn decides afresh. It replaces an inferred route or an + // ambiguity hold, and a hybrid `>>>` pin (the latest deliberate choice + // wins). With setSpeechTarget: false the channel is opened for + // reading and the route is unchanged; the result says what it is. + // The announcement rides THIS TOOL RESULT (model-requested content, + // distance zero) instead of a separate window notice, and + // lastAnnouncedRoute tracks it so announce-on-change stays coherent. + // Locus and hybrid modes only: explicit mode has no route. if (call.name === 'channel_open' && result?.success) { - const opened = - (result.data as { channelId?: string } | undefined)?.channelId - ?? (call.input as { channelId?: string } | undefined)?.channelId; - if (opened) { - this.activeTriggerChannels.set(agentName, opened); - const openerAgent = this.agents.get(agentName); - if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { - this.turnLocusPins.set(agentName, opened); - this.lastAnnouncedLocus.set(agentName, opened); - result = { - ...result, - data: { - ...(result.data as Record | undefined), - routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.', - }, + const openInput = call.input as { channelId?: string; serverId?: string; setSpeechTarget?: boolean | null } | undefined; + const opened = (result.data as { channelId?: string } | undefined)?.channelId ?? openInput?.channelId; + const openerAgent = this.agents.get(agentName); + if (opened && openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { + let routing: string; + if (openInput?.setSpeechTarget !== false) { + const resolved = this.channelRegistry!.resolveDestination({ + channelId: opened, + ...(openInput?.serverId ? { serverId: openInput.serverId } : {}), + }); + const destination = resolved && 'destination' in resolved ? resolved.destination : undefined; + const route: SpeechRoute = { + kind: 'channel', + serverId: destination?.serverId ?? openInput?.serverId ?? '', + channelId: opened, + ...(destination?.label ? { label: destination.label } : {}), + origin: 'open', }; - console.error( - `[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`, - ); + const next: TurnRoute = { route }; + this.turnRoutes.set(agentName, next); + this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); + if (openerAgent.proseRouting === 'hybrid') this.proseTargetPins.delete(agentName); + routing = + `Your unaddressed plain speech now goes to ${describeConversation(routeConversation(route))} for the ` + + 'rest of this turn. Other channels need an explicit send tool.'; + console.error(`[routing] ${agentName}: channel_open -> speech route ${opened} (announced in tool result)`); + } else { + const turn = this.turnRoutes.get(agentName); + routing = turn?.hold + ? 'Opened for reading. Your speech route is unchanged: it is held between ' + + `${turn.hold.conversations.map(describeConversation).join(' and ')}, so unaddressed plain speech stays as drafts.` + : turn?.route + ? `Opened for reading. Your plain speech still goes to ${describeConversation(routeConversation(turn.route))}.` + : 'Opened for reading. You have no speech route, so unaddressed plain speech is held as drafts.'; } + result = { + ...result, + data: { ...(result.data as Record | undefined), routing }, + }; } } this.pushEvent({ @@ -16317,7 +16476,7 @@ export class AgentFramework { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } } else { - const locus = this.turnLocusPins.get(agentName) ?? null; + const locus = this.routeChannelId(agentName); this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); }); @@ -17188,11 +17347,10 @@ export class AgentFramework { return { inferenceId, // Conversation identity = the agent (a trunk agent IS its own conversation; - // forks get their own agent). The turn's channel LOCUS — the "proper - // conversation tracking" this once flagged as a TODO — is now tracked - // per-agent in `activeTriggerChannels` and surfaced to the agent via + // forks get their own agent). The turn's speech route is tracked + // per-agent in `turnRoutes` and surfaced to the agent via // buildChannelContext (channels.defaultOutgoing) below, so the agent is - // told the same channel its speech will route to (item-3 redux). + // told the same channel its speech will route to. conversationId: agent.name, turnIndex: 0, // Simplified; needs per-conversation counter TODO userMessage: null, // Could extract from trigger context diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index bb8bd3d1a..73bb9d85e 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -93,8 +93,30 @@ interface PendingEvent { * whose `channelId` is the adapter's raw id (a bare Discord snowflake for a * DM) — unroutable. Absent when the host could not resolve one. */ routeChannelId?: string; + /** The triggering message's id, when the metadata carries one: the reply + * edge of a speech route inferred from this event. */ + messageId?: string; } +/** One conversation a batched gate wake carries, for the framework's + * speech-route inference (src/speech-routes.ts). */ +export type GateRouteCandidate = + | { + kind: 'channel'; + /** The registered channel; for an unroutable candidate, the raw id + * the event named (or `user:` when it named only an author). */ + channelId: string; + serverId?: string; + messageId?: string; + addressed: boolean; + at: number; + /** The host could not resolve the event's registered channel: it still + * competes for the turn (replying to an older conversation instead + * would answer the wrong person), but can never be the route. */ + unroutable?: true; + } + | { kind: 'surface'; surface: string; at: number }; + /** * Where a gate-requested wake came from, handed to the framework alongside the * reason so the turn's InferenceRequest can carry it (host telemetry stamps @@ -123,6 +145,15 @@ export interface WakeProvenance { /** Timestamp (ms) of the chosen event, so a consumer coalescing several * requests can order by event recency, not by flush order. */ at?: number; + /** + * Every conversation in the batch, addressed or not: each event whose + * registered channel the host resolved, and each local-surface message + * (console/API input, addressed by nature). The framework infers the + * turn's speech route from all of them — the addressed ones if any, else + * all; one conversation only, else the turn starts held — instead of + * trusting a single pick. + */ + routeCandidates?: GateRouteCandidate[]; } interface DebounceState { @@ -215,8 +246,36 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin if (!newest || e.timestamp >= newest.timestamp) newest = e; if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e; } + const routeCandidates: GateRouteCandidate[] = []; + for (const e of events) { + if (e.routeChannelId) { + routeCandidates.push({ + kind: 'channel', + channelId: e.routeChannelId, + ...(e.serverId ? { serverId: e.serverId } : {}), + ...(e.messageId ? { messageId: e.messageId } : {}), + addressed: e.addressed, + at: e.timestamp, + }); + } else if (e.eventType === 'external-message' || e.eventType === 'api:message') { + routeCandidates.push({ + kind: 'surface', + surface: e.serverId || (e.eventType === 'api:message' ? 'api' : 'external'), + at: e.timestamp, + }); + } else if (e.channelId || e.authorId) { + routeCandidates.push({ + kind: 'channel', + channelId: e.channelId || `user:${e.authorId}`, + ...(e.serverId ? { serverId: e.serverId } : {}), + addressed: e.addressed, + at: e.timestamp, + unroutable: true, + }); + } + } const pick = newestAddressed ?? newest; - if (!pick) return undefined; + if (!pick) return routeCandidates.length > 0 ? { routeCandidates } : undefined; // push-event channel ids are the adapter's raw ids (a Discord snowflake): // not a composite channel id, so not reported as one const channelId = pick.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined; @@ -227,6 +286,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin ...(pick.addressed ? { addressed: true } : {}), ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}), at: pick.timestamp, + ...(routeCandidates.length > 0 ? { routeCandidates } : {}), }; } @@ -1498,6 +1558,9 @@ export class EventGate { serverId: info.serverId || undefined, addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'), routeChannelId: this.routeChannelFor(info), + ...(typeof info.metadata?.messageId === 'string' && info.metadata.messageId + ? { messageId: info.metadata.messageId as string } + : {}), }; const existing = this.debounceTimers.get(policy.name); diff --git a/src/gate/index.ts b/src/gate/index.ts index 2722ce340..29e4f56aa 100644 --- a/src/gate/index.ts +++ b/src/gate/index.ts @@ -1,4 +1,4 @@ -export { EventGate, wakeProvenance, type WakeProvenance } from './event-gate.js'; +export { EventGate, wakeProvenance, type WakeProvenance, type GateRouteCandidate } from './event-gate.js'; export type { GateConfig, GateOptions, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index a17ffd3a2..3a93002c7 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -341,6 +341,12 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'string', description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.', }, + setSpeechTarget: { + type: 'boolean', + description: + 'Whether your unaddressed plain speech should go to this channel for the rest of this turn ' + + '(default true). false opens it for reading only; the result says where your speech goes.', + }, }, required: ['channelId'], }, @@ -379,11 +385,16 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.', + description: + 'Publish a message to a channel — an explicit send. Without channelId it goes to your current speech ' + + 'route (the conversation your plain speech is going to this turn); with no route it is refused, never ' + + 'guessed. The receipt names where it went, and whether delivery was confirmed, failed (nothing posted) ' + + 'or is unknown (it may have been posted).', inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' }, + channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to your current speech route)' }, + serverId: { type: 'string', description: 'Owning MCPL server; needed only when channelId is registered by more than one.' }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, @@ -459,6 +470,14 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // Constructor Options // ============================================================================ +/** An agent's current speech route as the registry needs it. */ +export type SpeechRouteView = + | { kind: 'channel'; serverId: string; channelId: string; threadId?: string; replyTo?: string } + | { kind: 'surface'; surface: string } + /** Competing conversations hold unaddressed speech (usable addresses). */ + | { kind: 'held'; conversations: string[] } + | { kind: 'none' }; + /** Where a publish was resolved to go — named by the registry, never by * the caller's spelling. */ export interface PublishDestination { @@ -554,27 +573,20 @@ interface ChannelRegistryOptions { * Resolve a conversation fork's HOME channel from its agent name. Conversation * forks are spawned bound to a single channel (the framework tracks this in * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their - * plain-text speech must route THERE. The process-global `defaultPublishChannel` - * tracks only the most-recent inbound across ALL channels, so with one fork per - * channel running concurrently it misroutes a fork's reply to whichever channel - * last spoke (item 3). Returns undefined for the trunk/primary agent, which has - * no home and correctly falls back to the global locus (heartbeats, etc.). + * plain-text speech must route THERE, whatever else arrives. Returns + * undefined for an agent without a home: its route comes from its turn. */ homeChannelResolver?: (agentName: string) => string | undefined; /** - * Resolve the channel that triggered an agent's CURRENT inference turn, by - * agent name. This is the fix for single-TRUNK agents (the only mode - * connectome-host runs — it never exposes conversation forks). A trunk agent - * has no fork home, so without this its plain-text speech falls back to the - * process-global `defaultPublishChannel`, which tracks the most-recent inbound - * across ALL channels and misroutes a reply to whichever channel last spoke - * under concurrency (item-3 redux). The framework tracks the triggering - * channel of the live turn per-agent and exposes it here; resolves to - * undefined for a heartbeat / no-trigger turn, which correctly keeps the - * global fallback. Consulted AFTER `homeChannelResolver` (a fork's home always - * wins), BEFORE `defaultPublishChannel`. + * The agent's current speech route (src/speech-routes.ts), as the + * framework holds it for the live turn: where its unaddressed plain speech + * goes, if anywhere. The same route feeds the beforeInference channel + * context (defaultOutgoing, and its reply edge as `incoming`) and is the + * destination of a channel_publish that names no channel — so what the + * agent is told and where it lands never diverge. There is no + * latest-inbound fallback: without a route there is nothing to default to. */ - activeChannelResolver?: (agentName: string) => string | undefined; + speechRouteResolver?: (agentName: string) => SpeechRouteView; } // ============================================================================ @@ -616,7 +628,7 @@ export class ChannelRegistry { channels: Array<{ channelId: string; label?: string }>; }) => void; private homeChannelResolver?: (agentName: string) => string | undefined; - private activeChannelResolver?: (agentName: string) => string | undefined; + private speechRouteResolver?: (agentName: string) => SpeechRouteView; private store?: JsStore; /** Registered channels, keyed by `{serverId}:{channelId}`. */ @@ -672,13 +684,6 @@ export class ChannelRegistry { */ private dmClassified = new Map(); - /** Most recent incoming channel ID — used for speech routing / default publish. */ - private defaultPublishChannel: string | null = null; - - /** Most recent incoming message metadata, used for buildChannelContext. */ - private defaultPublishMessageId: string | null = null; - private defaultPublishThreadId: string | undefined = undefined; - /** Per-channel typing indicator timers. */ private typingIntervals = new Map>(); @@ -730,7 +735,7 @@ export class ChannelRegistry { this.onRouteFailure = options?.onRouteFailure; this.onChannelAutoOpened = options?.onChannelAutoOpened; this.homeChannelResolver = options?.homeChannelResolver; - this.activeChannelResolver = options?.activeChannelResolver; + this.speechRouteResolver = options?.speechRouteResolver; this.store = options?.store; this.initializeLifecycleStore(); this.initializeLabelHistoryStore(); @@ -976,16 +981,11 @@ export class ChannelRegistry { if (message.tags) message.tags = expandCoreTags(message.tags); const convertedContent: ContentBlock[] = message.content.map(convertBlock); - // Track default publish channel (most recent ACCEPTED incoming) — - // deliberately after §14.5 validation: a rejected message from an - // unregistered channel must not retarget outbound speech (the locus is - // exactly the authority a self-attested channel would be stealing). - // A coalesced item is "accepted" only once the coalescer admits it, so - // for those this runs after the hook (below). + // An accepted message never retargets speech: a route comes from a + // turn's own wake or a deliberate choice, never from whichever channel + // last saw traffic (shelf-355). A coalesced item is "accepted" only + // once the coalescer admits it, so for those this runs after the hook. const markAccepted = () => { - this.defaultPublishChannel = message.channelId; - this.defaultPublishMessageId = message.messageId; - this.defaultPublishThreadId = message.threadId; // A server sending channels/incoming is authoritative evidence that // the transport is actually open. This repairs transient status only; // durable desired state still changes exclusively through lifecycle @@ -1257,13 +1257,6 @@ export class ChannelRegistry { // Accessors // ========================================================================== - /** - * Get the default publish channel ID (most recent incoming channel). - */ - getDefaultPublishChannel(): string | null { - return this.defaultPublishChannel; - } - /** * Get the descriptor for a channel by its channelId (first match across * servers). Used by the conversation router for DM classification. @@ -1372,7 +1365,7 @@ export class ChannelRegistry { }); case 'channel_publish': - return this.handleToolPublish(input as { channelId: string; content: string }); + return this.handleToolPublish(input as { channelId?: string; serverId?: string; content?: string; text?: string }, origin); case 'think': return this.handleToolThink(input as { content?: string }); @@ -1400,15 +1393,12 @@ export class ChannelRegistry { buildChannelContext(agentName?: string): ChannelContext | undefined { const openChannels = this.getOpenChannels(); - // Resolve the outbound locus the SAME way routeSpeech does, so the agent is - // told the channel its speech will actually land in: a conversation fork's - // home channel, else this turn's triggering channel (single trunk agent — - // item-3 redux), else the global default (heartbeats). Without this, a fork - // was advertised the global locus but published somewhere else; and a trunk - // agent was told the wrong channel under concurrency. - const home = agentName ? this.homeChannelResolver?.(agentName) : undefined; - const active = agentName ? this.activeChannelResolver?.(agentName) : undefined; - const outgoing = home ?? active ?? this.defaultPublishChannel; + // The agent's own speech route, the one its plain speech will actually + // use: the outgoing channel, and the message it answers as `incoming`. + // Without a channel route there is no outgoing channel to advertise — + // never a latest-inbound guess. + const route = agentName ? this.speechRouteResolver?.(agentName) : undefined; + const outgoing = route?.kind === 'channel' ? route.channelId : undefined; if (openChannels.length === 0 && !outgoing) { return undefined; @@ -1416,18 +1406,14 @@ export class ChannelRegistry { const context: ChannelContext = {}; - // Incoming: the most-recent inbound message (what the agent is replying to). - // Left process-global — per-channel inbound tracking (the right messageId for - // a fork's own channel) is a separate concern from the outbound routing fix. - if (this.defaultPublishChannel && this.defaultPublishMessageId) { + if (route?.kind === 'channel' && route.replyTo) { context.incoming = { - channelId: this.defaultPublishChannel, - messageId: this.defaultPublishMessageId, - threadId: this.defaultPublishThreadId, + channelId: route.channelId, + messageId: route.replyTo, + ...(route.threadId ? { threadId: route.threadId } : {}), }; } - // Default outgoing: the resolved outbound locus (home channel for forks). if (outgoing) { context.defaultOutgoing = { channelId: outgoing, @@ -1456,11 +1442,6 @@ export class ChannelRegistry { if (entry.serverId !== serverId) continue; this.channels.delete(key); this.stopTyping(entry.descriptor.id); - if (this.defaultPublishChannel === entry.descriptor.id) { - this.defaultPublishChannel = null; - this.defaultPublishMessageId = null; - this.defaultPublishThreadId = undefined; - } } // Desired state and migration markers deliberately survive disconnects. } @@ -1477,11 +1458,6 @@ export class ChannelRegistry { // Clear channels map this.channels.clear(); - - // Reset default publish tracking - this.defaultPublishChannel = null; - this.defaultPublishMessageId = null; - this.defaultPublishThreadId = undefined; } // ========================================================================== @@ -2032,7 +2008,8 @@ export class ChannelRegistry { /** * Publish into a channel on behalf of a named non-resident agent (the * subconscious's speak_in_channel). Same delivery path as the - * channel_publish tool; the agent name rides the speech-routed trace. + * channel_publish tool — the publish executor, with the same receipt — + * attributed to that agent; its name also rides the speech-routed trace. */ async publishForAgent( channelId: string, @@ -2040,7 +2017,7 @@ export class ChannelRegistry { agentName: string, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text }); - return this.handleToolPublish({ channelId, content: text }); + return this.handleToolPublish({ channelId, content: text }, { kind: 'agent', agentName }); } /** @@ -2997,9 +2974,14 @@ export class ChannelRegistry { return server; } + /** + * An agent's standing HOME channel — a conversation fork's — or null. It is + * the only route the registry knows of its own: every other route is the + * framework's, decided per turn (shelf-355), and incoming traffic never + * makes one. + */ resolveLocus(conversationId: string): string | null { - const home = this.homeChannelResolver?.(conversationId); - return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; + return this.homeChannelResolver?.(conversationId) ?? null; } /** @@ -3054,9 +3036,8 @@ export class ChannelRegistry { /** * Publish `text` to one registered channel and report what the attempt * established (PublishOutcome). The delivery executor behind plain speech - * (deliverSpeech / routeSpeech) and resident resends of held drafts; - * channel_publish (and publishForAgent, which shares its handler) moves - * onto it with shelf-356 — until then that tool reads its own receipt. + * (deliverSpeech / routeSpeech), resident resends of held drafts, and + * channel_publish (with publishForAgent, which shares its handler). * * Resolution is exact (resolveDestination): `serverId` plus `channelId` * when the caller has the server, otherwise the channel id must be @@ -3218,7 +3199,7 @@ export class ChannelRegistry { * stale global default and land the reply in the wrong channel (item-3, * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means * "this turn is pinned to no locus": fail loudly rather than guess. */ - locusChannelId: string | null, + locusChannelId: string | null | { serverId?: string; channelId: string }, ): Promise { const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; @@ -3250,8 +3231,11 @@ export class ChannelRegistry { return fail(null, reason, { status: 'failed', reason, at: Date.now() }); } - const outcome = await this.publish(conversationId, text, { channelId: locusChannelId }); - const channelId = outcome.destination?.channelId ?? locusChannelId; + // A route that knows its server publishes exactly there: the same + // channel id on another server is a different conversation. + const target = typeof locusChannelId === 'string' ? { channelId: locusChannelId } : locusChannelId; + const outcome = await this.publish(conversationId, text, target); + const channelId = outcome.destination?.channelId ?? target.channelId; if (outcome.status !== 'delivered') { return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome); } @@ -3277,7 +3261,7 @@ export class ChannelRegistry { async routeSpeech( conversationId: string, text: string, - locusChannelId: string | null, + locusChannelId: string | null | { serverId?: string; channelId: string }, ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { const outcome = await this.deliverSpeech(conversationId, text, locusChannelId); if (outcome.status !== 'delivered') return null; @@ -3288,7 +3272,10 @@ export class ChannelRegistry { }; } - private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise { + private async handleToolPublish( + input: { channelId?: string; serverId?: string; content?: string; text?: string }, + origin?: ChannelToolOrigin, + ): Promise { // Resolve content: accept both `content` and `text` (backward compat) const messageText = input.content ?? input.text; if (!messageText) { @@ -3299,59 +3286,58 @@ export class ChannelRegistry { }; } - // Resolve channelId: default to most recent incoming channel - const channelId = input.channelId ?? this.defaultPublishChannel; - if (!channelId) { - return { - success: false, - error: 'No channelId specified and no default channel available', - isError: true, - }; - } - - const entry = this.findChannelEntry(channelId); - if (!entry) { - return { - success: false, - error: `Channel not found: ${channelId}`, - isError: true, - }; - } - - const server = this.serverRegistry.getServer(entry.serverId); - if (!server) { - return { - success: false, - error: `Server not found: ${entry.serverId}`, - isError: true, - }; - } - - try { - if (!CapabilityGrant.of(server).has('channels.publish')) { + // The destination: the one named, else the caller's current speech + // route — never the most recent inbound channel (shelf-355). + let target: { serverId?: string; channelId: string }; + if (input.channelId) { + target = { channelId: input.channelId, ...(input.serverId ? { serverId: input.serverId } : {}) }; + } else { + const route = origin?.kind === 'agent' ? this.speechRouteResolver?.(origin.agentName) : undefined; + if (route?.kind !== 'channel') { + const why = route?.kind === 'held' + ? `your speech route is held between ${route.conversations.join(' and ')}` + : route?.kind === 'surface' + ? `your speech route is the local surface ${route.surface}, not a channel` + : 'you have no current speech route'; return { success: false, - error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`, + error: `No channelId given, and ${why}: name the channel to publish to. Nothing was sent.`, isError: true, }; } - const publishParams: ChannelsPublishParams = { - conversationId: '', // Framework will fill this when wired - channelId, - content: [{ type: 'text', text: messageText }], - }; + target = { serverId: route.serverId, channelId: route.channelId }; + } - const result = await server.sendChannelsPublish(publishParams); + const outcome = await this.publish( + origin?.kind === 'agent' ? origin.agentName : '', + messageText, + target, + ); + const where = outcome.destination + ? { serverId: outcome.destination.serverId, channelId: outcome.destination.channelId, ...(outcome.destination.label ? { channelLabel: outcome.destination.label } : {}) } + : { channelId: target.channelId, ...(target.serverId ? { serverId: target.serverId } : {}) }; + if (outcome.status === 'delivered') { return { success: true, - data: result ?? { delivered: true }, - }; - } catch (err) { - return { - success: false, - error: `Failed to publish to channel: ${(err as Error).message}`, - isError: true, + data: { + delivered: true, + status: 'delivered', + ...where, + ...(outcome.messageId ? { messageId: outcome.messageId } : {}), + }, }; } + // Not confirmed: say which, and where it was attempted. `unknown` may + // already be posted (a partial multi-part send, a timeout): never invite + // a blind retry. + const shown = `${where.channelId}${'channelLabel' in where && where.channelLabel ? ` (${where.channelLabel})` : ''}`; + return { + success: false, + error: outcome.status === 'unknown' + ? `Delivery to ${shown} was not confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. Check the channel before sending again.` + : `Not sent to ${shown}: ${outcome.reason ?? 'refused'}. Nothing was posted.`, + isError: true, + data: { delivered: false, status: outcome.status, ...where, ...(outcome.messageId ? { messageId: outcome.messageId } : {}) }, + }; } } diff --git a/src/speech-routes.ts b/src/speech-routes.ts new file mode 100644 index 000000000..e3ed2662a --- /dev/null +++ b/src/speech-routes.ts @@ -0,0 +1,178 @@ +/** + * Speech routes: where a resident's unaddressed plain speech goes (shelf-355). + * + * A route is decided deliberately or inferred from what triggered the turn, + * never from whichever channel last saw traffic: + * - deliberate: a conversation fork's home channel, or a channel the resident + * opened with `channel_open` (setSpeechTarget, the default); a hybrid + * `>>>target` prefix is deliberate too and lives in its own pin; + * - inferred, at a true new turn, from the wake's candidates: the addressed + * ones if any, else the conversational ones. Only candidates that name ONE + * conversation infer a route; several conversations start the turn held. + * + * A held turn (competing conversations at turn start, or a competing + * addressed or engaged arrival mid-turn) keeps unaddressed speech as drafts + * rather than guessing. Ambient traffic never suspends a route and never + * selects one over an addressed candidate. Explicit sends name their own + * destination and don't retarget narration. + * + * Pure: the framework supplies the candidates and the fork home, and owns + * the per-turn state. + */ + +/** A conversation a reply could belong to. */ +export type ConversationRef = + | { + kind: 'channel'; + serverId: string; + channelId: string; + threadId?: string; + /** The channel's registered label when it was seen. */ + label?: string; + } + | { + kind: 'surface'; + /** The local surface (console, TUI, API) — never a channel. */ + surface: string; + }; + +/** One conversation the turn's wake carried, as a route candidate. */ +export interface RouteCandidate { + conversation: ConversationRef; + /** Spoken TO the resident (mention, reply, DM, or a local surface's message). */ + addressed: boolean; + /** The triggering message within the conversation: the reply edge. */ + messageId?: string; + /** When its event happened (ms). */ + at: number; + /** Its registered channel could not be resolved: it competes for the + * turn like any conversation, but is never itself the route. */ + unroutable?: true; +} + +export type RouteOrigin = + /** Inferred from the turn's wake. */ + | 'trigger' + /** A conversation fork's home channel. */ + | 'home' + /** The resident's own channel_open (setSpeechTarget). */ + | 'open'; + +export type SpeechRoute = + | { + kind: 'channel'; + serverId: string; + channelId: string; + threadId?: string; + /** The message being answered, kept across same-conversation arrivals. */ + replyTo?: string; + label?: string; + origin: RouteOrigin; + } + | { + kind: 'surface'; + surface: string; + origin: 'trigger'; + }; + +/** The turn's routing state. */ +export interface TurnRoute { + /** Where unaddressed speech goes; null when it has no destination. */ + route: SpeechRoute | null; + /** Unaddressed speech is held as drafts: these conversations compete. */ + hold?: { + conversations: ConversationRef[]; + since: 'turn-start' | 'mid-turn'; + }; +} + +/** Identity of a conversation: server, channel and thread, or the surface. */ +export function conversationKey(c: ConversationRef): string { + return c.kind === 'surface' + ? `surface:${c.surface}` + : `channel:${c.serverId}\u0000${c.channelId}\u0000${c.threadId ?? ''}`; +} + +/** The conversation a route speaks into. */ +export function routeConversation(route: SpeechRoute): ConversationRef { + return route.kind === 'surface' + ? { kind: 'surface', surface: route.surface } + : { + kind: 'channel', + serverId: route.serverId, + channelId: route.channelId, + ...(route.threadId ? { threadId: route.threadId } : {}), + ...(route.label ? { label: route.label } : {}), + }; +} + +/** A usable address for a conversation, as a resident would write it. */ +export function describeConversation(c: ConversationRef): string { + if (c.kind === 'surface') return `${c.surface} (the local surface that messaged you)`; + const where = `${c.channelId}${c.threadId ? `, thread ${c.threadId}` : ''}`; + if (!c.label || c.label === c.channelId) return c.threadId ? `${c.channelId} (thread ${c.threadId})` : c.channelId; + const label = c.label.startsWith('#') || c.label.startsWith('DM') ? c.label : `#${c.label}`; + return `${label} (${where})`; +} + +/** + * The turn's route from its wake candidates (a true new turn only). + * A fork's home always wins. Otherwise the addressed candidates are + * considered if there are any, else every (conversational) candidate; they + * infer a route only when they name one conversation, from the newest + * candidate in it (its message is the reply edge). Several conversations + * start the turn held, naming each; none, or one whose channel couldn't be + * resolved, leaves the turn without a route. + */ +export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: SpeechRoute | null): TurnRoute { + if (home) return { route: home }; + const addressed = candidates.filter((c) => c.addressed); + const pool = addressed.length > 0 ? addressed : candidates; + if (pool.length === 0) return { route: null }; + const newestByConversation = new Map(); + for (const candidate of pool) { + const key = conversationKey(candidate.conversation); + const seen = newestByConversation.get(key); + if (!seen || candidate.at >= seen.at) newestByConversation.set(key, candidate); + } + if (newestByConversation.size > 1) { + const conversations = [...newestByConversation.values()] + .sort((a, b) => b.at - a.at) + .map((c) => c.conversation); + return { route: null, hold: { conversations, since: 'turn-start' } }; + } + const [chosen] = newestByConversation.values(); + // A conversation whose channel isn't resolvable can't be spoken into. + if (chosen!.unroutable) return { route: null }; + const c = chosen!.conversation; + return c.kind === 'surface' + ? { route: { kind: 'surface', surface: c.surface, origin: 'trigger' } } + : { + route: { + kind: 'channel', + serverId: c.serverId, + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + ...(chosen!.messageId ? { replyTo: chosen!.messageId } : {}), + ...(c.label ? { label: c.label } : {}), + origin: 'trigger', + }, + }; +} + +/** + * Whether a mid-turn arrival suspends the turn's route: only an inferred + * route (origin 'trigger') not already held, and only for a conversational + * message from a DIFFERENT conversation that addressed the resident, or that + * continues a conversation the resident explicitly sent into this turn. + * Ambient chatter never does; deliberate routes never are. + */ +export function suspendsRoute( + turn: TurnRoute, + arrival: { conversation: ConversationRef; addressed: boolean }, + engaged: (c: ConversationRef) => boolean, +): boolean { + if (turn.hold || !turn.route || turn.route.origin !== 'trigger') return false; + if (conversationKey(arrival.conversation) === conversationKey(routeConversation(turn.route))) return false; + return arrival.addressed || engaged(arrival.conversation); +} diff --git a/src/types/agent.ts b/src/types/agent.ts index b33fa81da..c0432a6bb 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -348,6 +348,15 @@ export interface InferenceRequest { wakeChannelId?: string; /** Timestamp of the event the wake provenance was taken from (ms). */ wakeAt?: number; + /** + * The conversations this wake carries, as speech-route candidates + * (src/speech-routes.ts): the triggering message's channel or local + * surface, whether it addressed the agent, and its message id (the reply + * edge). A batched gate wake carries its whole batch. At a true new turn + * the framework infers the turn's route from every candidate of the + * batch: the addressed ones if any, else all; one conversation only. + */ + routeCandidates?: import('../speech-routes.js').RouteCandidate[]; /** Suppress every automatic plain-prose delivery for this logical turn. * Explicit tool calls remain available. Used by authenticated silent wakes. */ suppressProse?: boolean; diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index ab2df9035..bf81bc339 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -8,8 +8,10 @@ * that; a DM push event never does. Either order misrouted the DM reply: * - ambient AFTER the DM: the ambient channel became the fallback; * - ambient BEFORE the DM: the DM never displaced it. - * Now a batch containing an addressed message routes to that message's - * registered channel; an ambient-only batch keeps the legacy fallback. + * Now the turn's speech route is inferred from the whole batch + * (shelf-355): its addressed messages if any, else all of it; one + * conversation routes there, several hold the reply as a draft. Nothing + * falls back to the most recent inbound channel. */ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; @@ -118,15 +120,68 @@ describe('batched wake routing', () => { incoming(GENERAL, 'ambient', 'unrelated chatter'); await waitFor(() => publishes().length >= 1, 'reply published'); assert.deepEqual(publishes(), [ROOM]); + // What the agent is told is where its speech went: the channel context + // advertises the same route, answering the mention. + const registry = (framework as unknown as { + channelRegistry: { buildChannelContext(agent: string): { defaultOutgoing?: { channelId: string }; incoming?: { channelId: string; messageId: string } } | undefined }; + }).channelRegistry; + const context = registry.buildChannelContext('scout'); + assert.equal(context?.defaultOutgoing?.channelId, ROOM); + assert.equal(context?.incoming?.channelId, ROOM); + assert.match(context?.incoming?.messageId ?? '', /^m-\d+$/); }); - it('an ambient-only batch keeps the legacy fallback (most recent inbound)', async () => { + const drafts = (): Array<{ id: string; text: string; reason: string; note?: string }> => + (framework as unknown as { proseDrafts: { open(agent: string): Array<{ id: string; text: string; reason: string; note?: string }> } }) + .proseDrafts.open('scout'); + const turnDone = async (): Promise => { + await waitFor(() => membrane.calls.length >= 1, 'the turn ran'); + await waitFor(() => framework.getAgent('scout')!.state.status === 'idle', 'the turn ended'); + await framework.runUntilIdle(); + }; + + it('an ambient-only batch in ONE conversation takes that conversation as its route', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'joining in' }])); incoming(ROOM, 'ambient', 'first'); await waitFor(() => sent('incoming-sent') === 1, 'first sent'); - incoming(GENERAL, 'ambient', 'second'); + incoming(ROOM, 'ambient', 'second'); await waitFor(() => publishes().length >= 1, 'reply published'); - assert.deepEqual(publishes(), [GENERAL]); + assert.deepEqual(publishes(), [ROOM]); + }); + + it('an ambient-only batch across two conversations holds the reply as a draft, never the newest channel', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'joining in' }])); + incoming(ROOM, 'ambient', 'first'); + await waitFor(() => sent('incoming-sent') === 1, 'first sent'); + incoming(GENERAL, 'ambient', 'second'); + await turnDone(); + assert.deepEqual(publishes(), [], 'nothing is guessed'); + const [held] = drafts(); + assert.equal(held?.text, 'joining in'); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', /discord:g1:room/); + assert.match(held?.note ?? '', /discord:g1:general/); + }); + + it('two conversations that addressed the agent in one batch: held, never guessed', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'which of you?' }])); + incoming(ROOM, 'addressed', '@scout hello'); + await waitFor(() => sent('incoming-sent') === 1, 'mention sent'); + dm('are you there?'); + await waitFor(() => sent('dm-sent') === 1, 'dm sent'); + await turnDone(); + assert.deepEqual(publishes(), []); + const [held] = drafts(); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', new RegExp(`${DM}|${RAW_DM}`)); + assert.match(held?.note ?? '', /discord:g1:room/); + // The resident decides: the held words go to whichever conversation it + // names, verbatim. + const result = await (framework as unknown as { + handleDraftsTool(agent: string, input: Record): Promise<{ success?: boolean; content?: unknown; data?: unknown }>; + }).handleDraftsTool('scout', { action: 'resend', draftIds: [held!.id], destination: DM }); + assert.match(JSON.stringify(result), /delivered to/); + assert.deepEqual(publishes(), [DM]); }); }); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 80a825658..56f6f38a3 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -1,7 +1,7 @@ import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, type SpeechRouteView } from '../src/mcpl/channel-registry.js'; import { parseProsePrefix } from '../src/mcpl/prose-grammar.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -15,7 +15,7 @@ type RouteFailure = { conversationId: string; channelId: string | null; reason: function makeRegistry( publishResult: { delivered?: boolean } | undefined, homeChannelResolver?: (agentName: string) => string | undefined, - activeChannelResolver?: (agentName: string) => string | undefined, + speechRouteResolver?: (agentName: string) => SpeechRouteView, onChannelAutoOpened?: (info: { conversationId?: string; serverId: string; @@ -60,7 +60,7 @@ function makeRegistry( { onRouteFailure: (info) => { failures.push(info); }, homeChannelResolver, - activeChannelResolver, + speechRouteResolver, onChannelAutoOpened, }, ); @@ -127,7 +127,7 @@ test('routeSpeech surfaces a failure when the server reports delivered:false', a seedRegistered(registry, 'discord', 'ch-x'); registry.handleIncoming('discord', incoming('ch-x', 'hi')); - const res = await registry.routeSpeech('cairn', 'undeliverable reply', registry.resolveLocus('cairn')); + const res = await registry.routeSpeech('cairn', 'undeliverable reply', 'ch-x'); assert.equal(res, null, 'a non-delivered send must not report success'); assert.equal(failures.length, 1, 'onRouteFailure should fire'); @@ -136,10 +136,9 @@ test('routeSpeech surfaces a failure when the server reports delivered:false', a assert.ok(traces.some(t => t.type === 'mcpl:speech-route-failed')); }); -test('routeSpeech routes a conversation fork to its HOME channel, not the global last-inbound (item 3)', async () => { - // Two channels are live. chanA registered first; then a message arrives on - // chanB, flipping the process-global defaultPublishChannel to chanB. A fork - // bound to chanA must still publish to chanA. +test('routeSpeech routes a conversation fork to its HOME channel, whatever arrives elsewhere (item 3)', async () => { + // Two channels are live and chanB spoke last. A fork bound to chanA must + // still publish to chanA. const homes: Record = { 'conversation-chanA-g1': 'chanA' }; const { registry, publishCalls } = makeRegistry( { delivered: true }, @@ -150,19 +149,18 @@ test('routeSpeech routes a conversation fork to its HOME channel, not the global registry.handleIncoming('discord', incoming('chanA', 'hi from A')); seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); - // Global locus is now chanB. - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); const res = await registry.routeSpeech('conversation-chanA-g1', 'reply for A', registry.resolveLocus('conversation-chanA-g1')); assert.deepEqual(res, { delivered: true, channelId: 'chanA' }, - 'fork must route to its home channel, not the global last-inbound'); + 'fork must route to its home channel, not the last inbound'); assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); }); -test('routeSpeech falls back to the global locus for the trunk agent (no home)', async () => { - // The trunk/primary agent has no home entry; it correctly uses the global - // most-recent-inbound channel. - const { registry, publishCalls } = makeRegistry( +test('incoming traffic never selects a destination: without a home there is no locus (shelf-355)', async () => { + // The old registry tracked the process-global most-recent inbound channel + // and routed homeless speech there. Now a route comes only from a turn's + // own wake or a deliberate choice, which the framework holds. + const { registry, publishCalls, failures } = makeRegistry( { delivered: true }, () => undefined, // no agent has a home ); @@ -172,35 +170,70 @@ test('routeSpeech falls back to the global locus for the trunk agent (no home)', seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); + assert.equal(registry.resolveLocus('trunk'), null); + assert.equal(registry.buildChannelContext('trunk')?.defaultOutgoing, undefined, + 'nothing is advertised as where speech goes'); const res = await registry.routeSpeech('trunk', 'heartbeat reply', registry.resolveLocus('trunk')); - assert.deepEqual(res, { delivered: true, channelId: 'chanB' }); - assert.equal(publishCalls.at(-1)?.channelId, 'chanB'); + assert.equal(res, null); + assert.equal(publishCalls.length, 0); + assert.match(failures.at(-1)!.reason, /no locus/); }); -test('buildChannelContext advertises the fork home as defaultOutgoing (item 3)', () => { - const homes: Record = { 'conversation-chanA-g1': 'chanA' }; - const { registry } = makeRegistry( - { delivered: true }, - (agentName) => homes[agentName], - ); - - seedRegistered(registry, 'discord', 'chanA'); +test('buildChannelContext advertises the agent\'s own route and its reply edge, and nothing for a held or absent route', () => { + const routes: Record = { + 'conversation-chanA-g1': { kind: 'channel', serverId: 'discord', channelId: 'chanA' }, + scout: { kind: 'channel', serverId: 'discord', channelId: 'chanB', replyTo: 'm-7', threadId: 't-1' }, + held: { kind: 'held', conversations: ['#chanA (chanA)', '#chanB (chanB)'] }, + tui: { kind: 'surface', surface: 'tui' }, + }; + const { registry } = makeRegistry({ delivered: true }, () => undefined, (n) => routes[n] ?? { kind: 'none' }); + seedRegistered(registry, 'discord', 'chanA', 'chanB'); registry.handleIncoming('discord', incoming('chanA', 'hi from A')); - seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); - // The fork is told chanA (where its speech actually lands)... - const forkCtx = registry.buildChannelContext('conversation-chanA-g1'); - assert.equal(forkCtx?.defaultOutgoing?.channelId, 'chanA'); + assert.equal(registry.buildChannelContext('conversation-chanA-g1')?.defaultOutgoing?.channelId, 'chanA'); + const scout = registry.buildChannelContext('scout'); + assert.equal(scout?.defaultOutgoing?.channelId, 'chanB'); + assert.deepEqual(scout?.incoming, { channelId: 'chanB', messageId: 'm-7', threadId: 't-1' }, + 'the reply edge is the route\'s own triggering message'); + for (const agent of ['held', 'tui', 'nobody']) { + const ctx = registry.buildChannelContext(agent); + assert.equal(ctx?.defaultOutgoing, undefined, `${agent}: no outgoing channel`); + assert.equal(ctx?.incoming, undefined, `${agent}: no reply edge`); + } +}); - // ...while the trunk agent (no home) is told the global default. - const trunkCtx = registry.buildChannelContext('trunk'); - assert.equal(trunkCtx?.defaultOutgoing?.channelId, 'chanB'); +test('a route that names its server publishes there, even when another server registers the same channel id', async () => { + const published: Array<{ server: string; channelId?: string }> = []; + const server = (id: string) => ({ + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (params: { channelId?: string }) => { + published.push({ server: id, channelId: params.channelId }); + return { delivered: true, messageId: `${id}-1` }; + }, + sendChannelsOpen: async () => ({}), + }); + const servers: Record = { a: server('a'), b: server('b') }; + const registry = new ChannelRegistry( + { getServer: (id: string) => servers[id] } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + {}, + ); + seedRegistered(registry, 'a', 'shared'); + seedRegistered(registry, 'b', 'shared'); + + const res = await registry.routeSpeech('scout', 'to b', { serverId: 'b', channelId: 'shared' }); + assert.deepEqual(res, { delivered: true, channelId: 'shared', messageId: 'b-1' }); + assert.deepEqual(published, [{ server: 'b', channelId: 'shared' }]); + // Without the server, the shared id is refused rather than guessed. + assert.equal(await registry.routeSpeech('scout', 'which one?', 'shared'), null); + assert.equal(published.length, 1); }); test('routeSpeech surfaces a failure when there is no locus at all', async () => { const { registry, failures } = makeRegistry({ delivered: true }); - // No handleIncoming → defaultPublishChannel is null. const res = await registry.routeSpeech('cairn', 'into the void', registry.resolveLocus('cairn')); assert.equal(res, null); assert.equal(failures.length, 1); @@ -208,85 +241,76 @@ test('routeSpeech surfaces a failure when there is no locus at all', async () => assert.match(failures[0].reason, /no locus/); }); -// ── item-3 redux: single TRUNK agents route to the turn's triggering channel ── +// ── channel_publish: an explicit send through the publish executor ── -test('routeSpeech routes a TRUNK agent to its ACTIVE triggering channel, not the global last-inbound (item-3 redux)', async () => { - // The exact live repro: scout (a single trunk agent) is answering channel A - // when a message arrives on channel B, flipping the process-global - // defaultPublishChannel to B. The reply must still land in A. Forks (home) - // don't exist here — connectome-host never spawns them — so the ONLY thing - // keeping A's answer in A is the active-triggering-channel resolver. - const active: Record = { scout: 'chanA' }; +test('channel_publish without a channel goes to the caller\'s route, and its receipt names the destination', async () => { const { registry, publishCalls } = makeRegistry( - { delivered: true }, - () => undefined, // no fork homes (trunk-only, connectome-host mode) - (agentName) => active[agentName], + { delivered: true, messageId: 'p-1' } as { delivered?: boolean }, + () => undefined, + (n) => (n === 'scout' ? { kind: 'channel', serverId: 'discord', channelId: 'chanA' } : { kind: 'none' }), ); + seedRegistered(registry, 'discord', 'chanA', 'chanB'); + registry.handleIncoming('discord', incoming('chanB', 'chanB spoke last')); - seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'A: sleep && date')); - seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'B: unrelated')); // flips global to chanB - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); - - const res = await registry.routeSpeech('scout', 'the date is ...', registry.resolveLocus('scout')); - assert.deepEqual(res, { delivered: true, channelId: 'chanA' }, - 'trunk reply must go to the channel that triggered the turn, not the global last-inbound'); - assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); + const res = await registry.handleChannelToolCall('channel_publish', { content: 'hello' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(res.success, true); + assert.deepEqual(res.data, { delivered: true, status: 'delivered', serverId: 'discord', channelId: 'chanA', channelLabel: 'chanA', messageId: 'p-1' }); + assert.equal(publishCalls.at(-1)?.channelId, 'chanA', 'the route, not the last inbound channel'); }); -test('routeSpeech precedence: fork HOME wins over the active triggering channel', async () => { - // A fork bound to chanA must route home even if its live turn was (somehow) - // triggered from chanB — home is the strongest signal and must not regress. - const { registry, publishCalls } = makeRegistry( - { delivered: true }, - (n) => (n === 'conversation-chanA-g1' ? 'chanA' : undefined), - () => 'chanB', - ); - seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi')); - seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi')); - - const res = await registry.routeSpeech('conversation-chanA-g1', 'reply', registry.resolveLocus('conversation-chanA-g1')); - assert.deepEqual(res, { delivered: true, channelId: 'chanA' }); - assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); +test('channel_publish without a channel or a route is refused, never guessed', async () => { + const views: Record = { + held: { kind: 'held', conversations: ['#chanA (chanA)', '#chanB (chanB)'] }, + tui: { kind: 'surface', surface: 'tui' }, + }; + const { registry, publishCalls } = makeRegistry({ delivered: true }, () => undefined, (n) => views[n] ?? { kind: 'none' }); + seedRegistered(registry, 'discord', 'chanA', 'chanB'); + registry.handleIncoming('discord', incoming('chanB', 'chanB spoke last')); + + const none = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'agent', agentName: 'nobody' }); + assert.equal(none.success, false); + assert.match(none.error!, /No channelId given, and you have no current speech route: name the channel to publish to\. Nothing was sent\./); + const held = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'agent', agentName: 'held' }); + assert.match(held.error!, /your speech route is held between #chanA \(chanA\) and #chanB \(chanB\)/); + const tui = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'agent', agentName: 'tui' }); + assert.match(tui.error!, /your speech route is the local surface tui, not a channel/); + const module = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'module' }); + assert.match(module.error!, /you have no current speech route/); + assert.equal(publishCalls.length, 0); }); -test('buildChannelContext advertises the active triggering channel as defaultOutgoing (item-3 redux)', () => { - const active: Record = { scout: 'chanA' }; - const { registry } = makeRegistry( - { delivered: true }, - () => undefined, - (n) => active[n], +test('channel_publish reports failed and unknown outcomes with the attempted destination', async () => { + const refused = makeRegistry({ delivered: false }); + seedRegistered(refused.registry, 'discord', 'chanA'); + const failed = await refused.registry.handleChannelToolCall('channel_publish', { channelId: 'chanA', content: 'x' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(failed.success, false); + assert.match(failed.error!, /^Not sent to chanA \(chanA\): .*Nothing was posted\.$/); + assert.deepEqual( + { status: (failed.data as { status: string }).status, serverId: (failed.data as { serverId: string }).serverId, channelId: (failed.data as { channelId: string }).channelId }, + { status: 'failed', serverId: 'discord', channelId: 'chanA' }, ); - seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); - seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi from B')); // global → chanB - // The trunk is TOLD chanA (where its speech will actually land), matching - // what routeSpeech resolves — not the global chanB. - const ctx = registry.buildChannelContext('scout'); - assert.equal(ctx?.defaultOutgoing?.channelId, 'chanA'); + const silent = makeRegistry(undefined); + seedRegistered(silent.registry, 'discord', 'chanA'); + const unknown = await silent.registry.handleChannelToolCall('channel_publish', { channelId: 'chanA', content: 'x' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(unknown.success, false); + assert.match(unknown.error!, /^Delivery to chanA \(chanA\) was not confirmed — it may or may not have been posted: .*Check the channel before sending again\.$/); + assert.equal((unknown.data as { status: string }).status, 'unknown'); }); test('ensureChannelRegistered keeps a DM closed while making its one-shot reply routable', async () => { // A Discord DM arrives via push/event (channel closed), so it is never - // registered and never updates defaultPublishChannel — routeSpeech would drop - // the reply. Registering it on inbound makes it a publishable locus; the - // active resolver (the woken turn's DM) then targets it. + // registered — routeSpeech would drop the reply. Registering it on inbound + // makes it a publishable destination; the woken turn's route (its DM) + // then targets it. const dm = 'discord:dm:42'; - const active: Record = { scout: dm }; const { registry, publishCalls, lookup, traces } = makeRegistry( { delivered: true }, () => undefined, - (n) => active[n], ); // No prior handleIncoming for the DM — it only ever came as a push event. assert.equal(lookup(dm), undefined); - assert.equal(registry.getDefaultPublishChannel(), null); registry.ensureChannelRegistered('discord', dm, 'DM with Antra'); @@ -296,9 +320,9 @@ test('ensureChannelRegistered keeps a DM closed while making its one-shot reply assert.equal(entry!.open, false, 'one-shot reachability is not a subscription'); assert.ok(traces.some((t) => t.type === 'mcpl:channel-lazy-registered')); - const res = await registry.routeSpeech('scout', 'replying in the DM', registry.resolveLocus('scout')); + const res = await registry.routeSpeech('scout', 'replying in the DM', { serverId: 'discord', channelId: dm }); assert.deepEqual(res, { delivered: true, channelId: dm }, - 'the DM reply must route back to the DM channel, not the global locus'); + 'the DM reply must route back to the DM channel'); assert.equal(publishCalls.at(-1)?.channelId, dm); }); diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 637cf1029..48bb1b1c1 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -33,7 +33,7 @@ function makeGate(configPath: string, opts?: { }) { const traces: TraceEntry[] = []; const messages: Array<{ participant: string; content: unknown; metadata?: unknown }> = []; - const inferenceRequests: Array<{ agentName: string; reason: string; source: string; channelId?: string; counterparty?: string; addressed?: boolean; at?: number; routeChannelId?: string }> = []; + const inferenceRequests: Array<{ agentName: string; reason: string; source: string; channelId?: string; counterparty?: string; addressed?: boolean; at?: number; routeChannelId?: string; routeCandidates?: Array> }> = []; const gate = new EventGate({ configPath, @@ -584,6 +584,33 @@ describe('debounce', () => { assert.strictEqual(inferenceRequests[0].routeChannelId, undefined); }); + it('a batched wake reports every conversation in its batch as a route candidate (shelf-355)', async () => { + const path = writeConfig('debounce-route-candidates.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const resolveRouteChannel = (info: GateEventInfo) => + info.eventType === 'mcpl:channel-incoming' ? info.channelId : undefined; + const { gate, inferenceRequests } = makeGate(path, { resolveRouteChannel }); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: '@agent hi', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'm-room' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:general', content: 'chatter', + metadata: { authorId: '7', messageId: 'm-gen' } })); + // An addressed push whose channel the host could not resolve still competes. + gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'discord', channelId: '1548', content: 'dm', + tags: ['chat:dm', 'chat:addressed'], metadata: { authorId: '42', channelId: '1548' } })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 1); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'channel', channelId: 'discord:g:room', serverId: 'discord', messageId: 'm-room', addressed: true }, + { kind: 'channel', channelId: 'discord:g:general', serverId: 'discord', messageId: 'm-gen', addressed: false }, + { kind: 'channel', channelId: '1548', serverId: 'discord', addressed: true, unroutable: true }, + ]); + }); + it('a batched wake with no channel-bearing event carries no provenance', async () => { const path = writeConfig('debounce-noprov.json', { policies: [ diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index f2dfaafef..8359abbcd 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -129,21 +129,33 @@ class RobotModule implements Module { } } -/** Minimal ChannelRegistry stub covering everything driveStream touches. */ -function stubChannelRegistry(framework: AgentFramework) { +/** Minimal ChannelRegistry stub covering everything driveStream touches. + * By default every agent has a HOME route (a fork-style deliberate route, + * `chan-live-N`); `{ home: false }` leaves routes to the turn's wake. */ +function stubChannelRegistry(framework: AgentFramework, opts: { home?: boolean } = {}) { const routed: Array<{ text: string; locus: string | null }> = []; let locusCalls = 0; const explicit: Record = { resolveLocus: () => { + if (opts.home === false) return null; locusCalls++; return `chan-live-${locusCalls}`; }, - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { - routed.push({ text, locus: locus ?? null }); + routeSpeech: async (_agent: string, text: string, target?: string | null | { channelId: string }) => { + const locus = target && typeof target === 'object' ? target.channelId : target ?? null; + routed.push({ text, locus }); // Mirror the real registry's outcome shape (delivery receipts read it). return locus ? { delivered: true, channelId: locus } : null; }, getDefaultPublishChannel: () => null, + // Hybrid `>>>#name` targets resolve to `chan-name` and deliver through + // the registry's outcome-returning form, recorded like routeSpeech. + resolveProseTarget: (spec: string) => ({ channelId: spec.startsWith('#') ? `chan-${spec.slice(1)}` : spec, label: spec }), + deliverSpeech: async (_agent: string, text: string, target?: string | null | { channelId: string }) => { + const locus = target && typeof target === 'object' ? target.channelId : target ?? null; + routed.push({ text, locus }); + return { status: 'delivered', destination: { serverId: 'stub', channelId: locus ?? '' }, at: Date.now() }; + }, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], @@ -157,6 +169,31 @@ function stubChannelRegistry(framework: AgentFramework) { return routed; } +/** A channel item's source envelope, as admission stamps it. */ +function envelope(channelId: string, messageId = 'm-arrival'): Record { + return { + kind: 'channel', lane: 'channels/incoming', serverId: 'srv', binding: 'srv', + channelId, messageId, acceptedAt: Date.now(), + }; +} + +/** Wake the agent from one conversation: the turn infers its route there. */ +function triggerFromChannel(framework: AgentFramework, channelId = 'chan-A'): void { + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'srv', timestamp: Date.now(), + channelId, addressed: true, + routeCandidates: [{ + conversation: { kind: 'channel', serverId: 'srv', channelId }, + addressed: true, messageId: 'm-trigger', at: Date.now(), + }], + }); +} + +function heldDrafts(framework: AgentFramework): Array<{ text: string; reason: string; note?: string }> { + return (framework as unknown as { proseDrafts: { open(agent: string): Array<{ text: string; reason: string; note?: string }> } }) + .proseDrafts.open('assistant'); +} + // --------------------------------------------------------------------------- describe('present while acting', () => { @@ -335,15 +372,14 @@ describe('present while acting', () => { await framework.stop(); }); - it('an ADDRESSED mid-turn injection re-pins the locus for subsequent prose', async () => { - // 2026-07-31 Mythos misroutes (n=6): someone explicitly addresses the - // agent from another channel mid-turn (mention / reply / DM); the - // agent's trailing prose answers THEM but the frozen pin delivered it - // to the stale locus (the hospital scene into antra's DM, and its - // mirror). Addressed conversational injections — the chat:addressed - // signal turn-START batching already prefers — now move the pin, with a - // one-line [routing] notice riding the same injection batch so window - // and wire agree. Ambient injections still cannot (previous test). + it('an ADDRESSED mid-turn arrival from another conversation holds an inferred route: later prose becomes a draft', async () => { + // 2026-07-31 misroutes (n=6): someone addresses the agent from another + // channel mid-turn; the agent's next unaddressed words could answer + // either conversation. The old re-pin guessed the newcomer and sent + // words still meant for the first conversation into the second + // (shelf-355). Now the inferred route is HELD: unaddressed speech becomes + // drafts for the rest of the turn, with a one-line [routing] notice + // riding the same injection batch so window and wire agree. membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Station four, proceeding.' }, { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, @@ -353,10 +389,12 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'hey, quick question over here?'; - module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; const notices: string[] = []; framework.onTrace((event) => { @@ -364,38 +402,33 @@ describe('present while acting', () => { if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); }); - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - // Round-1 prose predates the injection and belongs to the old pin; - // trailing prose follows the addressed speaker. - assert.deepEqual(routed, [ - { text: 'Station four, proceeding.', locus: 'chan-live-1' }, - { text: 'Answering the person who addressed me.', locus: 'discord:dm:antra' }, - ]); - // Boot-baseline announcement + the mid-turn re-pin notice. - assert.equal(notices.length, 2, 'the re-pin produced exactly one routing notice'); - // The notice rode the injection batch to the live stream (window == wire). - const injectedBatches = membrane.lastStream!.receivedToolResultOptions - .map((o) => o?.injectedMessages ?? []); - const wired = injectedBatches.flat().map((m) => JSON.stringify(m.content)); - assert.ok( - wired.some((s) => s.includes('quick question')), - 'the addressed message itself was injected', - ); + // Round-1 prose predates the arrival and goes to the turn's route; the + // trailing prose is held, never guessed into either conversation. + assert.deepEqual(routed, [{ text: 'Station four, proceeding.', locus: 'chan-A' }]); + const [held] = heldDrafts(framework); + assert.equal(held?.text, 'Answering the person who addressed me.'); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', /chan-A; discord:dm:antra/); + // Turn-start announcement + the mid-turn hold notice. + assert.equal(notices.length, 2, 'the hold produced exactly one routing notice'); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('quick question')), 'the addressed message itself was injected'); assert.ok( - wired.some((s) => s.includes('[routing] The conversation moved to discord:dm:antra')), - 'the re-pin notice was injected alongside it', + wired.some((t) => t.includes('[routing] discord:dm:antra addressed you mid-turn') && t.includes('instead of going to chan-A')), + 'the hold notice was injected alongside it', ); await framework.stop(); }); - it('a follow-up in a channel the agent explicitly engaged this turn re-pins', async () => { - // 2026-07-31 n=7 (q's #portables reply): the agent explicitly sent into - // a channel this turn; someone replies there WITHOUT a mention (ambient - // by tag, addressed by context). The engaged-channel leg moves the pin; - // trailing prose answering them follows. + it('a follow-up in a channel the agent explicitly engaged this turn holds the route', async () => { + // 2026-07-31 n=7 (q's #portables reply): the agent explicitly sent into a + // channel this turn; someone replies there WITHOUT a mention. Their + // follow-up competes for the agent's unaddressed words, so it holds them. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -404,12 +437,13 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'I need to swap out this eye screen'; module.interjectionMetadata = { channelId: 'discord:guild:portables', tags: ['chat:ambient', 'chat:from-human'], + inboundSource: envelope('discord:guild:portables'), }; // Simulate an explicit send into #portables earlier in THIS turn (the // production record happens on the MCPL send path; the harness module's @@ -423,22 +457,18 @@ describe('present while acting', () => { return origHandle(call); }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - assert.deepEqual(routed, [ - { text: 'Good plan on both counts.', locus: 'discord:guild:portables' }, - ], 'trailing prose followed the human reply into the engaged channel'); + assert.deepEqual(routed, [], 'neither conversation is guessed'); + assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Good plan on both counts.', 'ambiguous']]); await framework.stop(); }); - it('an agent-resident follow-up in an engaged channel re-pins too (no author-kind filter)', async () => { + it('an agent-resident follow-up in an engaged channel holds it too (no author-kind filter)', async () => { // Fleet participants include agent-residents — "bot" by Discord flag, - // full conversational participants in fact. Their in-flow replies in a - // channel the agent just engaged move the pin exactly like anyone - // else's (antra, 2026-07-31: the author-kind distinction is fragile and - // wrong for this fleet). + // full conversational participants in fact (antra, 2026-07-31). membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -447,12 +477,13 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = '*from the table* one receipt for the case, since I have a dated instance'; module.interjectionMetadata = { channelId: 'discord:guild:hospital', tags: ['chat:ambient', 'chat:from-bot'], + inboundSource: envelope('discord:guild:hospital'), }; const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { @@ -462,19 +493,18 @@ describe('present while acting', () => { return origHandle(call); }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - assert.deepEqual(routed, [ - { text: 'Continuing with the colleague.', locus: 'discord:guild:hospital' }, - ], 'the agent-resident follow-up moved the pin like any participant'); + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework).map((d) => d.reason), ['ambiguous']); await framework.stop(); }); - it('ambient chatter in a channel the agent did NOT engage still cannot move the pin', async () => { - // The Cairn-protection boundary, restated for the engaged-channel era: - // no mention, no engagement this turn → the pin holds. + it('ambient chatter in a channel the agent did NOT engage leaves the route alone', async () => { + // The Cairn-protection boundary: no mention, no engagement this turn → + // the route holds steady and speech keeps going where it was going. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -483,28 +513,27 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'unrelated lounge chatter'; module.interjectionMetadata = { channelId: 'discord:guild:lounge', tags: ['chat:ambient', 'chat:from-human'], + inboundSource: envelope('discord:guild:lounge'), }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - assert.deepEqual(routed, [ - { text: 'Continuing my report.', locus: 'chan-live-1' }, - ], 'un-engaged ambient left the pin alone'); + assert.deepEqual(routed, [{ text: 'Continuing my report.', locus: 'chan-A' }], 'un-engaged ambient changed nothing'); + assert.deepEqual(heldDrafts(framework), []); await framework.stop(); }); - it('an addressed injection from the CURRENT locus does not chatter a notice', async () => { - // Same-channel addressed input is the ordinary case (the person the - // agent is already talking to sends another message mid-turn): the pin - // is already right, so no re-pin and — critically for KV — no notice. + it('an addressed arrival in the SAME conversation keeps the route and adds no notice', async () => { + // The person the agent is already talking to sends another message + // mid-turn: nothing competes, so no hold and — for KV — no notice. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -513,10 +542,10 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'and one more thing'; - module.interjectionMetadata = { channelId: 'chan-live-1', tags: ['chat:addressed'] }; + module.interjectionMetadata = { channelId: 'chan-A', tags: ['chat:addressed'], inboundSource: envelope('chan-A') }; const notices: string[] = []; framework.onTrace((event) => { @@ -524,11 +553,75 @@ describe('present while acting', () => { if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); }); + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Continuing right here.', locus: 'chan-A' }]); + assert.equal(notices.length, 1, 'only the turn-start announcement'); + // Someone else speaking in the same conversation doesn't move the reply + // edge: the route still answers the message that woke the turn. + const view = (framework as unknown as { speechRouteView(agent: string): Record }).speechRouteView('assistant'); + assert.deepEqual(view, { kind: 'channel', serverId: 'srv', channelId: 'chan-A', replyTo: 'm-trigger' }); + + await framework.stop(); + }); + + it('hybrid: a >>> target set this turn survives a hold — deliberate pins are never held', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Opening note.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'And a follow-up for alpha.' }, + ] as ContentBlock[])); + + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'hybrid.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', proseRouting: 'hybrid' }], + modules: [module], + }); + module.framework = framework; + const routed = stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Opening note.', locus: 'chan-alpha' }, + { text: 'And a follow-up for alpha.', locus: 'chan-alpha' }, + ], 'the sticky >>> target keeps carrying unprefixed prose through the hold'); + assert.deepEqual(heldDrafts(framework), []); + + await framework.stop(); + }); + + it('a deliberate route (a fork home) is never held by an arrival', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still answering at home.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); // home route chan-live-1 + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'Continuing right here.', locus: 'chan-live-1' }]); - assert.equal(notices.length, 1, 'only the boot-baseline announcement — no re-pin chatter'); + assert.deepEqual(routed, [{ text: 'Still answering at home.', locus: 'chan-live-1' }]); + assert.deepEqual(heldDrafts(framework), []); await framework.stop(); }); @@ -563,7 +656,7 @@ describe('present while acting', () => { await framework.stop(); }); - it('a re-pinned turn\'s receipt names both channels in delivery order', async () => { + it('a held turn\'s receipt names where its speech landed and the draft it held', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Station four, proceeding.' }, { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, @@ -573,12 +666,14 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - stubChannelRegistry(framework); + stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'quick question over here?'; - module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); const cm = (framework as unknown as { @@ -589,7 +684,9 @@ describe('present while acting', () => { .filter((b) => b.type === 'text') .map((b) => b.text ?? '') .filter((t) => t.startsWith('[delivered]')); - assert.deepEqual(receipts, ['[delivered] plain speech → chan-live-1 · discord:dm:antra']); + assert.equal(receipts.length, 1); + assert.match(receipts[0]!, /^\[delivered\] plain speech → chan-A/); + assert.match(receipts[0]!, /held as draft d-[a-z2-9]{5}/); await framework.stop(); }); @@ -704,13 +801,11 @@ describe('present while acting', () => { await framework.stop(); }); - it('channel_open moves the pin mid-turn and announces in its own tool result', async () => { + it('channel_open sets the speech route mid-turn and announces it in its own tool result', async () => { // The agent's own deliberate open is the strongest "my next words go - // here" signal — stronger than any injection. The original 2026-07-21 - // Aria fix only set next-turn trigger state; the turn-frozen refactor - // silently regressed the reply-right-after-opening case. Now the pin - // moves immediately and the announcement rides the tool result itself - // (model-requested content: distance zero, safest role). + // here" signal: by default (setSpeechTarget) its unaddressed speech goes + // to the opened channel for the rest of the turn — replacing an inferred + // route, or a hold — and the announcement rides the tool result itself. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, ] as ContentBlock[], 'tool_use')); @@ -719,27 +814,78 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); assert.deepEqual(routed, [ { text: 'Hello, observatory.', locus: 'discord:guild:observatory' }, ], 'prose right after channel_open lands in the opened channel'); - // The announcement rode the tool result the model saw. const results = membrane.lastStream!.receivedToolResults.flat() as Array<{ content?: unknown }>; assert.ok( - JSON.stringify(results).includes('plain speech now lands in this channel'), + JSON.stringify(results).includes('Your unaddressed plain speech now goes to discord:guild:observatory for the rest of this turn'), 'routing note present in the channel_open tool result', ); await framework.stop(); }); + it('channel_open with setSpeechTarget: false opens for reading and leaves the route where it was', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory', setSpeechTarget: false } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still talking to chan-A.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = + async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Still talking to chan-A.', locus: 'chan-A' }]); + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assert.ok(results.includes('Opened for reading. Your plain speech still goes to chan-A.')); + + await framework.stop(); + }); + + it('channel_open with setSpeechTarget: false and no route says speech is held as drafts', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory', setSpeechTarget: false } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Thinking out loud.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = + async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); + + // A heartbeat-style wake: no conversation, so no route. + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'heartbeat', source: 'timer', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Thinking out loud.', 'no-destination']]); + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assert.ok(results.includes('You have no speech route, so unaddressed plain speech is held as drafts.')); + + await framework.stop(); + }); + it('reactions and system markers injected mid-turn do not clear send suppression', async () => { // A reaction (`chat:reaction` tag) or a `system: true` marker is not // conversational input: prose following an explicit send stays suppressed, @@ -790,11 +936,12 @@ describe('present while acting', () => { await framework.stop(); }); - it('announces the outbound locus in the window only when it changes', async () => { - // Turn 1 (locus chan-live-1, e2e): one durable [routing] notice — the - // boot baseline. Then the announce-on-change logic directly (MockMembrane - // supports one turn per test): same locus → silence (steady state must - // not chatter — KV); new locus → exactly one more notice. + it('announces the speech route in the window only when it changes', async () => { + // Turn 1 (home route chan-live-1, e2e): one durable [routing] notice — + // the boot baseline. Then the announce-on-change logic directly + // (MockMembrane supports one turn per test): same route → silence + // (steady state must not chatter — KV); a new route, a hold, or no + // route → exactly one notice each. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }] as ContentBlock[])); const framework = await createFramework(); @@ -810,25 +957,29 @@ describe('present while acting', () => { trigger(framework); await framework.runUntilIdle(); - assert.equal(notices.length, 1, 'first turn announced the boot-baseline locus'); + assert.equal(notices.length, 1, 'first turn announced the boot-baseline route'); + type Route = { route: unknown; hold?: unknown }; const announce = ( - framework as unknown as { - announceLocusIfChanged(agentName: string, locus: string | null): void; - } - ).announceLocusIfChanged.bind(framework); - - announce('assistant', 'chan-live-1'); - assert.equal(notices.length, 1, 'unchanged locus announced nothing'); - - announce('assistant', 'chan-B'); - assert.equal(notices.length, 2, 'changed locus announced exactly once'); - - announce('assistant', 'chan-B'); - assert.equal(notices.length, 2, 'steady state on the new locus stays silent'); - - announce('assistant', null); - assert.equal(notices.length, 3, 'losing the locus is announced too'); + framework as unknown as { announceRouteIfChanged(agentName: string, turn: Route): void } + ).announceRouteIfChanged.bind(framework); + const channel = (channelId: string, origin = 'trigger'): Route => + ({ route: { kind: 'channel', serverId: '', channelId, origin } }); + + announce('assistant', channel('chan-live-1', 'home')); + assert.equal(notices.length, 1, 'unchanged route announced nothing'); + announce('assistant', channel('chan-B')); + assert.equal(notices.length, 2, 'changed route announced exactly once'); + announce('assistant', channel('chan-B')); + assert.equal(notices.length, 2, 'steady state on the new route stays silent'); + const held: Route = { + route: null, + hold: { since: 'turn-start', conversations: [{ kind: 'channel', serverId: '', channelId: 'chan-B' }, { kind: 'surface', surface: 'tui' }] }, + }; + announce('assistant', held); + assert.equal(notices.length, 3, 'a held turn is announced'); + announce('assistant', { route: null }); + assert.equal(notices.length, 4, 'losing the route is announced too'); await framework.stop(); }); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts new file mode 100644 index 000000000..d170a2073 --- /dev/null +++ b/test/speech-route-turns.test.ts @@ -0,0 +1,126 @@ +/** + * The turn's speech route across a logical turn (shelf-355): decided once at + * a true new turn, kept by continuations and retries (including a route the + * resident chose with channel_open), and decided afresh — never inherited — + * by the next true new turn. A local surface route shows speech there and + * publishes nothing. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import type { InferenceRequest, ProcessEvent, Module, ModuleContext, ProcessState, EventResponse, ToolDefinition, ToolCall, ToolResult } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +type Route = { route: { kind: string; channelId?: string; surface?: string; origin?: string } | null; hold?: unknown }; + +function internals(framework: AgentFramework) { + return framework as unknown as { + turnRoutes: Map; + startAgentStream(agent: unknown, trigger?: InferenceRequest, attempt?: number): Promise; + channelRegistry: unknown; + }; +} + +class SurfaceModule implements Module { + readonly name = 'console'; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return []; } + async handleToolCall(_call: ToolCall): Promise { return { success: true }; } + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (event.type !== 'external-message') return {}; + return { + addMessages: [{ participant: 'Operator', content: [{ type: 'text', text: String((event as { content?: unknown }).content) }] }], + requestInference: true, + }; + } +} + +describe('speech route across a logical turn', () => { + let tempDir: string; + let membrane: MockMembrane; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'speech-route-turns-')); + membrane = new MockMembrane(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const makeFramework = (modules: Module[] = []) => AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules, + }); + + const fromChannel = (channelId: string): InferenceRequest => ({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId }, addressed: true, messageId: 'm1', at: Date.now() }], + }); + + it('continuations and retries keep the route — including one chosen with channel_open — and a new turn decides afresh', async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }])); + await i.startAgentStream(scout, fromChannel('chan-A')); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'chan-A'); + + // The resident opened another channel during this logical turn. + const opened: Route = { route: { kind: 'channel', channelId: 'chan-open', origin: 'open' } }; + i.turnRoutes.set('scout', opened); + + // A context-budget restart, a guard retry and a framework retry continue + // the same logical turn: none of them re-decides the route, even with + // candidates from elsewhere in hand. + for (const [trigger, attempt] of [ + [{ ...fromChannel('chan-B'), reason: 'context_budget_restart' }, 0], + [{ ...fromChannel('chan-B'), reason: 'tool_result_guard_retry' }, 0], + [fromChannel('chan-B'), 1], + ] as Array<[InferenceRequest, number]>) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'continuing' }])); + await i.startAgentStream(scout, trigger, attempt); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout'), opened, `${trigger.reason} (attempt ${attempt}) kept the route`); + } + + // A true new turn decides from its own wake: none here, so no route. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await i.startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + await framework.stop(); + }); + + it('a local surface wake routes speech to that surface: shown there, published nowhere, receipted as such', async () => { + const framework = await makeFramework([new SurfaceModule()]); + const i = internals(framework); + const published: string[] = []; + i.channelRegistry = new Proxy({ + resolveLocus: () => null, + routeSpeech: async (_a: string, text: string) => { published.push(text); return { delivered: true, channelId: 'x' }; }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello, operator.' }])); + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'hi scout', metadata: {} } as unknown as ProcessEvent); + await framework.runUntilIdle(); + + assert.deepEqual(i.turnRoutes.get('scout')?.route, { kind: 'surface', surface: 'tui', origin: 'trigger' }); + assert.deepEqual(published, [], 'surface speech is not published to any channel'); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + assert.ok(texts.includes('[delivered] plain speech → tui (the local surface that messaged you; not published to any channel)')); + assert.ok(texts.some((t) => t.startsWith('[routing] Your plain speech now goes to tui (the local surface that messaged you)'))); + await framework.stop(); + }); +}); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts new file mode 100644 index 000000000..490d13543 --- /dev/null +++ b/test/speech-routes.test.ts @@ -0,0 +1,150 @@ +/** + * Speech-route inference (src/speech-routes.ts, shelf-355): a turn's route + * from its wake's candidates, and which mid-turn arrivals hold it. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + conversationKey, + describeConversation, + inferTurnRoute, + suspendsRoute, + type ConversationRef, + type RouteCandidate, + type SpeechRoute, + type TurnRoute, +} from '../src/speech-routes.js'; + +const channel = (channelId: string, extra: Partial> = {}): ConversationRef => + ({ kind: 'channel', serverId: 'discord', channelId, ...extra }); +const candidate = ( + conversation: ConversationRef, + addressed: boolean, + at: number, + messageId?: string, +): RouteCandidate => ({ conversation, addressed, at, ...(messageId ? { messageId } : {}) }); + +describe('inferTurnRoute', () => { + it('a fork home always wins', () => { + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + const turn = inferTurnRoute([candidate(channel('elsewhere'), true, 5, 'm1')], home); + assert.deepEqual(turn, { route: home }); + }); + + it('no candidates: no route', () => { + assert.deepEqual(inferTurnRoute([]), { route: null }); + }); + + it('an addressed candidate outranks newer ambient chatter elsewhere', () => { + const turn = inferTurnRoute([ + candidate(channel('dm'), true, 1, 'm-dm'), + candidate(channel('general'), false, 2, 'm-gen'), + ]); + assert.deepEqual(turn.route, { kind: 'channel', serverId: 'discord', channelId: 'dm', replyTo: 'm-dm', origin: 'trigger' }); + assert.equal(turn.hold, undefined); + }); + + it('two addressed conversations hold the turn, naming both, newest first', () => { + const turn = inferTurnRoute([ + candidate(channel('room'), true, 1, 'm1'), + candidate(channel('dm'), true, 2, 'm2'), + candidate(channel('general'), false, 3, 'm3'), + ]); + assert.equal(turn.route, null); + assert.deepEqual(turn.hold, { since: 'turn-start', conversations: [channel('dm'), channel('room')] }); + }); + + it('ambient-only: one conversation routes there; two hold', () => { + const one = inferTurnRoute([candidate(channel('room'), false, 1, 'm1'), candidate(channel('room'), false, 2, 'm2')]); + assert.deepEqual(one.route, { kind: 'channel', serverId: 'discord', channelId: 'room', replyTo: 'm2', origin: 'trigger' }); + const two = inferTurnRoute([candidate(channel('room'), false, 1), candidate(channel('general'), false, 2)]); + assert.equal(two.route, null); + assert.equal(two.hold?.conversations.length, 2); + }); + + it('several candidates in one conversation answer the newest of the considered ones', () => { + const turn = inferTurnRoute([ + candidate(channel('room'), true, 1, 'mention'), + candidate(channel('room'), false, 5, 'later-ambient'), + candidate(channel('room'), true, 3, 'second-mention'), + ]); + assert.equal(turn.route?.kind === 'channel' ? turn.route.replyTo : undefined, 'second-mention', + 'the addressed pool decides, so its newest message is the reply edge'); + }); + + it('the same channel id on two servers, or two threads of one channel, are different conversations', () => { + const servers = inferTurnRoute([ + candidate({ kind: 'channel', serverId: 'a', channelId: 'shared' }, true, 1), + candidate({ kind: 'channel', serverId: 'b', channelId: 'shared' }, true, 2), + ]); + assert.equal(servers.hold?.conversations.length, 2); + const threads = inferTurnRoute([ + candidate(channel('forum', { threadId: 't1' }), true, 1), + candidate(channel('forum', { threadId: 't2' }), true, 2), + ]); + assert.equal(threads.hold?.conversations.length, 2); + const one = inferTurnRoute([candidate(channel('forum', { threadId: 't1', label: 'forum' }), true, 1, 'm1')]); + assert.deepEqual(one.route, { + kind: 'channel', serverId: 'discord', channelId: 'forum', threadId: 't1', replyTo: 'm1', label: 'forum', origin: 'trigger', + }); + }); + + it('a conversation whose channel could not be resolved competes, but is never the route', () => { + const raw = { conversation: channel('1548'), addressed: true, at: 2, unroutable: true as const }; + const competing = inferTurnRoute([candidate(channel('room'), true, 1, 'm1'), raw]); + assert.equal(competing.route, null); + assert.equal(competing.hold?.conversations.length, 2, 'never the older conversation by default'); + assert.deepEqual(inferTurnRoute([raw]), { route: null }); + }); + + it('a local surface is addressed by nature, and routes to the surface', () => { + const turn = inferTurnRoute([ + candidate({ kind: 'surface', surface: 'tui' }, true, 1), + candidate(channel('general'), false, 2), + ]); + assert.deepEqual(turn.route, { kind: 'surface', surface: 'tui', origin: 'trigger' }); + }); +}); + +describe('suspendsRoute', () => { + const routeA: TurnRoute = { route: { kind: 'channel', serverId: 'discord', channelId: 'A', replyTo: 'm1', origin: 'trigger' } }; + const notEngaged = () => false; + + it('an addressed arrival from another conversation suspends an inferred route', () => { + assert.equal(suspendsRoute(routeA, { conversation: channel('dm'), addressed: true }, notEngaged), true); + }); + + it('the same conversation never does, addressed or not', () => { + assert.equal(suspendsRoute(routeA, { conversation: channel('A'), addressed: true }, notEngaged), false); + }); + + it('ambient chatter does only in a conversation the resident engaged this turn', () => { + const arrival = { conversation: channel('lounge'), addressed: false }; + assert.equal(suspendsRoute(routeA, arrival, notEngaged), false); + assert.equal(suspendsRoute(routeA, arrival, (c) => c.kind === 'channel' && c.channelId === 'lounge'), true); + }); + + it('deliberate routes, held turns and route-less turns are never suspended', () => { + const arrival = { conversation: channel('dm'), addressed: true }; + for (const origin of ['home', 'open'] as const) { + assert.equal(suspendsRoute({ route: { kind: 'channel', serverId: 'discord', channelId: 'A', origin } }, arrival, notEngaged), false); + } + assert.equal(suspendsRoute({ ...routeA, hold: { since: 'turn-start', conversations: [] } }, arrival, notEngaged), false); + assert.equal(suspendsRoute({ route: null }, arrival, notEngaged), false); + }); +}); + +describe('conversation identity and wording', () => { + it('keys include server, channel and thread; surfaces are their own', () => { + assert.notEqual(conversationKey(channel('x')), conversationKey({ kind: 'channel', serverId: 'other', channelId: 'x' })); + assert.notEqual(conversationKey(channel('x')), conversationKey(channel('x', { threadId: 't' }))); + assert.notEqual(conversationKey({ kind: 'surface', surface: 'x' }), conversationKey(channel('x'))); + }); + + it('describes a conversation by a usable address', () => { + assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), '#room (discord:g1:room)'); + assert.equal(describeConversation(channel('discord:dm:7')), 'discord:dm:7'); + assert.equal(describeConversation(channel('forum', { threadId: 't1' })), 'forum (thread t1)'); + assert.equal(describeConversation({ kind: 'surface', surface: 'tui' }), 'tui (the local surface that messaged you)'); + }); +}); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index c9f79c75f..4254abad8 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -25,10 +25,10 @@ import { AgentFramework } from '../src/index.js'; import type { ProcessEvent } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -/** Reach the private per-turn triggering-channel map + the pure channel-deriver. */ +/** Reach the private per-turn speech routes + the pure channel-deriver. */ function internals(framework: AgentFramework) { return framework as unknown as { - activeTriggerChannels: Map; + turnRoutes: Map; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number; channelId?: string }>; derivePushEventChannel( origin: Record | undefined, @@ -310,23 +310,23 @@ describe('Trunk channel routing (item-3 redux)', () => { await fw3.stop(); }); - it('a channel-incoming trunk turn records its triggering channel', async () => { + it('a channel-incoming trunk turn takes its triggering conversation as its speech route', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); - framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: sleep && date')); + const event = channelIncoming('discord:guild:chanA', 'A: sleep && date'); + framework.pushEvent(event); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1, 'the trunk should have run one turn'); - assert.equal( - internals(framework).activeTriggerChannels.get('scout'), - 'discord:guild:chanA', - 'the turn must be routed to the channel that triggered it', - ); + const route = internals(framework).turnRoutes.get('scout')?.route; + assert.equal(route?.channelId, 'discord:guild:chanA', 'the turn must be routed to the channel that triggered it'); + assert.equal(route?.replyTo, (event as unknown as { messageId: string }).messageId, 'its message is the reply edge'); + assert.equal(route?.origin, 'trigger'); await framework.stop(); }); - it('a DM push-event turn records the reconstructed DM channel (item-3 redux DM sub-case)', async () => { + it('a DM push-event turn takes the reconstructed DM channel as its route (item-3 redux DM sub-case)', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hi in the DM' }])); const framework = await makeFramework(); @@ -335,48 +335,42 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.equal(membrane.calls.length, 1, 'the trunk should wake for the DM'); assert.equal( - internals(framework).activeTriggerChannels.get('scout'), + internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:dm:42', - 'the DM reply must route to the DM channel, not the global locus', + 'the DM reply must route to the DM channel', ); await framework.stop(); }); - it('the triggering channel tracks the CURRENT turn, never a stale one', async () => { + it('the route belongs to the CURRENT turn, never a stale one', async () => { const framework = await makeFramework(); const i = internals(framework); - // A channel-A turn sets the active channel... (push the response right before - // each turn: MockMembrane's stream consumes ALL queued responses at once.) + // (Push the response right before each turn: MockMembrane's stream + // consumes ALL queued responses at once.) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ans A' }])); framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(i.activeTriggerChannels.get('scout'), 'discord:guild:chanA'); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); - // ...then a DM turn OVERWRITES it — the next turn's reply must never inherit - // the previous turn's channel (the concurrency hazard this fix removes). + // A DM turn decides its own route — never inherits the previous turn's. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ans DM' }])); framework.pushEvent(dmPushEvent('42', 'now in a DM')); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2, 'both turns should have run'); - assert.equal( - i.activeTriggerChannels.get('scout'), - 'discord:dm:42', - 'the map must reflect the CURRENT turn’s channel, not chanA', - ); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:dm:42', 'the CURRENT turn’s channel, not chanA'); await framework.stop(); }); - it('startAgentStream clears the triggering channel for a no-channel (heartbeat) turn', async () => { + it('a no-trigger (heartbeat) turn has no speech route, whatever the previous turn had', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); const i = internals(framework); - // Simulate a stale channel left by a prior turn, then run a heartbeat/timer - // turn (an InferenceRequest with no channelId). startAgentStream must clear - // the entry up front so routeSpeech falls back to the global locus rather - // than replaying the stale channel. - i.activeTriggerChannels.set('scout', 'discord:guild:stale'); + // A stale route left by a prior turn must not carry over: a turn with no + // route candidates (a heartbeat/timer) has no destination, so its plain + // speech is held as a draft rather than sent anywhere. + i.turnRoutes.set('scout', { route: { kind: 'channel', channelId: 'discord:guild:stale', origin: 'trigger' } }); const scout = framework.getAgent('scout')!; await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise; @@ -385,11 +379,7 @@ describe('Trunk channel routing (item-3 redux)', () => { }); await framework.runUntilIdle(); - assert.equal( - i.activeTriggerChannels.has('scout'), - false, - 'a no-trigger turn must clear any stale triggering channel', - ); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'a no-trigger turn has no route'); await framework.stop(); }); }); From e354622b6d4e9f321c10f22eeabdbdadd7bcabdf Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 13:10:47 -0400 Subject: [PATCH 19/65] fix(routing): threads are never routes; unknown servers stay absent; honest hold notice; one conversational predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agnes-984's first review pass on 8a1d579 (room-203 #46576): 1. Thread conversations were routes delivery couldn't honor: identity included threadId and the notice promised the thread, but publishing carries no thread, so plain speech landed in the channel root. A thread conversation now competes (it can hold a turn) but is never itself a route: inferTurnRoute returns { route: null, unroutable: { conversation, reason: 'thread' } }; the routing notice says plain speech can't be posted into a thread and to answer with a send tool that names it; the held no-destination draft's note says the same. The gate's unresolvable channel case now records `unroutable` with reason 'unresolved' and its own notice. SpeechRoute never carries a thread. 2. The `serverId: ''` placeholder diverged between paths: plain speech omitted it and delivered, channel_publish passed it and was refused. ConversationRef.serverId and SpeechRoute.serverId are now optional and absent when unknown (homeRoute, candidateForChannel, channel_open's route, gate unroutable candidates, the registry view); channel_publish's default passes a server only when the route has one, resolving the id exactly as routeSpeech does. 3. The mid-turn hold notice always said "addressed you"; it now names the actual cause — "X addressed you mid-turn" or "X, where you sent a message this turn, continued mid-turn". 4. Turn-start candidates now go through the same conversational predicate as mid-turn arrivals: isConversational(tags, metadata) in speech-routes.ts (reactions and system markers are machinery), used by isConversationalInjection, every request-site candidate (incoming, router, push, coalesced) and the gate (PendingEvent.conversational), so a reaction that wakes a turn is never a route candidate. Tests: speech-routes (thread unroutable but competing; unknown server routes without a placeholder; unresolved records its reason; isConversational); channel-registry-routing (a route without a server: channel_publish and routeSpeech deliver alike); present-while-acting (engaged-hold notice names the continuation, never "addressed"); event-gate (a reaction in the batch is no candidate); trunk-channel-routing (a reaction wake has no route; a thread wake holds speech with the thread notice and note, nothing to the channel root). Focused: 141/141 + trunk 13/13. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 70 +++++++++++++++++++-------- src/gate/event-gate.ts | 8 +++ src/mcpl/channel-registry.ts | 6 ++- src/speech-routes.ts | 51 +++++++++++++++---- test/channel-registry-routing.test.ts | 14 ++++++ test/event-gate.test.ts | 17 +++++++ test/mcpl-coalescing.test.ts | 17 ++++++- test/present-while-acting.test.ts | 5 ++ test/speech-routes.test.ts | 31 ++++++++++-- test/trunk-channel-routing.test.ts | 44 +++++++++++++++++ 10 files changed, 225 insertions(+), 38 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index a7aaedae6..11c4ccf33 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -87,6 +87,7 @@ import { conversationKey, describeConversation, inferTurnRoute, + isConversational, routeConversation, suspendsRoute, type ConversationRef, @@ -229,9 +230,7 @@ const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send' const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; const m = metadata as Record; - if (m.system === true) return false; - if (Array.isArray(m.tags) && m.tags.includes('chat:reaction')) return false; - return true; + return isConversational(Array.isArray(m.tags) ? (m.tags as string[]) : undefined, m); }; /** @@ -1757,7 +1756,7 @@ export class AgentFramework { ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } : c.unroutable ? { - conversation: { kind: 'channel', serverId: c.serverId ?? '', channelId: c.channelId }, + conversation: { kind: 'channel', ...(c.serverId ? { serverId: c.serverId } : {}), channelId: c.channelId }, addressed: c.addressed, at: c.at, unroutable: true, @@ -6914,15 +6913,21 @@ export class AgentFramework { if (turn?.route && competing) { const m = competing.metadata as Record | undefined; const arrival = this.candidateFromSource(readInboundSource(m), true)!.conversation; + const addressedArrival = isAddressedMessage(m?.tags as string[] | undefined, m); const current = routeConversation(turn.route); const held: TurnRoute = { route: turn.route, hold: { conversations: [current, arrival], since: 'mid-turn' } }; this.turnRoutes.set(agent.name, held); this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); + // Name the actual cause: someone addressed the resident, or a + // conversation it sent into this turn continued. + const cause = addressedArrival + ? `${describeConversation(arrival)} addressed you mid-turn` + : `${describeConversation(arrival)}, where you sent a message this turn, continued mid-turn`; const noticeContent: ContentBlock[] = [{ type: 'text', text: - `[routing] ${describeConversation(arrival)} addressed you mid-turn, so your unaddressed plain ` + - `speech could now answer either conversation. For the rest of this turn it is held as drafts ` + + `[routing] ${cause}, so your unaddressed plain speech could now answer either conversation. ` + + 'For the rest of this turn it is held as drafts ' + `instead of going to ${describeConversation(current)}. Answer either one with an explicit send ` + '(or a channel_open); held words can be resent from your drafts.', }]; @@ -7254,7 +7259,9 @@ export class AgentFramework { this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference) { const addressedHere = isAddressedMessage(event.tags, event.metadata); - const candidate = this.candidateFromSource(source, addressedHere); + const candidate = isConversational(event.tags, event.metadata) + ? this.candidateFromSource(source, addressedHere) + : undefined; this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, @@ -7329,7 +7336,9 @@ export class AgentFramework { // tune-out's wake routing is the first setter.) const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName); - const candidate = this.candidateFromSource(source, addressed); + // A reaction or system marker can wake a turn, but answers nothing: + // it is never a route candidate (the same predicate as mid-turn). + const candidate = isConversational(event.tags, event.metadata) ? this.candidateFromSource(source, addressed) : undefined; for (const agentName of targetAgents) { if (!this.agents.has(agentName)) continue; this.pendingRequests.push({ @@ -7439,7 +7448,9 @@ export class AgentFramework { if (trigger) { const addressedHere = isAddressedMessage(event.tags, event.metadata); // A fork's home route always wins; the candidate is carried for parity. - const candidate = this.candidateFromSource(readInboundSource(messageMetadata), addressedHere); + const candidate = isConversational(event.tags, event.metadata) + ? this.candidateFromSource(readInboundSource(messageMetadata), addressedHere) + : undefined; this.pendingRequests.push({ agentName: agent.name, reason: 'mcpl:channel-incoming', @@ -8259,7 +8270,9 @@ export class AgentFramework { 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); const addressedHere = isAddressedMessage(occ.tags, origin); - const candidate = this.candidateFromSource(occ.event.event.inboundSource, addressedHere); + const candidate = isConversational(occ.tags, origin as Record | undefined) + ? this.candidateFromSource(occ.event.event.inboundSource, addressedHere) + : undefined; for (const agentName of await this.coalescedAudience(occ)) { this.pendingRequests.push({ agentName, @@ -8478,7 +8491,9 @@ export class AgentFramework { // A push that names a conversation (a DM, an addressed-while-closed // message) is a route candidate like its channels/incoming // counterpart; an unscoped push names none. - const pushCandidate = silentHeartbeat ? undefined : this.candidateFromSource(source, pushAddressed); + const pushCandidate = silentHeartbeat || !isConversational(event.tags, event.origin as Record | undefined) + ? undefined + : this.candidateFromSource(source, pushAddressed); for (const agentName of targetAgents) { this.pendingRequests.push({ agentName, @@ -9601,10 +9616,10 @@ export class AgentFramework { serverId?: string, messageId?: string, ): RouteCandidate { - const server = serverId || this.channelRegistry?.getChannelServerId(channelId) || ''; + const server = serverId || this.channelRegistry?.getChannelServerId(channelId) || undefined; const label = this.channelRegistry?.getDescriptor(channelId)?.label; return { - conversation: { kind: 'channel', serverId: server, channelId, ...(label ? { label } : {}) }, + conversation: { kind: 'channel', ...(server ? { serverId: server } : {}), channelId, ...(label ? { label } : {}) }, addressed, ...(messageId ? { messageId } : {}), at, @@ -9616,9 +9631,10 @@ export class AgentFramework { const home = this.conversationAgentHomes.get(agentName) ?? this.channelRegistry?.resolveLocus(agentName) ?? null; if (!home) return null; const label = this.channelRegistry?.getDescriptor(home)?.label; + const serverId = this.channelRegistry?.getChannelServerId(home) ?? undefined; return { kind: 'channel', - serverId: this.channelRegistry?.getChannelServerId(home) ?? '', + ...(serverId ? { serverId } : {}), channelId: home, ...(label ? { label } : {}), origin: 'home', @@ -9642,9 +9658,8 @@ export class AgentFramework { if (route.kind === 'surface') return { kind: 'surface', surface: route.surface }; return { kind: 'channel', - serverId: route.serverId, + ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, - ...(route.threadId ? { threadId: route.threadId } : {}), ...(route.replyTo ? { replyTo: route.replyTo } : {}), }; } @@ -9671,7 +9686,13 @@ export class AgentFramework { } const route = turn?.route; if (!route) { - this.holdProseDrafts(agent, [text], 'no-destination', hold); + const unroutable = turn?.unroutable; + this.holdProseDrafts(agent, [text], 'no-destination', { + ...hold, + ...(unroutable?.reason === 'thread' + ? { note: `the conversation that woke you is a thread (${describeConversation(unroutable.conversation)}); answer it with a send tool that names the thread` } + : {}), + }); return; } if (route.kind === 'surface') { @@ -9691,13 +9712,14 @@ export class AgentFramework { /** A turn route in words, for logs. */ private static routeText(turn: TurnRoute): string { if (turn.hold) return `held between ${turn.hold.conversations.map(describeConversation).join(' and ')}`; - return turn.route ? describeConversation(routeConversation(turn.route)) : 'none'; + if (turn.route) return describeConversation(routeConversation(turn.route)); + return turn.unroutable ? `none (${turn.unroutable.reason}: ${describeConversation(turn.unroutable.conversation)})` : 'none'; } /** Identity of a turn route for announce-on-change. */ private static routeKey(turn: TurnRoute): string { if (turn.hold) return `held:${turn.hold.conversations.map(conversationKey).sort().join('|')}`; - if (!turn.route) return 'none'; + if (!turn.route) return turn.unroutable ? `unroutable:${conversationKey(turn.unroutable.conversation)}` : 'none'; return conversationKey(routeConversation(turn.route)); } @@ -9728,6 +9750,13 @@ export class AgentFramework { `[routing] More than one conversation is waiting for you: ${turn.hold.conversations.map(describeConversation).join('; ')}. ` + 'Your unaddressed plain speech is held as drafts this turn rather than guessed: answer one with an ' + 'explicit send (or channel_open it), and resend held words with the drafts tool.'; + } else if (!turn.route && turn.unroutable) { + const where = describeConversation(turn.unroutable.conversation); + text = turn.unroutable.reason === 'thread' + ? `[routing] The conversation that woke you is a thread (${where}), and plain speech can't be posted ` + + 'into a thread: it is held as drafts. Answer it with a send tool that names the thread.' + : `[routing] The conversation that woke you (${where}) couldn't be resolved to a registered channel, ` + + 'so your plain speech is held as drafts. Use an explicit send tool to reach it.'; } else if (!turn.route) { text = '[routing] Your plain speech currently has no destination — it is held as drafts you can resend. ' + @@ -16181,9 +16210,10 @@ export class AgentFramework { ...(openInput?.serverId ? { serverId: openInput.serverId } : {}), }); const destination = resolved && 'destination' in resolved ? resolved.destination : undefined; + const openedServer = destination?.serverId ?? openInput?.serverId; const route: SpeechRoute = { kind: 'channel', - serverId: destination?.serverId ?? openInput?.serverId ?? '', + ...(openedServer ? { serverId: openedServer } : {}), channelId: opened, ...(destination?.label ? { label: destination.label } : {}), origin: 'open', diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 73bb9d85e..9a5577601 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -12,6 +12,7 @@ */ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { isConversational } from '../speech-routes.js'; import { dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; @@ -96,6 +97,9 @@ interface PendingEvent { /** The triggering message's id, when the metadata carries one: the reply * edge of a speech route inferred from this event. */ messageId?: string; + /** Conversational input rather than machinery (a reaction, a system + * marker): only conversational events are route candidates. */ + conversational: boolean; } /** One conversation a batched gate wake carries, for the framework's @@ -248,6 +252,9 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin } const routeCandidates: GateRouteCandidate[] = []; for (const e of events) { + // A reaction or system marker answers nothing: never a route candidate + // (the framework's mid-turn rule, applied at turn start too). + if (!e.conversational) continue; if (e.routeChannelId) { routeCandidates.push({ kind: 'channel', @@ -1561,6 +1568,7 @@ export class EventGate { ...(typeof info.metadata?.messageId === 'string' && info.metadata.messageId ? { messageId: info.metadata.messageId as string } : {}), + conversational: isConversational(info.tags, info.metadata), }; const existing = this.debounceTimers.get(policy.name); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 3a93002c7..59dc22214 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -472,7 +472,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ /** An agent's current speech route as the registry needs it. */ export type SpeechRouteView = - | { kind: 'channel'; serverId: string; channelId: string; threadId?: string; replyTo?: string } + | { kind: 'channel'; serverId?: string; channelId: string; threadId?: string; replyTo?: string } | { kind: 'surface'; surface: string } /** Competing conversations hold unaddressed speech (usable addresses). */ | { kind: 'held'; conversations: string[] } @@ -3305,7 +3305,9 @@ export class ChannelRegistry { isError: true, }; } - target = { serverId: route.serverId, channelId: route.channelId }; + // The route's server when it has one; otherwise the id alone, resolved + // exactly as plain speech on the same route resolves it. + target = { channelId: route.channelId, ...(route.serverId ? { serverId: route.serverId } : {}) }; } const outcome = await this.publish( diff --git a/src/speech-routes.ts b/src/speech-routes.ts index e3ed2662a..31f6be4ba 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -24,7 +24,10 @@ export type ConversationRef = | { kind: 'channel'; - serverId: string; + /** The MCPL server, when known. Absent — never a placeholder — when the + * channel id couldn't be tied to one server; delivery then resolves + * the id alone, the same way on every path. */ + serverId?: string; channelId: string; threadId?: string; /** The channel's registered label when it was seen. */ @@ -61,8 +64,13 @@ export type RouteOrigin = export type SpeechRoute = | { kind: 'channel'; - serverId: string; + /** As in ConversationRef: absent when unknown. */ + serverId?: string; channelId: string; + /** Never set on a route: plain speech can't be posted into a thread + * (publishing carries no thread), so a thread conversation is never + * inferred as a route. Kept for the type's symmetry with + * ConversationRef. */ threadId?: string; /** The message being answered, kept across same-conversation arrivals. */ replyTo?: string; @@ -84,13 +92,17 @@ export interface TurnRoute { conversations: ConversationRef[]; since: 'turn-start' | 'mid-turn'; }; + /** No route because the one conversation the turn answers can't take + * plain speech: a thread (publishing carries no thread), or a channel the + * host couldn't resolve. Said in the routing notice and the drafts' note. */ + unroutable?: { conversation: ConversationRef; reason: 'thread' | 'unresolved' }; } /** Identity of a conversation: server, channel and thread, or the surface. */ export function conversationKey(c: ConversationRef): string { return c.kind === 'surface' ? `surface:${c.surface}` - : `channel:${c.serverId}\u0000${c.channelId}\u0000${c.threadId ?? ''}`; + : `channel:${c.serverId ?? ''}\u0000${c.channelId}\u0000${c.threadId ?? ''}`; } /** The conversation a route speaks into. */ @@ -99,7 +111,7 @@ export function routeConversation(route: SpeechRoute): ConversationRef { ? { kind: 'surface', surface: route.surface } : { kind: 'channel', - serverId: route.serverId, + ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, ...(route.threadId ? { threadId: route.threadId } : {}), ...(route.label ? { label: route.label } : {}), @@ -121,8 +133,9 @@ export function describeConversation(c: ConversationRef): string { * considered if there are any, else every (conversational) candidate; they * infer a route only when they name one conversation, from the newest * candidate in it (its message is the reply edge). Several conversations - * start the turn held, naming each; none, or one whose channel couldn't be - * resolved, leaves the turn without a route. + * start the turn held, naming each; none leaves the turn without a route, + * and so does one that can't take plain speech (a thread, or a channel that + * couldn't be resolved), which the turn records as `unroutable`. */ export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: SpeechRoute | null): TurnRoute { if (home) return { route: home }; @@ -142,17 +155,20 @@ export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: Spe return { route: null, hold: { conversations, since: 'turn-start' } }; } const [chosen] = newestByConversation.values(); - // A conversation whose channel isn't resolvable can't be spoken into. - if (chosen!.unroutable) return { route: null }; const c = chosen!.conversation; + // A conversation whose channel isn't resolvable can't be spoken into, and + // neither can a thread: publishing carries no thread, so plain speech + // would land in the channel root — a different conversation than the one + // being answered. Both still compete for the turn (above). + if (chosen!.unroutable) return { route: null, unroutable: { conversation: c, reason: 'unresolved' } }; + if (c.kind === 'channel' && c.threadId) return { route: null, unroutable: { conversation: c, reason: 'thread' } }; return c.kind === 'surface' ? { route: { kind: 'surface', surface: c.surface, origin: 'trigger' } } : { route: { kind: 'channel', - serverId: c.serverId, + ...(c.serverId ? { serverId: c.serverId } : {}), channelId: c.channelId, - ...(c.threadId ? { threadId: c.threadId } : {}), ...(chosen!.messageId ? { replyTo: chosen!.messageId } : {}), ...(c.label ? { label: c.label } : {}), origin: 'trigger', @@ -160,6 +176,21 @@ export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: Spe }; } +/** + * Whether an item is conversational input — something the resident might be + * answering — rather than machinery: a system marker (send-failure notices, + * routing notices) or a reaction or its removal (`chat:reaction`, + * `chat:reaction-remove`, MCPL RFC-001) is not. One + * predicate for every place that asks: turn-start route candidates, mid-turn + * holds, and clearing send suppression. + */ +export function isConversational(tags: readonly string[] | undefined, metadata?: Record): boolean { + if (metadata?.system === true) return false; + // Both reaction tags (MCPL RFC-001 / SPEC §16.2 keep them distinct). + if (tags?.includes('chat:reaction') || tags?.includes('chat:reaction-remove')) return false; + return true; +} + /** * Whether a mid-turn arrival suspends the turn's route: only an inferred * route (origin 'trigger') not already held, and only for a conversational diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 56f6f38a3..5731866ce 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -258,6 +258,20 @@ test('channel_publish without a channel goes to the caller\'s route, and its rec assert.equal(publishCalls.at(-1)?.channelId, 'chanA', 'the route, not the last inbound channel'); }); +test('a route whose server is unknown resolves the same way for channel_publish as for plain speech', async () => { + const { registry, publishCalls } = makeRegistry( + { delivered: true, messageId: 'p-2' } as { delivered?: boolean }, + () => undefined, + () => ({ kind: 'channel', channelId: 'chanA' }), + ); + seedRegistered(registry, 'discord', 'chanA'); + const published = await registry.handleChannelToolCall('channel_publish', { content: 'explicit' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(published.success, true, JSON.stringify(published)); + const spoken = await registry.routeSpeech('scout', 'plain speech', 'chanA'); + assert.deepEqual(spoken, { delivered: true, channelId: 'chanA', messageId: 'p-2' }); + assert.deepEqual(publishCalls.map((c) => c.channelId), ['chanA', 'chanA']); +}); + test('channel_publish without a channel or a route is refused, never guessed', async () => { const views: Record = { held: { kind: 'held', conversations: ['#chanA (chanA)', '#chanB (chanB)'] }, diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 48bb1b1c1..065ef9537 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -611,6 +611,23 @@ describe('debounce', () => { ]); }); + it('a reaction in the batch is never a route candidate (the same predicate as mid-turn)', async () => { + const path = writeConfig('debounce-route-reaction.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: '👍', + tags: ['chat:reaction', 'chat:addressed'], metadata: { authorId: '1', messageId: 'r-1' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:general', content: 'hi', + metadata: { authorId: '2', messageId: 'm-2' } })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 1); + assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:general']); + }); + it('a batched wake with no channel-bearing event carries no provenance', async () => { const path = writeConfig('debounce-noprov.json', { policies: [ diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 185562ba1..52367e39b 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -175,7 +175,22 @@ test('vector 43: a mixed-lane edit keeps the stable reply target', async (t) => await f.framework.runUntilIdle(); await f.send('push/event', { ...f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' }), origin: { messageId: 'm', authorId: 'u', authorName: 'User', threadId: 'thread' } }); await f.turn(); - assert.equal(f.published.at(-1)?.channelId, 'chat'); + // Both lanes name one conversation — the same channel, thread and message — + // so the turn has a single reply target, never a hold between "two". + const sources = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .map((m) => m.metadata?.inboundSource as { channelId?: string; threadId?: string; messageId?: string } | undefined) + .filter((s) => s !== undefined) + .map((s) => [s!.channelId, s!.threadId, s!.messageId]); + assert.deepEqual(sources, [['chat', 'thread', 'm'], ['chat', 'thread', 'm']]); + // That target is a thread, and publishing carries no thread (shelf-355): + // plain speech is held rather than posted into the channel root, which is + // a different conversation. + const turn = (f.framework as unknown as { turnRoutes: Map }) + .turnRoutes.get('agent'); + assert.equal(turn?.route, null); + assert.equal(turn?.hold, undefined); + assert.deepEqual([turn?.unroutable?.reason, turn?.unroutable?.conversation.channelId, turn?.unroutable?.conversation.threadId], ['thread', 'chat', 'thread']); + assert.equal(f.published.length, 0); }); test('vectors 13/15: a channel-scoped push needs current channel authority and a declared channel', async (t) => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 8359abbcd..c471ec20d 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -462,6 +462,11 @@ describe('present while acting', () => { assert.deepEqual(routed, [], 'neither conversation is guessed'); assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Good plan on both counts.', 'ambiguous']]); + // The notice names the actual cause: a continued conversation, not an address. + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('[routing] discord:guild:portables, where you sent a message this turn, continued mid-turn'))); + assert.ok(!wired.some((t) => t.includes('addressed you mid-turn'))); await framework.stop(); }); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 490d13543..62700d57e 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -8,6 +8,7 @@ import { conversationKey, describeConversation, inferTurnRoute, + isConversational, suspendsRoute, type ConversationRef, type RouteCandidate, @@ -83,10 +84,20 @@ describe('inferTurnRoute', () => { candidate(channel('forum', { threadId: 't2' }), true, 2), ]); assert.equal(threads.hold?.conversations.length, 2); - const one = inferTurnRoute([candidate(channel('forum', { threadId: 't1', label: 'forum' }), true, 1, 'm1')]); - assert.deepEqual(one.route, { - kind: 'channel', serverId: 'discord', channelId: 'forum', threadId: 't1', replyTo: 'm1', label: 'forum', origin: 'trigger', - }); + }); + + it('a thread conversation competes but is never a route: publishing carries no thread', () => { + const thread = channel('forum', { threadId: 't1', label: 'forum' }); + const one = inferTurnRoute([candidate(thread, true, 1, 'm1')]); + assert.deepEqual(one, { route: null, unroutable: { conversation: thread, reason: 'thread' } }, + 'speech would otherwise land in the channel root, a different conversation'); + const held = inferTurnRoute([candidate(thread, true, 1), candidate(channel('room'), true, 2)]); + assert.equal(held.hold?.conversations.length, 2, 'it still holds a turn when it competes'); + }); + + it('a channel whose server is unknown routes without a placeholder server', () => { + const turn = inferTurnRoute([candidate({ kind: 'channel', channelId: 'solo' }, true, 1, 'm1')]); + assert.deepEqual(turn.route, { kind: 'channel', channelId: 'solo', replyTo: 'm1', origin: 'trigger' }); }); it('a conversation whose channel could not be resolved competes, but is never the route', () => { @@ -94,7 +105,7 @@ describe('inferTurnRoute', () => { const competing = inferTurnRoute([candidate(channel('room'), true, 1, 'm1'), raw]); assert.equal(competing.route, null); assert.equal(competing.hold?.conversations.length, 2, 'never the older conversation by default'); - assert.deepEqual(inferTurnRoute([raw]), { route: null }); + assert.deepEqual(inferTurnRoute([raw]), { route: null, unroutable: { conversation: channel('1548'), reason: 'unresolved' } }); }); it('a local surface is addressed by nature, and routes to the surface', () => { @@ -134,6 +145,16 @@ describe('suspendsRoute', () => { }); }); +describe('isConversational', () => { + it('reactions and system markers are machinery, everything else is conversation', () => { + assert.equal(isConversational(['chat:reaction'], {}), false); + assert.equal(isConversational(['chat:reaction-remove'], {}), false, 'a removal is machinery too'); + assert.equal(isConversational(['chat:ambient'], { system: true }), false); + assert.equal(isConversational(['chat:addressed'], {}), true); + assert.equal(isConversational(undefined, undefined), true); + }); +}); + describe('conversation identity and wording', () => { it('keys include server, channel and thread; surfaces are their own', () => { assert.notEqual(conversationKey(channel('x')), conversationKey({ kind: 'channel', serverId: 'other', channelId: 'x' })); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 4254abad8..8b15de727 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -362,6 +362,50 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('a reaction that wakes a turn answers nothing: no route candidate', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); + const framework = await makeFramework(); + framework.pushEvent({ + ...(channelIncoming('discord:guild:chanA', '👍') as unknown as Record), + tags: ['chat:reaction', 'chat:addressed'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); + await framework.stop(); + }); + + it('a thread wakes the turn but plain speech is held: publishing carries no thread', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the topic' }])); + const framework = await makeFramework(); + // A channel subsystem to deliver through (speech is only routed with one). + const published: string[] = []; + internals(framework).channelRegistry = new Proxy({ + resolveLocus: () => null, + routeSpeech: async (_a: string, text: string) => { published.push(text); return { delivered: true, channelId: 'x' }; }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + framework.pushEvent({ + ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), + serverId: 'zulip', + threadId: 'topic-a', + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'thread'); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + assert.ok(texts.some((t) => t.startsWith('[routing] The conversation that woke you is a thread'))); + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ text: string; reason: string; note?: string }> } }) + .proseDrafts.open('scout'); + assert.deepEqual(drafts.map((d) => [d.text, d.reason]), [['answering the topic', 'no-destination']]); + assert.match(drafts[0]!.note ?? '', /a thread .*send tool that names the thread/); + assert.deepEqual(published, [], 'nothing went to the channel root'); + await framework.stop(); + }); + it('a no-trigger (heartbeat) turn has no speech route, whatever the previous turn had', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); From 6756af263c6fbc4b068c2cb91784a5367d7f8fce Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 17:32:51 -0400 Subject: [PATCH 20/65] feat(routing): publish only where MCPL RFC-011 declares the place; threads become routes on exact channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumer side of the explicit publish-target contract (shelf-355; settled in room-203 #47417, #47497, #47765; protocol anima-research/mcpl#17, types anima-research/mcpl-core-ts#9). channels/publish named a channel but not a place inside it, so a connector with threads chose where a post landed (slack-mcpl #6: the newest incoming message's thread, even for a root reply). Replaces f9f42d5's blanket "threads are never routes" with a declaration-gated rule. The rule, on every AF publication path: - ChannelRegistry.publish names its place: the target's threadId, or the channel root (null). It publishes only to a channel whose descriptor declares capabilities.publish.target, a thread only on `exact`; otherwise it fails before anything is opened or sent (placeRefusalText). An invalid thread id fails too. sendChannelsPublish keeps a targeted publish in the Request form. - Echo check (RFC-011 §5): delivered:true counts only when the receipt's threadId equals the place asked for; missing or different is `unknown`, with a reason naming both. The contradictory-evidence classifier is kept, and a refusal's `reason` is carried into the failed outcome. - PublishDestination gains threadId; receipts, draft attempts and channel_publish results name it. Routes (speech-routes.ts): inferTurnRoute takes the declaration lookup. A conversation is routable only on a declared channel, a thread only on `exact`; otherwise unroutable `untargetable` (no declaration) or `thread` (a thread on a `root` channel: a contradiction, held, never posted to the root). A fork home follows the same rule. Routes carry their threadId, and plain speech, channel_publish's default, the sleep announcement and the receipt follow it. Gate-batched candidates now carry each event's thread (event-gate PendingEvent/GateRouteCandidate), so a thread and its channel's root are different conversations on that path too. Resident controls: channel_publish, drafts.resend and channel_open (with setSpeechTarget) take an optional threadId; a channel named alone means its root, never a borrowed thread. channel_open on an undeclared channel opens it but sets no target, and says why. Notices and refusals name the connector's own tools only as tools to consult — those it lists that are classed comms (RFC-008) or named send/reply/post/say, else the prefix its tools share — and otherwise state only that publication from here is unavailable until the connector declares a target (Tessa-974 #48922). Streams (RFC-011 §6): outgoing chunks and completions go only to a declared channel and carry threadId null; a channel whose route is a thread is not streamed, since one channel's stream could carry both thread speech and root envelopes. Tests: publish-outcome (root sent explicitly, exact threads, undeclared refused with nothing opened, invalid ids, four bad echoes, root echo, refusal reason, a withdrawn declaration); speech-routes (exact threads, root contradiction, undeclared, home, per-channel lookup); trunk routing (thread into its thread, contradiction held, undeclared held with notice); event-gate (thread on batched candidates); a new targeted-publish.test.ts end to end over the fixture, which now implements RFC-011 as a server (per-channel declarations, honor-or-refuse, echo, wrong/no-echo modes, a `declare` op, connector tools, DM channels registered with a declaration as discord-mcpl does). Existing stubs and fixtures declare `root` and echo; vector 43 now replies into its thread on an `exact` channel. Lazily registered (host-minted) DM channels are undeclared, and the routing test says so. Changelog: targeted-publish.breaking.md (adoption: connectors first; what undeclared channels lose) and targeted-publish.changed.md. Co-Authored-By: Claude Opus 5.5 --- changelog.d/targeted-publish.breaking.md | 30 +++ changelog.d/targeted-publish.changed.md | 22 ++ src/framework.ts | 227 +++++++++++++---- src/gate/event-gate.ts | 10 + src/mcpl/channel-registry.ts | 203 ++++++++++++--- src/mcpl/server-connection.ts | 4 +- src/mcpl/types.ts | 26 ++ src/speech-routes.ts | 64 +++-- test/channel-registry-routing.test.ts | 45 ++-- test/event-gate.test.ts | 21 ++ test/explicit-prose-routing.test.ts | 5 +- test/fixtures/speech-route-mcpl-server.mjs | 102 ++++++-- test/fixtures/tune-out-mcpl-server.mjs | 6 +- test/fixtures/wake-locus-mcpl-server.mjs | 26 +- test/helpers/coalescing-fixture.ts | 15 +- test/mcpl-coalescing.test.ts | 15 +- test/present-while-acting.test.ts | 3 + test/prose-drafts.test.ts | 2 + test/publish-outcome.test.ts | 116 ++++++++- test/same-round-think-policy.test.ts | 2 +- test/silent-heartbeat.test.ts | 2 +- test/speech-route-turns.test.ts | 9 + test/speech-routes.test.ts | 63 +++-- test/targeted-publish.test.ts | 237 ++++++++++++++++++ test/tool-result-guard.test.ts | 4 +- ...ol-wrapper-prose-guard-integration.test.ts | 16 +- test/trunk-channel-routing.test.ts | 79 +++++- 27 files changed, 1161 insertions(+), 193 deletions(-) create mode 100644 changelog.d/targeted-publish.breaking.md create mode 100644 changelog.d/targeted-publish.changed.md create mode 100644 test/targeted-publish.test.ts diff --git a/changelog.d/targeted-publish.breaking.md b/changelog.d/targeted-publish.breaking.md new file mode 100644 index 000000000..da4fc9778 --- /dev/null +++ b/changelog.d/targeted-publish.breaking.md @@ -0,0 +1,30 @@ +- **The framework publishes only where a connector declares where its posts + land (MCPL RFC-011, anima-research/mcpl#17).** A channel is published to only + when its descriptor declares `capabilities.publish.target`: `exact` (posts + into a named thread or at the root) or `root` (a channel without threads). + On an undeclared channel, a connector might choose the place itself, for + example the thread of the newest incoming message (slack-mcpl#6). + - **Every publication names its place.** It carries `threadId`: the + route's thread, or `null` for the channel root. A delivery counts only + when the connector's echo names that place. A missing or different echo + is `unknown`. + - **Migration:** upgrade connectors before, or with, the framework. Until a + connector declares its channels: + - the framework's own publication there is unavailable. Plain speech is + held as drafts, with a notice naming the connector's own tools to + consult. + - `channel_publish`, draft resends and `>>` envelopes to such a channel + are refused, with nothing sent. + + The connector's own send tools are unaffected. Declaring releases: + discord-mcpl#67, slack-mcpl#20, telegram-mcpL#2, portal#46, + eidoverse-worlds#223 and anima-dnd. pocket-body stays undeclared by its + owner's decision, so its deliberate tools (`ring`, `say`, `listen`) remain + the way to reach it. + - **Host-minted channels count as undeclared.** That covers a DM channel + the framework registers lazily from a push event, because the connector + never described it. Connectors that register their DM channels with a + declaration, as discord-mcpl does, are unaffected. + - **Hosts calling `ChannelRegistry.publish` directly:** a target may name + `threadId`. `PublishDestination` gains `threadId`, and an undeclared + channel is refused before anything is opened or sent. diff --git a/changelog.d/targeted-publish.changed.md b/changelog.d/targeted-publish.changed.md new file mode 100644 index 000000000..3658619f3 --- /dev/null +++ b/changelog.d/targeted-publish.changed.md @@ -0,0 +1,22 @@ +- **A thread can be a speech route, where its connector posts into named + threads.** A conversation in a thread is routed into that thread when its + channel declares `exact` (MCPL RFC-011), and the routing notice and the + `[delivered]` receipt name the thread. + - On a channel that declares `root`, a thread is a contradiction. The turn + records it as unroutable, and speech is held rather than posted to the + root. + - An undeclared channel is unroutable as `untargetable`. + - Gate-batched wakes now carry each event's thread, so a thread and its + channel's root are different conversations on every path. +- **Residents can choose a thread deliberately.** + - `channel_publish`, `drafts.resend` and `channel_open` (with + `setSpeechTarget`) take an optional `threadId`. + - A channel named without one means its root, never a thread borrowed from + an earlier route. + - `channel_open` on an undeclared channel still opens it, but sets no + speech target, and its result explains why. +- **Outgoing streams follow the same rule.** A stream goes only to a + declared channel, names its place, and is skipped when the route is a + thread, since one channel's stream can't name two places. +- Refusal results (`delivered: false` with a `reason` and no message) keep + the connector's reason. diff --git a/src/framework.ts b/src/framework.ts index 11c4ccf33..135af549b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -81,7 +81,7 @@ import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.j 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, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; +import { ChannelRegistry, publishPlaceRefusal, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { conversationKey, @@ -89,6 +89,7 @@ import { inferTurnRoute, isConversational, routeConversation, + routeRefusal, suspendsRoute, type ConversationRef, type RouteCandidate, @@ -1756,12 +1757,17 @@ export class AgentFramework { ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } : c.unroutable ? { - conversation: { kind: 'channel', ...(c.serverId ? { serverId: c.serverId } : {}), channelId: c.channelId }, + conversation: { + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + }, addressed: c.addressed, at: c.at, unroutable: true, } - : framework.candidateForChannel(c.channelId, c.addressed, c.at, c.serverId, c.messageId)), + : framework.candidateForChannel(c.channelId, c.addressed, c.at, c.serverId, c.messageId, c.threadId)), } : {}), }); @@ -4702,6 +4708,13 @@ export class AgentFramework { type: ['string', 'null'], description: 'resend: where to publish — "#channel", "@person" or a channel id. null otherwise.', }, + threadId: { + type: ['string', 'null'], + description: + 'resend: a thread of the destination channel to post in (the id from a [source: … · thread ] ' + + 'line), where its connector can post into a named thread. null, like omitting it, means the ' + + "channel's root.", + }, confirmDuplicate: { type: ['boolean', 'null'], description: @@ -9074,7 +9087,7 @@ export class AgentFramework { private static destinationText(d: PublishDestination): string { const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : ''; - return `${label}(${d.channelId})`; + return `${label}(${d.channelId}${d.threadId ? `, thread ${d.threadId}` : ''})`; } /** One line per draft: id, state, when, why, size, preview. */ @@ -9231,6 +9244,7 @@ export class AgentFramework { if (ids.length > 20) return refuse('draftIds names more than 20 drafts; act on them in smaller groups.'); } if (present(input.destination) && action !== 'resend') return unused('destination'); + if (present(input.threadId) && action !== 'resend') return unused('threadId'); if (present(input.confirmDuplicate) && action !== 'resend') return unused('confirmDuplicate'); if (present(input.offset) && action !== 'list') return unused('offset'); @@ -9349,9 +9363,26 @@ export class AgentFramework { } const target = registry.resolveDestination({ channelId: resolved.channelId }); if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`); - const destination = target.destination; - if (home && destination.channelId !== home) { - return refuse(`This conversation is bound to channel ${home}; resending to ${destination.channelId} is not allowed. Nothing was sent.`); + if (home && target.destination.channelId !== home) { + return refuse(`This conversation is bound to channel ${home}; resending to ${target.destination.channelId} is not allowed. Nothing was sent.`); + } + // The place inside the channel (MCPL RFC-011): the thread named with it, + // else the root — never a thread borrowed from where the words were held. + if (present(input.threadId) && (typeof input.threadId !== 'string' || !input.threadId.trim())) { + return refuse('threadId must be a thread id of the destination channel, or null for its root.'); + } + const destination: PublishDestination = { + ...target.destination, + threadId: present(input.threadId) ? (input.threadId as string).trim() : null, + }; + const placeRefusal = publishPlaceRefusal(registry.publishTarget(destination), destination.threadId ?? null); + if (placeRefusal) { + const descriptor = registry.getDescriptor(destination.channelId); + const why = descriptor + ? ChannelRegistry.placeRefusalText(placeRefusal, descriptor, destination.threadId ?? null) + : `${destination.channelId} can't be published to exactly`; + const consult = this.connectorToolsText(destination.serverId); + return refuse(`${why}.${consult ? ` ${consult}` : ''} Nothing was sent.`); } // Refuse up front, before anything is published, rather than half-way. @@ -9403,7 +9434,7 @@ export class AgentFramework { stopped = true; continue; } - const outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId }); + const outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId, threadId: destination.threadId ?? null }); let recorded = true; try { this.proseDrafts.recordOutcome(agentName, d.id, attemptId, outcome); @@ -9461,10 +9492,11 @@ export class AgentFramework { return undefined; } - /** Record a successful plain-prose delivery for this turn's receipt. */ + /** Record a successful plain-prose delivery for this turn's receipt: its + * channel, and its thread when it went into one (`channel\0thread`). */ private recordProseDelivery( agentName: string, - outcome: { delivered: boolean; channelId: string } | null | undefined, + outcome: { delivered: boolean; channelId: string; threadId?: string | null } | null | undefined, ): void { if (!outcome?.delivered) return; let list = this.turnProseDeliveries.get(agentName); @@ -9472,7 +9504,7 @@ export class AgentFramework { list = []; this.turnProseDeliveries.set(agentName, list); } - list.push(outcome.channelId); + list.push(outcome.threadId ? `${outcome.channelId}\u0000${outcome.threadId}` : outcome.channelId); } /** @@ -9507,11 +9539,13 @@ export class AgentFramework { shown.push(`${id.slice('surface:'.length)} (the local surface that messaged you; not published to any channel)`); continue; } - const label = this.channelRegistry?.getDescriptor(id)?.label; + const [channelId, threadId] = id.split('\u0000') as [string, string | undefined]; + const label = this.channelRegistry?.getDescriptor(channelId)?.label; + const where = threadId ? `${channelId}, thread ${threadId}` : channelId; shown.push( - label && label !== id - ? `${label.startsWith('#') ? label : `#${label}`} (${id})` - : id, + label && label !== channelId + ? `${label.startsWith('#') ? label : `#${label}`} (${where})` + : threadId ? `${channelId} (thread ${threadId})` : channelId, ); } const notes: string[] = []; @@ -9615,11 +9649,18 @@ export class AgentFramework { at: number, serverId?: string, messageId?: string, + threadId?: string, ): RouteCandidate { const server = serverId || this.channelRegistry?.getChannelServerId(channelId) || undefined; const label = this.channelRegistry?.getDescriptor(channelId)?.label; return { - conversation: { kind: 'channel', ...(server ? { serverId: server } : {}), channelId, ...(label ? { label } : {}) }, + conversation: { + kind: 'channel', + ...(server ? { serverId: server } : {}), + channelId, + ...(threadId ? { threadId } : {}), + ...(label ? { label } : {}), + }, addressed, ...(messageId ? { messageId } : {}), at, @@ -9648,6 +9689,72 @@ export class AgentFramework { return turn && !turn.hold && turn.route?.kind === 'channel' ? turn.route.channelId : null; } + /** + * The exact place the agent's unaddressed speech is published to now — its + * channel route's server, channel and thread (null: the channel root) — or + * null when it has no channel route or is held. + */ + private routeTarget(agentName: string): { serverId?: string; channelId: string; threadId: string | null } | null { + const turn = this.turnRoutes?.get(agentName); + if (!turn || turn.hold || turn.route?.kind !== 'channel') return null; + const route = turn.route; + return { ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, threadId: route.threadId ?? null }; + } + + /** A channel's declared publish target (MCPL RFC-011), read live from the registry. */ + private publishTargetOf(channel: { serverId?: string; channelId: string }): 'exact' | 'root' | undefined { + return this.channelRegistry?.publishTarget(channel); + } + + /** + * Connector tools worth consulting for a conversation the framework can't + * publish into, as the resident calls them — only what the connector + * actually lists: its tools of class `comms` (MCPL RFC-008), or whose own + * names begin send/reply/post/say; failing those, the prefix its tools + * share. A match says where to look, not that the tool can post there: + * the resident judges from the tool's own description. + */ + private connectorSendTools(serverId: string | undefined): { tools: string[]; prefix?: string } { + if (!serverId) return { tools: [] }; + const tools: string[] = []; + let prefix: string | undefined; + for (const tool of this.mcplTools ?? []) { + if (this.mcplToolServers?.get(tool.name) !== serverId) continue; + const hit = this.resolveMcplTool(tool.name); + prefix ??= hit?.[1]; + const own = hit ? tool.name.slice(hit[1].length + 2) : tool.name; + const comms = this.effectiveToolClass(tool.name, true).classes.includes('comms' as ToolClass); + if (comms || /^(send|reply|post|say)(_|$)/i.test(own)) tools.push(tool.name); + } + return { tools: tools.slice(0, 4), ...(prefix ? { prefix } : {}) }; + } + + /** The connector tools to consult, in words; empty when it lists none. */ + private connectorToolsText(serverId: string | undefined): string { + const { tools, prefix } = this.connectorSendTools(serverId); + if (tools.length > 0) return `Its connector's own tools may reach it; consult them: ${tools.map((t) => `\`${t}\``).join(', ')}.`; + if (prefix) return `Its connector's own tools (\`${prefix}--…\`) may reach it; consult their descriptions.`; + return ''; + } + + /** Why plain speech can't go to a conversation, and which connector tools to consult. */ + private unroutableText(unroutable: NonNullable): string { + const c = unroutable.conversation; + const where = describeConversation(c); + const consult = c.kind === 'channel' ? this.connectorToolsText(c.serverId) : ''; + const tail = consult ? ` ${consult}` : ''; + switch (unroutable.reason) { + case 'untargetable': + return `${where}: its connector doesn't declare where a post lands (MCPL RFC-011) — it may choose a thread ` + + `itself — so publication from here is unavailable until it does.${tail}`; + case 'thread': + return `${where} is a thread, but its connector declares the channel has no threads, so publication from ` + + `here can't target it.${tail}`; + case 'unresolved': + return `${where} couldn't be resolved to a registered channel.${tail || ' Use an explicit send tool to reach it.'}`; + } + } + /** The agent's route as the registry needs it (context, channel_publish default). */ private speechRouteView(agentName: string): SpeechRouteView { const turn = this.turnRoutes.get(agentName); @@ -9660,6 +9767,7 @@ export class AgentFramework { kind: 'channel', ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, + ...(route.threadId ? { threadId: route.threadId } : {}), ...(route.replyTo ? { replyTo: route.replyTo } : {}), }; } @@ -9689,9 +9797,7 @@ export class AgentFramework { const unroutable = turn?.unroutable; this.holdProseDrafts(agent, [text], 'no-destination', { ...hold, - ...(unroutable?.reason === 'thread' - ? { note: `the conversation that woke you is a thread (${describeConversation(unroutable.conversation)}); answer it with a send tool that names the thread` } - : {}), + ...(unroutable ? { note: this.unroutableText(unroutable) } : {}), }); return; } @@ -9704,7 +9810,8 @@ export class AgentFramework { this.holdProseDrafts(agent, [text], 'no-destination', hold); return; } - const target = route.serverId ? { serverId: route.serverId, channelId: route.channelId } : route.channelId; + // The route's exact place: its server, channel and thread (or root). + const target = this.routeTarget(agent.name)!; const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target); this.recordProseDelivery(agent.name, outcome); } @@ -9751,12 +9858,9 @@ export class AgentFramework { 'Your unaddressed plain speech is held as drafts this turn rather than guessed: answer one with an ' + 'explicit send (or channel_open it), and resend held words with the drafts tool.'; } else if (!turn.route && turn.unroutable) { - const where = describeConversation(turn.unroutable.conversation); - text = turn.unroutable.reason === 'thread' - ? `[routing] The conversation that woke you is a thread (${where}), and plain speech can't be posted ` + - 'into a thread: it is held as drafts. Answer it with a send tool that names the thread.' - : `[routing] The conversation that woke you (${where}) couldn't be resolved to a registered channel, ` + - 'so your plain speech is held as drafts. Use an explicit send tool to reach it.'; + text = + `[routing] Your plain speech can't go to the conversation in front of you, so it is held as drafts. ` + + this.unroutableText(turn.unroutable); } else if (!turn.route) { text = '[routing] Your plain speech currently has no destination — it is held as drafts you can resend. ' + @@ -10486,7 +10590,7 @@ export class AgentFramework { // last saw traffic. A silent control-plane wake has none. const turn: TurnRoute = trigger?.suppressProse ? { route: null } - : inferTurnRoute(trigger?.routeCandidates ?? [], this.homeRoute(agent.name)); + : inferTurnRoute(trigger?.routeCandidates ?? [], this.homeRoute(agent.name), (c) => this.publishTargetOf(c)); this.turnRoutes.set(agent.name, turn); this.midTurnInputSignals.delete(agent.name); if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); @@ -10856,10 +10960,21 @@ export class AgentFramework { }, }) : null; + // The place a stream's final publish targets (MCPL RFC-011 §6). A + // channel's stream can carry both the route's plain speech and envelope + // text sent to the channel's root, so a channel whose route is a thread + // isn't streamed at all (fail-closed: the stream could not name one + // place); every other stream names the root. + const streamPlace = (channelId: string): null | undefined => { + const target = this.routeTarget(agent.name); + return target && target.channelId === channelId && target.threadId !== null ? undefined : null; + }; const emitOutgoing = (deltas: { channelId: string; delta: string }[]): void => { for (const rd of deltas) { + const place = streamPlace(rd.channelId); + if (place === undefined) continue; this.channelRegistry!.sendOutgoingChunk( - rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta, + rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta, place, ); } }; @@ -12189,7 +12304,9 @@ export class AgentFramework { if (frameReachedTerminal && proseStream) { emitOutgoing(proseStream.finish()); for (const [channelId, text] of proseStream.byChannel()) { - this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text); + const place = streamPlace(channelId); + if (place === undefined) continue; + this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, place); } } this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`); @@ -16199,10 +16316,19 @@ export class AgentFramework { // lastAnnouncedRoute tracks it so announce-on-change stays coherent. // Locus and hybrid modes only: explicit mode has no route. if (call.name === 'channel_open' && result?.success) { - const openInput = call.input as { channelId?: string; serverId?: string; setSpeechTarget?: boolean | null } | undefined; + const openInput = call.input as { channelId?: string; serverId?: string; threadId?: string | null; setSpeechTarget?: boolean | null } | undefined; const opened = (result.data as { channelId?: string } | undefined)?.channelId ?? openInput?.channelId; const openerAgent = this.agents.get(agentName); if (opened && openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { + const unchanged = (): string => { + const turn = this.turnRoutes.get(agentName); + return turn?.hold + ? 'Your speech route is unchanged: it is held between ' + + `${turn.hold.conversations.map(describeConversation).join(' and ')}, so unaddressed plain speech stays as drafts.` + : turn?.route + ? `Your plain speech still goes to ${describeConversation(routeConversation(turn.route))}.` + : 'You have no speech route, so unaddressed plain speech is held as drafts.'; + }; let routing: string; if (openInput?.setSpeechTarget !== false) { const resolved = this.channelRegistry!.resolveDestination({ @@ -16211,29 +16337,38 @@ export class AgentFramework { }); const destination = resolved && 'destination' in resolved ? resolved.destination : undefined; const openedServer = destination?.serverId ?? openInput?.serverId; + // A channel named alone means its root — never a thread + // borrowed from an earlier route; a thread is named with it. + const threadId = typeof openInput?.threadId === 'string' && openInput.threadId ? openInput.threadId : undefined; const route: SpeechRoute = { kind: 'channel', ...(openedServer ? { serverId: openedServer } : {}), channelId: opened, + ...(threadId ? { threadId } : {}), ...(destination?.label ? { label: destination.label } : {}), origin: 'open', }; - const next: TurnRoute = { route }; - this.turnRoutes.set(agentName, next); - this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); - if (openerAgent.proseRouting === 'hybrid') this.proseTargetPins.delete(agentName); - routing = - `Your unaddressed plain speech now goes to ${describeConversation(routeConversation(route))} for the ` + - 'rest of this turn. Other channels need an explicit send tool.'; - console.error(`[routing] ${agentName}: channel_open -> speech route ${opened} (announced in tool result)`); + // Only a place the framework can publish into exactly becomes + // a route (MCPL RFC-011); otherwise the open stands, and the + // route doesn't move. + const refusal = routeRefusal(routeConversation(route), (c) => this.publishTargetOf(c)); + if (refusal) { + routing = + `Opened, but your plain speech can't go there: ${this.unroutableText({ conversation: routeConversation(route), reason: refusal })} ` + + unchanged(); + console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (${refusal})`); + } else { + const next: TurnRoute = { route }; + this.turnRoutes.set(agentName, next); + this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); + if (openerAgent.proseRouting === 'hybrid') this.proseTargetPins.delete(agentName); + routing = + `Your unaddressed plain speech now goes to ${describeConversation(routeConversation(route))} for the ` + + 'rest of this turn. Other channels need an explicit send tool.'; + console.error(`[routing] ${agentName}: channel_open -> speech route ${opened}${threadId ? ` thread ${threadId}` : ''} (announced in tool result)`); + } } else { - const turn = this.turnRoutes.get(agentName); - routing = turn?.hold - ? 'Opened for reading. Your speech route is unchanged: it is held between ' + - `${turn.hold.conversations.map(describeConversation).join(' and ')}, so unaddressed plain speech stays as drafts.` - : turn?.route - ? `Opened for reading. Your plain speech still goes to ${describeConversation(routeConversation(turn.route))}.` - : 'Opened for reading. You have no speech route, so unaddressed plain speech is held as drafts.'; + routing = `Opened for reading. ${unchanged()}`; } result = { ...result, @@ -16506,7 +16641,7 @@ export class AgentFramework { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } } else { - const locus = this.routeChannelId(agentName); + const locus = this.routeTarget(agentName); this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); }); diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 9a5577601..56dd07e9b 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -97,6 +97,9 @@ interface PendingEvent { /** The triggering message's id, when the metadata carries one: the reply * edge of a speech route inferred from this event. */ messageId?: string; + /** The thread the event belongs to, when the metadata carries one: part + * of its conversation's identity (a thread is not its channel's root). */ + threadId?: string; /** Conversational input rather than machinery (a reaction, a system * marker): only conversational events are route candidates. */ conversational: boolean; @@ -111,6 +114,8 @@ export type GateRouteCandidate = * the event named (or `user:` when it named only an author). */ channelId: string; serverId?: string; + /** The thread the conversation is, when it is one. */ + threadId?: string; messageId?: string; addressed: boolean; at: number; @@ -260,6 +265,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin kind: 'channel', channelId: e.routeChannelId, ...(e.serverId ? { serverId: e.serverId } : {}), + ...(e.threadId ? { threadId: e.threadId } : {}), ...(e.messageId ? { messageId: e.messageId } : {}), addressed: e.addressed, at: e.timestamp, @@ -275,6 +281,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin kind: 'channel', channelId: e.channelId || `user:${e.authorId}`, ...(e.serverId ? { serverId: e.serverId } : {}), + ...(e.threadId ? { threadId: e.threadId } : {}), addressed: e.addressed, at: e.timestamp, unroutable: true, @@ -1568,6 +1575,9 @@ export class EventGate { ...(typeof info.metadata?.messageId === 'string' && info.metadata.messageId ? { messageId: info.metadata.messageId as string } : {}), + ...(typeof info.metadata?.threadId === 'string' && info.metadata.threadId + ? { threadId: info.metadata.threadId as string } + : {}), conversational: isConversational(info.tags, info.metadata), }; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 59dc22214..671f48a97 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -347,6 +347,13 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ 'Whether your unaddressed plain speech should go to this channel for the rest of this turn ' + '(default true). false opens it for reading only; the result says where your speech goes.', }, + threadId: { + type: 'string', + description: + 'With setSpeechTarget, a thread of this channel to speak into instead of its root (the thread ' + + 'id from a [source: … · thread ] line). Only where the channel\'s connector can post into ' + + 'a named thread; without it, your speech goes to the channel root.', + }, }, required: ['channelId'], }, @@ -387,14 +394,21 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ name: 'channel_publish', description: 'Publish a message to a channel — an explicit send. Without channelId it goes to your current speech ' + - 'route (the conversation your plain speech is going to this turn); with no route it is refused, never ' + - 'guessed. The receipt names where it went, and whether delivery was confirmed, failed (nothing posted) ' + - 'or is unknown (it may have been posted).', + 'route (the conversation your plain speech is going to this turn, thread included); with no route it is ' + + 'refused, never guessed. With channelId it goes to that channel\'s root, or into threadId. The receipt ' + + 'names where it went, and whether delivery was confirmed, failed (nothing posted) or is unknown (it may ' + + 'have been posted).', inputSchema: { type: 'object' as const, properties: { channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to your current speech route)' }, serverId: { type: 'string', description: 'Owning MCPL server; needed only when channelId is registered by more than one.' }, + threadId: { + type: 'string', + description: + 'A thread of channelId to post in (the id from a [source: … · thread ] line); omitted, the post ' + + 'goes to the channel root. Only where the channel\'s connector can post into a named thread.', + }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, @@ -485,6 +499,34 @@ export interface PublishDestination { channelId: string; /** The channel's registered label when the attempt was made. */ label?: string; + /** + * The place inside the channel the publish asked for (MCPL RFC-011): a + * thread id, or null for the channel root. Absent only on a destination + * recorded before targeted publishing. + */ + threadId?: string | null; +} + +/** + * A channel's declared publish target (MCPL RFC-011 + * `capabilities.publish.target`): `exact` posts exactly where a publish + * says (a thread, or the root), `root` has no threads. Undefined — no + * declaration, or a malformed one — means no guarantee: the framework does + * not publish there, because the connector may choose a place itself. + */ +export function declaredPublishTarget(descriptor: ChannelDescriptor | undefined): 'exact' | 'root' | undefined { + const target = (descriptor?.capabilities as { publish?: { target?: unknown } } | undefined)?.publish?.target; + return target === 'exact' || target === 'root' ? target : undefined; +} + +/** Where a publish may go inside a channel, given its declaration: why not, or undefined when it may. */ +export function publishPlaceRefusal( + declared: 'exact' | 'root' | undefined, + threadId: string | null, +): 'undeclared' | 'no-threads' | undefined { + if (!declared) return 'undeclared'; + if (threadId !== null && declared !== 'exact') return 'no-threads'; + return undefined; } /** @@ -2930,11 +2972,13 @@ export class ChannelRegistry { inferenceId: string, index: number, delta: string, + /** The final publish's place (RFC-011 §6): a thread, or null for the root. */ + threadId: string | null = null, ): void { - const server = this.streamingServerFor(channelId); + const server = this.streamingServerFor(channelId, threadId); if (!server) return; try { - server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta }); + server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta, threadId }); } catch { /* observer surface — never disturb the turn */ } } @@ -2944,8 +2988,10 @@ export class ChannelRegistry { conversationId: string, inferenceId: string, text: string, + /** The final publish's place (RFC-011 §6): a thread, or null for the root. */ + threadId: string | null = null, ): void { - const server = this.streamingServerFor(channelId); + const server = this.streamingServerFor(channelId, threadId); if (!server) return; try { server.sendChannelsOutgoingComplete({ @@ -2953,17 +2999,21 @@ export class ChannelRegistry { conversationId, channelId, content: [{ type: 'text', text }], + threadId, }); } catch { /* observer surface — never disturb the turn */ } } - private streamingServerFor(channelId: string) { + private streamingServerFor(channelId: string, threadId: string | null) { // The map is keyed `${serverId}:${channelId}`; stream targets arrive as // bare descriptor ids (what resolveProseTarget returns). Scan like the // resolver does, and fail closed on cross-server ambiguity — the same // never-guess rule that governs delivery. const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); if (matches.length !== 1) return null; + // §14.3 fail-closed: nothing streams that delivery would refuse — and + // delivery publishes only where the place is declared (RFC-011 §6). + if (publishPlaceRefusal(declaredPublishTarget(matches[0]!.descriptor), threadId)) return null; const server = this.serverRegistry.getServer(matches[0]!.serverId); // §5.4: the GRANT gates streaming, not the raw advertisement. The old // `capabilities?.channels?.streaming` check was doubly wrong: undefined @@ -3004,6 +3054,17 @@ export class ChannelRegistry { }; } + /** + * The publish target a registered channel declares (MCPL RFC-011), read + * live: a `channels/changed` that adds or withdraws it applies to the next + * decision. Undefined when the channel doesn't resolve to exactly one + * registration or declares nothing. + */ + publishTarget(target: { serverId?: string; channelId: string }): 'exact' | 'root' | undefined { + const found = this.findExactEntry(target); + return 'error' in found ? undefined : declaredPublishTarget(found.entry.descriptor); + } + private findExactEntry( target: { serverId?: string; channelId: string }, ): { entry: ChannelEntry } | { error: string } { @@ -3049,31 +3110,48 @@ export class ChannelRegistry { * destination is opened first (`openSource` names why), and a failed open * sends nothing. * - * Only the connector's `delivered: true` confirms a post. `failed` means - * nothing was posted: the attempt stopped before the request was written, - * or the connector answered `delivered: false` and named no message (no - * `messageId`, or null). Everything else is `unknown`: once the request - * has been handed to the transport, an error response, a timeout, a lost - * connection, or a missing, malformed or contradictory receipt (such as - * `delivered: false` beside a message id, valid or not) means the post may - * or may not exist, and the caller must not treat it as safe to retry - * blindly. + * Every publish names its place inside the channel (MCPL RFC-011): the + * target's `threadId`, or the channel root (null) when it names none. It + * goes only to a channel that declares `capabilities.publish.target`, and + * a thread only to one that declares `exact`; anything else is refused + * before anything is opened or sent, because the connector would choose + * a place itself. + * + * Only the connector's `delivered: true` with an echo of the requested + * place confirms a post. `failed` means nothing was posted: the attempt + * stopped before the request was written, or the connector answered + * `delivered: false` and named no message (no `messageId`, or null) — a + * refusal. Everything else is `unknown`: once the request has been handed + * to the transport, an error response, a timeout, a lost connection, or a + * missing, malformed or contradictory receipt (such as `delivered: false` + * beside a message id, valid or not, or a delivery whose echoed place is + * missing or different) means the post may or may not exist where it was + * asked to go, and the caller must not treat it as safe to retry blindly. */ async publish( conversationId: string, text: string, - target: { serverId?: string; channelId: string }, + target: { serverId?: string; channelId: string; threadId?: string | null }, openSource: 'opened-by-delivery' | 'opened-by-reply' = 'opened-by-delivery', ): Promise { const at = (): number => Date.now(); const found = this.findExactEntry(target); if ('error' in found) return { status: 'failed', reason: found.error, at: at() }; const entry = found.entry; + const place = target.threadId === undefined ? null : target.threadId; const destination: PublishDestination = { serverId: entry.serverId, channelId: entry.descriptor.id, ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}), + threadId: place, }; + if (place !== null && (typeof place !== 'string' || place === '')) { + return { status: 'failed', destination, reason: 'the thread to post in must be a non-empty thread id', at: at() }; + } + const refusal = publishPlaceRefusal(declaredPublishTarget(entry.descriptor), place); + if (refusal) { + return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(refusal, entry.descriptor, place), at: at() }; + } if (!entry.open) { try { @@ -3126,6 +3204,7 @@ export class ChannelRegistry { conversationId, channelId: entry.descriptor.id, content: [{ type: 'text', text }], + threadId: place, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -3143,20 +3222,39 @@ export class ChannelRegistry { at: at(), }; } - const receipt = (result ?? {}) as { delivered?: unknown; messageId?: unknown }; + const receipt = (result ?? {}) as { delivered?: unknown; messageId?: unknown; threadId?: unknown; reason?: unknown }; const messageId = typeof receipt.messageId === 'string' && receipt.messageId ? receipt.messageId : undefined; if (receipt.delivered === true) { + // RFC-011 §5: the echo must name the place asked for. Missing or + // different, something was posted — but not provably there. + if (!('threadId' in receipt) || receipt.threadId !== place) { + const asked = place === null ? 'the channel root' : `thread ${place}`; + const echoed = !('threadId' in receipt) || receipt.threadId === undefined + ? 'did not say where it posted' + : receipt.threadId === null + ? 'reported posting at the channel root' + : `reported posting in ${typeof receipt.threadId === 'string' ? `thread ${receipt.threadId}` : `an unreadable place ${JSON.stringify(receipt.threadId)}`}`; + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: `server "${entry.serverId}" reported delivery but ${echoed}, not ${asked} (where it landed is unconfirmed)`, + at: at(), + }; + } return { status: 'delivered', destination, ...(messageId ? { messageId } : {}), at: at() }; } if (receipt.delivered === false) { // `delivered: false` proves nothing went out only when the receipt - // names no message at all. A message id beside it — valid or - // malformed — contradicts it: something may have been posted. + // names no message at all — a refusal (RFC-011 §4). A message id + // beside it — valid or malformed — contradicts it: something may have + // been posted. if (receipt.messageId === undefined || receipt.messageId === null) { + const why = typeof receipt.reason === 'string' && receipt.reason.trim() ? `: ${receipt.reason.trim()}` : ''; return { status: 'failed', destination, - reason: `server "${entry.serverId}" reported delivered:false`, + reason: `server "${entry.serverId}" reported delivered:false${why}`, at: at(), }; } @@ -3183,6 +3281,19 @@ export class ChannelRegistry { }; } + /** Why a publish's place was refused, in words a resident can act on. */ + static placeRefusalText( + refusal: 'undeclared' | 'no-threads', + descriptor: ChannelDescriptor, + threadId: string | null, + ): string { + const where = descriptor.label && descriptor.label !== descriptor.id ? `${descriptor.label} (${descriptor.id})` : descriptor.id; + return refusal === 'undeclared' + ? `${where}'s connector doesn't declare where a post lands (MCPL RFC-011), so it may choose a thread itself; ` + + 'the framework doesn\'t publish there' + : `${where} has no threads (its connector declares root), so a post into thread ${threadId} is refused`; + } + /** * Plain speech for one turn segment: publish `text` to the turn's frozen * locus and report the PublishOutcome. A failure (or an uncertain outcome) @@ -3199,7 +3310,7 @@ export class ChannelRegistry { * stale global default and land the reply in the wrong channel (item-3, * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means * "this turn is pinned to no locus": fail loudly rather than guess. */ - locusChannelId: string | null | { serverId?: string; channelId: string }, + locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, ): Promise { const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; @@ -3261,19 +3372,20 @@ export class ChannelRegistry { async routeSpeech( conversationId: string, text: string, - locusChannelId: string | null | { serverId?: string; channelId: string }, - ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { + locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, + ): Promise<{ delivered: boolean; channelId: string; threadId?: string; messageId?: string } | null> { const outcome = await this.deliverSpeech(conversationId, text, locusChannelId); if (outcome.status !== 'delivered') return null; return { delivered: true, channelId: outcome.destination!.channelId, + ...(outcome.destination!.threadId ? { threadId: outcome.destination!.threadId } : {}), ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}), }; } private async handleToolPublish( - input: { channelId?: string; serverId?: string; content?: string; text?: string }, + input: { channelId?: string; serverId?: string; threadId?: string | null; content?: string; text?: string }, origin?: ChannelToolOrigin, ): Promise { // Resolve content: accept both `content` and `text` (backward compat) @@ -3286,11 +3398,23 @@ export class ChannelRegistry { }; } - // The destination: the one named, else the caller's current speech - // route — never the most recent inbound channel (shelf-355). - let target: { serverId?: string; channelId: string }; + // The destination: the one named — its root, or the thread named with + // it — else the caller's current speech route, thread included; never + // the most recent inbound channel (shelf-355), and never a thread + // borrowed from an earlier route for a channel named explicitly. + if (input.threadId !== undefined && input.threadId !== null && (typeof input.threadId !== 'string' || !input.threadId)) { + return { success: false, error: 'threadId must be a thread id (or left out for the channel root). Nothing was sent.', isError: true }; + } + if (input.threadId && !input.channelId) { + return { success: false, error: 'threadId needs the channelId it belongs to. Nothing was sent.', isError: true }; + } + let target: { serverId?: string; channelId: string; threadId: string | null }; if (input.channelId) { - target = { channelId: input.channelId, ...(input.serverId ? { serverId: input.serverId } : {}) }; + target = { + channelId: input.channelId, + ...(input.serverId ? { serverId: input.serverId } : {}), + threadId: input.threadId || null, + }; } else { const route = origin?.kind === 'agent' ? this.speechRouteResolver?.(origin.agentName) : undefined; if (route?.kind !== 'channel') { @@ -3306,8 +3430,13 @@ export class ChannelRegistry { }; } // The route's server when it has one; otherwise the id alone, resolved - // exactly as plain speech on the same route resolves it. - target = { channelId: route.channelId, ...(route.serverId ? { serverId: route.serverId } : {}) }; + // exactly as plain speech on the same route resolves it — and its + // thread, when the route is one. + target = { + channelId: route.channelId, + ...(route.serverId ? { serverId: route.serverId } : {}), + threadId: route.threadId ?? null, + }; } const outcome = await this.publish( @@ -3316,8 +3445,13 @@ export class ChannelRegistry { target, ); const where = outcome.destination - ? { serverId: outcome.destination.serverId, channelId: outcome.destination.channelId, ...(outcome.destination.label ? { channelLabel: outcome.destination.label } : {}) } - : { channelId: target.channelId, ...(target.serverId ? { serverId: target.serverId } : {}) }; + ? { + serverId: outcome.destination.serverId, + channelId: outcome.destination.channelId, + ...(outcome.destination.label ? { channelLabel: outcome.destination.label } : {}), + threadId: outcome.destination.threadId ?? null, + } + : { channelId: target.channelId, ...(target.serverId ? { serverId: target.serverId } : {}), threadId: target.threadId }; if (outcome.status === 'delivered') { return { success: true, @@ -3332,7 +3466,8 @@ export class ChannelRegistry { // Not confirmed: say which, and where it was attempted. `unknown` may // already be posted (a partial multi-part send, a timeout): never invite // a blind retry. - const shown = `${where.channelId}${'channelLabel' in where && where.channelLabel ? ` (${where.channelLabel})` : ''}`; + const shown = `${where.channelId}${'channelLabel' in where && where.channelLabel ? ` (${where.channelLabel})` : ''}` + + `${where.threadId ? `, thread ${where.threadId}` : ''}`; return { success: false, error: outcome.status === 'unknown' diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 18f82d096..fdb16f1d0 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -777,7 +777,9 @@ export class McplServerConnection extends EventEmitter { * When `params.stream` is true, sends as a notification (no ACK). */ sendChannelsPublish(params: ChannelsPublishParams): Promise { - if (params.stream) { + // A targeted publish (MCPL RFC-011) is always a Request: a Notification + // has no result, so where it landed could never be confirmed. + if (params.stream && params.threadId === undefined) { this.sendNotification(McplMethod.ChannelsPublish, params as unknown as Record); return Promise.resolve(); } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index a5732e1d6..de3191654 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -976,6 +976,17 @@ export interface ChannelCapabilities { kind?: string; supportsValue?: boolean; }; + /** + * MCPL RFC-011: where a `channels/publish` to this channel lands — a + * per-channel behavioral declaration, not a capability path. `exact`: a + * publish carrying `threadId` lands exactly there (that thread, or the root + * for null) or is refused with nothing posted. `root`: the channel has no + * threads; a string `threadId` is refused. Absent: no guarantee, and the + * framework does not publish there. + */ + publish?: { + target: 'exact' | 'root'; + }; } export interface ChannelHistoryRequest { @@ -1106,6 +1117,8 @@ export interface ChannelsOutgoingChunkParams { channelId: string; index: number; delta: string; + /** MCPL RFC-011 §6: the place the stream's final publish targets. */ + threadId?: string | null; } /** @@ -1118,6 +1131,8 @@ export interface ChannelsOutgoingCompleteParams { conversationId: string; channelId: string; content: McplContentBlock[]; + /** MCPL RFC-011 §6: the place the stream's final publish targets. */ + threadId?: string | null; } /** @@ -1130,6 +1145,13 @@ export interface ChannelsPublishParams { channelId: string; stream?: boolean; content: McplContentBlock[]; + /** + * MCPL RFC-011: where in the channel to post — a non-empty string for that + * thread, null for the channel root; absent is legacy (the server + * chooses). Sent only to a channel that declares + * `capabilities.publish.target`, and only in the Request form. + */ + threadId?: string | null; } /** @@ -1138,6 +1160,10 @@ export interface ChannelsPublishParams { export interface ChannelsPublishResult { delivered: boolean; messageId?: string; + /** MCPL RFC-011: where the post landed (null: the channel root). */ + threadId?: string | null; + /** MCPL RFC-011: why a publish was refused, or a note on the outcome. */ + reason?: string; } /** diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 31f6be4ba..2dc0ea9f2 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -16,6 +16,12 @@ * selects one over an addressed candidate. Explicit sends name their own * destination and don't retarget narration. * + * A route is only ever a place the framework can publish to exactly (MCPL + * RFC-011): a channel whose connector declares `capabilities.publish.target`, + * and a thread only on one that declares `exact`. Anything else is + * unroutable, and speech for it is held rather than handed to a connector + * that would choose the place itself. + * * Pure: the framework supplies the candidates and the fork home, and owns * the per-turn state. */ @@ -67,10 +73,9 @@ export type SpeechRoute = /** As in ConversationRef: absent when unknown. */ serverId?: string; channelId: string; - /** Never set on a route: plain speech can't be posted into a thread - * (publishing carries no thread), so a thread conversation is never - * inferred as a route. Kept for the type's symmetry with - * ConversationRef. */ + /** The thread the route speaks into — only on a channel whose + * connector posts into a named thread (RFC-011 `exact`). Absent: the + * channel root. */ threadId?: string; /** The message being answered, kept across same-conversation arrivals. */ replyTo?: string; @@ -93,9 +98,28 @@ export interface TurnRoute { since: 'turn-start' | 'mid-turn'; }; /** No route because the one conversation the turn answers can't take - * plain speech: a thread (publishing carries no thread), or a channel the - * host couldn't resolve. Said in the routing notice and the drafts' note. */ - unroutable?: { conversation: ConversationRef; reason: 'thread' | 'unresolved' }; + * plain speech, said in the routing notice and the drafts' note: + * - `untargetable`: its channel's connector doesn't declare where a post + * lands (RFC-011), so the framework doesn't publish there; + * - `thread`: a thread on a channel whose connector can't post into a + * named thread (it declares `root`: a contradiction the connector + * shouldn't produce); + * - `unresolved`: a channel the host couldn't resolve. */ + unroutable?: { conversation: ConversationRef; reason: UnroutableReason }; +} + +export type UnroutableReason = 'untargetable' | 'thread' | 'unresolved'; + +/** A channel's declared publish target (RFC-011), as the framework reads it. */ +export type PublishTargetOf = (channel: { serverId?: string; channelId: string }) => 'exact' | 'root' | undefined; + +/** Why a channel conversation can't be a route, or undefined when it can. */ +export function routeRefusal(c: ConversationRef, targetOf: PublishTargetOf): UnroutableReason | undefined { + if (c.kind !== 'channel') return undefined; + const declared = targetOf(c); + if (!declared) return 'untargetable'; + if (c.threadId && declared !== 'exact') return 'thread'; + return undefined; } /** Identity of a conversation: server, channel and thread, or the surface. */ @@ -134,11 +158,19 @@ export function describeConversation(c: ConversationRef): string { * infer a route only when they name one conversation, from the newest * candidate in it (its message is the reply edge). Several conversations * start the turn held, naming each; none leaves the turn without a route, - * and so does one that can't take plain speech (a thread, or a channel that - * couldn't be resolved), which the turn records as `unroutable`. + * and so does one that can't take plain speech — a channel the framework + * can't publish into exactly (`targetOf`, RFC-011), or one that couldn't be + * resolved — which the turn records as `unroutable`. */ -export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: SpeechRoute | null): TurnRoute { - if (home) return { route: home }; +export function inferTurnRoute( + candidates: readonly RouteCandidate[], + home: SpeechRoute | null | undefined, + targetOf: PublishTargetOf, +): TurnRoute { + if (home) { + const refusal = routeRefusal(routeConversation(home), targetOf); + return refusal ? { route: null, unroutable: { conversation: routeConversation(home), reason: refusal } } : { route: home }; + } const addressed = candidates.filter((c) => c.addressed); const pool = addressed.length > 0 ? addressed : candidates; if (pool.length === 0) return { route: null }; @@ -157,11 +189,12 @@ export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: Spe const [chosen] = newestByConversation.values(); const c = chosen!.conversation; // A conversation whose channel isn't resolvable can't be spoken into, and - // neither can a thread: publishing carries no thread, so plain speech - // would land in the channel root — a different conversation than the one - // being answered. Both still compete for the turn (above). + // neither can one the framework can't publish into exactly: its connector + // would choose the place, which may be another conversation (RFC-011). + // Both still compete for the turn (above). if (chosen!.unroutable) return { route: null, unroutable: { conversation: c, reason: 'unresolved' } }; - if (c.kind === 'channel' && c.threadId) return { route: null, unroutable: { conversation: c, reason: 'thread' } }; + const refusal = routeRefusal(c, targetOf); + if (refusal) return { route: null, unroutable: { conversation: c, reason: refusal } }; return c.kind === 'surface' ? { route: { kind: 'surface', surface: c.surface, origin: 'trigger' } } : { @@ -169,6 +202,7 @@ export function inferTurnRoute(candidates: readonly RouteCandidate[], home?: Spe kind: 'channel', ...(c.serverId ? { serverId: c.serverId } : {}), channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), ...(chosen!.messageId ? { replyTo: chosen!.messageId } : {}), ...(c.label ? { label: c.label } : {}), origin: 'trigger', diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 5731866ce..3248b460b 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -25,7 +25,7 @@ function makeRegistry( ) { const failures: RouteFailure[] = []; const traces: Array<{ type: string; [k: string]: unknown }> = []; - const publishCalls: Array<{ channelId?: string; conversationId?: string }> = []; + const publishCalls: Array<{ channelId?: string; conversationId?: string; threadId?: string | null }> = []; const openCalls: Array<{ channelId?: string }> = []; const closeCalls: Array<{ channelId?: string }> = []; @@ -34,9 +34,10 @@ function makeRegistry( const mockServer = { // Post-policy state: full grant, so tests exercise delivery, not §5.3 denial. grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), - sendChannelsPublish: async (params: { channelId?: string; conversationId?: string }) => { + sendChannelsPublish: async (params: { channelId?: string; conversationId?: string; threadId?: string | null }) => { publishCalls.push(params); - return publishResult; + // A conforming MCPL RFC-011 server: a delivery echoes the place asked for. + return publishResult?.delivered === true && 'threadId' in params ? { ...publishResult, threadId: params.threadId } : publishResult; }, sendChannelsOpen: async (params: { channelId?: string }) => { if (failOpens) throw new Error('open refused by server'); @@ -94,14 +95,19 @@ function incoming(channelId: string, text: string, channelName?: string) { /** §14.5: channels/incoming no longer mints unknown channels — seed the * registered state a conforming server would have created via - * channels/register before feeding incoming traffic. */ + * channels/register before feeding incoming traffic. Each channel declares + * an MCPL RFC-011 publish target (`root`), as a conforming server's do. */ function seedRegistered(registry: ChannelRegistry, serverId: string, ...ids: string[]): void { const map = (registry as unknown as { - channels: Map; + channels: Map; open: boolean }>; }).channels; for (const id of ids) { if (!map.has(`${serverId}:${id}`)) { - map.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: serverId, label: id }, open: false }); + map.set(`${serverId}:${id}`, { + serverId, + descriptor: { id, type: serverId, label: id, capabilities: { publish: { target: 'root' } } }, + open: false, + }); } } } @@ -207,9 +213,9 @@ test('a route that names its server publishes there, even when another server re const published: Array<{ server: string; channelId?: string }> = []; const server = (id: string) => ({ grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), - sendChannelsPublish: async (params: { channelId?: string }) => { + sendChannelsPublish: async (params: { channelId?: string; threadId?: string | null }) => { published.push({ server: id, channelId: params.channelId }); - return { delivered: true, messageId: `${id}-1` }; + return { delivered: true, messageId: `${id}-1`, threadId: params.threadId }; }, sendChannelsOpen: async () => ({}), }); @@ -254,7 +260,7 @@ test('channel_publish without a channel goes to the caller\'s route, and its rec const res = await registry.handleChannelToolCall('channel_publish', { content: 'hello' }, { kind: 'agent', agentName: 'scout' }); assert.equal(res.success, true); - assert.deepEqual(res.data, { delivered: true, status: 'delivered', serverId: 'discord', channelId: 'chanA', channelLabel: 'chanA', messageId: 'p-1' }); + assert.deepEqual(res.data, { delivered: true, status: 'delivered', serverId: 'discord', channelId: 'chanA', channelLabel: 'chanA', threadId: null, messageId: 'p-1' }); assert.equal(publishCalls.at(-1)?.channelId, 'chanA', 'the route, not the last inbound channel'); }); @@ -312,11 +318,12 @@ test('channel_publish reports failed and unknown outcomes with the attempted des assert.equal((unknown.data as { status: string }).status, 'unknown'); }); -test('ensureChannelRegistered keeps a DM closed while making its one-shot reply routable', async () => { - // A Discord DM arrives via push/event (channel closed), so it is never - // registered — routeSpeech would drop the reply. Registering it on inbound - // makes it a publishable destination; the woken turn's route (its DM) - // then targets it. +test('ensureChannelRegistered keeps a DM closed; the reply goes out once its connector declares the channel', async () => { + // A Discord DM arrives via push/event (channel closed). The host registers + // it lazily so it resolves — but a host-minted channel carries no MCPL + // RFC-011 declaration, so the framework doesn't publish there: the + // connector might choose the place itself. A connector that registers the + // DM with a declaration (as discord-mcpl does) makes the reply routable. const dm = 'discord:dm:42'; const { registry, publishCalls, lookup, traces } = makeRegistry( { delivered: true }, @@ -334,10 +341,18 @@ test('ensureChannelRegistered keeps a DM closed while making its one-shot reply assert.equal(entry!.open, false, 'one-shot reachability is not a subscription'); assert.ok(traces.some((t) => t.type === 'mcpl:channel-lazy-registered')); + const undeclared = await registry.routeSpeech('scout', 'replying in the DM', { serverId: 'discord', channelId: dm }); + assert.equal(undeclared, null, 'no declaration, no publication'); + assert.equal(publishCalls.length, 0); + + await registry.handleChanged('discord', { + updated: [{ id: dm, type: 'discord', label: 'DM with Antra', direction: 'bidirectional', capabilities: { publish: { target: 'root' } } }], + }); const res = await registry.routeSpeech('scout', 'replying in the DM', { serverId: 'discord', channelId: dm }); assert.deepEqual(res, { delivered: true, channelId: dm }, - 'the DM reply must route back to the DM channel'); + 'the DM reply routes back to the DM channel once it is declared'); assert.equal(publishCalls.at(-1)?.channelId, dm); + assert.equal(publishCalls.at(-1)?.threadId, null, 'at the channel itself'); }); test('ensureChannelRegistered is idempotent and does not reopen a closed channel', () => { diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 065ef9537..65d1d6db4 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -611,6 +611,27 @@ describe('debounce', () => { ]); }); + it('a batched candidate keeps its thread: a thread and its channel root are different conversations', async () => { + const path = writeConfig('debounce-route-thread.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const resolveRouteChannel = (info: GateEventInfo) => info.channelId; + const { gate, inferenceRequests } = makeGate(path, { resolveRouteChannel }); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'slack', channelId: 'slack:C1', content: '@agent in thread', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'm-t', threadId: '1700.0001' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'slack', channelId: 'slack:C1', content: '@agent at root', + tags: ['chat:addressed'], metadata: { authorId: '2', messageId: 'm-r' } })); + await new Promise(r => setTimeout(r, 150)); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'channel', channelId: 'slack:C1', serverId: 'slack', threadId: '1700.0001', messageId: 'm-t', addressed: true }, + { kind: 'channel', channelId: 'slack:C1', serverId: 'slack', messageId: 'm-r', addressed: true }, + ]); + }); + it('a reaction in the batch is never a route candidate (the same predicate as mid-turn)', async () => { const path = writeConfig('debounce-route-reaction.json', { policies: [ diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 588f08774..64d509ea6 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -96,7 +96,7 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, + getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { @@ -346,7 +346,8 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'Ordinary field speech.', locus: 'world:commons' }]); + // The route's exact place: its channel, at the root (MCPL RFC-011). + assert.deepEqual(routed, [{ text: 'Ordinary field speech.', locus: { channelId: 'world:commons', threadId: null } }]); await framework.stop(); }); diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs index 6d6a757d5..9e3976eab 100644 --- a/test/fixtures/speech-route-mcpl-server.mjs +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -1,8 +1,18 @@ // MCPL fixture for speech routing, drafts and provenance tests. // -// Registers the channels in CHANNELS (JSON array of {id,label}; two Discord -// guild channels by default) and, on command, sends inbound traffic over -// BOTH MCPL lanes. The TEST appends one JSON object per line to COMMAND_PATH: +// Registers the channels in CHANNELS (JSON array of {id,label,publishTarget?}; +// two Discord guild channels by default) and, on command, sends inbound +// traffic over BOTH MCPL lanes. Each channel declares its MCPL RFC-011 +// publish target: its own publishTarget ('exact' | 'root' | null for none), +// else PUBLISH_TARGET (default 'root'). channels/publish follows RFC-011 as a +// server that implements it: a threadId is honored or refused, never ignored, +// and a delivery echoes where it posted. A DM's channel (discord:dm:) is +// registered with its declaration (DM_TARGET, default 'root') just before +// the DM is pushed, as an RFC-011 connector registers its DM channels; +// DM_TARGET=none leaves it to the host's lazy registration, undeclared. +// The tool names in TOOLS (JSON array) +// are listed, so a test can see which connector tools a notice names. +// The TEST appends one JSON object per line to COMMAND_PATH: // {"op":"incoming","channelId":…,"messageId":…,"mode":"ambient"|"addressed", // "text":…, "threadId"?, "metadata"?, "eventId"?, "coalesce"?} // {"op":"dm","eventId":…,"authorId":…,"authorName"?,"rawChannelId":…,"text":…, @@ -10,10 +20,12 @@ // {"op":"push","eventId":…,"origin"?,"tags"?,"text":…,"featureSet"?, // "content"? (content blocks, instead of text; [] included)} // {"op":"rename","channelId":…,"label":…} (channels/changed) -// {"op":"publish-mode","mode":"delivered"|"not-delivered"|"no-receipt"|"error"|"hang"} +// {"op":"publish-mode","mode":"delivered"|"not-delivered"|"no-receipt"|"error"|"hang"|"no-echo"|"wrong-echo"} +// {"op":"declare","channelId":…,"target":"exact"|"root"|null} (channels/changed) // {"op":"history","channelId":…,"history":[ChannelIncomingMessage…]} // Host-side channels/publish and channels/open calls are recorded to -// STATUS_PATH as JSONL ({event:'publish', channelId, text, messageId}). +// STATUS_PATH as JSONL ({event:'publish', channelId, text, messageId, +// threadId}; threadId absent when the request carried none). import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; @@ -25,6 +37,15 @@ const CHANNELS = process.env.CHANNELS { id: 'discord:g1:room', label: '#room (Guild One)' }, { id: 'discord:g1:general', label: '#general (Guild One)' }, ]; +const DEFAULT_TARGET = process.env.PUBLISH_TARGET === 'none' ? null : (process.env.PUBLISH_TARGET ?? 'root'); +for (const c of CHANNELS) if (c.publishTarget === undefined) c.publishTarget = DEFAULT_TARGET; +const TOOLS = process.env.TOOLS ? JSON.parse(process.env.TOOLS) : []; +const DM_TARGET = process.env.DM_TARGET === 'none' ? null : (process.env.DM_TARGET ?? 'root'); +const registeredDms = new Set(); +const descriptor = (c) => ({ + id: c.id, type: 'discord', label: c.label, direction: 'bidirectional', + ...(c.publishTarget ? { capabilities: { publish: { target: c.publishTarget } } } : {}), +}); const log = (event, extra = {}) => { if (!statusPath) return; @@ -72,6 +93,23 @@ function pollCommands() { }, }); } else if (c.op === 'dm') { + const dmChannel = `discord:dm:${c.rawChannelId}`; + if (DM_TARGET && !registeredDms.has(dmChannel)) { + registeredDms.add(dmChannel); + const dm = { id: dmChannel, label: `DM: ${c.authorName ?? 'antra'}`, publishTarget: DM_TARGET }; + CHANNELS.push(dm); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { + added: [{ + ...descriptor(dm), + metadata: { channelType: 'dm', recipientName: c.authorName ?? 'antra', ...(c.authorId ? { recipientId: c.authorId } : {}) }, + }], + }, + }); + } log('dm-sent', { eventId: c.eventId }); send({ jsonrpc: '2.0', @@ -118,10 +156,20 @@ function pollCommands() { id: nextId++, method: 'channels/changed', params: { - updated: [{ id: c.channelId, type: 'discord', label: c.label, direction: 'bidirectional' }], + updated: [descriptor(channel ?? { id: c.channelId, label: c.label, publishTarget: DEFAULT_TARGET })], }, }); log('renamed', { channelId: c.channelId, label: c.label }); + } else if (c.op === 'declare') { + const channel = CHANNELS.find((x) => x.id === c.channelId); + if (channel) channel.publishTarget = c.target; + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { updated: [descriptor(channel ?? { id: c.channelId, label: c.channelId, publishTarget: c.target })] }, + }); + log('declared', { channelId: c.channelId, target: c.target }); } else if (c.op === 'publish-mode') { publishMode = c.mode; log('publish-mode', { mode: c.mode }); @@ -173,20 +221,18 @@ rl.on('line', (line) => { id: 400, method: 'channels/register', params: { - channels: CHANNELS.map((c) => ({ - id: c.id, type: 'discord', label: c.label, direction: 'bidirectional', initiallyOpen: true, - })), + channels: CHANNELS.map((c) => ({ ...descriptor(c), initiallyOpen: true })), }, }); return; } if (msg.method === 'channels/open') { const channelId = msg.params?.channelId; - const channel = CHANNELS.find((x) => x.id === channelId) ?? { id: channelId, label: channelId }; + const channel = CHANNELS.find((x) => x.id === channelId) ?? { id: channelId, label: channelId, publishTarget: DEFAULT_TARGET }; const history = msg.params?.history ? histories.get(channelId) : undefined; log('channel-opened', { channelId, history: !!history }); reply(msg.id, { - channel: { id: channel.id, type: 'discord', label: channel.label, direction: 'bidirectional' }, + channel: descriptor(channel), ...(history ? { history } : {}), }); return; @@ -198,6 +244,25 @@ rl.on('line', (line) => { if (msg.method === 'channels/publish') { const text = (msg.params?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join('\n'); const channelId = msg.params?.channelId; + const targeted = msg.params && 'threadId' in msg.params; + const threadId = msg.params?.threadId; + const placed = targeted ? { threadId } : {}; + // RFC-011, as a server that implements it: never ignore a threadId. + if (targeted) { + const declared = CHANNELS.find((x) => x.id === channelId)?.publishTarget ?? null; + const why = threadId !== null && (typeof threadId !== 'string' || threadId === '') + ? 'invalid threadId' + : !declared + ? 'this channel declares no publish target' + : declared === 'root' && threadId !== null + ? 'this channel has no threads' + : undefined; + if (why) { + log('publish-refused', { channelId, text, ...placed, reason: why }); + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: false, reason: why }); + return; + } + } if (publishMode === 'hang') { log('publish-hung', { channelId, text }); return; // never answered: the host's request times out @@ -208,19 +273,26 @@ rl.on('line', (line) => { return; } if (publishMode === 'not-delivered') { - log('publish-refused', { channelId, text }); + log('publish-refused', { channelId, text, ...placed }); if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: false }); return; } const messageId = `posted-${++posted}`; - log('publish', { channelId, text, messageId }); + log('publish', { channelId, text, messageId, ...placed }); if (msg.id !== undefined && msg.id !== null) { - reply(msg.id, publishMode === 'no-receipt' ? {} : { delivered: true, messageId }); + const echo = publishMode === 'no-echo' + ? {} + : publishMode === 'wrong-echo' + ? { threadId: threadId === null ? 'some-other-thread' : null } + : placed; + reply(msg.id, publishMode === 'no-receipt' ? {} : { delivered: true, messageId, ...echo }); } return; } if (msg.method === 'tools/list') { - reply(msg.id, { tools: [] }); + reply(msg.id, { + tools: TOOLS.map((name) => ({ name, description: `fixture tool ${name}`, inputSchema: { type: 'object', properties: {} } })), + }); return; } if (msg.method === 'tools/call') { diff --git a/test/fixtures/tune-out-mcpl-server.mjs b/test/fixtures/tune-out-mcpl-server.mjs index bdb8b6b95..57fc2b74d 100644 --- a/test/fixtures/tune-out-mcpl-server.mjs +++ b/test/fixtures/tune-out-mcpl-server.mjs @@ -109,6 +109,8 @@ rl.on('line', (line) => { label: 'noisy', direction: 'bidirectional', initiallyOpen: true, + // MCPL RFC-011: the channel posts exactly where it is asked. + capabilities: { publish: { target: 'root' } }, }], }, }); @@ -134,7 +136,9 @@ rl.on('line', (line) => { } if (msg.method === 'channels/publish') { log('publish', { channelId: msg.params?.channelId }); - if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true }); + // RFC-011: a delivery echoes the place it was asked for. + const placed = msg.params && 'threadId' in msg.params ? { threadId: msg.params.threadId } : {}; + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true, ...placed }); return; } if (msg.method === 'tools/list') { diff --git a/test/fixtures/wake-locus-mcpl-server.mjs b/test/fixtures/wake-locus-mcpl-server.mjs index 203b4e0e5..d109e8ab9 100644 --- a/test/fixtures/wake-locus-mcpl-server.mjs +++ b/test/fixtures/wake-locus-mcpl-server.mjs @@ -6,6 +6,9 @@ // TEST appends lines to COMMAND_PATH: // incoming // dm +// Every channel declares an MCPL RFC-011 publish target (root), and a DM's +// channel (discord:dm:) is registered with one just before the DM is +// pushed, as discord-mcpl registers its DM channels. // Host-side channels/publish and channels/open calls are recorded to // STATUS_PATH as JSONL. import { appendFileSync, existsSync, readFileSync } from 'node:fs'; @@ -27,6 +30,7 @@ const CHANNELS = [ ]; let nextId = 500; let processedCommands = 0; +const registeredDms = new Set(); function pollCommands() { if (!commandPath || !existsSync(commandPath)) return; @@ -56,6 +60,22 @@ function pollCommands() { }); } else if (kind === 'dm') { const [eventId, authorId, rawChannelId, ...words] = rest; + const dmChannel = `discord:dm:${rawChannelId}`; + if (!registeredDms.has(dmChannel)) { + registeredDms.add(dmChannel); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { + added: [{ + id: dmChannel, type: 'discord', label: 'DM: antra', direction: 'bidirectional', + metadata: { channelType: 'dm', recipientName: 'antra', recipientId: authorId }, + capabilities: { publish: { target: 'root' } }, + }], + }, + }); + } log('dm-sent', { eventId }); send({ jsonrpc: '2.0', @@ -126,6 +146,8 @@ rl.on('line', (line) => { params: { channels: CHANNELS.map((c) => ({ id: c.id, type: 'discord', label: c.label, direction: 'bidirectional', initiallyOpen: true, + // MCPL RFC-011: every channel posts exactly where it is asked. + capabilities: { publish: { target: 'root' } }, })), }, }); @@ -142,7 +164,9 @@ rl.on('line', (line) => { } if (msg.method === 'channels/publish') { log('publish', { channelId: msg.params?.channelId }); - if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true }); + // RFC-011: a delivery echoes the place it was asked for. + const placed = msg.params && 'threadId' in msg.params ? { threadId: msg.params.threadId } : {}; + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true, ...placed }); return; } if (msg.method === 'tools/list') { diff --git a/test/helpers/coalescing-fixture.ts b/test/helpers/coalescing-fixture.ts index 2a2405683..14f46ab5b 100644 --- a/test/helpers/coalescing-fixture.ts +++ b/test/helpers/coalescing-fixture.ts @@ -44,7 +44,11 @@ export async function fixture(options: { server?: Record; frame } } }, serverInfo: { name: 'editor', version: '1' } }); } else if (m.method === 'featureSets/update') reply({ accepted: true }); else if (m.method === 'tools/list') reply({ tools: [] }); - else if (m.method === 'channels/publish') { published.push(m.params); if (m.id !== undefined) reply({ delivered: true }); } + else if (m.method === 'channels/publish') { + published.push(m.params); + // RFC-011: a delivery echoes the place it was asked for. + if (m.id !== undefined) reply({ delivered: true, ...('threadId' in (m.params ?? {}) ? { threadId: m.params.threadId } : {}) }); + } else if (m.method === 'channels/close') reply({ closed: true }); else if (m.method === 'channels/open') reply({ channel: { id: m.params.channelId, type: 'discord', label: m.params.channelId } }); else if (m.method === 'push/render') { renders.push(m.params); reply(await renderer(m.params)); } @@ -77,7 +81,14 @@ export async function fixture(options: { server?: Record; frame port: (wss.address() as AddressInfo).port, disconnect: () => socket.terminate(), online: (value: boolean) => { online = value; }, - register: (id = 'chat') => send('channels/register', { channels: [{ id, type: 'discord', label: id, metadata: { channelType: 'guild_text' } }] }), + /** Registers a channel; it declares an RFC-011 publish target (default + * `exact`: a thread posts into that thread), or none with `null`. */ + register: (id = 'chat', target: 'exact' | 'root' | null = 'exact') => send('channels/register', { + channels: [{ + id, type: 'discord', label: id, metadata: { channelType: 'guild_text' }, + ...(target ? { capabilities: { publish: { target } } } : {}), + }], + }), /** A coalesced channels/incoming message for platform message `m`. */ channel: (eventId: string, text: string, flags: Record = {}, channelId = 'chat', messageId = 'm') => ({ channelId, messageId, eventId, timestamp: TS, author: { id: 'u', name: 'User' }, tags: ['chat:mention'], diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 52367e39b..933fe4828 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -176,21 +176,16 @@ test('vector 43: a mixed-lane edit keeps the stable reply target', async (t) => await f.send('push/event', { ...f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' }), origin: { messageId: 'm', authorId: 'u', authorName: 'User', threadId: 'thread' } }); await f.turn(); // Both lanes name one conversation — the same channel, thread and message — - // so the turn has a single reply target, never a hold between "two". + // so the turn has a single reply target, never a hold between "two"; and + // the channel posts into a named thread (RFC-011 exact), so the reply goes + // into that thread, not the channel root. const sources = f.framework.getAgent('agent')!.getContextManager().getAllMessages() .map((m) => m.metadata?.inboundSource as { channelId?: string; threadId?: string; messageId?: string } | undefined) .filter((s) => s !== undefined) .map((s) => [s!.channelId, s!.threadId, s!.messageId]); assert.deepEqual(sources, [['chat', 'thread', 'm'], ['chat', 'thread', 'm']]); - // That target is a thread, and publishing carries no thread (shelf-355): - // plain speech is held rather than posted into the channel root, which is - // a different conversation. - const turn = (f.framework as unknown as { turnRoutes: Map }) - .turnRoutes.get('agent'); - assert.equal(turn?.route, null); - assert.equal(turn?.hold, undefined); - assert.deepEqual([turn?.unroutable?.reason, turn?.unroutable?.conversation.channelId, turn?.unroutable?.conversation.threadId], ['thread', 'chat', 'thread']); - assert.equal(f.published.length, 0); + assert.equal(f.published.at(-1)?.channelId, 'chat'); + assert.equal((f.published.at(-1) as { threadId?: unknown } | undefined)?.threadId, 'thread'); }); test('vectors 13/15: a channel-scoped push needs current channel authority and a declared channel', async (t) => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c471ec20d..ab982b226 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -159,6 +159,9 @@ function stubChannelRegistry(framework: AgentFramework, opts: { home?: boolean } isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // Every channel declares an MCPL RFC-011 publish target, so it can be a + // route (a route is only ever a place the framework publishes to exactly). + publishTarget: () => 'root', }; // Everything else driveStream/stop touches (startTyping, stopTyping, // stopAll, ensureChannelRegistered, ...) becomes a no-op via Proxy so the diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index bfea62ed2..bbc6ecd9d 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -814,6 +814,8 @@ describe('drafts resend ownership', () => { resolve: (o) => resolve({ destination: { serverId: 'discord', channelId: target.channelId }, at: Date.now(), ...(o as object) }), })), getChannelTools: () => [], + // Every channel declares an MCPL RFC-011 publish target. + publishTarget: () => 'root', }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(stub, { get: (t, prop: string) => (prop in t ? t[prop] : () => undefined), diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index d9d2d79d3..73d08eb98 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -8,6 +8,11 @@ * follow a partial multi-part post), a timeout, a lost connection, or a * missing/malformed receipt. The attempted destination is always named by * the registry, never by the caller's spelling. + * + * Every publish names its place inside the channel (MCPL RFC-011): a thread, + * or the root. It goes only to a channel that declares a publish target, a + * thread only to one that declares `exact`, and a delivery counts only when + * its echo names the place asked for. */ import { test, describe, it } from 'node:test'; import assert from 'node:assert/strict'; @@ -22,17 +27,17 @@ import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -type Publish = (params: { channelId: string }) => Promise; +type Publish = (params: { channelId: string; threadId?: string | null }) => Promise; function registryWith(servers: Record, failures: unknown[] = []) { - const published: Array<{ serverId: string; channelId: string }> = []; + const published: Array<{ serverId: string; channelId: string; threadId?: string | null }> = []; const mocks = new Map(); for (const [id, s] of Object.entries(servers)) { mocks.set(id, { id, grant: new CapabilityGrant(new Set(s.grant === false ? [] : ALL_CAPABILITY_PATHS), []), - sendChannelsPublish: async (params: { channelId: string }) => { - published.push({ serverId: id, channelId: params.channelId }); + sendChannelsPublish: async (params: { channelId: string; threadId?: string | null }) => { + published.push({ serverId: id, channelId: params.channelId, ...('threadId' in params ? { threadId: params.threadId } : {}) }); return s.publish(params); }, sendChannelsOpen: async () => ({}), @@ -46,14 +51,20 @@ function registryWith(servers: Record { failures.push(info); } }, ); const channels = (registry as unknown as { - channels: Map; + channels: Map; open: boolean }>; }).channels; - const seed = (serverId: string, id: string, label: string) => - channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open: true }); + /** A registered channel; it declares `target` (default root), or nothing for null. */ + const seed = (serverId: string, id: string, label: string, target: 'exact' | 'root' | null = 'root') => + channels.set(`${serverId}:${id}`, { + serverId, + descriptor: { id, type: 'discord', label, ...(target ? { capabilities: { publish: { target } } } : {}) }, + open: true, + }); return { registry, published, seed }; } -const ok: Publish = async () => ({ delivered: true, messageId: 'posted-1' }); +/** A conforming delivery: it echoes the place it was asked for. */ +const ok: Publish = async (params) => ({ delivered: true, messageId: 'posted-1', threadId: params.threadId }); describe('ChannelRegistry.publish outcomes', () => { it('confirms only delivered:true, naming the destination from the registry', async () => { @@ -62,7 +73,7 @@ describe('ChannelRegistry.publish outcomes', () => { const outcome = await registry.publish('agent', 'hello', { channelId: 'discord:g1:room' }); assert.equal(outcome.status, 'delivered'); assert.equal(outcome.messageId, 'posted-1'); - assert.deepEqual(outcome.destination, { serverId: 'discord', channelId: 'discord:g1:room', label: '#room (Guild)' }); + assert.deepEqual(outcome.destination, { serverId: 'discord', channelId: 'discord:g1:room', label: '#room (Guild)', threadId: null }); assert.equal(typeof outcome.at, 'number'); }); @@ -176,7 +187,7 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(published.length, 0, 'never routed through whichever server came first'); const exact = await registry.publish('agent', 'x', { serverId: 'beta', channelId: 'discord:g1:room' }); assert.equal(exact.status, 'delivered'); - assert.deepEqual(published, [{ serverId: 'beta', channelId: 'discord:g1:room' }]); + assert.deepEqual(published, [{ serverId: 'beta', channelId: 'discord:g1:room', threadId: null }]); assert.equal(exact.destination?.label, '#room (Beta)'); }); @@ -188,6 +199,91 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(published.length, 0); }); + it('names the root explicitly: a publish without a thread sends threadId null, never omits it', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'c', '#c'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'delivered'); + assert.deepEqual(published, [{ serverId: 'discord', channelId: 'c', threadId: null }]); + }); + + it('posts into a thread only where the channel declares exact targeting', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'forum', '#forum', 'exact'); + seed('discord', 'plain', '#plain', 'root'); + const inThread = await registry.publish('agent', 'x', { channelId: 'forum', threadId: 't-1' }); + assert.equal(inThread.status, 'delivered'); + assert.equal(inThread.destination?.threadId, 't-1'); + const onRoot = await registry.publish('agent', 'x', { channelId: 'plain', threadId: 't-1' }); + assert.equal(onRoot.status, 'failed'); + assert.match(onRoot.reason ?? '', /has no threads/); + assert.deepEqual(published, [{ serverId: 'discord', channelId: 'forum', threadId: 't-1' }], 'the threadless channel saw nothing'); + }); + + it('never publishes to a channel that declares no publish target, and opens nothing first', async () => { + const opened: string[] = []; + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'legacy', '#legacy', null); + const channels = (registry as unknown as { channels: Map }).channels; + channels.get('discord:legacy')!.open = false; + (registry as unknown as { openChannelNow: (e: unknown) => Promise }).openChannelNow = async () => { opened.push('legacy'); }; + const outcome = await registry.publish('agent', 'x', { channelId: 'legacy' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason ?? '', /doesn't declare where a post lands/); + assert.equal(outcome.destination?.channelId, 'legacy'); + assert.deepEqual(published, [], 'the connector would choose the place itself'); + assert.deepEqual(opened, [], 'refused before any side effect'); + }); + + it('refuses an invalid thread id before dispatch', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'forum', '#forum', 'exact'); + for (const threadId of ['', 7 as unknown as string, { id: 't' } as unknown as string]) { + assert.equal((await registry.publish('agent', 'x', { channelId: 'forum', threadId })).status, 'failed', JSON.stringify(threadId)); + } + assert.equal(published.length, 0); + }); + + for (const [what, echo] of [ + ['no echo at all', {}], + ['an echo of the root when a thread was asked for', { threadId: null }], + ['an echo of another thread', { threadId: 't-2' }], + ['an unreadable echo', { threadId: 42 }], + ] as const) { + it(`treats a delivery with ${what} as unknown: something was posted, not provably where asked`, async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: true, messageId: 'p-1', ...echo }) } }); + seed('discord', 'forum', '#forum', 'exact'); + const outcome = await registry.publish('agent', 'x', { channelId: 'forum', threadId: 't-1' }); + assert.equal(outcome.status, 'unknown'); + assert.equal(outcome.messageId, 'p-1'); + assert.match(outcome.reason ?? '', /not thread t-1/); + }); + } + + it('a root publish needs a root echo', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: true, messageId: 'p-1', threadId: 'stray' }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.match(outcome.reason ?? '', /reported posting in thread stray, not the channel root/); + }); + + it('keeps a refusal\'s reason: delivered:false with no message is failed, in the connector\'s words', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, reason: 'thread t-1 is archived' }) } }); + seed('discord', 'forum', '#forum', 'exact'); + const outcome = await registry.publish('agent', 'x', { channelId: 'forum', threadId: 't-1' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason ?? '', /thread t-1 is archived/); + }); + + it('a channels/changed that withdraws the declaration applies to the next publish', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'c', '#c', 'root'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'delivered'); + seed('discord', 'c', '#c', null); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'failed'); + assert.equal(published.length, 1); + }); + it('routeSpeech reports a failed and an uncertain delivery as different outcomes', async () => { const failures: Array<{ outcome?: string }> = []; const { registry, seed } = registryWith({ discord: { publish: async () => ({}) }, other: { publish: async () => ({ delivered: false }) } }, failures); diff --git a/test/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index b3b3f252e..e12f7e869 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -128,7 +128,7 @@ function makeChannelRegistry(framework: AgentFramework) { }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, + getDescriptor: () => undefined, publishTarget: () => 'root', }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { get: (target, prop: string) => (prop in target ? target[prop as keyof typeof target] : () => undefined), diff --git a/test/silent-heartbeat.test.ts b/test/silent-heartbeat.test.ts index 26f4edf6e..f86455f01 100644 --- a/test/silent-heartbeat.test.ts +++ b/test/silent-heartbeat.test.ts @@ -49,7 +49,7 @@ async function make() { routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, startTyping: (channel: string) => { typing.push(channel); }, stopTyping: () => {}, - getChannelTools: () => [], getDescriptor: () => undefined, + getChannelTools: () => [], getDescriptor: () => undefined, publishTarget: () => 'root', }, { get: (t, p: string) => p in t ? (t as any)[p] : () => undefined }); return { dir, membrane, mod, framework, routed, typing }; } diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index d170a2073..baf7c8d31 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -68,6 +68,15 @@ describe('speech route across a logical turn', () => { const framework = await makeFramework(); const i = internals(framework); const scout = framework.getAgent('scout')!; + // A channel subsystem whose channels declare an MCPL RFC-011 publish + // target: a route is only ever a place the framework publishes to exactly. + i.channelRegistry = new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + routeSpeech: async () => ({ delivered: true, channelId: 'x' }), + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }])); await i.startAgentStream(scout, fromChannel('chan-A')); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 62700d57e..6bc8d2268 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -24,23 +24,27 @@ const candidate = ( at: number, messageId?: string, ): RouteCandidate => ({ conversation, addressed, at, ...(messageId ? { messageId } : {}) }); +/** Every channel declares `root` (MCPL RFC-011): the common, threadless case. */ +const rootEverywhere = (): 'root' => 'root'; +const exactEverywhere = (): 'exact' => 'exact'; +const declaredNowhere = (): undefined => undefined; describe('inferTurnRoute', () => { it('a fork home always wins', () => { const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; - const turn = inferTurnRoute([candidate(channel('elsewhere'), true, 5, 'm1')], home); + const turn = inferTurnRoute([candidate(channel('elsewhere'), true, 5, 'm1')], home, rootEverywhere); assert.deepEqual(turn, { route: home }); }); it('no candidates: no route', () => { - assert.deepEqual(inferTurnRoute([]), { route: null }); + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere), { route: null }); }); it('an addressed candidate outranks newer ambient chatter elsewhere', () => { const turn = inferTurnRoute([ candidate(channel('dm'), true, 1, 'm-dm'), candidate(channel('general'), false, 2, 'm-gen'), - ]); + ], undefined, rootEverywhere); assert.deepEqual(turn.route, { kind: 'channel', serverId: 'discord', channelId: 'dm', replyTo: 'm-dm', origin: 'trigger' }); assert.equal(turn.hold, undefined); }); @@ -50,15 +54,15 @@ describe('inferTurnRoute', () => { candidate(channel('room'), true, 1, 'm1'), candidate(channel('dm'), true, 2, 'm2'), candidate(channel('general'), false, 3, 'm3'), - ]); + ], undefined, rootEverywhere); assert.equal(turn.route, null); assert.deepEqual(turn.hold, { since: 'turn-start', conversations: [channel('dm'), channel('room')] }); }); it('ambient-only: one conversation routes there; two hold', () => { - const one = inferTurnRoute([candidate(channel('room'), false, 1, 'm1'), candidate(channel('room'), false, 2, 'm2')]); + const one = inferTurnRoute([candidate(channel('room'), false, 1, 'm1'), candidate(channel('room'), false, 2, 'm2')], undefined, rootEverywhere); assert.deepEqual(one.route, { kind: 'channel', serverId: 'discord', channelId: 'room', replyTo: 'm2', origin: 'trigger' }); - const two = inferTurnRoute([candidate(channel('room'), false, 1), candidate(channel('general'), false, 2)]); + const two = inferTurnRoute([candidate(channel('room'), false, 1), candidate(channel('general'), false, 2)], undefined, rootEverywhere); assert.equal(two.route, null); assert.equal(two.hold?.conversations.length, 2); }); @@ -68,7 +72,7 @@ describe('inferTurnRoute', () => { candidate(channel('room'), true, 1, 'mention'), candidate(channel('room'), false, 5, 'later-ambient'), candidate(channel('room'), true, 3, 'second-mention'), - ]); + ], undefined, rootEverywhere); assert.equal(turn.route?.kind === 'channel' ? turn.route.replyTo : undefined, 'second-mention', 'the addressed pool decides, so its newest message is the reply edge'); }); @@ -77,42 +81,65 @@ describe('inferTurnRoute', () => { const servers = inferTurnRoute([ candidate({ kind: 'channel', serverId: 'a', channelId: 'shared' }, true, 1), candidate({ kind: 'channel', serverId: 'b', channelId: 'shared' }, true, 2), - ]); + ], undefined, rootEverywhere); assert.equal(servers.hold?.conversations.length, 2); const threads = inferTurnRoute([ candidate(channel('forum', { threadId: 't1' }), true, 1), candidate(channel('forum', { threadId: 't2' }), true, 2), - ]); + ], undefined, rootEverywhere); assert.equal(threads.hold?.conversations.length, 2); }); - it('a thread conversation competes but is never a route: publishing carries no thread', () => { + it('a thread is a route only where the connector posts into a named thread (RFC-011 exact)', () => { const thread = channel('forum', { threadId: 't1', label: 'forum' }); - const one = inferTurnRoute([candidate(thread, true, 1, 'm1')]); - assert.deepEqual(one, { route: null, unroutable: { conversation: thread, reason: 'thread' } }, - 'speech would otherwise land in the channel root, a different conversation'); - const held = inferTurnRoute([candidate(thread, true, 1), candidate(channel('room'), true, 2)]); + const exact = inferTurnRoute([candidate(thread, true, 1, 'm1')], undefined, exactEverywhere); + assert.deepEqual(exact.route, { + kind: 'channel', serverId: 'discord', channelId: 'forum', threadId: 't1', replyTo: 'm1', label: 'forum', origin: 'trigger', + }); + // A thread on a channel declared threadless is a contradiction: held, + // never posted to the root (a different conversation). + const root = inferTurnRoute([candidate(thread, true, 1, 'm1')], undefined, rootEverywhere); + assert.deepEqual(root, { route: null, unroutable: { conversation: thread, reason: 'thread' } }); + const held = inferTurnRoute([candidate(thread, true, 1), candidate(channel('room'), true, 2)], undefined, exactEverywhere); assert.equal(held.hold?.conversations.length, 2, 'it still holds a turn when it competes'); }); + it('a channel whose connector declares no publish target is never a route (RFC-011)', () => { + const one = inferTurnRoute([candidate(channel('room'), true, 1, 'm1')], undefined, declaredNowhere); + assert.deepEqual(one, { route: null, unroutable: { conversation: channel('room'), reason: 'untargetable' } }, + 'the connector would choose the place — perhaps a thread — itself'); + const thread = channel('forum', { threadId: 't1' }); + assert.equal(inferTurnRoute([candidate(thread, true, 1)], undefined, declaredNowhere).unroutable?.reason, 'untargetable', + 'no declaration outranks the thread question'); + // A fork home is the same: no declaration, no route. + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + assert.deepEqual(inferTurnRoute([], home, declaredNowhere), { + route: null, unroutable: { conversation: { kind: 'channel', serverId: 'discord', channelId: 'home' }, reason: 'untargetable' }, + }); + // The lookup is per channel. + const mixed = (c: { channelId: string }): 'root' | undefined => (c.channelId === 'room' ? 'root' : undefined); + assert.equal(inferTurnRoute([candidate(channel('room'), true, 1)], undefined, mixed).route?.kind, 'channel'); + assert.equal(inferTurnRoute([candidate(channel('other'), true, 1)], undefined, mixed).unroutable?.reason, 'untargetable'); + }); + it('a channel whose server is unknown routes without a placeholder server', () => { - const turn = inferTurnRoute([candidate({ kind: 'channel', channelId: 'solo' }, true, 1, 'm1')]); + const turn = inferTurnRoute([candidate({ kind: 'channel', channelId: 'solo' }, true, 1, 'm1')], undefined, rootEverywhere); assert.deepEqual(turn.route, { kind: 'channel', channelId: 'solo', replyTo: 'm1', origin: 'trigger' }); }); it('a conversation whose channel could not be resolved competes, but is never the route', () => { const raw = { conversation: channel('1548'), addressed: true, at: 2, unroutable: true as const }; - const competing = inferTurnRoute([candidate(channel('room'), true, 1, 'm1'), raw]); + const competing = inferTurnRoute([candidate(channel('room'), true, 1, 'm1'), raw], undefined, rootEverywhere); assert.equal(competing.route, null); assert.equal(competing.hold?.conversations.length, 2, 'never the older conversation by default'); - assert.deepEqual(inferTurnRoute([raw]), { route: null, unroutable: { conversation: channel('1548'), reason: 'unresolved' } }); + assert.deepEqual(inferTurnRoute([raw], undefined, rootEverywhere), { route: null, unroutable: { conversation: channel('1548'), reason: 'unresolved' } }); }); it('a local surface is addressed by nature, and routes to the surface', () => { const turn = inferTurnRoute([ candidate({ kind: 'surface', surface: 'tui' }, true, 1), candidate(channel('general'), false, 2), - ]); + ], undefined, rootEverywhere); assert.deepEqual(turn.route, { kind: 'surface', surface: 'tui', origin: 'trigger' }); }); }); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts new file mode 100644 index 000000000..7cdaa53c8 --- /dev/null +++ b/test/targeted-publish.test.ts @@ -0,0 +1,237 @@ +/** + * Targeted publish (MCPL RFC-011), end to end over a real MCPL child that + * implements it (fixtures/speech-route-mcpl-server.mjs): every publication + * the framework makes names its place inside the channel — a thread, or the + * root — and goes only where the connector declares it posts exactly + * (`capabilities.publish.target`). Plain speech, channel_publish, draft + * resends and channel_open's speech target all follow the one rule; a + * delivery counts only when its echo names the place asked for. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework, type ContentBlock } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { ProseDraftStore } from '../src/prose-drafts.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); +/** Posts exactly where asked, threads included. */ +const ROOM = 'discord:g1:room'; +/** Has no threads. */ +const GENERAL = 'discord:g1:general'; +/** Declares nothing: the connector would choose the place itself. */ +const LEGACY = 'discord:g1:legacy'; +const CHANNELS = [ + { id: ROOM, label: '#room (Guild One)', publishTarget: 'exact' }, + { id: GENERAL, label: '#general (Guild One)', publishTarget: 'root' }, + { id: LEGACY, label: '#legacy (Guild One)', publishTarget: null }, +]; + +interface Logged { event: string; channelId?: string; text?: string; threadId?: string | null; reason?: string } + +describe('targeted publish (MCPL RFC-011)', () => { + let dir: string; + let commandPath: string; + let statusPath: string; + let membrane: MockMembrane; + let framework: AgentFramework; + let incoming = 0; + + const until = async (cond: () => boolean, what: string): Promise => { + const deadline = Date.now() + 15_000; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out: ${what}`); + await new Promise((r) => setTimeout(r, 20)); + } + }; + const command = (c: Record): void => appendFileSync(commandPath, JSON.stringify(c) + '\n'); + const logged = (): Logged[] => existsSync(statusPath) + ? readFileSync(statusPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l) as Logged) + : []; + const publishes = (): Logged[] => logged().filter((e) => e.event === 'publish' || e.event === 'publish-refused'); + const texts = (): string[] => framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + const drafts = () => (framework as unknown as { proseDrafts: ProseDraftStore }).proseDrafts.open('scout'); + const toolResults = (): string => JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); + const registry = () => (framework as unknown as { + channelRegistry: { listChannelsRaw(): unknown[]; publishTarget(t: { channelId: string }): string | undefined }; + }).channelRegistry; + + /** One message in `channelId` (a thread of it with `threadId`) starts a turn; resolves when it settles. */ + const turn = async ( + responses: Array>, + channelId: string, + extra: Record = {}, + ): Promise => { + for (const r of responses) membrane.pushResponse(r); + const before = membrane.calls.length; + command({ op: 'incoming', channelId, messageId: `in-${++incoming}`, mode: 'addressed', text: `message ${incoming}`, ...extra }); + await until(() => membrane.calls.length > before, 'turn start'); + await framework.runUntilIdle(); + await until(() => !(framework as unknown as { activeTurnTokens: Map }).activeTurnTokens.has('scout'), 'turn end'); + await framework.runUntilIdle(); + }; + const say = (text: string) => createMockResponse([{ type: 'text', text }]); + const call = (name: string, input: Record, id = `c-${name}`) => + createMockResponse([{ type: 'tool_use', id, name, input }] as ContentBlock[], 'tool_use'); + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'targeted-publish-')); + commandPath = join(dir, 'commands.jsonl'); + statusPath = join(dir, 'status.jsonl'); + writeFileSync(commandPath, ''); + membrane = new MockMembrane(); + incoming = 0; + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [FIXTURE], + env: { + STATUS_PATH: statusPath, + COMMAND_PATH: commandPath, + CHANNELS: JSON.stringify(CHANNELS), + TOOLS: JSON.stringify(['reply_message', 'ring']), + }, + }], + modules: [], + }); + await framework.start(); + await until(() => registry().listChannelsRaw().length >= 3, 'registration'); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads each channel\'s declaration from its descriptor', () => { + assert.equal(registry().publishTarget({ channelId: ROOM }), 'exact'); + assert.equal(registry().publishTarget({ channelId: GENERAL }), 'root'); + assert.equal(registry().publishTarget({ channelId: LEGACY }), undefined); + }); + + it('plain speech answering a thread goes into that thread, and its receipt says so', async () => { + await turn([say('answering the thread')], ROOM, { threadId: 't-1' }); + assert.deepEqual(publishes().map((p) => [p.event, p.channelId, p.threadId, p.text]), [['publish', ROOM, 't-1', 'answering the thread']]); + assert.ok(texts().some((t) => t.startsWith('[routing] Your plain speech now lands in #room (Guild One) (discord:g1:room, thread t-1)'))); + assert.ok(texts().some((t) => t.startsWith('[delivered]') && t.includes('thread t-1')), texts().filter((t) => t.startsWith('[delivered]')).join(' | ')); + }); + + it('plain speech answering the channel itself names the root explicitly: threadId null on the wire', async () => { + await turn([say('at the top level')], ROOM); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[ROOM, null]]); + }); + + it('a channel that declares nothing: speech is held as a draft, nothing is published, and the connector\'s tools are named to consult', async () => { + await turn([say('hello legacy')], LEGACY); + assert.deepEqual(publishes(), []); + assert.deepEqual(drafts().map((d) => [d.text, d.reason]), [['hello legacy', 'no-destination']]); + const notice = texts().find((t) => t.startsWith('[routing]')) ?? ''; + assert.match(notice, /#legacy \(Guild One\) \(discord:g1:legacy\): its connector doesn't declare where a post lands \(MCPL RFC-011\)/); + assert.match(notice, /publication from here is unavailable until it does/); + assert.match(notice, /may reach it; consult them: `mcpl--discord--reply_message`\./, 'a send-named tool, not the unrelated one'); + assert.doesNotMatch(notice, /ring/); + }); + + it('a thread on a channel that declares no threads: held, never posted to the root', async () => { + await turn([say('into the void')], GENERAL, { threadId: 't-9' }); + assert.deepEqual(publishes(), []); + assert.deepEqual(drafts().map((d) => d.reason), ['no-destination']); + assert.match(drafts()[0]!.note ?? '', /is a thread, but its connector declares the channel has no threads/); + }); + + it('channel_publish posts into a named thread, refuses a thread where there are none, and means the root without one', async () => { + await turn([ + call('channel_publish', { channelId: ROOM, threadId: 't-2', content: 'into t-2' }, 'p1'), + call('channel_publish', { channelId: GENERAL, threadId: 't-3', content: 'nowhere' }, 'p2'), + call('channel_publish', { channelId: ROOM, content: 'at the root' }, 'p3'), + call('channel_publish', { channelId: LEGACY, content: 'legacy' }, 'p4'), + createMockResponse([]), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [ + [ROOM, 't-2', 'into t-2'], + [ROOM, null, 'at the root'], + ], 'the threadless and the undeclared channels saw nothing'); + const results = toolResults(); + assert.match(results, /has no threads \(its connector declares root\), so a post into thread t-3 is refused/); + assert.match(results, /doesn't declare where a post lands \(MCPL RFC-011\)/); + }); + + it('channel_publish without a channel follows the route, thread included', async () => { + await turn([call('channel_publish', { content: 'explicitly here' }), createMockResponse([])], ROOM, { threadId: 't-4' }); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[ROOM, 't-4', 'explicitly here']]); + }); + + it('a held draft can be resent into a named thread, or to a channel\'s root', async () => { + await turn([say('words for later')], LEGACY); + const [held] = drafts(); + assert.ok(held); + await turn([ + call('drafts', { action: 'resend', draftIds: [held!.id], destination: ROOM, threadId: 't-5' }), + createMockResponse([]), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[ROOM, 't-5', 'words for later']]); + assert.match(toolResults(), /delivered to #room \(Guild One\) \(discord:g1:room, thread t-5\)/); + }); + + it('resending to a channel that declares nothing is refused before anything is attempted', async () => { + await turn([say('kept')], LEGACY); + const [held] = drafts(); + await turn([ + call('drafts', { action: 'resend', draftIds: [held!.id], destination: LEGACY }), + createMockResponse([]), + ], GENERAL); + assert.deepEqual(publishes().filter((p) => p.text === 'kept'), []); + assert.match(toolResults(), /doesn't declare where a post lands[^]*consult them: `mcpl--discord--reply_message`[^]*Nothing was sent/); + assert.equal(drafts()[0]!.attempts.length, 0, 'no attempt was recorded'); + }); + + it('channel_open can make a thread the speech target; a channel named alone means its root', async () => { + await turn([ + call('channel_open', { channelId: ROOM, threadId: 't-6' }), + say('in t-6'), + ], GENERAL); + await turn([ + call('channel_open', { channelId: ROOM }), + say('at the root of room'), + ], ROOM, { threadId: 't-7' }); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [ + [ROOM, 't-6', 'in t-6'], + [ROOM, null, 'at the root of room'], + ], 'an explicit channel-only choice never borrows the thread the turn came from'); + }); + + it('channel_open on a channel that declares nothing opens it but sets no speech target', async () => { + await turn([ + call('channel_open', { channelId: LEGACY }), + say('still to general'), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[GENERAL, null, 'still to general']]); + assert.match(toolResults(), /Opened, but your plain speech can't go there: .*doesn't declare where a post lands.*Your plain speech still goes to #general/); + }); + + it('a delivery whose echo names another place is unconfirmed, never receipted as delivered', async () => { + command({ op: 'publish-mode', mode: 'wrong-echo' }); + await until(() => logged().some((e) => e.event === 'publish-mode'), 'mode'); + await turn([say('somewhere?')], ROOM, { threadId: 't-8' }); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[ROOM, 't-8']]); + assert.ok(!texts().some((t) => t.startsWith('[delivered]')), 'not claimed as delivered'); + assert.ok(texts().some((t) => /was not confirmed/.test(t) && /reported posting at the channel root, not thread t-8/.test(t)), + texts().filter((t) => t.includes('confirmed')).join(' | ')); + }); + + it('a channels/changed that withdraws the declaration applies to the next turn', async () => { + await turn([say('first')], GENERAL); + command({ op: 'declare', channelId: GENERAL, target: null }); + await until(() => registry().publishTarget({ channelId: GENERAL }) === undefined, 'withdrawn'); + await turn([say('second')], GENERAL); + assert.deepEqual(publishes().map((p) => p.text), ['first']); + assert.deepEqual(drafts().map((d) => d.text), ['second']); + }); +}); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 71ad0787a..054691106 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -322,7 +322,7 @@ test('native Membrane observes the first refusal even when guard is enabled by a }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, getChannelTools: () => [], + getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const tokenTraces: string[] = []; framework.onTrace((event) => { @@ -540,7 +540,7 @@ function fakeRegistry(framework: AgentFramework) { }, sendOutgoingChunk: () => {}, getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, getChannelTools: () => [], + getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); return routed; } diff --git a/test/tool-wrapper-prose-guard-integration.test.ts b/test/tool-wrapper-prose-guard-integration.test.ts index 5d8010f0b..e740fc07f 100644 --- a/test/tool-wrapper-prose-guard-integration.test.ts +++ b/test/tool-wrapper-prose-guard-integration.test.ts @@ -123,7 +123,7 @@ async function run(enabled?: boolean, allowedTools?: string[]) { resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, speech: string) => { routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -174,7 +174,7 @@ describe('tool wrapper prose guard integration', () => { (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_c: string, _a: string, _id: string, _i: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -191,7 +191,7 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; @@ -210,7 +210,7 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', maxStreamTokens: 1, toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; @@ -232,7 +232,7 @@ describe('tool wrapper prose guard integration', () => { resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); (framework as unknown as Record).channelEventModule = { getChannelId: () => 'world:test' }; framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); @@ -262,7 +262,7 @@ describe('tool wrapper prose guard integration', () => { }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -416,7 +416,7 @@ describe('tool wrapper prose guard integration', () => { routeSpeech: async () => ({ delivered: true, channelId: 'world:test' }), sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => outgoing.push(['chunk', delta]), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => outgoing.push(['complete', text]), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], resolveProseTarget: () => ({ channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'ephemeral-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); @@ -463,7 +463,7 @@ describe('tool wrapper prose guard integration', () => { routeSpeech: async () => ({ delivered: true, channelId: 'world:test' }), sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => events.push(`chunk:${delta}`), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => events.push(`complete:${text}`), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], resolveProseTarget: () => ({ channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'idle-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 8b15de727..55ca51b2a 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -38,6 +38,26 @@ function internals(framework: AgentFramework) { }; } +/** + * A channel subsystem stand-in whose channels all declare `target` as their + * MCPL RFC-011 publish target: a route is only ever a place the framework + * can publish to exactly. Plain speech it is asked to publish is recorded. + */ +function declaringRegistry(target: 'exact' | 'root' | undefined) { + const published: Array<{ text: string; to: unknown }> = []; + const registry = new Proxy({ + publishTarget: () => target, + resolveLocus: () => null, + routeSpeech: async (_a: string, text: string, to: unknown) => { + published.push({ text, to }); + return { delivered: true, channelId: 'x' }; + }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + return { registry, published }; +} + function channelIncoming(channelId: string, text: string): ProcessEvent { return { type: 'mcpl:channel-incoming', @@ -313,6 +333,7 @@ describe('Trunk channel routing (item-3 redux)', () => { it('a channel-incoming trunk turn takes its triggering conversation as its speech route', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); + internals(framework).channelRegistry = declaringRegistry('root').registry; const event = channelIncoming('discord:guild:chanA', 'A: sleep && date'); framework.pushEvent(event); @@ -329,6 +350,7 @@ describe('Trunk channel routing (item-3 redux)', () => { it('a DM push-event turn takes the reconstructed DM channel as its route (item-3 redux DM sub-case)', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hi in the DM' }])); const framework = await makeFramework(); + internals(framework).channelRegistry = declaringRegistry('root').registry; framework.pushEvent(dmPushEvent('42', 'hey scout, ping')); await framework.runUntilIdle(); @@ -345,6 +367,7 @@ describe('Trunk channel routing (item-3 redux)', () => { it('the route belongs to the CURRENT turn, never a stale one', async () => { const framework = await makeFramework(); const i = internals(framework); + i.channelRegistry = declaringRegistry('root').registry; // (Push the response right before each turn: MockMembrane's stream // consumes ALL queued responses at once.) @@ -375,17 +398,30 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it('a thread wakes the turn but plain speech is held: publishing carries no thread', async () => { + it('a thread is a route where its channel posts into named threads (MCPL RFC-011 exact)', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the topic' }])); const framework = await makeFramework(); - // A channel subsystem to deliver through (speech is only routed with one). - const published: string[] = []; - internals(framework).channelRegistry = new Proxy({ - resolveLocus: () => null, - routeSpeech: async (_a: string, text: string) => { published.push(text); return { delivered: true, channelId: 'x' }; }, - getDescriptor: () => undefined, - getChannelTools: () => [], - } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const { registry, published } = declaringRegistry('exact'); + internals(framework).channelRegistry = registry; + framework.pushEvent({ + ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), + serverId: 'zulip', + threadId: 'topic-a', + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: { channelId?: string; threadId?: string } | null }; + assert.equal(turn.route?.channelId, 'zulip:stream:7'); + assert.equal(turn.route?.threadId, 'topic-a'); + assert.deepEqual(published, [{ text: 'answering the topic', to: { serverId: 'zulip', channelId: 'zulip:stream:7', threadId: 'topic-a' } }], + 'into the thread, never the channel root'); + await framework.stop(); + }); + + it('a thread on a channel that declares no threads wakes the turn but plain speech is held, never sent to the root', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the topic' }])); + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('root'); + internals(framework).channelRegistry = registry; framework.pushEvent({ ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), serverId: 'zulip', @@ -397,15 +433,36 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.equal(turn.unroutable?.reason, 'thread'); const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); - assert.ok(texts.some((t) => t.startsWith('[routing] The conversation that woke you is a thread'))); + assert.ok(texts.some((t) => t.startsWith('[routing] Your plain speech can\'t go to the conversation in front of you') && + /is a thread, but its connector declares the channel has no threads/.test(t))); const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ text: string; reason: string; note?: string }> } }) .proseDrafts.open('scout'); assert.deepEqual(drafts.map((d) => [d.text, d.reason]), [['answering the topic', 'no-destination']]); - assert.match(drafts[0]!.note ?? '', /a thread .*send tool that names the thread/); + assert.match(drafts[0]!.note ?? '', /is a thread, but its connector declares the channel has no threads/); assert.deepEqual(published, [], 'nothing went to the channel root'); await framework.stop(); }); + it('a channel whose connector declares no publish target is no route: speech is held, nothing published', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello there' }])); + const framework = await makeFramework(); + const { registry, published } = declaringRegistry(undefined); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'untargetable'); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + const notice = texts.find((t) => t.startsWith('[routing]')); + assert.match(notice ?? '', /its connector doesn't declare where a post lands \(MCPL RFC-011\)/); + assert.match(notice ?? '', /publication from here is unavailable until it does/); + assert.doesNotMatch(notice ?? '', /consult/, 'no connector tools are claimed when the connector lists none'); + assert.deepEqual(published, []); + await framework.stop(); + }); + it('a no-trigger (heartbeat) turn has no speech route, whatever the previous turn had', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); From 05bff076dec327842ab8083bbfcce01cb0922489 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 18:00:12 -0400 Subject: [PATCH 21/65] fix(routing): receipts keep the destination as delivered; channel_publish checks selectors before defaulting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tessa-974's bounded recheck at feb44a4 (room-203 #49361; probe /tmp/tessa-356-target-receipts-probe.mjs): 1. Plain-speech receipts discarded server identity. recordProseDelivery kept channel and thread only, and appendProseDeliveryReceipt looked the label up later by bare channel id, so deliveries to alpha/shared/topic and beta/shared/topic collapsed into one line under one server's label, and a later rename rewrote what the receipt said was used. Each delivery is now recorded as resolved at delivery — server, channel, the label it had then, thread (ProseDelivery) — deduped on server, channel and thread; a channel id that two delivered servers share is shown with its server ("#Alpha (alpha/shared, thread topic) · #Beta (beta/shared, …)"). routeSpeech returns the delivered destination's serverId and label for this; the {{unsent}} receipt dedupe uses the same key. 2. channel_publish normalised selectors by truthiness: {channelId:'only', serverId:''} posted on the sole registered server although publish() itself refuses an empty selector, and an empty channelId fell through to the speech route. Supplied channelId, serverId and threadId are now checked before any default — empty or non-string is refused with nothing sent; a serverId or threadId without its channelId is refused; null still means "not in use". Tests: speech-route-turns (two servers sharing a channel id and thread stay two destinations with their delivery-time labels after a rename); channel-registry-routing (five bad selector shapes refused at the tool entry point with nothing published, null treated as omitted); routeSpeech's results now include serverId and label. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 70 ++++++++++++++++++++------- src/mcpl/channel-registry.ts | 39 +++++++++++---- test/channel-registry-routing.test.ts | 37 ++++++++++++-- test/speech-route-turns.test.ts | 27 +++++++++++ 4 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 135af549b..ad2cccf69 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1003,6 +1003,15 @@ function truncateReason(reason: string, max = 160): string { return reason.length <= max ? reason : reason.slice(0, max) + '…'; } +/** Where one segment of a turn's plain speech was delivered, as resolved then. */ +interface ProseDelivery { + serverId?: string; + channelId: string; + /** The channel's label when the words were delivered. */ + label?: string; + threadId?: string; +} + export class AgentFramework { private toolPresentations = new Map(); private presentationPreviews = new WeakMap(); @@ -1090,7 +1099,7 @@ export class AgentFramework { * words landed (2026-07-31 misroute series; antra: minimal receipts). * Cleared at every fresh turn's start; budget restarts keep it (same * logical turn, receipt covers the whole turn). */ - private turnProseDeliveries: Map = new Map(); + private turnProseDeliveries: Map = new Map(); /** Count of plain-prose segments SUPPRESSED this turn by sticky * explicit-send silencing. The suppression rule itself is untouched * (antra, 2026-07-31: visibility is enough) — but it must never be a @@ -9492,11 +9501,15 @@ export class AgentFramework { return undefined; } - /** Record a successful plain-prose delivery for this turn's receipt: its - * channel, and its thread when it went into one (`channel\0thread`). */ + /** + * Record a successful plain-prose delivery for this turn's receipt: the + * destination as resolved when it was delivered — its server, channel, + * the label it had then, and its thread — so the receipt says exactly + * where the words went, whatever is renamed or shares an id later. + */ private recordProseDelivery( agentName: string, - outcome: { delivered: boolean; channelId: string; threadId?: string | null } | null | undefined, + outcome: { delivered: boolean; channelId: string; serverId?: string; label?: string; threadId?: string | null } | null | undefined, ): void { if (!outcome?.delivered) return; let list = this.turnProseDeliveries.get(agentName); @@ -9504,7 +9517,21 @@ export class AgentFramework { list = []; this.turnProseDeliveries.set(agentName, list); } - list.push(outcome.threadId ? `${outcome.channelId}\u0000${outcome.threadId}` : outcome.channelId); + const surface = outcome.channelId.startsWith('surface:'); + const label = surface ? undefined : outcome.label ?? (outcome.serverId + ? this.channelRegistry?.resolveDestination({ serverId: outcome.serverId, channelId: outcome.channelId }) + : this.channelRegistry?.resolveDestination({ channelId: outcome.channelId })); + list.push({ + channelId: outcome.channelId, + ...(outcome.serverId ? { serverId: outcome.serverId } : {}), + ...(typeof label === 'string' ? { label } : label && 'destination' in label && label.destination.label ? { label: label.destination.label } : {}), + ...(outcome.threadId ? { threadId: outcome.threadId } : {}), + }); + } + + /** A delivery's identity for the receipt: server, channel and thread. */ + private static deliveryKey(d: { serverId?: string; channelId: string; threadId?: string | null }): string { + return `${d.serverId ?? ''}\u0000${d.channelId}\u0000${d.threadId ?? ''}`; } /** @@ -9531,21 +9558,28 @@ export class AgentFramework { this.turnProseSuppressed.delete(agent.name); const seen = new Set(); const shown: string[] = []; - for (const id of list ?? []) { - if (seen.has(id)) continue; - seen.add(id); - if (id.startsWith('surface:')) { + const unique: ProseDelivery[] = []; + for (const d of list ?? []) { + const key = AgentFramework.deliveryKey(d); + if (seen.has(key)) continue; + seen.add(key); + unique.push(d); + } + // A channel id two servers share is named with its server. + const servers = new Map>(); + for (const d of unique) servers.set(d.channelId, (servers.get(d.channelId) ?? new Set()).add(d.serverId ?? '')); + for (const d of unique) { + if (d.channelId.startsWith('surface:')) { // Speech on a surface route was shown there, never published. - shown.push(`${id.slice('surface:'.length)} (the local surface that messaged you; not published to any channel)`); + shown.push(`${d.channelId.slice('surface:'.length)} (the local surface that messaged you; not published to any channel)`); continue; } - const [channelId, threadId] = id.split('\u0000') as [string, string | undefined]; - const label = this.channelRegistry?.getDescriptor(channelId)?.label; - const where = threadId ? `${channelId}, thread ${threadId}` : channelId; + const id = (servers.get(d.channelId)?.size ?? 0) > 1 && d.serverId ? `${d.serverId}/${d.channelId}` : d.channelId; + const where = d.threadId ? `${id}, thread ${d.threadId}` : id; shown.push( - label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${where})` - : threadId ? `${channelId} (thread ${threadId})` : channelId, + d.label && d.label !== d.channelId + ? `${d.label.startsWith('#') ? d.label : `#${d.label}`} (${where})` + : d.threadId || id !== d.channelId ? `${d.channelId} (${where})` : d.channelId, ); } const notes: string[] = []; @@ -9574,7 +9608,7 @@ export class AgentFramework { const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; // A hybrid `{{unsent}}` envelope is already listed above, as the plain // speech it was; a resend is not. - if (attempt.via === 'unsent-token' && seen.has(attempt.destination.channelId)) { + if (attempt.via === 'unsent-token' && seen.has(AgentFramework.deliveryKey(attempt.destination))) { notes.push(`draft ${d.id} delivered ${how}`); } else { shown.push(`${AgentFramework.destinationText(attempt.destination)} (draft ${d.id}, ${how})`); @@ -10081,7 +10115,7 @@ export class AgentFramework { } try { const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); - this.recordProseDelivery(agent.name, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null); + this.recordProseDelivery(agent.name, outcome.status === 'delivered' ? { delivered: true, ...outcome.destination! } : null); if (outcome.status === 'delivered') { this.proseBounceStreaks.delete(agent.name); } else { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 671f48a97..b3067941d 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1407,7 +1407,7 @@ export class ChannelRegistry { }); case 'channel_publish': - return this.handleToolPublish(input as { channelId?: string; serverId?: string; content?: string; text?: string }, origin); + return this.handleToolPublish(input as { channelId?: string; serverId?: string; threadId?: string | null; content?: string; text?: string }, origin); case 'think': return this.handleToolThink(input as { content?: string }); @@ -3373,12 +3373,16 @@ export class ChannelRegistry { conversationId: string, text: string, locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, - ): Promise<{ delivered: boolean; channelId: string; threadId?: string; messageId?: string } | null> { + ): Promise<{ delivered: boolean; serverId: string; channelId: string; label?: string; threadId?: string; messageId?: string } | null> { const outcome = await this.deliverSpeech(conversationId, text, locusChannelId); if (outcome.status !== 'delivered') return null; + // The destination as resolved at delivery — server, channel, the label it + // had then, and the thread — so a receipt names exactly where it went. return { delivered: true, + serverId: outcome.destination!.serverId, channelId: outcome.destination!.channelId, + ...(outcome.destination!.label ? { label: outcome.destination!.label } : {}), ...(outcome.destination!.threadId ? { threadId: outcome.destination!.threadId } : {}), ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}), }; @@ -3402,18 +3406,33 @@ export class ChannelRegistry { // it — else the caller's current speech route, thread included; never // the most recent inbound channel (shelf-355), and never a thread // borrowed from an earlier route for a channel named explicitly. - if (input.threadId !== undefined && input.threadId !== null && (typeof input.threadId !== 'string' || !input.threadId)) { - return { success: false, error: 'threadId must be a thread id (or left out for the channel root). Nothing was sent.', isError: true }; + // Supplied selectors are checked before any default applies: an empty + // or non-string one is refused, never read as omission (which would + // widen it to "your route" or "whichever server has the id"). null means + // the field is not in use. + const refuse = (error: string): ToolResult => ({ success: false, error: `${error} Nothing was sent.`, isError: true }); + const supplied = (v: unknown): boolean => v !== undefined && v !== null; + if (supplied(input.channelId) && (typeof input.channelId !== 'string' || !input.channelId)) { + return refuse('channelId must name a channel (leave it out to publish to your speech route).'); + } + if (supplied(input.serverId) && (typeof input.serverId !== 'string' || !input.serverId)) { + return refuse('serverId must name a server (leave it out to resolve the channel id alone).'); + } + if (supplied(input.threadId) && (typeof input.threadId !== 'string' || !input.threadId)) { + return refuse('threadId must be a thread id (or left out for the channel root).'); } - if (input.threadId && !input.channelId) { - return { success: false, error: 'threadId needs the channelId it belongs to. Nothing was sent.', isError: true }; + if (supplied(input.serverId) && !supplied(input.channelId)) { + return refuse('serverId needs the channelId it belongs to.'); + } + if (supplied(input.threadId) && !supplied(input.channelId)) { + return refuse('threadId needs the channelId it belongs to.'); } let target: { serverId?: string; channelId: string; threadId: string | null }; - if (input.channelId) { + if (supplied(input.channelId)) { target = { - channelId: input.channelId, - ...(input.serverId ? { serverId: input.serverId } : {}), - threadId: input.threadId || null, + channelId: input.channelId as string, + ...(supplied(input.serverId) ? { serverId: input.serverId as string } : {}), + threadId: supplied(input.threadId) ? (input.threadId as string) : null, }; } else { const route = origin?.kind === 'agent' ? this.speechRouteResolver?.(origin.agentName) : undefined; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 3248b460b..dcc0f3fb3 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -157,7 +157,7 @@ test('routeSpeech routes a conversation fork to its HOME channel, whatever arriv registry.handleIncoming('discord', incoming('chanB', 'hi from B')); const res = await registry.routeSpeech('conversation-chanA-g1', 'reply for A', registry.resolveLocus('conversation-chanA-g1')); - assert.deepEqual(res, { delivered: true, channelId: 'chanA' }, + assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'chanA', label: 'chanA' }, 'fork must route to its home channel, not the last inbound'); assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); }); @@ -231,7 +231,7 @@ test('a route that names its server publishes there, even when another server re seedRegistered(registry, 'b', 'shared'); const res = await registry.routeSpeech('scout', 'to b', { serverId: 'b', channelId: 'shared' }); - assert.deepEqual(res, { delivered: true, channelId: 'shared', messageId: 'b-1' }); + assert.deepEqual(res, { delivered: true, serverId: 'b', channelId: 'shared', label: 'shared', messageId: 'b-1' }); assert.deepEqual(published, [{ server: 'b', channelId: 'shared' }]); // Without the server, the shared id is refused rather than guessed. assert.equal(await registry.routeSpeech('scout', 'which one?', 'shared'), null); @@ -274,7 +274,7 @@ test('a route whose server is unknown resolves the same way for channel_publish const published = await registry.handleChannelToolCall('channel_publish', { content: 'explicit' }, { kind: 'agent', agentName: 'scout' }); assert.equal(published.success, true, JSON.stringify(published)); const spoken = await registry.routeSpeech('scout', 'plain speech', 'chanA'); - assert.deepEqual(spoken, { delivered: true, channelId: 'chanA', messageId: 'p-2' }); + assert.deepEqual(spoken, { delivered: true, serverId: 'discord', channelId: 'chanA', label: 'chanA', messageId: 'p-2' }); assert.deepEqual(publishCalls.map((c) => c.channelId), ['chanA', 'chanA']); }); @@ -299,6 +299,33 @@ test('channel_publish without a channel or a route is refused, never guessed', a assert.equal(publishCalls.length, 0); }); +test('channel_publish checks supplied selectors before any default: an empty one is refused, never read as omitted', async () => { + const { registry, publishCalls } = makeRegistry( + { delivered: true, messageId: 'p-9' } as { delivered?: boolean }, + () => undefined, + () => ({ kind: 'channel', serverId: 'discord', channelId: 'routed' }), + ); + seedRegistered(registry, 'discord', 'only', 'routed'); + const caller = { kind: 'agent' as const, agentName: 'scout' }; + for (const [input, why] of [ + [{ channelId: 'only', serverId: '', content: 'x' }, /serverId must name a server/], + [{ channelId: 'only', serverId: 7, content: 'x' }, /serverId must name a server/], + [{ channelId: '', content: 'x' }, /channelId must name a channel/], + [{ serverId: 'discord', content: 'x' }, /serverId needs the channelId it belongs to/], + [{ channelId: 'only', threadId: '', content: 'x' }, /threadId must be a thread id/], + ] as const) { + const res = await registry.handleChannelToolCall('channel_publish', input, caller); + assert.equal(res.success, false, JSON.stringify(input)); + assert.match(res.error!, why); + assert.match(res.error!, /Nothing was sent\.$/); + } + assert.equal(publishCalls.length, 0, 'not the sole server, and not the route'); + // null means "not in use": the field is omitted, not invalid. + const ok = await registry.handleChannelToolCall('channel_publish', { channelId: 'only', serverId: null, threadId: null, content: 'y' }, caller); + assert.equal(ok.success, true, JSON.stringify(ok)); + assert.deepEqual(publishCalls.map((c) => [c.channelId, c.threadId]), [['only', null]]); +}); + test('channel_publish reports failed and unknown outcomes with the attempted destination', async () => { const refused = makeRegistry({ delivered: false }); seedRegistered(refused.registry, 'discord', 'chanA'); @@ -349,7 +376,7 @@ test('ensureChannelRegistered keeps a DM closed; the reply goes out once its con updated: [{ id: dm, type: 'discord', label: 'DM with Antra', direction: 'bidirectional', capabilities: { publish: { target: 'root' } } }], }); const res = await registry.routeSpeech('scout', 'replying in the DM', { serverId: 'discord', channelId: dm }); - assert.deepEqual(res, { delivered: true, channelId: dm }, + assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: dm, label: 'DM with Antra' }, 'the DM reply routes back to the DM channel once it is declared'); assert.equal(publishCalls.at(-1)?.channelId, dm); assert.equal(publishCalls.at(-1)?.threadId, null, 'at the channel itself'); @@ -383,7 +410,7 @@ test('routeSpeech into a closed locus opens the channel first, then delivers', a const res = await registry.routeSpeech('sol', 'a reply meant for the DM', 'dm-alice'); - assert.deepEqual(res, { delivered: true, channelId: 'dm-alice' }); + assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'dm-alice', label: 'dm-alice' }); assert.equal(openCalls.length, 1, 'delivery into a closed channel must open it'); assert.equal(openCalls[0]!.channelId, 'dm-alice'); assert.equal(lookup('dm-alice')!.open, true, 'live state flips open'); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index baf7c8d31..07c9e2c2f 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -132,4 +132,31 @@ describe('speech route across a logical turn', () => { assert.ok(texts.some((t) => t.startsWith('[routing] Your plain speech now goes to tui (the local surface that messaged you)'))); await framework.stop(); }); + + it('the [delivered] receipt names each destination as delivered: server, label then, and thread', async () => { + const framework = await makeFramework(); + const scout = framework.getAgent('scout')!; + let currentLabel = '#Alpha (renamed later)'; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + getDescriptor: () => ({ label: currentLabel }), + resolveDestination: () => ({ error: 'shared id' }), + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const f = framework as unknown as { + recordProseDelivery(agent: string, outcome: unknown): void; + appendProseDeliveryReceipt(agent: unknown): void; + }; + // The same channel id and thread on two servers, then a repeat of the first. + f.recordProseDelivery('scout', { delivered: true, serverId: 'alpha', channelId: 'shared', label: '#Alpha', threadId: 'topic' }); + f.recordProseDelivery('scout', { delivered: true, serverId: 'beta', channelId: 'shared', label: '#Beta', threadId: 'topic' }); + f.recordProseDelivery('scout', { delivered: true, serverId: 'alpha', channelId: 'shared', label: '#Alpha', threadId: 'topic' }); + currentLabel = '#Renamed'; + f.appendProseDeliveryReceipt(scout); + const receipt = scout.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((t) => t.startsWith('[delivered]')); + assert.equal(receipt, '[delivered] plain speech → #Alpha (alpha/shared, thread topic) · #Beta (beta/shared, thread topic)', + 'two servers stay two destinations, each with the label it had when the words went out'); + await framework.stop(); + }); }); From cb93817236bf457db15d50d258da4c449be2124f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 18:25:45 -0400 Subject: [PATCH 22/65] fix(routing): every receipt names its destination's server; gate threads pinned to the protocol field; stream rule tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the 355 part at 8b5658a/b5667c8: - Tessa-974 (room-203 #49649): the receipt added a channel's server only when two servers happened to receive speech in one turn, so a single delivery to beta/shared/topic still read "#Beta (shared, thread topic)" although another registration owns `shared` (labels can collide too). Every channel destination is now written self-contained as `#label (server / channel-id, thread T)` — `server / channel` as the source headers write it — in the [delivered] receipt and in every draft receipt and resend line (placeText, shared by destinationText); the collision-counting branch is gone. A destination with no label reads `(server / channel-id)`. - Agnes-984 (#49680) finding 1: whether an adapter's metadata could set a gate candidate's thread. It cannot on channels/incoming — the registry builds the gate's metadata with the protocol fields (messageId, threadId) after the adapter's metadata, always present even when undefined — but nothing said or tested so; now a comment does, and a test feeds a root message whose adapter metadata carries threadId/messageId, and a thread message whose metadata disagrees, and checks the gate sees only the protocol values. (On push/event the thread is the origin's threadId, which RFC-011 revision 3 binds.) - Finding 2: targeted-publish.breaking.md called the connector PRs releases and named anima-dnd as one; they are open PRs, and anima-dnd's awaits its maintainers' choice of route (anima-dnd#1). - Finding 3: the RFC-011 §6 stream rule had no test. publish-outcome now drives the registry over exact/null, exact/t-1, root/null (streamed, with that place on chunk and completion) and root/t-1, undeclared (nothing); trunk-channel-routing drives a turn on a thread route (no chunk or completion at all, the speech still published into the thread) and one on the root (chunks and one completion, all threadId null). - Nit: channel_open with threadId and setSpeechTarget: false now says the thread was not used. Co-Authored-By: Claude Opus 5.5 --- changelog.d/targeted-publish.breaking.md | 10 +++---- src/framework.ts | 36 +++++++++++++++--------- src/mcpl/channel-registry.ts | 5 ++++ test/channel-registry-routing.test.ts | 26 +++++++++++++++++ test/explicit-prose-routing.test.ts | 2 +- test/prose-drafts.test.ts | 18 ++++++------ test/publish-outcome.test.ts | 33 ++++++++++++++++++++++ test/speech-route-turns.test.ts | 9 +++++- test/targeted-publish.test.ts | 11 +++++++- test/trunk-channel-routing.test.ts | 31 ++++++++++++++++++++ 10 files changed, 151 insertions(+), 30 deletions(-) diff --git a/changelog.d/targeted-publish.breaking.md b/changelog.d/targeted-publish.breaking.md index da4fc9778..b57c214f5 100644 --- a/changelog.d/targeted-publish.breaking.md +++ b/changelog.d/targeted-publish.breaking.md @@ -16,11 +16,11 @@ - `channel_publish`, draft resends and `>>` envelopes to such a channel are refused, with nothing sent. - The connector's own send tools are unaffected. Declaring releases: - discord-mcpl#67, slack-mcpl#20, telegram-mcpL#2, portal#46, - eidoverse-worlds#223 and anima-dnd. pocket-body stays undeclared by its - owner's decision, so its deliberate tools (`ring`, `say`, `listen`) remain - the way to reach it. + The connector's own send tools are unaffected. The declaring changes are + open as discord-mcpl#67, slack-mcpl#20, telegram-mcpL#2, portal#46 and + eidoverse-worlds#223. anima-dnd's awaits its maintainers' choice of route + (anima-dnd#1). pocket-body stays undeclared by its owner's decision, so its + deliberate tools (`ring`, `say`, `listen`) remain the way to reach it. - **Host-minted channels count as undeclared.** That covers a DM channel the framework registers lazily from a push event, because the connector never described it. Connectors that register their DM channels with a diff --git a/src/framework.ts b/src/framework.ts index ad2cccf69..cccec934a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9095,8 +9095,24 @@ export class AgentFramework { } private static destinationText(d: PublishDestination): string { - const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : ''; - return `${label}(${d.channelId}${d.threadId ? `, thread ${d.threadId}` : ''})`; + return AgentFramework.placeText(d); + } + + /** + * A destination in words, self-contained: its label as recorded, then the + * server and channel it resolved to — written `server / channel-id`, as + * the source headers write them — and its thread. The server is always + * named, never inferred from what else is on screen: the same channel id + * (and label) can exist on another server. + */ + private static placeText(d: { serverId?: string; channelId: string; label?: string; threadId?: string | null }): string { + const id = d.serverId ? `${d.serverId} / ${d.channelId}` : d.channelId; + const where = d.threadId ? `${id}, thread ${d.threadId}` : id; + if (d.label && d.label !== d.channelId) { + const label = d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`; + return `${label} (${where})`; + } + return d.serverId || d.threadId ? `(${where})` : d.channelId; } /** One line per draft: id, state, when, why, size, preview. */ @@ -9565,22 +9581,13 @@ export class AgentFramework { seen.add(key); unique.push(d); } - // A channel id two servers share is named with its server. - const servers = new Map>(); - for (const d of unique) servers.set(d.channelId, (servers.get(d.channelId) ?? new Set()).add(d.serverId ?? '')); for (const d of unique) { if (d.channelId.startsWith('surface:')) { // Speech on a surface route was shown there, never published. shown.push(`${d.channelId.slice('surface:'.length)} (the local surface that messaged you; not published to any channel)`); continue; } - const id = (servers.get(d.channelId)?.size ?? 0) > 1 && d.serverId ? `${d.serverId}/${d.channelId}` : d.channelId; - const where = d.threadId ? `${id}, thread ${d.threadId}` : id; - shown.push( - d.label && d.label !== d.channelId - ? `${d.label.startsWith('#') ? d.label : `#${d.label}`} (${where})` - : d.threadId || id !== d.channelId ? `${d.channelId} (${where})` : d.channelId, - ); + shown.push(AgentFramework.placeText(d)); } const notes: string[] = []; // Each of the turn's drafts as it stands now: a mid-turn notice lets the @@ -16402,7 +16409,10 @@ export class AgentFramework { console.error(`[routing] ${agentName}: channel_open -> speech route ${opened}${threadId ? ` thread ${threadId}` : ''} (announced in tool result)`); } } else { - routing = `Opened for reading. ${unchanged()}`; + const ignored = typeof openInput?.threadId === 'string' && openInput.threadId + ? ` threadId ${openInput.threadId} chooses where speech goes, so with setSpeechTarget: false it was not used.` + : ''; + routing = `Opened for reading.${ignored} ${unchanged()}`; } result = { ...result, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index b3067941d..c0c662a32 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1046,6 +1046,11 @@ export class ChannelRegistry { triggerInference = this.shouldTriggerInference( textContent, { + // The adapter's own metadata first; the protocol's fields after + // it, always present (even undefined), so an adapter metadata key + // can never stand in for them — the gate's route candidates read + // threadId and messageId from here, and a thread decides where a + // post lands (MCPL RFC-011). ...message.metadata, eventType: 'mcpl:channel-incoming', serverId, diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index dcc0f3fb3..3ca98aa4d 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -128,6 +128,32 @@ test('handleIncoming REJECTS an unknown channel instead of minting it (§14.5)', assert.equal(registry.resolveLocus('cairn'), null, 'a rejected message must not establish a locus'); }); +test('the gate reads a message\'s thread from the protocol field, never from adapter metadata', async () => { + const seen: Array> = []; + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { shouldTriggerInference: (_content, metadata) => { seen.push(metadata); return true; } }, + ); + seedRegistered(registry, 'slack', 'slack:C1'); + const message = (extra: Record) => ({ + messages: [{ + channelId: 'slack:C1', messageId: `m-${seen.length}`, author: { id: 'u1', name: 'Ada' }, + timestamp: '2026-10-07T00:00:00.000Z', content: [{ type: 'text' as const, text: 'hi' }], ...extra, + }], + }); + // A root message whose adapter metadata happens to carry a threadId key. + await registry.handleIncoming('slack', message({ metadata: { threadId: 'forged', messageId: 'forged-m' } })); + // A thread message whose adapter metadata disagrees with the protocol field. + await registry.handleIncoming('slack', message({ threadId: '1700.0001', metadata: { threadId: 'forged' } })); + assert.equal(seen.length, 2); + assert.equal(seen[0]!.threadId, undefined, 'a root message stays at the root'); + assert.equal(seen[0]!.messageId, 'm-0', 'the protocol message id wins too'); + assert.equal(seen[1]!.threadId, '1700.0001'); +}); + test('routeSpeech surfaces a failure when the server reports delivered:false', async () => { const { registry, failures, traces } = makeRegistry({ delivered: false }); seedRegistered(registry, 'discord', 'ch-x'); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 64d509ea6..af0367b9d 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -336,7 +336,7 @@ describe('explicit prose routing', () => { const texts = cm.getAllMessages().flatMap(m => m.content) .filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.includes(authored), 'source/author view retains exact envelope'); - assert.ok(texts.includes('[delivered] plain speech → discord:g:cafe'), 'canonical destination receipt enters context'); + assert.ok(texts.includes('[delivered] plain speech → (stub / discord:g:cafe)'), 'canonical destination receipt enters context, server named'); await framework.stop(); }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index bbc6ecd9d..cbfa90397 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -353,7 +353,7 @@ describe('held prose drafts, end to end', () => { const [list, read, resend] = h.toolResults(); assert.match(list!, /3 open drafts, newest first/); assert.ok(read!.endsWith(`exactly as a resend publishes it):\n${segments[1]}`), `read shows the text in full: ${read}`); - assert.match(resend!, new RegExp(`${ids[0]}: delivered to #room \\(Guild One\\) \\(${ROOM}\\), message posted-2`)); + assert.match(resend!, new RegExp(`${ids[0]}: delivered to #room \\(Guild One\\) \\(discord / ${ROOM}\\), message posted-2`)); const sent = h.publishes().slice(1); assert.deepEqual(sent.map((p) => [p.channelId, p.text]), segments.map((s) => [ROOM, s]), 'verbatim, in order'); assert.ok(h.store().open('scout').length === 0, 'nothing left open'); @@ -528,7 +528,7 @@ describe('held prose drafts, end to end', () => { createMockResponse([]), ]); const [receipt, refused] = h.toolResults(); - assert.match(receipt!, new RegExp(`${deliver!.id}: delivered — confirmed at .* to #room \\(Guild One\\) \\(${ROOM}\\), message posted-\\d+ .*not sent again`)); + assert.match(receipt!, new RegExp(`${deliver!.id}: delivered — confirmed at .* to #room \\(Guild One\\) \\(discord / ${ROOM}\\), message posted-\\d+ .*not sent again`)); assert.match(refused!, new RegExp(`${dismiss!.id} was dismissed`)); assert.equal(h.publishes().length, published, 'no new publish'); } finally { @@ -572,7 +572,7 @@ describe('held prose drafts, end to end', () => { const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours have not been named to you yet: /); assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); held .*"orphaned words"\)/); - assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); + assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(discord / ${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); assert.doesNotMatch(notice, /They were not sent/); await h.turn([createMockResponse([])]); assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'named once'); @@ -840,7 +840,7 @@ describe('drafts resend ownership', () => { const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); await t.tick(); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); - assert.match(t.say(await sending), /delivered to \(chan\), message m-a/); + assert.match(t.say(await sending), /delivered to \(discord \/ chan\), message m-a/); // The channel is gone: destination resolution would now fail. const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; registry.resolveProseTarget = () => ({ error: 'no channel matches' }); @@ -869,8 +869,8 @@ describe('drafts resend ownership', () => { assert.deepEqual(t.pending.map((p) => p.text), ['B words']); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); const result = t.say(await batch); - assert.match(result, /delivered to \(chan\), message m-a/); - assert.match(result, /delivered to \(chan\), message m-b/); + assert.match(result, /delivered to \(discord \/ chan\), message m-a/); + assert.match(result, /delivered to \(discord \/ chan\), message m-b/); assert.equal(t.pending.length, 0, 'B was published exactly once'); } finally { await t.close(); @@ -1012,7 +1012,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end ]); assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere', 'resend me']); assert.equal(h.texts().find((t) => t.startsWith('[delivered]')), - `[delivered] plain speech → #room (Guild One) (${ROOM}) (draft ${ids[0]}, by your resend) · ` + + `[delivered] plain speech → #room (Guild One) (discord / ${ROOM}) (draft ${ids[0]}, by your resend) · ` + `1 plain-speech segment(s) held as draft ${ids[2]} (not sent — drafts can resend them unchanged, or dismiss them) · ` + `draft ${ids[1]} dismissed by you`); @@ -1023,7 +1023,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end createMockResponse([]), ]); assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( - `^\\[delivered\\] nothing confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + + `^\\[delivered\\] nothing confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(discord / ${ROOM}\\) at .+ ` + 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); } finally { await h.close(); @@ -1078,7 +1078,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end const words = notice.content.map((b) => (b as { text?: string }).text ?? '').join(''); assert.ok(words.startsWith(`[drafts] scout: 2 plain-speech segments held as drafts ${free.id}, ${copy.id} — `), `no "not sent" over words that may be out: ${words}`); assert.ok(words.includes(`drafts(action: "resend", draftIds: ["${free.id}"], destination: "#channel") delivers it unchanged`), words); - assert.ok(words.includes(`${copy.id} includes the words of ${original.id}, and ${original.id}'s attempt to #room (Guild One) (${ROOM})`), words); + assert.ok(words.includes(`${copy.id} includes the words of ${original.id}, and ${original.id}'s attempt to #room (Guild One) (discord / ${ROOM})`), words); assert.ok(words.includes(`so check that channel before resending ${copy.id} (drafts(action: "resend", draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true))`), words); assert.ok(!words.includes(`draftIds: ["${free.id}", "${copy.id}"], destination`), 'no plain resend offered for the copy'); } finally { diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 73d08eb98..18aca2cc9 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -284,6 +284,39 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(published.length, 1); }); + it('streams only where it would publish, carrying the same place (RFC-011 §6)', () => { + const chunks: Array<{ channelId: string; threadId?: unknown }> = []; + const completes: Array<{ channelId: string; threadId?: unknown }> = []; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsOutgoingChunk: (p: { channelId: string; threadId?: unknown }) => chunks.push({ channelId: p.channelId, threadId: p.threadId }), + sendChannelsOutgoingComplete: (p: { channelId: string; threadId?: unknown }) => completes.push({ channelId: p.channelId, threadId: p.threadId }), + }; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + {}, + ); + const channels = (registry as unknown as { channels: Map }).channels; + const seedIt = (id: string, target: 'exact' | 'root' | null) => channels.set(`discord:${id}`, { + serverId: 'discord', + descriptor: { id, type: 'discord', label: id, ...(target ? { capabilities: { publish: { target } } } : {}) }, + open: true, + }); + seedIt('exact', 'exact'); + seedIt('root', 'root'); + seedIt('none', null); + for (const [channelId, threadId] of [['exact', null], ['exact', 't-1'], ['root', null], ['root', 't-1'], ['none', null]] as const) { + registry.sendOutgoingChunk(channelId, 'agent', 'inf-1', 0, 'Hello', threadId); + registry.sendOutgoingComplete(channelId, 'agent', 'inf-1', 'Hello', threadId); + } + const expected = [{ channelId: 'exact', threadId: null }, { channelId: 'exact', threadId: 't-1' }, { channelId: 'root', threadId: null }]; + assert.deepEqual(chunks, expected, 'no stream into a thread where there are none, nor to an undeclared channel'); + assert.deepEqual(completes, expected); + }); + it('routeSpeech reports a failed and an uncertain delivery as different outcomes', async () => { const failures: Array<{ outcome?: string }> = []; const { registry, seed } = registryWith({ discord: { publish: async () => ({}) }, other: { publish: async () => ({ delivered: false }) } }, failures); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index 07c9e2c2f..3e205f94d 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -155,8 +155,15 @@ describe('speech route across a logical turn', () => { const receipt = scout.getContextManager().getAllMessages() .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) .find((t) => t.startsWith('[delivered]')); - assert.equal(receipt, '[delivered] plain speech → #Alpha (alpha/shared, thread topic) · #Beta (beta/shared, thread topic)', + assert.equal(receipt, '[delivered] plain speech → #Alpha (alpha / shared, thread topic) · #Beta (beta / shared, thread topic)', 'two servers stay two destinations, each with the label it had when the words went out'); + // A single delivery names its server too: the receipt is self-contained. + f.recordProseDelivery('scout', { delivered: true, serverId: 'beta', channelId: 'shared', label: '#Beta', threadId: 'topic' }); + f.appendProseDeliveryReceipt(scout); + const second = scout.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .filter((t) => t.startsWith('[delivered]')).at(-1); + assert.equal(second, '[delivered] plain speech → #Beta (beta / shared, thread topic)'); await framework.stop(); }); }); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts index 7cdaa53c8..a81f046c9 100644 --- a/test/targeted-publish.test.ts +++ b/test/targeted-publish.test.ts @@ -177,7 +177,7 @@ describe('targeted publish (MCPL RFC-011)', () => { createMockResponse([]), ], GENERAL); assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[ROOM, 't-5', 'words for later']]); - assert.match(toolResults(), /delivered to #room \(Guild One\) \(discord:g1:room, thread t-5\)/); + assert.match(toolResults(), /delivered to #room \(Guild One\) \(discord \/ discord:g1:room, thread t-5\)/); }); it('resending to a channel that declares nothing is refused before anything is attempted', async () => { @@ -207,6 +207,15 @@ describe('targeted publish (MCPL RFC-011)', () => { ], 'an explicit channel-only choice never borrows the thread the turn came from'); }); + it('channel_open with a thread but setSpeechTarget: false says the thread was not used', async () => { + await turn([ + call('channel_open', { channelId: ROOM, threadId: 't-9', setSpeechTarget: false }), + say('still to general'), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[GENERAL, null]]); + assert.match(toolResults(), /Opened for reading\. threadId t-9 chooses where speech goes, so with setSpeechTarget: false it was not used\./); + }); + it('channel_open on a channel that declares nothing opens it but sets no speech target', async () => { await turn([ call('channel_open', { channelId: LEGACY }), diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 55ca51b2a..6f3becb79 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -417,6 +417,37 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('a thread route is not streamed; a root route streams with threadId null (RFC-011 §6)', async () => { + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('exact'); + const streamed: Array<{ kind: string; channelId: string; threadId: unknown }> = []; + Object.assign(registry as Record, { + sendOutgoingChunk: (channelId: string, _a: string, _i: string, _n: number, _d: string, threadId: unknown) => + streamed.push({ kind: 'chunk', channelId, threadId }), + sendOutgoingComplete: (channelId: string, _a: string, _i: string, _t: string, threadId: unknown) => + streamed.push({ kind: 'complete', channelId, threadId }), + }); + internals(framework).channelRegistry = registry; + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'into the thread' }])); + framework.pushEvent({ + ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), + serverId: 'zulip', + threadId: 'topic-a', + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(streamed.length, 0, 'one channel stream could carry thread speech and root envelopes alike: none at all'); + assert.equal(published.length, 1, 'the speech itself was still published, into the thread'); + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'at the root' }])); + framework.pushEvent({ ...(channelIncoming('zulip:stream:7', 'at root') as unknown as Record), serverId: 'zulip' } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.ok(streamed.some((e) => e.kind === 'chunk'), 'the root route streams'); + assert.ok(streamed.every((e) => e.channelId === 'zulip:stream:7' && e.threadId === null), JSON.stringify(streamed)); + assert.equal(streamed.filter((e) => e.kind === 'complete').length, 1); + await framework.stop(); + }); + it('a thread on a channel that declares no threads wakes the turn but plain speech is held, never sent to the root', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the topic' }])); const framework = await makeFramework(); From 42c3f5a83412f98c21f256802f05d05086474011 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 18:32:17 -0400 Subject: [PATCH 23/65] fix(gate): the frozen envelope names a batched candidate's conversation; origin keys can't override host fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agnes-984's 355 review finding 1 (room-203 #49680), as Tessa-974 framed it (#49785): the frozen envelope must outrank free-form metadata on the gate path, so a gate-batched wake and the direct path agree on where a reply goes — a thread decides where a post lands (MCPL RFC-011). - Both lanes now freeze an ordinary item's source envelope at admission before the gate reads it (it was frozen just after), and hand the gate the envelope under INBOUND_SOURCE_KEY, last. A coalesced item's envelope is still frozen by its coalescer at delivery. - EventGate.handleDebounce takes a channel item's server, registered channel, thread and message from that envelope, ahead of metadata; free metadata is only the fallback for items without one. - push/event: the gate's metadata spread the server's origin after the host's own fields, so an origin key could stand in for serverId, eventType, featureSet or eventId — e.g. an origin.serverId naming another server would have made a batched candidate route to that server's channel with the same id. The origin now comes first and the host's fields after it. (channels/incoming already put the protocol fields after the adapter's metadata.) Tests: event-gate (a push whose metadata and route resolver name a forged server, thread and message: the candidate is the envelope's); a new push-gate-metadata test (origin serverId/eventType/featureSet/eventId and an origin-supplied envelope key are overridden, the origin's RFC-011 thread is kept, the envelope is frozen once before the gate); inbound-source's exactly-once acceptance tests still pass with the earlier freeze. Co-Authored-By: Claude Opus 5.5 --- src/gate/event-gate.ts | 22 +++++++----- src/mcpl/channel-registry.ts | 59 ++++++++++++++++--------------- src/mcpl/push-handler.ts | 61 ++++++++++++++++++--------------- test/event-gate.test.ts | 25 ++++++++++++++ test/push-gate-metadata.test.ts | 45 ++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 63 deletions(-) create mode 100644 test/push-gate-metadata.test.ts diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 56dd07e9b..2b89a9864 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -13,6 +13,7 @@ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; import { isConversational } from '../speech-routes.js'; +import { readInboundSource } from '../mcpl/inbound-source.js'; import { dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; @@ -1554,6 +1555,15 @@ export class EventGate { private handleDebounce(policy: GatePolicy, info: GateEventInfo): void { const debounceMs = (policy.behavior as { debounce: number }).debounce; + // The item's frozen source envelope, when its lane supplies one, names + // its conversation — server, registered channel, thread, message — ahead + // of anything in free-form metadata (the direct path reads the same + // envelope, so both paths agree on where a reply goes). + const envelope = readInboundSource(info.metadata); + const conversation = envelope?.kind === 'channel' ? envelope : undefined; + const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + const messageId = conversation ? conversation.messageId : str(info.metadata?.messageId); + const threadId = conversation ? conversation.threadId : str(info.metadata?.threadId); const event: PendingEvent = { policyName: policy.name, @@ -1569,15 +1579,11 @@ export class EventGate { ? (info.metadata.channelName as string) : undefined, authorId: this.extractAuthorId(info.metadata) ?? undefined, - serverId: info.serverId || undefined, + serverId: conversation?.serverId ?? (info.serverId || undefined), addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'), - routeChannelId: this.routeChannelFor(info), - ...(typeof info.metadata?.messageId === 'string' && info.metadata.messageId - ? { messageId: info.metadata.messageId as string } - : {}), - ...(typeof info.metadata?.threadId === 'string' && info.metadata.threadId - ? { threadId: info.metadata.threadId as string } - : {}), + routeChannelId: conversation?.channelId ?? this.routeChannelFor(info), + ...(messageId ? { messageId } : {}), + ...(threadId ? { threadId } : {}), conversational: isConversational(info.tags, info.metadata), }; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index c0c662a32..52eca160d 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -39,7 +39,7 @@ import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js import { expandCoreTags } from './tags.js'; import { validateCoalescedContent } from './push-coalescer.js'; import { CapabilityGrant } from './capability-grant.js'; -import type { InboundSource } from './inbound-source.js'; +import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; import { McplRequestError } from './server-connection.js'; // ============================================================================ @@ -1036,21 +1036,45 @@ export class ChannelRegistry { }; if (!coalesced) markAccepted(); + // Build the incoming event; whether it triggers inference is decided + // just below. + const event: McplChannelIncomingEvent = { + type: 'mcpl:channel-incoming', + serverId, + channelId: message.channelId, + messageId: message.messageId, + threadId: message.threadId, + author: message.author, + content: convertedContent, + timestamp: message.timestamp, + metadata: message.metadata, + ...(message.tags ? { tags: message.tags } : {}), + triggerInference: true, + acceptedAt: Date.now(), + }; + + // The source envelope of an ordinary message is frozen here, at + // admission, before it is gated, queued or acknowledged: a rename or + // rebind while it waits cannot change where it says it came from, and + // the gate reads the same envelope the direct path does. (A coalesced + // message's envelope is frozen by its coalescer when it is delivered.) + const inboundSource = coalesced ? undefined : this.acceptInbound?.(event); + if (inboundSource) event.inboundSource = inboundSource; + // Determine whether to trigger inference - let triggerInference = true; if (this.shouldTriggerInference) { const textContent = message.content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map((b) => b.text) .join('\n'); - triggerInference = this.shouldTriggerInference( + event.triggerInference = this.shouldTriggerInference( textContent, { // The adapter's own metadata first; the protocol's fields after // it, always present (even undefined), so an adapter metadata key - // can never stand in for them — the gate's route candidates read - // threadId and messageId from here, and a thread decides where a - // post lands (MCPL RFC-011). + // can never stand in for them; and the frozen envelope last, which + // the gate's route candidates read first (a thread decides where a + // post lands, MCPL RFC-011). ...message.metadata, eventType: 'mcpl:channel-incoming', serverId, @@ -1059,26 +1083,11 @@ export class ChannelRegistry { threadId: message.threadId, author: message.author, ...(message.tags ? { tags: message.tags } : {}), + ...(inboundSource ? { [INBOUND_SOURCE_KEY]: inboundSource } : {}), }, ); } - // Build the incoming event - const event: McplChannelIncomingEvent = { - type: 'mcpl:channel-incoming', - serverId, - channelId: message.channelId, - messageId: message.messageId, - threadId: message.threadId, - author: message.author, - content: convertedContent, - timestamp: message.timestamp, - metadata: message.metadata, - ...(message.tags ? { tags: message.tags } : {}), - triggerInference, - acceptedAt: Date.now(), - }; - if (coalesced) { // RFC-006 §14.3: a coalesced message is admitted like any other and // then handed, with the event the ordinary path would have queued, to @@ -1099,12 +1108,6 @@ export class ChannelRegistry { continue; } - // The source envelope is frozen here, at admission, before the message - // is queued or acknowledged: a rename or rebind while it waits in the - // queue cannot change where it says it came from. - const inboundSource = this.acceptInbound?.(event); - if (inboundSource) event.inboundSource = inboundSource; - // Push to the processing queue // Cast through unknown because McplChannelIncomingEvent matches the // CustomEvent `${string}:${string}` type pattern but lacks an index signature. diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 6a903ab00..727574421 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -19,7 +19,7 @@ import type { FeatureSetManager } from './feature-set-manager.js'; import { McplFeatureSetError } from './feature-set-manager.js'; import { expandCoreTags } from './tags.js'; import { validateCoalescedContent } from './push-coalescer.js'; -import type { InboundSource } from './inbound-source.js'; +import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; // ============================================================================ // McplPushEvent (the ProcessEvent shape pushed to the queue) @@ -263,28 +263,10 @@ export class PushHandler { // 3. Convert content blocks const content: ContentBlock[] = params.payload.content.map(convertBlock); - // 4. Check shouldTriggerInference callback - let triggerInference = true; - if (this.shouldTriggerInference) { - const textContent = content - .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') - .map((b) => b.text) - .join('\n'); - const metadata: Record = { - serverId, - featureSet: params.featureSet, - eventId: params.eventId, - eventType: 'mcpl:push-event', - ...(params.origin ?? {}), - ...(params.tags ? { tags: params.tags } : {}), - }; - triggerInference = this.shouldTriggerInference(textContent, metadata); - } - - // 5. Generate inferenceId + // 4. Generate inferenceId const inferenceId = `${serverId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - // 6. Push event to queue + // 5. The event; whether it triggers inference is decided below. const pushEvent: McplPushEvent = { type: 'mcpl:push-event', serverId, @@ -295,9 +277,39 @@ export class PushHandler { tags: params.tags, timestamp: params.timestamp, inferenceId, - triggerInference, + triggerInference: true, acceptedAt: Date.now(), }; + // The source envelope of an ordinary push is frozen at admission, before + // it is gated, queued or acknowledged: nothing that changes while it + // waits can rewrite where it came from, and the gate reads the same + // envelope the direct path does. (A coalesced push's envelope is frozen + // by its coalescer when it is delivered.) + const inboundSource = coalesced ? undefined : this.acceptInbound?.(pushEvent); + if (inboundSource) pushEvent.inboundSource = inboundSource; + + // 6. Check shouldTriggerInference callback + if (this.shouldTriggerInference) { + const textContent = content + .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') + .map((b) => b.text) + .join('\n'); + // The server's origin first; the host's own fields after it, so an + // origin key can never stand in for which server sent this or what + // kind of event it is; the frozen envelope last, which the gate's + // route candidates read first. + const metadata: Record = { + ...(params.origin ?? {}), + serverId, + featureSet: params.featureSet, + eventId: params.eventId, + eventType: 'mcpl:push-event', + ...(params.tags ? { tags: params.tags } : {}), + ...(inboundSource ? { [INBOUND_SOURCE_KEY]: inboundSource } : {}), + }; + pushEvent.triggerInference = this.shouldTriggerInference(textContent, metadata); + } + if (coalesced) { // RFC-006: the coalescer decides whether this occurrence replaces an // unread one, appends, or withdraws; it delivers through the same event @@ -314,11 +326,6 @@ export class PushHandler { } return; } - // The source envelope is frozen at admission, before queueing or the - // acknowledgement: nothing that changes while the push waits in the - // queue can rewrite where it came from. - const inboundSource = this.acceptInbound?.(pushEvent); - if (inboundSource) pushEvent.inboundSource = inboundSource; this.pushEventFn(pushEvent); // 7. Emit trace diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 65d1d6db4..790e33b9e 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -632,6 +632,31 @@ describe('debounce', () => { ]); }); + it('a candidate takes its conversation from the frozen envelope, ahead of free-form metadata', async () => { + const path = writeConfig('debounce-route-envelope.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + // The route resolver would read the forged origin; the envelope wins. + const { gate, inferenceRequests } = makeGate(path, { resolveRouteChannel: () => 'slack:FORGED' }); + gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'forged-server', channelId: 'C1', content: '@agent in a thread', + tags: ['chat:addressed'], + metadata: { + authorId: '1', messageId: 'forged-m', threadId: 'forged-t', + inboundSource: { + kind: 'channel', lane: 'push/event', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', + threadId: '1700.0001', messageId: 'm-env', acceptedAt: 1, + }, + } })); + await new Promise(r => setTimeout(r, 150)); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'channel', channelId: 'slack:C1', serverId: 'slack', threadId: '1700.0001', messageId: 'm-env', addressed: true }, + ]); + }); + it('a reaction in the batch is never a route candidate (the same predicate as mid-turn)', async () => { const path = writeConfig('debounce-route-reaction.json', { policies: [ diff --git a/test/push-gate-metadata.test.ts b/test/push-gate-metadata.test.ts new file mode 100644 index 000000000..b8d8bfeb9 --- /dev/null +++ b/test/push-gate-metadata.test.ts @@ -0,0 +1,45 @@ +/** + * The push lane's gate metadata: what a server puts in a push/event's + * `origin` is the server's own account, and can never stand in for which + * server sent it, what kind of event it is, or the conversation its frozen + * source envelope names (MCPL RFC-011 binds the thread the origin names, but + * the host's fields and envelope are the host's). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PushHandler } from '../src/mcpl/push-handler.js'; +import { INBOUND_SOURCE_KEY, type InboundSource } from '../src/mcpl/inbound-source.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; + +test('origin keys cannot override the host\'s fields, and the envelope rides last', async () => { + const seen: Array> = []; + const envelope: InboundSource = { + kind: 'channel', lane: 'push/event', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', + threadId: '1700.0001', messageId: 'm-1', eventId: 'ev-1', acceptedAt: 1, + }; + let accepted = 0; + const handler = new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + () => {}, + () => {}, + (_content, metadata) => { seen.push(metadata); return true; }, + undefined, + () => { accepted++; return envelope; }, + ); + await handler.handlePushEvent('slack', { + featureSet: 'chat', + eventId: 'ev-1', + timestamp: '2026-10-07T00:00:00.000Z', + origin: { serverId: 'forged', eventType: 'forged', featureSet: 'forged', eventId: 'forged', threadId: '1700.0001', [INBOUND_SOURCE_KEY]: { forged: true } }, + payload: { content: [{ type: 'text', text: 'hi' }] }, + } as never); + assert.equal(accepted, 1, 'the envelope is frozen once, before the gate reads it'); + assert.equal(seen.length, 1); + const m = seen[0]!; + assert.equal(m.serverId, 'slack'); + assert.equal(m.eventType, 'mcpl:push-event'); + assert.equal(m.featureSet, 'chat'); + assert.equal(m.eventId, 'ev-1'); + assert.equal(m.threadId, '1700.0001', 'the origin\'s own (RFC-011-bound) thread is kept'); + assert.deepEqual(m[INBOUND_SOURCE_KEY], envelope, 'the frozen envelope, not anything the origin carried under its key'); +}); From 56827f51ca9c66fb31ce922052dcafaeff5d7f1b Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Wed, 7 Oct 2026 21:37:53 -0400 Subject: [PATCH 24/65] fix(routing): a fork's route answers the message that woke it in its home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review of #256 (finding 8, "Forks lose their reply message"): homeRoute() never carries a replyTo, and inferTurnRoute returned the home untouched, so a conversation fork lost its beforeInference `incoming` context (buildChannelContext only advertises `incoming` from the route's reply edge) and reactToRefusal had no message to react to — a regression from before #256, when forks were handed the process-global last inbound (which could even be another channel's). The home still always wins; its reply edge is now the newest of the wake's candidates in the home conversation itself (exact conversationKey: server, channel and thread), addressed candidates first, exactly as for an inferred route. No candidate there, or one without a message id, leaves the home without a reply edge, as before. `considered` and `newestOf` name the two rules the inferred path already used, so both paths share them. Test: speech-routes "a fork home answers the newest message the wake carried in the home conversation itself" (red at 42c3f5a). Review follow-up in room-273; Mira-1782 reviews. --- src/speech-routes.ts | 29 ++++++++++++++++++++++++----- test/speech-routes.test.ts | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 2dc0ea9f2..06810e9a0 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -153,7 +153,10 @@ export function describeConversation(c: ConversationRef): string { /** * The turn's route from its wake candidates (a true new turn only). - * A fork's home always wins. Otherwise the addressed candidates are + * A fork's home always wins; the message it answers is the newest of the + * candidates in the home conversation itself (addressed ones first), as for + * an inferred route, and none when no candidate is there. Otherwise the + * addressed candidates are * considered if there are any, else every (conversational) candidate; they * infer a route only when they name one conversation, from the newest * candidate in it (its message is the reply edge). Several conversations @@ -168,11 +171,14 @@ export function inferTurnRoute( targetOf: PublishTargetOf, ): TurnRoute { if (home) { - const refusal = routeRefusal(routeConversation(home), targetOf); - return refusal ? { route: null, unroutable: { conversation: routeConversation(home), reason: refusal } } : { route: home }; + const conversation = routeConversation(home); + const refusal = routeRefusal(conversation, targetOf); + if (refusal) return { route: null, unroutable: { conversation, reason: refusal } }; + const key = conversationKey(conversation); + const replyTo = newestOf(considered(candidates.filter((c) => conversationKey(c.conversation) === key)))?.messageId; + return { route: replyTo && home.kind === 'channel' ? { ...home, replyTo } : home }; } - const addressed = candidates.filter((c) => c.addressed); - const pool = addressed.length > 0 ? addressed : candidates; + const pool = considered(candidates); if (pool.length === 0) return { route: null }; const newestByConversation = new Map(); for (const candidate of pool) { @@ -210,6 +216,19 @@ export function inferTurnRoute( }; } +/** The candidates a route is inferred from: the addressed ones if any, else all. */ +function considered(candidates: readonly RouteCandidate[]): readonly RouteCandidate[] { + const addressed = candidates.filter((c) => c.addressed); + return addressed.length > 0 ? addressed : candidates; +} + +/** The newest candidate (the latest of equals). */ +function newestOf(candidates: readonly RouteCandidate[]): RouteCandidate | undefined { + let newest: RouteCandidate | undefined; + for (const candidate of candidates) if (!newest || candidate.at >= newest.at) newest = candidate; + return newest; +} + /** * Whether an item is conversational input — something the resident might be * answering — rather than machinery: a system marker (send-failure notices, diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 6bc8d2268..93520f1ee 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -36,6 +36,25 @@ describe('inferTurnRoute', () => { assert.deepEqual(turn, { route: home }); }); + it('a fork home answers the newest message the wake carried in the home conversation itself', () => { + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + const turn = inferTurnRoute([ + candidate(channel('home'), false, 1, 'm-old'), + candidate(channel('home'), false, 3, 'm-new'), + candidate(channel('elsewhere'), true, 4, 'm-else'), + candidate(channel('home', { threadId: 't1' }), true, 5, 'm-thread'), + ], home, exactEverywhere); + assert.deepEqual(turn, { route: { ...home, replyTo: 'm-new' } }); + // Addressed candidates in the home conversation come first, as for an inferred route. + const addressed = inferTurnRoute([ + candidate(channel('home'), true, 1, 'm-asked'), + candidate(channel('home'), false, 2, 'm-later'), + ], home, rootEverywhere); + assert.deepEqual(addressed, { route: { ...home, replyTo: 'm-asked' } }); + // A candidate without a message gives no reply edge. + assert.deepEqual(inferTurnRoute([candidate(channel('home'), true, 1)], home, rootEverywhere), { route: home }); + }); + it('no candidates: no route', () => { assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere), { route: null }); }); From ff899f7505a8f1fb2afc76485b762b82dea5d439 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Wed, 7 Oct 2026 21:38:03 -0400 Subject: [PATCH 25/65] fix(mcpl): publish rechecks its destination after opening a closed channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review of #256 (finding 7, "Recheck declarations after opening"): publish() checked the channel's RFC-011 declaration and the place, then awaited openChannelNow() — a round trip to the connector — and sent with the entry it had resolved before the wait. A `channels/changed` meanwhile could remove the channel or withdraw its declaration, and the host still sent the targeted request, relying on the connector to refuse it; #256 promises the host refuses "before anything is opened or sent". After an open, publish now re-finds the entry by the identity the destination resolved to — its server and descriptor id, never the original selector (Mira-1782's point: a server-omitted selector could resolve afresh to another server that registered the same id during the open) — and rechecks declaration and place immediately before sending. A channel that went away fails with that reason; a withdrawn declaration fails with the usual place refusal; the outcome's destination is built from the fresh entry. Nothing awaits between the recheck and the send's server lookup, so no further window remains. Test: channel-registry-routing "publish rechecks its destination after opening it: a declaration withdrawn meanwhile refuses the send" — withdrawn, removed, and moved-to-another-server cases send nothing; unchanged sends (red at 42c3f5a). Review follow-up in room-273; Mira-1782 reviews. --- src/mcpl/channel-registry.ts | 28 +++++++++--- test/channel-registry-routing.test.ts | 62 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 52eca160d..0888d3055 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3145,14 +3145,15 @@ export class ChannelRegistry { const at = (): number => Date.now(); const found = this.findExactEntry(target); if ('error' in found) return { status: 'failed', reason: found.error, at: at() }; - const entry = found.entry; + let entry = found.entry; const place = target.threadId === undefined ? null : target.threadId; - const destination: PublishDestination = { - serverId: entry.serverId, - channelId: entry.descriptor.id, - ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}), + const destinationOf = (e: ChannelEntry): PublishDestination => ({ + serverId: e.serverId, + channelId: e.descriptor.id, + ...(e.descriptor.label ? { label: e.descriptor.label } : {}), threadId: place, - }; + }); + let destination = destinationOf(entry); if (place !== null && (typeof place !== 'string' || place === '')) { return { status: 'failed', destination, reason: 'the thread to post in must be a non-empty thread id', at: at() }; } @@ -3189,6 +3190,21 @@ export class ChannelRegistry { at: at(), }; } + // The open waited on the connector, and a `channels/changed` meanwhile + // can have removed the channel or withdrawn its declaration. Recheck + // the entry the destination resolved to (its server and channel, never + // the original selector, which could now resolve to another server) + // immediately before sending. + const current = this.findExactEntry({ serverId: entry.serverId, channelId: entry.descriptor.id }); + if ('error' in current) { + return { status: 'failed', destination, reason: `the channel went away while it was being opened: ${current.error}`, at: at() }; + } + entry = current.entry; + destination = destinationOf(entry); + const withdrawn = publishPlaceRefusal(declaredPublishTarget(entry.descriptor), place); + if (withdrawn) { + return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(withdrawn, entry.descriptor, place), at: at() }; + } } const server = this.serverRegistry.getServer(entry.serverId); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 3ca98aa4d..129a05999 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -154,6 +154,68 @@ test('the gate reads a message\'s thread from the protocol field, never from ada assert.equal(seen[1]!.threadId, '1700.0001'); }); +test('publish rechecks its destination after opening it: a declaration withdrawn meanwhile refuses the send', async () => { + // A closed channel declares where posts land; while the open waits on the + // connector, a channels/changed withdraws that (or removes the channel, or + // moves the id to another server). The host refuses, rather than relying + // on the connector to. + const published: unknown[] = []; + let duringOpen: () => void = () => {}; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsOpen: async () => { duringOpen(); return {}; }, + sendChannelsPublish: async (params: { threadId?: string | null }) => { + published.push(params); + return { delivered: true, threadId: params.threadId }; + }, + }; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + ); + const channels = (registry as unknown as { + channels: Map; open: boolean }>; + }).channels; + const seed = (): void => { + channels.clear(); + seedRegistered(registry, 'discord', 'dm-alice'); + }; + + seed(); + duringOpen = () => { channels.get('discord:dm-alice')!.descriptor = { id: 'dm-alice', type: 'discord', label: 'dm-alice' }; }; + const withdrawn = await registry.publish('sol', 'reply', { serverId: 'discord', channelId: 'dm-alice', threadId: null }); + assert.equal(withdrawn.status, 'failed'); + assert.match(withdrawn.reason ?? '', /declare/i); + + seed(); + duringOpen = () => { channels.delete('discord:dm-alice'); }; + const removed = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(removed.status, 'failed'); + assert.match(removed.reason ?? '', /went away while it was being opened/); + + // Removed from its server while another server registers the same id: the + // destination it resolved to is gone, and the send never moves to the other. + seed(); + duringOpen = () => { + channels.delete('discord:dm-alice'); + seedRegistered(registry, 'slack', 'dm-alice'); + }; + const moved = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(moved.status, 'failed'); + assert.equal(moved.destination?.serverId, 'discord'); + + assert.deepEqual(published, [], 'nothing was sent in any case'); + + // Unchanged across the open: it sends. + seed(); + duringOpen = () => {}; + const sent = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(sent.status, 'delivered'); + assert.equal(published.length, 1); +}); + test('routeSpeech surfaces a failure when the server reports delivered:false', async () => { const { registry, failures, traces } = makeRegistry({ delivered: false }); seedRegistered(registry, 'discord', 'ch-x'); From ff299453e62cb84ca40c4174eec0053332ea8640 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Wed, 7 Oct 2026 21:38:43 -0400 Subject: [PATCH 26/65] fix(gate): coalesced items are gated with the host envelope their coalescer freezes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review of #256 (finding 3, "Coalesced wakes lose their channel"): an ordinary push or channels/incoming message has its source envelope frozen at admission, before the gate reads it, but a coalesced one reached the gate with none — its envelope was built later, inside the coalescer's admission. With a debounce rule the gate's pending event then named its conversation from free metadata: a channel-scoped push whose origin carried only message and author details woke with no channel candidate (speech held as drafts), and one whose origin named another channel could route the reply there. Worse than the finding's wording: with no host envelope in the gate metadata, a connector-supplied `inboundSource` key in a push's origin or a message's adapter metadata was what readInboundSource returned, so a connector could forge another server's or channel's conversation for the gate's route candidates. - The host builds a coalesced item's envelope at admission too, before the gate, with a pure builder (coalescedPushSource, coalescedIncomingSource) that observes no acceptance; the coalescer freezes and carries that very envelope, and its `accepted` callback stays the one acceptance observation (no rejected or replayed occurrence becomes a fresh accepted input). A channel-scoped push's envelope names the channel its subject names, never one its origin names. PushHandler takes the builder as a new optional constructor argument, ChannelRegistry as the `coalescedSource` option. - The envelope key is always set after the spread of origin/adapter metadata, even when undefined, as the protocol fields already were, so nothing the connector sends can pose as the host's envelope. Tests: push-gate-metadata "a coalesced push is gated with the host envelope its coalescer will freeze..." and "with no host envelope, an origin key can't pose as one"; channel-registry-routing "a coalesced message is gated with the host envelope its coalescer will freeze..." (all red at 42c3f5a). Review follow-up in room-273; Mira-1782 reviews. --- src/framework.ts | 96 ++++++++++++++++++--------- src/mcpl/channel-registry.ts | 38 ++++++++--- src/mcpl/push-handler.ts | 26 +++++--- test/channel-registry-routing.test.ts | 38 +++++++++++ test/push-gate-metadata.test.ts | 62 +++++++++++++++++ 5 files changed, 210 insertions(+), 50 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index cccec934a..ce2804e06 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8051,6 +8051,58 @@ export class AgentFramework { return run; } + /** + * The source envelope of a coalesced `channels/incoming` message, built at + * admission (before it is gated) and frozen by its coalescer. Building it + * observes no acceptance: the coalescer does that if it admits the + * occurrence. + */ + private coalescedIncomingSource( + serverId: string, + message: ChannelIncomingMessage, + event: { acceptedAt?: number }, + ): InboundSource { + return this.channelSource({ + lane: 'channels/incoming', + coalesced: true, + serverId, + channelId: message.channelId, + threadId: message.threadId, + messageId: message.messageId, + eventId: message.eventId, + replyTo: message.metadata?.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: message.timestamp, + }); + } + + /** + * The source envelope of a coalesced push, built at admission (before it is + * gated) and frozen by its coalescer; like coalescedIncomingSource, it + * observes no acceptance. A channel-scoped push belongs to the channel its + * subject names — never one its `origin` names. + */ + private coalescedPushSource(serverId: string, params: PushEventParams, event: McplPushEvent): InboundSource { + const c = params.coalesce; + const origin = params.origin ?? {}; + const deferred = !!c?.deferred; + return c?.channelId + ? this.channelSource({ + lane: 'push/event', + coalesced: true, + serverId, + channelId: c.channelId, + threadId: origin.threadId, + messageId: origin.messageId, + eventId: params.eventId, + replyTo: origin.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: params.timestamp, + deferred, + }) + : this.pushSource(event, { deferred }); + } + private handleCoalescedIncoming( serverId: string, message: ChannelIncomingMessage, @@ -8065,20 +8117,10 @@ export class AgentFramework { if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required'); validateCoalescedContent(message.content); const c = message.coalesce; - // The inbound source envelope, frozen at acceptance: deferral, fan-out - // and replay deliver it unchanged (mcpl/inbound-source.ts). - const inboundSource = this.channelSource({ - lane: 'channels/incoming', - coalesced: true, - serverId, - channelId: message.channelId, - threadId: message.threadId, - messageId: message.messageId, - eventId: message.eventId, - replyTo: message.metadata?.replyTo, - acceptedAt: event.acceptedAt, - sourceTimestamp: message.timestamp, - }); + // The inbound source envelope the gate read, frozen at acceptance: + // deferral, fan-out and replay deliver it unchanged + // (mcpl/inbound-source.ts). + const inboundSource = event.inboundSource ?? this.coalescedIncomingSource(serverId, message, event); const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -8109,23 +8151,9 @@ 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); - // The inbound source envelope, frozen at acceptance (see the channel - // lane). A channel-scoped push belongs to the channel its subject names. - const inboundSource: InboundSource = c.channelId - ? this.channelSource({ - lane: 'push/event', - coalesced: true, - serverId, - channelId: c.channelId, - threadId: origin.threadId, - messageId: origin.messageId, - eventId: params.eventId, - replyTo: origin.replyTo, - acceptedAt: event.acceptedAt, - sourceTimestamp: params.timestamp, - deferred: !!c.deferred, - }) - : this.pushSource(event, { deferred: !!c.deferred }); + // The inbound source envelope the gate read, frozen at acceptance (see + // the channel lane). + const inboundSource = event.inboundSource ?? this.coalescedPushSource(serverId, params, event); return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -14578,6 +14606,9 @@ export class AgentFramework { this.noteInboundAccepted(source); return source; }, + // A coalesced push: its envelope before it is gated; its coalescer + // observes the acceptance. + (serverId, params, event) => this.coalescedPushSource(serverId, params, event), ); // Server-initiated inference router (Step 6) @@ -14616,6 +14647,9 @@ export class AgentFramework { this.noteInboundAccepted(source); return source; }, + // A coalesced message: its envelope before it is gated; its + // coalescer observes the acceptance. + coalescedSource: (serverId, message, event) => this.coalescedIncomingSource(serverId, message, event), sendTypingFn: (serverId, channelId, metadata, op) => { const server = this.mcplServerRegistry!.getServer(serverId); if (server) { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 0888d3055..502298175 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -563,6 +563,17 @@ interface ChannelRegistryOptions { * acceptance here; coalesced work is stamped and observed by its own path. */ acceptInbound?: (event: McplChannelIncomingEvent) => InboundSource | undefined; + /** + * A `channels/incoming` message carrying `coalesce`: its source envelope, + * built — not accepted — before it is gated. Its coalescer freezes and + * carries this same envelope, and observes the acceptance itself if it + * admits the occurrence. + */ + coalescedSource?: ( + serverId: string, + message: ChannelIncomingMessage, + event: McplChannelIncomingEvent, + ) => InboundSource | undefined; /** * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with * the event the ordinary path would have queued. The handler decides @@ -751,6 +762,7 @@ export class ChannelRegistry { private migratedLegacyPolicies = new Set(); private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming']; private acceptInbound?: ChannelRegistryOptions['acceptInbound']; + private coalescedSource?: ChannelRegistryOptions['coalescedSource']; constructor( serverRegistry: McplServerRegistry, @@ -768,6 +780,7 @@ export class ChannelRegistry { ) { this.handleCoalescedIncoming = options?.handleCoalescedIncoming; this.acceptInbound = options?.acceptInbound; + this.coalescedSource = options?.coalescedSource; this.serverRegistry = serverRegistry; this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -1053,12 +1066,16 @@ export class ChannelRegistry { acceptedAt: Date.now(), }; - // The source envelope of an ordinary message is frozen here, at - // admission, before it is gated, queued or acknowledged: a rename or - // rebind while it waits cannot change where it says it came from, and - // the gate reads the same envelope the direct path does. (A coalesced - // message's envelope is frozen by its coalescer when it is delivered.) - const inboundSource = coalesced ? undefined : this.acceptInbound?.(event); + // The source envelope is the host's, built here, at admission, before + // the message is gated, queued or acknowledged: a rename or rebind + // while it waits cannot change where it says it came from, and the + // gate reads the same envelope the direct path does. An ordinary + // message's acceptance is observed now; a coalesced one's envelope is + // only built here, and its coalescer freezes it and observes the + // acceptance if it admits the occurrence. + const inboundSource = coalesced + ? this.coalescedSource?.(serverId, message, event) + : this.acceptInbound?.(event); if (inboundSource) event.inboundSource = inboundSource; // Determine whether to trigger inference @@ -1072,9 +1089,10 @@ export class ChannelRegistry { { // The adapter's own metadata first; the protocol's fields after // it, always present (even undefined), so an adapter metadata key - // can never stand in for them; and the frozen envelope last, which - // the gate's route candidates read first (a thread decides where a - // post lands, MCPL RFC-011). + // can never stand in for them; and the frozen envelope last — + // always present, even undefined, so an adapter key can't pose as + // it either — which the gate's route candidates read first (a + // thread decides where a post lands, MCPL RFC-011). ...message.metadata, eventType: 'mcpl:channel-incoming', serverId, @@ -1083,7 +1101,7 @@ export class ChannelRegistry { threadId: message.threadId, author: message.author, ...(message.tags ? { tags: message.tags } : {}), - ...(inboundSource ? { [INBOUND_SOURCE_KEY]: inboundSource } : {}), + [INBOUND_SOURCE_KEY]: inboundSource, }, ); } diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 727574421..d3a56cccf 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -187,6 +187,10 @@ export class PushHandler { * or acknowledged: return its source envelope, frozen now * (mcpl/inbound-source.ts). Coalesced work is stamped by its own path. */ private readonly acceptInbound?: (event: McplPushEvent) => InboundSource | undefined, + /** A coalesced push's source envelope, built — not accepted — before it + * is gated: its coalescer freezes and carries this same envelope, and + * observes the acceptance itself if it admits the occurrence. */ + private readonly coalescedSource?: (serverId: string, params: PushEventParams, event: McplPushEvent) => InboundSource | undefined, ) { this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -280,12 +284,15 @@ export class PushHandler { triggerInference: true, acceptedAt: Date.now(), }; - // The source envelope of an ordinary push is frozen at admission, before - // it is gated, queued or acknowledged: nothing that changes while it - // waits can rewrite where it came from, and the gate reads the same - // envelope the direct path does. (A coalesced push's envelope is frozen - // by its coalescer when it is delivered.) - const inboundSource = coalesced ? undefined : this.acceptInbound?.(pushEvent); + // The source envelope is the host's, built at admission, before the item + // is gated, queued or acknowledged: nothing that changes while it waits + // can rewrite where it came from, and the gate reads the same envelope + // the direct path does. An ordinary push's acceptance is observed now; a + // coalesced push's envelope is only built here, and its coalescer + // freezes it and observes the acceptance if it admits the occurrence. + const inboundSource = coalesced + ? this.coalescedSource?.(serverId, params, pushEvent) + : this.acceptInbound?.(pushEvent); if (inboundSource) pushEvent.inboundSource = inboundSource; // 6. Check shouldTriggerInference callback @@ -296,8 +303,9 @@ export class PushHandler { .join('\n'); // The server's origin first; the host's own fields after it, so an // origin key can never stand in for which server sent this or what - // kind of event it is; the frozen envelope last, which the gate's - // route candidates read first. + // kind of event it is; the frozen envelope last — always present, even + // undefined, so an origin key can't pose as it either — which the + // gate's route candidates read first. const metadata: Record = { ...(params.origin ?? {}), serverId, @@ -305,7 +313,7 @@ export class PushHandler { eventId: params.eventId, eventType: 'mcpl:push-event', ...(params.tags ? { tags: params.tags } : {}), - ...(inboundSource ? { [INBOUND_SOURCE_KEY]: inboundSource } : {}), + [INBOUND_SOURCE_KEY]: inboundSource, }; pushEvent.triggerInference = this.shouldTriggerInference(textContent, metadata); } diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 129a05999..1e280bee6 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -154,6 +154,44 @@ test('the gate reads a message\'s thread from the protocol field, never from ada assert.equal(seen[1]!.threadId, '1700.0001'); }); +test('a coalesced message is gated with the host envelope its coalescer will freeze; adapter metadata can\'t pose as one', async () => { + const seen: Array> = []; + const envelope = { + kind: 'channel', lane: 'channels/incoming', coalesced: true, serverId: 'slack', binding: 'b1', + channelId: 'slack:C1', messageId: 'm-c', acceptedAt: 1, + }; + let accepted = 0; + const handed: Array<{ inboundSource?: unknown }> = []; + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { + shouldTriggerInference: (_content, metadata) => { seen.push(metadata); return false; }, + acceptInbound: () => { accepted++; return undefined; }, + coalescedSource: () => envelope as never, + handleCoalescedIncoming: async (_serverId, message, event) => { + handed.push(event as { inboundSource?: unknown }); + return { messageId: message.messageId, accepted: true }; + }, + }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:OTHER'); + await registry.handleIncoming('slack', { + messages: [{ + channelId: 'slack:C1', messageId: 'm-c', author: { id: 'u1', name: 'Ada' }, + timestamp: '2026-10-07T00:00:00.000Z', content: [{ type: 'text' as const, text: 'hi' }], + eventId: 'ev-c', coalesce: { key: 'k1' }, + metadata: { inboundSource: { kind: 'channel', serverId: 'forged', channelId: 'slack:OTHER' } }, + }], + } as never); + assert.equal(accepted, 0, 'building the envelope accepts nothing: the coalescer observes its own admissions'); + assert.equal(seen.length, 1); + assert.equal(seen[0]!.inboundSource, envelope, 'the gate reads the host envelope'); + assert.equal(handed[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate read'); +}); + test('publish rechecks its destination after opening it: a declaration withdrawn meanwhile refuses the send', async () => { // A closed channel declares where posts land; while the open waits on the // connector, a channels/changed withdraws that (or removes the channel, or diff --git a/test/push-gate-metadata.test.ts b/test/push-gate-metadata.test.ts index b8d8bfeb9..d233c675e 100644 --- a/test/push-gate-metadata.test.ts +++ b/test/push-gate-metadata.test.ts @@ -43,3 +43,65 @@ test('origin keys cannot override the host\'s fields, and the envelope rides las assert.equal(m.threadId, '1700.0001', 'the origin\'s own (RFC-011-bound) thread is kept'); assert.deepEqual(m[INBOUND_SOURCE_KEY], envelope, 'the frozen envelope, not anything the origin carried under its key'); }); + +test('a coalesced push is gated with the host envelope its coalescer will freeze, and its acceptance is not observed at the gate', async () => { + const seen: Array> = []; + const envelope: InboundSource = { + kind: 'channel', lane: 'push/event', coalesced: true, serverId: 'slack', binding: 'b1', channelId: 'slack:C1', + messageId: 'm-1', eventId: 'ev-c1', acceptedAt: 1, + }; + let accepted = 0; + let built = 0; + const coalescedEvents: Array<{ inboundSource?: InboundSource }> = []; + const handler = new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + () => {}, + () => {}, + (_content, metadata) => { seen.push(metadata); return false; }, + async (_serverId, _params, event) => { coalescedEvents.push(event); return { accepted: true } as never; }, + () => { accepted++; return undefined; }, + (_serverId, params) => { + built++; + assert.equal((params as { coalesce?: { channelId?: string } }).coalesce?.channelId, 'slack:C1'); + return envelope; + }, + ); + await handler.handlePushEvent('slack', { + featureSet: 'chat', + eventId: 'ev-c1', + timestamp: '2026-10-07T00:00:00.000Z', + // The subject names the channel; the origin names another, and carries a key posing as an envelope. + coalesce: { key: 'k1', channelId: 'slack:C1' }, + origin: { channelId: 'slack:OTHER', messageId: 'm-1', [INBOUND_SOURCE_KEY]: { forged: true } }, + payload: { content: [{ type: 'text', text: 'hi' }] }, + } as never); + assert.equal(built, 1); + assert.equal(accepted, 0, 'building a coalesced envelope accepts nothing: the coalescer observes its own admissions'); + assert.equal(seen.length, 1); + assert.deepEqual(seen[0]![INBOUND_SOURCE_KEY], envelope, 'the gate reads the host envelope, not the origin\'s channel or key'); + assert.equal(coalescedEvents.length, 1); + assert.equal(coalescedEvents[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate read'); +}); + +test('with no host envelope, an origin key can\'t pose as one', async () => { + const seen: Array> = []; + const handler = new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + () => {}, + () => {}, + (_content, metadata) => { seen.push(metadata); return true; }, + ); + const forged = { + kind: 'channel', lane: 'push/event', serverId: 'other-server', binding: 'b', channelId: 'other:C9', acceptedAt: 1, + }; + await handler.handlePushEvent('slack', { + featureSet: 'chat', + eventId: 'ev-2', + timestamp: '2026-10-07T00:00:00.000Z', + origin: { [INBOUND_SOURCE_KEY]: forged }, + payload: { content: [{ type: 'text', text: 'hi' }] }, + } as never); + assert.equal(seen.length, 1); + assert.ok(INBOUND_SOURCE_KEY in seen[0]!, 'the key is the host\'s, present even when it has no envelope'); + assert.equal(seen[0]![INBOUND_SOURCE_KEY], undefined); +}); From eaba9a89047f14c417a6e46461b1037bfb3b1044 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Wed, 7 Oct 2026 22:46:12 -0400 Subject: [PATCH 27/65] fix(routing): speech goes where the route stood when its words were written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review of #256, findings 2, 4, 5 and 6, with the adjacent paths the room-273 review found (Mira-1782; Petra-1012 on the retry boundary). One rule underlies the first group: a channel_open or a hold that lands while words wait on the speech chain governs only words written after it. (Finding 1, outgoing streams, is the next commit.) Queued speech (finding 2, "Queued replies change destination"; and the hybrid path): - Each round's words take a SpeechMoment — the turn's route — when the round ends, before its own calls run; the `complete` paths take theirs at the top. speakUnaddressed and deliverHybridProse publish along that moment instead of reading the route at delivery. At 42c3f5a a same-round channel_open could already move the round's own words (its route change raced the idle chain); a busy chain made every queued round follow a later open. The `complete` paths also ran after the agent was idle, so a delivery-time read could see the NEXT turn's route (the 2026-07-22 Sol misroute's shape). - One speech chain per agent (chainSpeech/speechSettled) instead of one per physical stream: a budget restart or framework retry no longer lets its words overtake the previous stream's still-publishing ones, and a true new turn waits for the previous turn's chain before clearing turn state, so a late link can't write into the next turn. The text-only and trailing paths, and the silenced holds, publish on it too. - Hybrid `>>>` choices (proseTargetPins, proseHybridSuppressed) are read and written only on the chain, in the order the words were written. A route-setting channel_open supersedes them in that same order: it chains a link that clears them, after the words written before the open (already chained when its result arrives) and before any written after (chained only once the result is in). That fixes both directions of the race: a stale queued envelope setting a pin the next round would follow, and an open deleting a pin that older queued words still relied on. - A channel_open whose result arrives after a newer turn has begun (it outlived its turn: an abort mid-open) moves neither the route nor the hybrid choices of the turn it didn't belong to, and its result says so. logicalTurns numbers an agent's logical turns, advanced only at a true new turn, so restarts and retries count as the same turn. An open that completes after its turn ended and before the next begins still lands; the next true turn settles the chain, then clears choices and infers its own route. Hybrid suppression after an open (finding 4, "Opening cannot restore hybrid speech"): the chained clear covers the suppression a `>>>skip_reply` or failed envelope left, not only the pin; setSpeechTarget: false, or an open whose place can't be a route, leaves both. Retry boundary (Petra-1012, confirmed by Mira-1782): a framework retry (attempt > 0) continues the logical turn, as the route already did. It now also keeps engagement, `>>`/`>>>` choices, and the deliveries, drafts and private counts its receipt reports; only a true new turn clears them. Selectors (finding 5, "Invalid choices become real destinations"): dispatchChannelToolCall checks supplied selectors before any default, as channel_publish already did: channel_open refuses an empty or non-string threadId or serverId, or a non-boolean setSpeechTarget, with nothing opened; a conversation fork's channel_publish/channel_close refuses an empty or non-string channelId rather than reading it as home. null still means "not in use". Engagement (finding 6, "Other threads hold the reply"): turnEngagement records where the host knows the resident sent — the exact conversation (conversationKey: server, channel, thread) of a resend or channel_publish that was delivered or unknown (one rule, noteSendEngagement; unknown is a possible reply claim, not a delivery) — and only the server and channel of a connector's own send tool, which chooses its place itself (slack-mcpl's reply_message reports a connector-specific threadTs, and its ambient thread follow-ups carry no addressed tag). The hold's notice says how certain it is: sent there, tried to send there, or somewhere in the channel. channel_publish recorded no engagement before; it now does. Hybrid `>>>` and explicit `>>` envelopes record none: engagement is read only by the hold, which suspends only the inferred route, and an envelope takes that out of use for the rest of the turn. Changelog fragments describe each change, including the publish recheck after opening (ff899f7), with Mira-1782's accuracy notes: ambient chatter holds nothing only in a conversation the resident hasn't sent into, and a fork's reply edge is its newest addressed message when it has one. Tests (present-while-acting "speech follows the moment its words were written"; the behavioural ones red at 42c3f5a): queued speech across a channel_open, plain and hybrid; open ending skip_reply suppression (and setSpeechTarget: false keeping it); a retry keeping its pin and engagement; sibling-thread chatter vs a known-place send; the three engagement notices; channel_publish outcomes; channel_open and fork selector refusals. Review follow-up in room-273; Mira-1782 reviews. --- changelog.d/speech-routes.changed.md | 32 +- changelog.d/targeted-publish.changed.md | 8 + src/framework.ts | 500 ++++++++++++++++-------- test/present-while-acting.test.ts | 463 +++++++++++++++++++++- 4 files changed, 821 insertions(+), 182 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 9a2681241..fbd15acfd 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -17,17 +17,31 @@ routing fix kept for ambient-only batches, and this change replaces it. A surface route shows speech on that surface and publishes it to no channel. - Context-budget restarts, guard retries and framework retries keep the - turn's route. Only the next true new turn decides again. + turn's route, and everything else the turn has chosen or done: its + explicit-send engagement, its `>>` or `>>>` choice, and the deliveries and + drafts its receipt reports. Only the next true new turn decides again. +- **Speech goes where the route stood when its words were written.** A + `channel_open`, or a hold, that lands while earlier words still wait to be + published governs only words written after it; the same goes for a + `channel_open` superseding a hybrid `>>>` choice. A text-only or trailing + reply is published along its own turn's route, even once the agent is idle + and a next turn may have begun. - **Mid-turn ambiguity hold replaces the addressed re-pin.** When a presented mid-turn arrival from another conversation addresses the resident, or continues a conversation the resident explicitly sent into this turn, an inferred route is held for the rest of the turn instead of moving to the - newcomer. A notice rides the same injection batch. Ambient chatter, - reactions and arrivals in the same conversation change nothing, and the - reply edge stays on the message that woke the turn. Deliberate routes are + newcomer. A notice rides the same injection batch. A send whose place the + host knows (a resend or `channel_publish`, delivered or unconfirmed) + engages exactly that conversation, thread included; a connector's own send + tool chooses its place itself, so it engages its whole channel, and the + notice says where in the channel isn't known. Ambient chatter in a + conversation the resident hasn't sent into this turn, reactions, and + arrivals in the same conversation change nothing, and the reply edge stays + on the message that woke the turn. Deliberate routes are never held: a fork's home, a `channel_open`, and a hybrid `>>>` target. - **`channel_open` sets the speech route** for the rest of the turn by - default, replacing an inferred route, a hold, or a hybrid `>>>` target. The + default, replacing an inferred route, a hold, a hybrid `>>>` target, or the + suppression a `>>>skip_reply` or a failed envelope left. The new `setSpeechTarget: false` opens the channel for reading only, and the result says where speech goes then. - **`channel_publish` is an explicit send through the publish executor.** @@ -41,8 +55,12 @@ - The beforeInference channel context (`defaultOutgoing` and `incoming`), refusal reactions, rewind notices and the failure log now use the agent's own route and its reply edge, so what the agent is told matches where its - words go. + words go. A fork's reply edge is the wake's newest addressed message in + its home conversation, or else its newest message there. - A batched gate wake reports every conversation in its batch to the framework (`WakeProvenance.routeCandidates`). An addressed event whose registered channel the host can't resolve still competes, so the turn is - held, but it is never itself a route. + held, but it is never itself a route. A coalesced item is gated with the + host's source envelope too, the one its coalescer then freezes, and an + `inboundSource` key in a connector's origin or metadata is never read as + one. diff --git a/changelog.d/targeted-publish.changed.md b/changelog.d/targeted-publish.changed.md index 3658619f3..aef589829 100644 --- a/changelog.d/targeted-publish.changed.md +++ b/changelog.d/targeted-publish.changed.md @@ -13,10 +13,18 @@ `setSpeechTarget`) take an optional `threadId`. - A channel named without one means its root, never a thread borrowed from an earlier route. + - A supplied selector is checked before any default applies: an empty or + non-string `threadId` or `serverId` on `channel_open`, or a non-boolean + `setSpeechTarget`, is refused with nothing opened, and a conversation + fork's empty `channelId` is refused rather than read as its home. - `channel_open` on an undeclared channel still opens it, but sets no speech target, and its result explains why. - **Outgoing streams follow the same rule.** A stream goes only to a declared channel, names its place, and is skipped when the route is a thread, since one channel's stream can't name two places. +- **Publishing rechecks after opening.** When a publish has to open a closed + channel first, it checks the destination again before sending: a channel + removed, or a declaration withdrawn, while the open was pending refuses the + send, rather than relying on the connector to. - Refusal results (`delivered: false` with a `reason` and no message) keep the connector's reason. diff --git a/src/framework.ts b/src/framework.ts index ce2804e06..e71d025f8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -271,6 +271,17 @@ function estimateAppendedRoundTokens( return Math.ceil(chars / 4) + images * 1600 + blockCount * 20; } +/** + * What decides where one piece of speech goes, captured when its words were + * produced: the turn's route then. Speech is published later, in order, on + * the agent's speech chain, and a tool result (a channel_open) or an arrival + * (a hold) can land in between; neither may change where words already + * written go. + */ +interface SpeechMoment { + turn: TurnRoute | undefined; +} + /** * Explicit delivery tools whose successful use into a closed channel OPENS * it (send implies engagement — see openIfClosedForSend). Bare tool names, @@ -1078,19 +1089,29 @@ export class AgentFramework { * traffic never selects a new destination. Lives here, not in driveStream, * so context-budget restarts and retries keep it. */ private turnRoutes: Map = new Map(); - /** Channels the agent EXPLICITLY sent into during the CURRENT turn - * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second ambiguity - * signal: a conversational reply in a channel the agent just engaged - * competes for its unaddressed words even without a mention (2026-07-31 - * n=7: q's #portables follow-up — no @mention — was answered in trailing - * prose that followed the stale pin into repligate's DM), so it holds an - * inferred route (shelf-355). No author-kind filter: agent-residents are - * full participants, and the bot flag tracks nothing that matters - * (antra). Reactions/system markers are excluded by - * isConversationalInjection; the engaged-this-turn scope keeps unrelated - * channels from holding anything. Cleared at every fresh turn's start; - * budget restarts keep it. */ - private turnEngagedChannels: Map> = new Map(); + /** Where the agent EXPLICITLY sent during the CURRENT turn, per agent: the + * second ambiguity signal. A conversational reply where the agent just + * sent competes for its unaddressed words even without a mention + * (2026-07-31 n=7: q's #portables follow-up — no @mention — was answered + * in trailing prose that followed the stale pin into repligate's DM), so + * it holds an inferred route (shelf-355). Recorded as exactly as the host + * knows the place (noteSendEngagement, noteChannelEngagement): + * - `conversations`: the exact conversation (conversationKey: server, + * channel, thread) of the host's own publications — a resend or + * channel_publish whose outcome was delivered, or unknown (something + * may have been posted there: a possible reply claim, not a delivery); + * - `channels`: the server and channel of a connector's own send tool + * (SEND_ENGAGEMENT_TOOLS), which chooses the place in the channel + * itself, so any conversation there may be the one it engaged. + * No author-kind filter: agent-residents are full participants, and the + * bot flag tracks nothing that matters (antra). Reactions/system markers + * are excluded by isConversationalInjection; the engaged-this-turn scope + * keeps unrelated conversations from holding anything. Cleared at every + * fresh turn's start; budget restarts keep it. */ + private turnEngagement: Map; + channels: Set; + }> = new Map(); /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery * order (deduped at render). Feeds the `[delivered]` receipt appended at * logical turn end: explicit sends receipt themselves via their @@ -1141,13 +1162,26 @@ export class AgentFramework { private turnDraftFailures: Map = new Map(); // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- - /** Sticky per-TURN delivery target set by the turn's first `>>` prefix. - * Cleared at every non-restart turn start; survives context restarts. */ + /** Sticky per-TURN delivery target set by a `>>` (explicit) or `>>>` + * (hybrid) prefix. Written and read on the speech chain, in the order the + * words were written. Cleared at a true new turn; a context restart or + * framework retry continues the turn and keeps it. */ private proseTargetPins: Map = new Map(); /** Hybrid router is fail-closed after a `>>>skip_reply` envelope (the * rest is private) or a malformed/unresolved/undelivered one (the rest is - * held as drafts, having no destination) until a new valid target. */ + * held as drafts, having no destination) until a new valid target. On the + * speech chain, like the pins. */ private proseHybridSuppressed: Map = new Map(); + /** Per agent: the number of its current logical turn, advanced only at a + * true new turn (a context restart or framework retry keeps it). A tool + * completion that arrives after a newer turn has begun compares it, so it + * can't change that turn's speech (channel_open). */ + private logicalTurns: Map = new Map(); + /** Per agent: the ordered chain of speech deliveries (chainSpeech). One + * chain for all of an agent's physical streams, so a restart's or + * retry's speech can never overtake words still being published from + * the stream before it. Each link catches its own error. */ + private speechChains: Map> = new Map(); /** Agents whose current turn requested `!` continuation — re-woken when the * turn completes instead of pausing until the next external event. */ private proseContinuations: Set = new Set(); @@ -6921,8 +6955,7 @@ export class AgentFramework { this.roundPresentsInjections.get(agent.name) === true ) { const turn = this.turnRoutes.get(agent.name); - const engaged = this.turnEngagedChannels.get(agent.name); - const isEngaged = (c: ConversationRef): boolean => c.kind === 'channel' && engaged?.has(c.channelId) === true; + const isEngaged = (c: ConversationRef): boolean => this.engagementOf(agent.name, c) !== undefined; const competing = turn ? [...midTurnInjections].reverse().find((inj) => { if (!isConversationalInjection(inj.metadata)) return false; const m = inj.metadata as Record | undefined; @@ -6940,11 +6973,19 @@ export class AgentFramework { const held: TurnRoute = { route: turn.route, hold: { conversations: [current, arrival], since: 'mid-turn' } }; this.turnRoutes.set(agent.name, held); this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); - // Name the actual cause: someone addressed the resident, or a - // conversation it sent into this turn continued. + // Name the actual cause, as certain as the host is of it: + // someone addressed the resident, or a conversation continued + // where it sent this turn — there exactly, there maybe (the + // send's delivery is unconfirmed), or somewhere in the channel + // (a connector's own tool chose where). + const engagement = this.engagementOf(agent.name, arrival); const cause = addressedArrival ? `${describeConversation(arrival)} addressed you mid-turn` - : `${describeConversation(arrival)}, where you sent a message this turn, continued mid-turn`; + : engagement === 'delivered' + ? `${describeConversation(arrival)}, where you sent a message this turn, continued mid-turn` + : engagement === 'unknown' + ? `${describeConversation(arrival)}, where you tried to send a message this turn (its delivery wasn't confirmed), continued mid-turn` + : `${describeConversation(arrival)} continued mid-turn, in a channel you sent something into this turn (where in the channel isn't known)`; const noticeContent: ContentBlock[] = [{ type: 'text', text: @@ -9506,14 +9547,9 @@ export class AgentFramework { }); const where = AgentFramework.destinationText(outcome.destination ?? destination); const unrecorded = recorded ? '' : ' (this outcome could not be recorded; the draft will read as unconfirmed)'; + this.noteSendEngagement(agentName, outcome.status, outcome.destination ?? destination); if (outcome.status === 'delivered') { lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); - let engaged = this.turnEngagedChannels.get(agentName); - if (!engaged) { - engaged = new Set(); - this.turnEngagedChannels.set(agentName, engaged); - } - engaged.add(destination.channelId); continue; } stopped = true; @@ -9751,6 +9787,79 @@ export class AgentFramework { }; } + /** The current logical turn's number (logicalTurns). */ + private logicalTurn(agentName: string): number { + return this.logicalTurns?.get(agentName) ?? 0; + } + + /** Append a link to the agent's speech chain (speechChains); it runs after every earlier one. */ + private chainSpeech(agentName: string, what: string, work: () => Promise | void): void { + const chains = (this.speechChains ??= new Map()); + chains.set(agentName, (chains.get(agentName) ?? Promise.resolve()) + .then(work) + .catch((err) => console.error(`${what} failed:`, err))); + } + + /** Settles once every link chained so far for the agent has. */ + private speechSettled(agentName: string): Promise { + return this.speechChains?.get(agentName) ?? Promise.resolve(); + } + + /** + * Record an explicit send by the host itself — a resend or channel_publish + * — as engagement (turnEngagement), from its outcome: delivered, or + * unknown (something may have been posted: a possible reply claim, not a + * delivery), engages the exact conversation asked for; failed engages + * nothing. The one rule for every host-publication path. + */ + private noteSendEngagement( + agentName: string, + status: PublishOutcome['status'], + place: { serverId?: string; channelId: string; threadId?: string | null }, + ): void { + if (status === 'failed') return; + const key = conversationKey({ + kind: 'channel', + ...(place.serverId ? { serverId: place.serverId } : {}), + channelId: place.channelId, + ...(place.threadId ? { threadId: place.threadId } : {}), + }); + const engaged = this.engagementFor(agentName); + if (engaged.conversations.get(key) !== 'delivered') engaged.conversations.set(key, status); + } + + /** Record a connector's own send tool, whose place in the channel the host can't know, as engagement of the channel (turnEngagement). */ + private noteChannelEngagement(agentName: string, serverId: string | undefined, channelId: string): void { + this.engagementFor(agentName).channels.add(AgentFramework.channelScopeKey(serverId, channelId)); + } + + private engagementFor(agentName: string): { conversations: Map; channels: Set } { + const all = (this.turnEngagement ??= new Map()); + let engaged = all.get(agentName); + if (!engaged) { + engaged = { conversations: new Map(), channels: new Set() }; + all.set(agentName, engaged); + } + return engaged; + } + + /** + * How the resident engaged a conversation this turn, if it did: `delivered` + * or `unknown` — the host published there itself, confirmed or not — or + * `channel`: a connector's send tool posted somewhere in its channel. + */ + private engagementOf(agentName: string, c: ConversationRef): 'delivered' | 'unknown' | 'channel' | undefined { + if (c.kind !== 'channel') return undefined; + const engaged = this.turnEngagement?.get(agentName); + if (!engaged) return undefined; + return engaged.conversations.get(conversationKey(c)) + ?? (engaged.channels.has(AgentFramework.channelScopeKey(c.serverId, c.channelId)) ? 'channel' : undefined); + } + + private static channelScopeKey(serverId: string | undefined, channelId: string): string { + return `${serverId ?? ''}\u0000${channelId}`; + } + /** The channel the agent's unaddressed speech goes to now, if it is a channel and not held. */ private routeChannelId(agentName: string): string | null { // Optional chaining: prototype-built harnesses leave the map unset. @@ -9842,8 +9951,11 @@ export class AgentFramework { } /** - * Deliver one segment of unaddressed plain speech along the turn's route: - * a channel route publishes it; a surface route shows it there (the + * Deliver one segment of unaddressed plain speech along the route it was + * written under (its SpeechMoment, captured when the words were produced: + * a channel_open or a hold that lands while they wait on the speech chain + * governs only words written after it): a channel route publishes it; a + * surface route shows it there (the * surface already holds the turn's words) and publishes nothing; a held * turn keeps it as an `ambiguous` draft naming the competing * conversations; no route keeps it as a `no-destination` draft. @@ -9852,8 +9964,10 @@ export class AgentFramework { agent: Agent, text: string, hold: { round?: number; notice: 'now' | 'later' }, + /** When the words were produced: the route they were written under. */ + moment: SpeechMoment = { turn: this.turnRoutes?.get(agent.name) }, ): Promise { - const turn = this.turnRoutes.get(agent.name); + const turn = moment.turn; if (turn?.hold) { this.holdProseDrafts(agent, [text], 'ambiguous', { ...hold, @@ -9880,7 +9994,7 @@ export class AgentFramework { return; } // The route's exact place: its server, channel and thread (or root). - const target = this.routeTarget(agent.name)!; + const target = { ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, threadId: route.threadId ?? null }; const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target); this.recordProseDelivery(agent.name, outcome); } @@ -10111,6 +10225,8 @@ export class AgentFramework { agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + /** When the words were produced: the route they were written under. */ + moment: SpeechMoment = { turn: this.turnRoutes?.get(agent.name) }, ): Promise { for (const envelope of splitHybridEnvelopes(rawText)) { const normalized = envelope; @@ -10186,14 +10302,15 @@ export class AgentFramework { continue; } // A `>>>target` set this turn is deliberate and wins; otherwise the - // envelope is unaddressed speech, which follows the turn's route. + // envelope is unaddressed speech, which follows the route it was + // written under. const sticky = this.proseTargetPins.get(agent.name); try { if (sticky) { const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, sticky); this.recordProseDelivery(agent.name, outcome); } else { - await this.speakUnaddressed(agent, envelope, hold); + await this.speakUnaddressed(agent, envelope, hold, moment); } } catch (err) { console.error('hybrid locus delivery failed:', err); @@ -10549,6 +10666,12 @@ export class AgentFramework { const continuingTurn = trigger?.reason === 'context_budget_restart' || trigger?.reason === 'tool_result_guard_retry'; const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting; + // A true new turn begins once the previous turn's speech has landed. A + // turn that ends without settling its chain (an abort, an explicit-mode + // endTurn) could otherwise publish late into this one: its words + // overtaken by this turn's, its `>>`/`>>>` choices and deliveries + // written into the state cleared for this turn below. + if (attempt === 0 && !continuingTurn) await this.speechSettled(agent.name); // Flush messages deferred during the PREVIOUS turn — before the // checkpoint, the locus announcement, and the compile — so a turn started // by a queued wake actually CONTAINS the message that woke it. (2026-07-31 @@ -10633,22 +10756,28 @@ export class AgentFramework { // A framework retry is a new physical stream in the same logical turn. this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); } - // Fresh turn: forget the previous turn's explicit-send engagements and - // prose deliveries — both are strictly turn-scoped (a restart continues - // the same logical turn and keeps them). - this.turnEngagedChannels.delete(agent.name); - this.turnProseDeliveries.delete(agent.name); - this.turnProseSuppressed.delete(agent.name); - this.turnDrafts.delete(agent.name); - this.turnProsePrivate.delete(agent.name); - this.turnDraftFailures.delete(agent.name); - this.proseHybridSuppressed.delete(agent.name); - if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); + if (attempt === 0) { + // A true new turn: forget the previous turn's explicit-send + // engagements, prose deliveries, drafts and deliberate prose choices + // (a `>>` or `>>>` target, a hybrid suppression) — all strictly + // turn-scoped. A framework retry (attempt > 0), like a restart, + // continues the same logical turn: its sends were made, its words + // delivered or held, its choices chosen, and none of that rolls back + // with a failed stream, so it keeps them, as it keeps the route. + this.turnEngagement?.delete(agent.name); + this.turnProseDeliveries.delete(agent.name); + this.turnProseSuppressed.delete(agent.name); + this.turnDrafts.delete(agent.name); + this.turnProsePrivate.delete(agent.name); + this.turnDraftFailures.delete(agent.name); + this.proseHybridSuppressed.delete(agent.name); + this.proseTargetPins.delete(agent.name); + (this.logicalTurns ??= new Map()).set(agent.name, this.logicalTurn(agent.name) + 1); + } if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { // Explicit and disabled prose routing have no inferred route. // Explicit mode uses a turn-scoped `>>` target; disabled mode has no // prose target at all. Neither mode infers or announces a route. - this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); this.turnRoutes.delete(agent.name); @@ -10920,25 +11049,23 @@ export class AgentFramework { // ---- Present-while-acting turn state --------------------------------- // The turn's speech route (turnRoutes) was decided in startAgentStream // before this stream began and lives there, so a context-budget restart - // (same logical turn, fresh driveStream) keeps it. Unaddressed speech goes - // through speakUnaddressed, which reads it at delivery: an arrival can - // HOLD an inferred route mid-turn (the ambiguity hold), but nothing that - // arrives can point it somewhere else. - const routeLabel = (): string => AgentFramework.routeText(this.turnRoutes.get(agent.name) ?? { route: null }); - - // Ordered delivery chain for live-routed prose. Links are enqueued + // (same logical turn, fresh driveStream) keeps it. An arrival can HOLD an + // inferred route mid-turn (the ambiguity hold), and a channel_open can + // set a deliberate one, but nothing that arrives can point it somewhere + // else. Speech is published along the route it was WRITTEN under: each + // round's words take their SpeechMoment when the round ends, so a hold + // or an open that lands while they wait on the speech chain governs only + // words written after it. + const routeLabel = (moment: SpeechMoment): string => AgentFramework.routeText(moment.turn ?? { route: null }); + + // Ordered delivery for live-routed prose: the agent's speech chain + // (chainSpeech), shared by its physical streams. Links are enqueued // WITHOUT awaiting in the stream-event loop — an awaited network post // here would stall consumption of the next round's events by // segments × RTT on every prose-bearing round. The 'complete' case // awaits the chain before routing trailing prose, so in-channel ordering // is preserved end-to-end. Each link catches its own error: one failed // post must not silence the rest of the turn. - let turnSpeechChain: Promise = Promise.resolve(); - const enqueueSpeech = (text: string, hold: { round?: number; notice: 'now' | 'later' }): void => { - turnSpeechChain = turnSpeechChain - .then(() => this.speakUnaddressed(agent, text, hold)) - .catch((err) => console.error('mid-turn speech routing failed:', err)); - }; // Sticky explicit-send suppression: prose after send_message stays quiet // to prevent a redundant "sent it" postscript. Fresh injected input @@ -11232,6 +11359,11 @@ export class AgentFramework { agent.enterWaitingForTools(event.calls, stream); + // This round's words were written before any of its calls ran: + // they go where the route stood then, whatever those calls (a + // channel_open) or the arrivals at their results (a hold) change. + const roundMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name) }; + for (const call of event.calls) { // RFC-007: register at the one dispatch point for model-issued // calls, keyed by (agent, call id) with this inference's id; @@ -11298,14 +11430,12 @@ export class AgentFramework { // In order behind earlier rounds' envelopes: the router // state they leave is where this round's starts. const cause = silenceCause; - turnSpeechChain = turnSpeechChain - .then(() => this.holdSilencedProse(agent, roundRuns, cause, hasSameRoundPrivateThink, holdOpts)) - .catch((err) => console.error('mid-turn hybrid prose hold failed:', err)); + this.chainSpeech(agent.name, 'mid-turn hybrid prose hold', () => + this.holdSilencedProse(agent, roundRuns, cause, hasSameRoundPrivateThink, holdOpts)); } else if (!hasSameRoundPrivateThink) { for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverHybridProse(agent, seg, holdOpts)) - .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err)); + this.chainSpeech(agent.name, 'mid-turn hybrid prose delivery', () => + this.deliverHybridProse(agent, seg, holdOpts, roundMoment)); } } } else if (turnProseRouting === 'explicit') { @@ -11317,9 +11447,7 @@ export class AgentFramework { `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`, ); for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverProse(agent, seg, holdOpts)) - .catch((err) => console.error('mid-turn prose delivery failed:', err)); + this.chainSpeech(agent.name, 'mid-turn prose delivery', () => this.deliverProse(agent, seg, holdOpts)); } } } else if (turnSilenced) { @@ -11335,10 +11463,10 @@ export class AgentFramework { ); } else { console.error( - `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${routeLabel()}`, + `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${routeLabel(roundMoment)}`, ); for (const seg of roundSegments) { - enqueueSpeech(seg, holdOpts); + this.chainSpeech(agent.name, 'mid-turn speech routing', () => this.speakUnaddressed(agent, seg, holdOpts, roundMoment)); } } } @@ -11349,6 +11477,11 @@ export class AgentFramework { } case 'complete': { + // The final round's words were all written by now: they go where + // the route stood as they were, even though they are published + // after the agent is idle, when a next turn may already have + // decided its own (the 2026-07-22 Sol DM misroute's shape). + const finalMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name) }; adoptInjectedRound(); const durationMs = Date.now() - startTime; let response = event.response; @@ -11402,7 +11535,7 @@ export class AgentFramework { reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens, budget: agent.maxStreamTokens, }); - await turnSpeechChain; + await this.speechSettled(agent.name); if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) { generationLost = true; lifecyclePhase = 'aborted'; @@ -11784,43 +11917,38 @@ export class AgentFramework { // (a fresh text-only turn starts unsilenced). Explicit // mode is exempt, as mid-turn. console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced: ${silenceCause})`); - this.holdSilencedProse(agent, speechRuns, silenceCause, false, textOnlyHold); + const cause = silenceCause; + this.chainSpeech(agent.name, 'text-only prose hold', () => + this.holdSilencedProse(agent, speechRuns, cause, false, textOnlyHold)); } else if (turnProseRouting === 'hybrid') { console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`); for (const segment of speechSegments) { - try { - await this.deliverHybridProse(agent, segment, textOnlyHold); - } catch (err) { - console.error('text-only hybrid prose delivery failed:', err); - } + this.chainSpeech(agent.name, 'text-only hybrid prose delivery', () => + this.deliverHybridProse(agent, segment, textOnlyHold, finalMoment)); } } else if (turnProseRouting === 'explicit') { console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`); for (const segment of speechSegments) { - try { - await this.deliverProse(agent, segment, textOnlyHold); - } catch (err) { - console.error('text-only prose delivery failed:', err); - } + this.chainSpeech(agent.name, 'text-only prose delivery', () => this.deliverProse(agent, segment, textOnlyHold)); } } else { - // Route to the TURN-FROZEN locus, like every other speech - // path. This dispatch runs AFTER the agent is idle, so a live - // resolution here can read the NEXT turn's trigger state (or - // a post-restart cleared one) and land the reply in a stale - // channel — the 2026-07-22 Sol DM-to-guild misroute. The - // frozen pin is immune to both races. + // Route along the route the words were written under + // (finalMoment), like every other speech path. This + // dispatch runs AFTER the agent is idle, so a live + // resolution here can read the NEXT turn's route (or a + // post-restart cleared one) and land the reply in a stale + // channel — the 2026-07-22 Sol DM-to-guild misroute. console.error( - `[routing] ${agent.name}: text-only turn -> routing speech -> ${routeLabel()}`, + `[routing] ${agent.name}: text-only turn -> routing speech -> ${routeLabel(finalMoment)}`, ); for (const segment of speechSegments) { - try { - await this.speakUnaddressed(agent, segment, textOnlyHold); - } catch (err) { - console.error('speech routing failed:', err); - } + this.chainSpeech(agent.name, 'speech routing', () => this.speakUnaddressed(agent, segment, textOnlyHold, finalMoment)); } } + // In order behind anything an earlier physical stream of the + // turn still has on the chain, and settled before the turn's + // receipt is written. + await this.speechSettled(agent.name); } } else if (this.channelRegistry && hadToolCalls && allText.length > 0) { // Tool-call turn that also produced prose. When live routing was @@ -11855,11 +11983,10 @@ export class AgentFramework { const runs = splitProseRuns(liveProseRouting ? terminalContent : response.content); const segments = runs.map((run) => run.trim()); - // Preserve in-channel ordering: everything enqueued live must - // land before the trailing prose. Awaited even when silenced — - // the chain may still be flushing earlier rounds' posts. - await turnSpeechChain; - + // Preserve in-channel ordering: trailing prose is chained behind + // everything enqueued live (by this stream or an earlier one of + // the turn), and the chain is settled even when silenced — it + // may still be flushing earlier rounds' posts. if (turnProseRouting === 'disabled') { if (segments.length > 0) { console.error( @@ -11869,14 +11996,12 @@ export class AgentFramework { } } else if (turnProseRouting === 'hybrid') { if (silenced && segments.length > 0) { - this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); + this.chainSpeech(agent.name, 'trailing hybrid prose hold', () => + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold)); } else if (segments.length > 0) { for (const seg of segments) { - try { - await this.deliverHybridProse(agent, seg, trailingHold); - } catch (err) { - console.error('trailing hybrid prose delivery failed:', err); - } + this.chainSpeech(agent.name, 'trailing hybrid prose delivery', () => + this.deliverHybridProse(agent, seg, trailingHold, finalMoment)); } } } else if (turnProseRouting === 'explicit') { @@ -11885,11 +12010,7 @@ export class AgentFramework { `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`, ); for (const seg of segments) { - try { - await this.deliverProse(agent, seg, trailingHold); - } catch (err) { - console.error('trailing prose delivery failed:', err); - } + this.chainSpeech(agent.name, 'trailing prose delivery', () => this.deliverProse(agent, seg, trailingHold)); } } } else if (silenced || segments.length === 0) { @@ -11898,38 +12019,34 @@ export class AgentFramework { `(${silenced ? `turn silenced: ${trailingCause}` : 'no trailing prose'})`, ); if (silenced && segments.length > 0) { - this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); + this.chainSpeech(agent.name, 'trailing prose hold', () => + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold)); } } else { - // Reuse the locus pinned at the turn's first live-routed - // segment (or resolve it now for a turn whose only prose is - // trailing). This dispatch runs AFTER the agent is idle (see - // PR #32 note below), so a queued inbound from another channel - // could otherwise overwrite the per-agent triggering channel - // between segments — and a turn that narrated live into one + // Along the route the words were written under (finalMoment). + // This dispatch runs AFTER the agent is idle (see PR #32 note + // below), so a next turn could otherwise have decided its own + // route meanwhile — and a turn that narrated live into one // channel must not land its postscript in another. console.error( - `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${routeLabel()}`, + `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${routeLabel(finalMoment)}`, ); - // Deliver sequentially (await each) so the segments land in order. + // Chained, so the segments land in order. for (const seg of segments) { - try { - await this.speakUnaddressed(agent, seg, trailingHold); - } catch (err) { - console.error('speech routing failed:', err); - } + this.chainSpeech(agent.name, 'speech routing', () => this.speakUnaddressed(agent, seg, trailingHold, finalMoment)); } } + await this.speechSettled(agent.name); } // NOTE: agent.reset() + onInferenceEnded() already ran above, // BEFORE dispatchSpeech. Locus routing is speech dispatch and // doesn't depend on the status field, so it correctly runs after. // Logical turn end (normal completion): drop the `[delivered]` - // receipt. All deliveries are settled — the live chain was - // awaited before trailing dispatch, and trailing/text-only - // segments were awaited in-loop. Locus mode only; explicit-mode - // envelopes acknowledge themselves through the prose gateway. + // receipt. All deliveries are settled — the speech chain, with + // the trailing and text-only segments on it, was awaited above. + // Locus mode only; explicit-mode envelopes acknowledge themselves + // through the prose gateway. if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent); } @@ -12100,7 +12217,7 @@ export class AgentFramework { // start) and the continuation stream's end writes ONE // receipt for the whole turn. if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') { - await turnSpeechChain; + await this.speechSettled(agent.name); this.appendProseDeliveryReceipt(agent); } return; @@ -16245,21 +16362,19 @@ export class AgentFramework { .openIfClosedForSend(target, serverId) .then(({ status, channelId, label }) => { // Turn-scoped engagement record (2026-07-31 n=7; the - // ambiguity hold): a follow-up in a channel the agent - // explicitly sent into this turn competes for its speech. - // Recorded here — not eagerly — because injection metadata - // carries the CANONICAL channel id (descriptor form), while - // send args often carry the raw provider id; this callback - // has the resolved form. The already-open path resolves on - // the microtask queue, i.e. before the tool-result event - // that evaluates injections. + // ambiguity hold): a follow-up where the agent explicitly + // sent this turn competes for its speech. A connector's own + // send tool chooses its place in the channel, so what the + // host knows is the channel: any conversation in it may be + // the one engaged (turnEngagement). Recorded here — not + // eagerly — because injection metadata carries the + // CANONICAL channel id (descriptor form), while send args + // often carry the raw provider id; this callback has the + // resolved form. The already-open path resolves on the + // microtask queue, i.e. before the tool-result event that + // evaluates injections. if (status !== 'unknown-channel' && status !== 'ambiguous') { - let engaged = this.turnEngagedChannels.get(agentName); - if (!engaged) { - engaged = new Set(); - this.turnEngagedChannels.set(agentName, engaged); - } - engaged.add(channelId ?? target); + this.noteChannelEngagement(agentName, serverId, channelId ?? target); } if (status === 'opened') { console.error( @@ -16333,6 +16448,28 @@ export class AgentFramework { * Dispatch a synthesized channel tool call. */ private dispatchChannelToolCall(agentName: string, call: ToolCall): void { + const reject = (error: string, who = ''): void => { + // RFC-007: the guard refuses before the tool runs — no lifecycle events. + this.toolLifecycleEmitter?.refuse(agentName, call.id); + this.emitTrace({ + type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, + error: `${who}${error}`, + }); + this.pushEvent({ + type: 'tool-result', + callId: call.id, + agentName, + moduleName: 'channels', + result: { success: false, error, isError: true }, + }); + }; + // Supplied selectors are checked before any default applies, as + // channel_publish's own are: an empty or non-string one is refused, + // never read as omission (a fork's home, the channel root, "any + // server"). null means the field is not in use. + const supplied = (v: unknown): boolean => v !== undefined && v !== null; + const named = (v: unknown): boolean => typeof v === 'string' && v.length > 0; + // Conversation forks act only on their home channel. channel_publish and // channel_close default a missing channelId to home and reject foreign // ones; channel_open is rejected outright — opening channels mutates @@ -16340,45 +16477,64 @@ export class AgentFramework { // are scoped against), which is not a fork's call to make. const home = this.conversationAgentHomes.get(agentName); if (home) { - const reject = (error: string): void => { - // RFC-007: the guard refuses before the tool runs — no lifecycle events. - this.toolLifecycleEmitter?.refuse(agentName, call.id); - this.emitTrace({ - type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, - error: `conversation agent ${agentName}: ${error}`, - }); - this.pushEvent({ - type: 'tool-result', - callId: call.id, - agentName, - moduleName: 'channels', - result: { success: false, error, isError: true }, - }); - }; - + const who = `conversation agent ${agentName}: `; if (call.name === 'channel_open') { - reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`); + reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`, who); return; } if (call.name === 'channel_publish' || call.name === 'channel_close') { - const input = (call.input ?? {}) as { channelId?: string }; - if (!input.channelId) { + const input = (call.input ?? {}) as { channelId?: unknown }; + if (!supplied(input.channelId)) { call = { ...call, input: { ...input, channelId: home } }; + } else if (!named(input.channelId)) { + reject(`channelId must name a channel (leave it out for this conversation's channel, ${home}). Nothing was ${call.name === 'channel_publish' ? 'sent' : 'closed'}.`, who); + return; } else if (input.channelId !== home) { const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing'; - reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`); + reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId as string} is not allowed.`, who); return; } } } + // channel_open's own choices: where speech goes (a thread, with + // setSpeechTarget) and which server's channel. Checked before anything is + // opened, so an invalid choice can't open a channel or become a route. + if (call.name === 'channel_open') { + const input = (call.input ?? {}) as { serverId?: unknown; threadId?: unknown; setSpeechTarget?: unknown }; + const invalid = supplied(input.serverId) && !named(input.serverId) + ? 'serverId must name a server (leave it out to resolve the channel id alone).' + : supplied(input.threadId) && !named(input.threadId) + ? 'threadId must be a thread id (or left out for the channel root).' + : supplied(input.setSpeechTarget) && typeof input.setSpeechTarget !== 'boolean' + ? 'setSpeechTarget must be true or false (left out, it is true).' + : undefined; + if (invalid) { + reject(`${invalid} Nothing was opened.`); + return; + } + } + this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); + const turnAtDispatch = this.logicalTurn(agentName); this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) .then((result) => { const durationMs = Date.now() - startTime; this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs }); + // channel_publish is an explicit send to an exact place: its outcome + // engages the conversation it named, as a resend's does. + if (call.name === 'channel_publish') { + const sent = result?.data as { status?: unknown; serverId?: unknown; channelId?: unknown; threadId?: unknown } | undefined; + if ((sent?.status === 'delivered' || sent?.status === 'unknown') && typeof sent.channelId === 'string') { + this.noteSendEngagement(agentName, sent.status, { + ...(typeof sent.serverId === 'string' ? { serverId: sent.serverId } : {}), + channelId: sent.channelId, + threadId: typeof sent.threadId === 'string' ? sent.threadId : null, + }); + } + } // channel_open is a deliberate speech choice (shelf-355): unless the // resident says setSpeechTarget: false, its unaddressed plain speech // goes to the opened channel for the rest of this logical turn — a @@ -16405,7 +16561,12 @@ export class AgentFramework { : 'You have no speech route, so unaddressed plain speech is held as drafts.'; }; let routing: string; - if (openInput?.setSpeechTarget !== false) { + if (this.logicalTurn(agentName) !== turnAtDispatch) { + // The turn that asked has been superseded by a newer one, whose + // speech is its own to decide: the open stands, the route and + // prose choices don't move. + routing = 'Opened. A newer turn has begun since this call, so its speech route is unchanged.'; + } else if (openInput?.setSpeechTarget !== false) { const resolved = this.channelRegistry!.resolveDestination({ channelId: opened, ...(openInput?.serverId ? { serverId: openInput.serverId } : {}), @@ -16436,7 +16597,18 @@ export class AgentFramework { const next: TurnRoute = { route }; this.turnRoutes.set(agentName, next); this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); - if (openerAgent.proseRouting === 'hybrid') this.proseTargetPins.delete(agentName); + // The open supersedes a hybrid `>>>` choice — a pin, or the + // suppression a skip_reply or failed envelope left — in the + // order the resident chose: on the speech chain, after the + // words written before it (already chained) and before any + // written after (chained only once this result is in). + if (openerAgent.proseRouting === 'hybrid') { + this.chainSpeech(agentName, 'channel_open hybrid reset', () => { + if (this.logicalTurn(agentName) !== turnAtDispatch) return; + this.proseTargetPins.delete(agentName); + this.proseHybridSuppressed.delete(agentName); + }); + } routing = `Your unaddressed plain speech now goes to ${describeConversation(routeConversation(route))} for the ` + 'rest of this turn. Other channels need an explicit send tool.'; diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index ab982b226..6dabe19d3 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -32,8 +32,8 @@ import type { ToolResult, } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; -import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -import type { ContentBlock } from '@animalabs/membrane'; +import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +import type { ContentBlock, StreamEvent } from '@animalabs/membrane'; // --------------------------------------------------------------------------- // Test module: tools that optionally emit a mid-turn external message @@ -192,6 +192,15 @@ function triggerFromChannel(framework: AgentFramework, channelId = 'chan-A'): vo }); } +/** The framework's engagement record (turnEngagement), as its send paths write and the hold reads it. */ +function engage(framework: AgentFramework) { + return framework as unknown as { + noteSendEngagement(agent: string, status: 'delivered' | 'unknown' | 'failed', place: { serverId?: string; channelId: string; threadId?: string | null }): void; + noteChannelEngagement(agent: string, serverId: string | undefined, channelId: string): void; + engagementOf(agent: string, c: { kind: 'channel'; serverId?: string; channelId: string; threadId?: string }): string | undefined; + }; +} + function heldDrafts(framework: AgentFramework): Array<{ text: string; reason: string; note?: string }> { return (framework as unknown as { proseDrafts: { open(agent: string): Array<{ text: string; reason: string; note?: string }> } }) .proseDrafts.open('assistant'); @@ -448,15 +457,13 @@ describe('present while acting', () => { tags: ['chat:ambient', 'chat:from-human'], inboundSource: envelope('discord:guild:portables'), }; - // Simulate an explicit send into #portables earlier in THIS turn (the - // production record happens on the MCPL send path; the harness module's - // tools are not MCPL, so seed the turn-scoped set mid-turn — after the + // Simulate an explicit send into #portables earlier in THIS turn, as a + // delivered resend or channel_publish records it (the harness module's + // tools are not MCPL, so seed the turn's engagement mid-turn — after the // turn-start clear, before the injection boundary). const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { - (framework as unknown as { - turnEngagedChannels: Map>; - }).turnEngagedChannels.set('assistant', new Set(['discord:guild:portables'])); + engage(framework).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'discord:guild:portables', threadId: null }); return origHandle(call); }; @@ -495,9 +502,8 @@ describe('present while acting', () => { }; const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { - (framework as unknown as { - turnEngagedChannels: Map>; - }).turnEngagedChannels.set('assistant', new Set(['discord:guild:hospital'])); + // As a connector's own send tool records it: the channel. + engage(framework).noteChannelEngagement('assistant', 'srv', 'discord:guild:hospital'); return origHandle(call); }; @@ -1047,3 +1053,438 @@ describe('present while acting', () => { await framework.stop(); }); }); + +// --------------------------------------------------------------------------- +// Speech goes where the route stood when its words were written (#256 review) +// --------------------------------------------------------------------------- + +describe('speech follows the moment its words were written', () => { + let tempDir: string; + let membrane: MockMembrane; + let module: RobotModule; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + membrane = new MockMembrane(); + module = new RobotModule(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + async function createFramework(proseRouting?: 'hybrid'): Promise { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', ...(proseRouting ? { proseRouting } : {}) }], + modules: [module], + }); + module.framework = framework; + return framework; + } + + type Registry = Record; + const registryOf = (framework: AgentFramework): Registry => + (framework as unknown as { channelRegistry: Registry }).channelRegistry; + + /** channel_open succeeds for whatever channel it names. */ + function opensChannels(framework: AgentFramework): void { + registryOf(framework).handleChannelToolCall = async (name: string, input: { channelId?: string }) => + name === 'channel_open' ? { success: true, data: { channelId: input.channelId, opened: true } } : { success: true }; + } + + /** The registry method's first call waits `ms` first, so later speech queues behind it. */ + function slowFirst(framework: AgentFramework, method: 'routeSpeech' | 'deliverSpeech', ms = 60): void { + const registry = registryOf(framework); + const fast = registry[method] as (...args: unknown[]) => Promise; + let first = true; + registry[method] = async (...args: unknown[]) => { + if (first) { + first = false; + await new Promise((r) => setTimeout(r, ms)); + } + return fast(...args); + }; + } + + /** An addressed arrival from another conversation, at the next tool call: it holds an inferred route. */ + function addressedArrivalElsewhere(): void { + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + } + + /** Wake the agent from a thread of `channelId`. */ + function triggerFromThread(framework: AgentFramework, channelId: string, threadId: string): void { + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'srv', timestamp: Date.now(), + channelId, addressed: true, + routeCandidates: [{ + conversation: { kind: 'channel', serverId: 'srv', channelId, threadId }, + addressed: true, messageId: 'm-trigger', at: Date.now(), + }], + }); + } + + // ---- queued speech (review findings 2 and 4) ---------------------------- + + it('a channel_open landing while earlier words wait on the speech chain does not move them', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'First words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Second words, still for chan-A.' }, + { type: 'tool_use', id: 'c2', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Third words, for the observatory.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + slowFirst(framework, 'routeSpeech'); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'First words.', locus: 'chan-A' }, + { text: 'Second words, still for chan-A.', locus: 'chan-A' }, + { text: 'Third words, for the observatory.', locus: 'discord:guild:observatory' }, + ]); + + await framework.stop(); + }); + + it('hybrid: words written under a `>>>` choice keep it though a channel_open lands before they are published', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Pinned words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'More for alpha.' }, + { type: 'tool_use', id: 'c2', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'After the open.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + slowFirst(framework, 'deliverSpeech'); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Pinned words.', locus: 'chan-alpha' }, + { text: 'More for alpha.', locus: 'chan-alpha' }, + { text: 'After the open.', locus: 'discord:guild:observatory' }, + ], 'the open supersedes the pin for words written after it, and only those'); + + await framework.stop(); + }); + + it('hybrid: a channel_open that sets the speech route ends a `>>>skip_reply` suppression', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>skip_reply An aside to myself.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Hello, observatory.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Hello, observatory.', locus: 'discord:guild:observatory' }]); + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assert.ok(results.includes('Your unaddressed plain speech now goes to discord:guild:observatory')); + + await framework.stop(); + }); + + it('hybrid: a channel_open with setSpeechTarget: false leaves a `>>>skip_reply` suppression in place', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>skip_reply An aside to myself.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory', setSpeechTarget: false } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still private.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework), [], 'kept private, not drafted'); + + await framework.stop(); + }); + + it('hybrid: a framework retry continues the turn, keeping its `>>>` choice and engagement', async () => { + const pinnedRound = [ + { type: 'text', text: '>>>#alpha Pinned words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[]; + class ToolThenErrorStream { + private events: StreamEvent[] = [{ + type: 'tokens', content: '>>>#alpha Pinned words.', meta: { type: 'text', visible: true, blockIndex: 0 }, + } as StreamEvent, { + type: 'tool-calls', + calls: [{ id: 'c1', name: 'robot--move', input: { dir: 'north' } }], + context: { rawText: '', preamble: '', depth: 0, previousResults: [], accumulated: '', roundContent: pinnedRound }, + } as StreamEvent]; + private wake: (() => void) | null = null; + private done = false; + get isWaitingForTools() { return !this.done; } + get pendingToolCallIds() { return this.done ? [] : ['c1']; } + get toolDepth() { return 0; } + provideToolResults(): void { this.done = true; this.events.push({ type: 'error', error: new Error('connection reset') } as StreamEvent); this.wake?.(); this.wake = null; } + cancel(): void { this.done = true; this.wake?.(); this.wake = null; } + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { while (this.events.length) yield this.events.shift()!; if (this.done) return; await new Promise((r) => { this.wake = r; }); } + } + } + let streams = 0; + const retrying = { + complete: async () => createMockResponse([{ type: 'text', text: 'unused' }] as ContentBlock[]), + streamYielding: () => streams++ === 0 + ? new ToolThenErrorStream() + : new MockYieldingStream([createMockResponse([{ type: 'text', text: 'Unprefixed, after the retry.' }] as ContentBlock[])]), + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'retry.chronicle'), + membrane: retrying as unknown as import('@animalabs/membrane').Membrane, + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', proseRouting: 'hybrid' }], + modules: [module], + errorPolicy: { maxRetries: 1, onInferenceError: (_e: Error, _a: string, attempt: number) => ({ retry: attempt < 1, delayMs: 0 }) }, + }); + module.framework = framework; + const routed = stubChannelRegistry(framework, { home: false }); + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + engage(framework).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }); + return origHandle(call); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(streams, 2, 'the failed stream was retried once'); + assert.deepEqual(routed, [ + { text: 'Pinned words.', locus: 'chan-alpha' }, + { text: 'Unprefixed, after the retry.', locus: 'chan-alpha' }, + ], 'the pin the failed attempt chose still carries unprefixed speech'); + assert.equal(engage(framework).engagementOf('assistant', { kind: 'channel', serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), 'delivered', + 'the send the failed attempt made still counts'); + + await framework.stop(); + }); + + // ---- engagement by conversation (review finding 6) ---------------------- + + it('a send whose place the host knows engages that thread only: ambient chatter in a sibling thread holds nothing', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still answering chan-A.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'unrelated chatter in another thread'; + module.interjectionMetadata = { + channelId: 'chan-T', tags: ['chat:ambient', 'chat:from-human'], inboundSource: { ...envelope('chan-T'), threadId: 't2' }, + }; + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + engage(framework).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }); + return origHandle(call); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Still answering chan-A.', locus: 'chan-A' }]); + assert.deepEqual(heldDrafts(framework), []); + + await framework.stop(); + }); + + it('engagement holds as certainly as the host knows it: there, maybe there, or somewhere in the channel', async () => { + const cases: Array<{ + name: string; + record: (f: AgentFramework) => void; + arrivalThread: string; + notice: string; + }> = [ + { + name: 'a delivered send, followed up in its own thread', + record: (f) => engage(f).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), + arrivalThread: 't1', + notice: 'chan-T (thread t1), where you sent a message this turn, continued mid-turn', + }, + { + name: 'an unconfirmed send, followed up in its own thread', + record: (f) => engage(f).noteSendEngagement('assistant', 'unknown', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), + arrivalThread: 't1', + notice: 'chan-T (thread t1), where you tried to send a message this turn (its delivery wasn\'t confirmed), continued mid-turn', + }, + { + name: 'a connector send tool, whose place in the channel the host can\'t know', + record: (f) => engage(f).noteChannelEngagement('assistant', 'srv', 'chan-T'), + arrivalThread: 't2', + notice: 'chan-T (thread t2) continued mid-turn, in a channel you sent something into this turn (where in the channel isn\'t known)', + }, + ]; + for (const c of cases) { + membrane = new MockMembrane(); + module = new RobotModule(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Which conversation is this for?' }, + ] as ContentBlock[])); + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'a follow-up'; + module.interjectionMetadata = { + channelId: 'chan-T', tags: ['chat:ambient', 'chat:from-human'], inboundSource: { ...envelope('chan-T'), threadId: c.arrivalThread }, + }; + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + c.record(framework); + return origHandle(call); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [], c.name); + assert.deepEqual(heldDrafts(framework).map((d) => d.reason), ['ambiguous'], c.name); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes(`[routing] ${c.notice}`)), `${c.name}: ${wired.join(' | ')}`); + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('channel_publish engages the conversation its outcome names when delivered or unknown, and nothing when it fails', async () => { + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + const dispatch = (framework as unknown as { dispatchChannelToolCall(agent: string, call: unknown): void }); + const outcomes: Array<[string, string, string | undefined]> = [ + ['delivered', 't1', 'delivered'], + ['unknown', 't2', 'unknown'], + ['failed', 't3', undefined], + ]; + for (const [status, threadId, expected] of outcomes) { + registryOf(framework).handleChannelToolCall = async () => ({ + success: status === 'delivered', + data: { delivered: status === 'delivered', status, serverId: 'srv', channelId: 'chan-T', threadId }, + }); + dispatch.dispatchChannelToolCall('assistant', { id: `p-${status}`, name: 'channel_publish', input: { channelId: 'chan-T', threadId, content: 'hi' } }); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(engage(framework).engagementOf('assistant', { kind: 'channel', serverId: 'srv', channelId: 'chan-T', threadId }), expected, status); + } + await framework.stop(); + }); + + // ---- supplied selectors (review finding 5) ------------------------------ + + it('channel_open refuses an invalid thread, server or setSpeechTarget before opening anything', async () => { + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + let opened = 0; + registryOf(framework).handleChannelToolCall = async () => { opened++; return { success: true, data: { channelId: 'chan-B' } }; }; + const results: Array<{ callId: string; result: { success: boolean; error?: string } }> = []; + const f = framework as unknown as { + dispatchChannelToolCall(agent: string, call: unknown): void; + pushEvent(e: unknown): void; + }; + const push = f.pushEvent.bind(framework); + f.pushEvent = (e: unknown) => { + const ev = e as { type?: string; callId?: string; result?: { success: boolean; error?: string } }; + if (ev.type === 'tool-result') results.push({ callId: ev.callId!, result: ev.result! }); + else push(e); + }; + const invalid: Array<[Record, RegExp]> = [ + [{ channelId: 'chan-B', threadId: '' }, /threadId must be a thread id/], + [{ channelId: 'chan-B', threadId: 7 }, /threadId must be a thread id/], + [{ channelId: 'chan-B', serverId: '' }, /serverId must name a server/], + [{ channelId: 'chan-B', setSpeechTarget: 'false' }, /setSpeechTarget must be true or false/], + ]; + for (const [i, [input, why]] of invalid.entries()) { + f.dispatchChannelToolCall('assistant', { id: `o${i}`, name: 'channel_open', input }); + } + await new Promise((r) => setTimeout(r, 5)); + assert.equal(opened, 0, 'nothing was opened'); + assert.equal(results.length, invalid.length); + for (const [i, [, why]] of invalid.entries()) { + const r = results.find((x) => x.callId === `o${i}`)!; + assert.equal(r.result.success, false); + assert.match(r.result.error ?? '', why); + assert.match(r.result.error ?? '', /Nothing was opened\./); + } + // null means "not in use", as for channel_publish's selectors. + f.dispatchChannelToolCall('assistant', { id: 'ok', name: 'channel_open', input: { channelId: 'chan-B', threadId: null, serverId: null, setSpeechTarget: null } }); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(opened, 1); + await framework.stop(); + }); + + it('a conversation fork refuses a supplied but empty channelId instead of reading it as its home', async () => { + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('assistant', 'chan-home'); + const handled: Array<{ name: string; input: Record }> = []; + registryOf(framework).handleChannelToolCall = async (name: string, input: Record) => { + handled.push({ name, input }); + return { success: true, data: {} }; + }; + const results: Array<{ callId: string; result: { success: boolean; error?: string } }> = []; + const f = framework as unknown as { dispatchChannelToolCall(agent: string, call: unknown): void; pushEvent(e: unknown): void }; + const push = f.pushEvent.bind(framework); + f.pushEvent = (e: unknown) => { + const ev = e as { type?: string; callId?: string; result?: { success: boolean; error?: string } }; + if (ev.type === 'tool-result') results.push({ callId: ev.callId!, result: ev.result! }); + else push(e); + }; + f.dispatchChannelToolCall('assistant', { id: 'empty', name: 'channel_publish', input: { channelId: '', content: 'hi' } }); + f.dispatchChannelToolCall('assistant', { id: 'number', name: 'channel_close', input: { channelId: 5 } }); + f.dispatchChannelToolCall('assistant', { id: 'null', name: 'channel_publish', input: { channelId: null, content: 'hi' } }); + f.dispatchChannelToolCall('assistant', { id: 'absent', name: 'channel_publish', input: { content: 'hi' } }); + await new Promise((r) => setTimeout(r, 5)); + assert.match(results.find((r) => r.callId === 'empty')!.result.error ?? '', /channelId must name a channel .*Nothing was sent\./); + assert.match(results.find((r) => r.callId === 'number')!.result.error ?? '', /channelId must name a channel .*Nothing was closed\./); + assert.deepEqual(handled.map((h) => [h.name, h.input.channelId]), [ + ['channel_publish', 'chan-home'], + ['channel_publish', 'chan-home'], + ], 'only an omitted (or null) channelId means the home'); + await framework.stop(); + }); + + // ---- outgoing streams (review finding 1) -------------------------------- + +}); From ae42e953eaffeefacaa62b4866ce52c5762d3c07 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Wed, 7 Oct 2026 22:46:49 -0400 Subject: [PATCH 28/65] fix(streams): outgoing streams carry only published speech MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's finding 1 ("Held speech still streams") and the review that followed it in room-273. Mira-1782 found that every repair of the speculative stream left another divergence from delivery: a round's later `>>>` prefix parsed as body, a failed envelope's choice kept streaming, and — the decisive one — a later call in the SAME round (a same-round private `think`, a `skip_reply`, an explicit send's silence) makes words private or held that the stream had already carried. No per-token parser can honor a decision made after the tokens; for discord-mcpl's voice output, the one consumer that declares `channels.streaming`, those words could be spoken aloud. MCPL §14.3 already says a chunk MUST NOT contain text delivery would refuse and that the host never streams speculatively. Mira-1782 and I (with Petra-1012, the PR's author, agreeing in room-203) chose one decision instead of two: the speech chain admits each round under the existing privacy and silencing policy and decides where it goes, and the outgoing stream observes that decision. A chunk goes out only after a confirmed publish (Mira's call: a failed outcome that says "nothing was sent" must stay true, and voice gives no proof it didn't speak): - streamPublished: after speakUnaddressed, a hybrid sticky or `>>>X` envelope, or an explicit `>>X` envelope is delivered, its text goes to the outgoing stream of the inference that wrote it (SpeechMoment now carries inferenceId), on the outcome's own server and channel. A thread placement isn't streamed, so a channel's stream names one place, the root. Held, private, drafted, failed and unconfirmed words never stream. - completeOutgoingStreams: each physical stream's teardown chains, after its last speech, one completion per channel for every inference id it minted, with exactly the text the stream carried (a chunk the registry refused isn't counted). - ChannelRegistry.sendOutgoingChunk/sendOutgoingComplete take that destination ({serverId, channelId}) and find the exact entry, never re-resolving a bare channel id; they still stream only to a declared place whose server holds the `channels.streaming` grant. The chunk says whether it went out. - toolWrapperProseGuard agents stream too: their stream was disabled only because chunks were speculative, and published text can't expose a wrapper. Removed: ProseStreamRouter (internal, not exported, unused outside the framework) and its tests, the token-time emitOutgoing/streamPlace state, the guard-held text copies, and the router resets on a refusal retry or guard refusal (an abandoned attempt's words never reach the chain). The inference:tokens trace is unchanged. The cost: voice follows each published round instead of overlapping generation — a text-only reply is voiced once it is written and posted. Speech still lands between tool rounds while the agent acts. The changelog says so plainly. Tests: present-while-acting — a hold stops the stream; a thread placement never streams while a later root publication in its channel does; a stream follows a channel_open; held words never resurface; each round's prefix names its own destination and a later skip_reply streams nothing; an undelivered envelope streams nothing, nor do the words held after it, while a newer prefix's publication does; a send-silenced turn streams nothing; a private-think round neither streams nor pins later speech; completion follows the stream's last speech under one inference id; an aborted stream completes what it streamed; completion never replays a refused chunk. Mira-1782's three probes are among them; each failed against the speculative stream. publish-outcome checks the registry's destination rule (two servers sharing a channel id), trunk-channel-routing that a root chunk follows its confirmed publish, and the wrapper-guard tests that guarded prose streams only once published and that an idle-disposed run's unpublished words never stream. Review follow-up in room-273; Mira-1782 reviews. --- changelog.d/targeted-publish.changed.md | 15 +- src/framework.ts | 209 ++++++---- src/mcpl/channel-registry.ts | 60 +-- src/mcpl/prose-grammar.ts | 8 +- src/mcpl/prose-stream-router.ts | 212 ---------- test/present-while-acting.test.ts | 386 +++++++++++++++++- test/prose-stream-router.test.ts | 149 ------- test/publish-outcome.test.ts | 45 +- ...ol-wrapper-prose-guard-integration.test.ts | 15 +- test/trunk-channel-routing.test.ts | 23 +- 10 files changed, 599 insertions(+), 523 deletions(-) delete mode 100644 src/mcpl/prose-stream-router.ts delete mode 100644 test/prose-stream-router.test.ts diff --git a/changelog.d/targeted-publish.changed.md b/changelog.d/targeted-publish.changed.md index aef589829..9ff1653b0 100644 --- a/changelog.d/targeted-publish.changed.md +++ b/changelog.d/targeted-publish.changed.md @@ -19,9 +19,18 @@ fork's empty `channelId` is refused rather than read as its home. - `channel_open` on an undeclared channel still opens it, but sets no speech target, and its result explains why. -- **Outgoing streams follow the same rule.** A stream goes only to a - declared channel, names its place, and is skipped when the route is a - thread, since one channel's stream can't name two places. +- **Outgoing streams carry only published speech.** A channel's outgoing + stream (MCPL §14.3) now carries prose once a publish has confirmed it, at + the channel's root, on the server and channel that publish resolved; it no + longer streams words while they are generated. A later call in the same + round can still make them private (a same-round private `think`, a + `skip_reply`), held (an explicit send's silence) or unsent, so held, + private, failed and unconfirmed words never stream, and a voice surface + speaks only what was posted. A thread placement isn't streamed, so a + channel's stream names one place. Each physical stream completes, after its + last speech, exactly what it streamed. Voice therefore follows each + published round rather than overlapping generation; speech still lands + between tool rounds. - **Publishing rechecks after opening.** When a publish has to open a closed channel first, it checks the destination again before sending: a channel removed, or a declaration withdrawn, while the open was pending refuses the diff --git a/src/framework.ts b/src/framework.ts index e71d025f8..00bb98742 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -78,7 +78,6 @@ import { } from './mcpl/push-coalescer.js'; import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js'; import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; -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, publishPlaceRefusal, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; @@ -276,10 +275,12 @@ function estimateAppendedRoundTokens( * produced: the turn's route then. Speech is published later, in order, on * the agent's speech chain, and a tool result (a channel_open) or an arrival * (a hold) can land in between; neither may change where words already - * written go. + * written go. `inferenceId` names the physical stream that wrote them: once + * published, they go to that inference's outgoing stream (MCPL §14.3). */ interface SpeechMoment { turn: TurnRoute | undefined; + inferenceId?: string; } /** @@ -324,9 +325,8 @@ const isAddressedMessage = ( _metadata?: Record, ): boolean => Array.isArray(tags) && tags.includes('chat:addressed'); -// 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. +// parseProsePrefix lives in ./mcpl/prose-grammar.ts. Outgoing streams (Spec +// 14.3) carry only what delivery published, so no second parser reads it. /** One-time primer appended when an agent's proseRouting mode changes. */ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): string { @@ -1177,6 +1177,15 @@ export class AgentFramework { * completion that arrives after a newer turn has begun compares it, so it * can't change that turn's speech (channel_open). */ private logicalTurns: Map = new Map(); + /** Outgoing streams (MCPL §14.3) by inference id: what each physical + * stream's published prose has sent to each channel so far, and the next + * chunk index. Completed and removed after the stream's last speech + * (completeOutgoingStreams). */ + private outgoingStreams: Map; + }> = new Map(); /** Per agent: the ordered chain of speech deliveries (chainSpeech). One * chain for all of an agent's physical streams, so a restart's or * retry's speech can never overtake words still being published from @@ -9792,6 +9801,57 @@ export class AgentFramework { return this.logicalTurns?.get(agentName) ?? 0; } + /** + * Prose the resident's speech has just published, confirmed, at a channel + * root goes to that inference's outgoing stream too (MCPL §14.3): a voice + * surface speaks only what was published, where it was published — + * never held, private, failed or unconfirmed words. The chunk names the + * outcome's own server and channel, and the root; a thread placement isn't + * streamed, so a channel's stream names one place. + */ + private streamPublished( + agentName: string, + moment: SpeechMoment, + published: { serverId: string; channelId: string; threadId?: string | null }, + text: string, + ): void { + if (!moment.inferenceId || !this.channelRegistry || published.threadId || !text) return; + const streams = (this.outgoingStreams ??= new Map()); + let stream = streams.get(moment.inferenceId); + if (!stream) { + stream = { conversationId: agentName, index: 0, channels: new Map() }; + streams.set(moment.inferenceId, stream); + } + const sent = this.channelRegistry.sendOutgoingChunk( + { serverId: published.serverId, channelId: published.channelId }, + agentName, moment.inferenceId, stream.index++, text, + ); + if (!sent) return; + const key = `${published.serverId}\u0000${published.channelId}`; + const channel = stream.channels.get(key); + if (channel) channel.text += text; + else stream.channels.set(key, { serverId: published.serverId, channelId: published.channelId, text }); + } + + /** + * Close the outgoing streams of these inferences: each channel gets its + * one completion, with exactly the text its stream carried. Chained at the + * physical stream's end, so it follows that stream's last speech. + */ + private completeOutgoingStreams(inferenceIds: Iterable): void { + for (const inferenceId of inferenceIds) { + const stream = this.outgoingStreams?.get(inferenceId); + if (!stream) continue; + this.outgoingStreams.delete(inferenceId); + for (const channel of stream.channels.values()) { + this.channelRegistry?.sendOutgoingComplete( + { serverId: channel.serverId, channelId: channel.channelId }, + stream.conversationId, inferenceId, channel.text, + ); + } + } + } + /** Append a link to the agent's speech chain (speechChains); it runs after every earlier one. */ private chainSpeech(agentName: string, what: string, work: () => Promise | void): void { const chains = (this.speechChains ??= new Map()); @@ -9964,7 +10024,7 @@ export class AgentFramework { agent: Agent, text: string, hold: { round?: number; notice: 'now' | 'later' }, - /** When the words were produced: the route they were written under. */ + /** When the words were produced: the route they were written under, and their stream. */ moment: SpeechMoment = { turn: this.turnRoutes?.get(agent.name) }, ): Promise { const turn = moment.turn; @@ -9997,6 +10057,7 @@ export class AgentFramework { const target = { ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, threadId: route.threadId ?? null }; const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target); this.recordProseDelivery(agent.name, outcome); + if (outcome?.delivered) this.streamPublished(agent.name, moment, outcome, text); } /** A turn route in words, for logs. */ @@ -10119,7 +10180,13 @@ export class AgentFramework { * nothing is ever sent to a destination the model did not name (directly * or via the turn's sticky target). */ - private async deliverProse(agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }): Promise { + private async deliverProse( + agent: Agent, + rawText: string, + hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + /** When the words were produced: their outgoing stream, once published. */ + moment: SpeechMoment = { turn: undefined }, + ): Promise { // Multi-envelope: a single prose block may address SEVERAL destinations — // every line beginning with `>>` opens a new envelope, routed // independently (first live use: Tilde 2026-07-24, one block carrying a @@ -10143,7 +10210,7 @@ export class AgentFramework { for (const envelope of envelopes) { if (!envelope.trim()) continue; - await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''), hold); + await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''), hold, moment); } } @@ -10225,7 +10292,7 @@ export class AgentFramework { agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, - /** When the words were produced: the route they were written under. */ + /** When the words were produced: the route they were written under, and their stream. */ moment: SpeechMoment = { turn: this.turnRoutes?.get(agent.name) }, ): Promise { for (const envelope of splitHybridEnvelopes(rawText)) { @@ -10268,6 +10335,7 @@ export class AgentFramework { const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); this.recordProseDelivery(agent.name, outcome.status === 'delivered' ? { delivered: true, ...outcome.destination! } : null); if (outcome.status === 'delivered') { + this.streamPublished(agent.name, moment, outcome.destination!, body); this.proseBounceStreaks.delete(agent.name); } else { this.proseTargetPins.delete(agent.name); @@ -10309,6 +10377,7 @@ export class AgentFramework { if (sticky) { const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, sticky); this.recordProseDelivery(agent.name, outcome); + if (outcome?.delivered) this.streamPublished(agent.name, moment, outcome, envelope); } else { await this.speakUnaddressed(agent, envelope, hold, moment); } @@ -10330,7 +10399,12 @@ export class AgentFramework { } } - private async deliverProseEnvelope(agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' }): Promise { + private async deliverProseEnvelope( + agent: Agent, + rawText: string, + hold: { round?: number; notice: 'now' | 'later' }, + moment: SpeechMoment, + ): Promise { const name = agent.name; const parsed = parseProsePrefix(rawText); if (parsed.continueTurn) this.proseContinuations.add(name); @@ -10379,7 +10453,10 @@ export class AgentFramework { try { const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft); - if (outcome.status === 'delivered') this.proseBounceStreaks.delete(name); + if (outcome.status === 'delivered') { + this.streamPublished(name, moment, outcome.destination!, body); + this.proseBounceStreaks.delete(name); + } } catch (err) { console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); } @@ -11119,13 +11196,13 @@ export class AgentFramework { `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; // NOT const: a membrane refusal retry abandons everything streamed so // far, and the surface must not concatenate two attempts. Re-minting the - // id makes the discarded attempt's chunk buffer orphaned (never - // finalized) instead of being appended to — see the 'retrying' case. + // id makes the discarded attempt a separate inference instead of one + // appended to — see the 'retrying' case. let outgoingInferenceId = newOutgoingInferenceId(); // RFC-007: every inference id THIS stream mints, so the stream's end - // aborts only its own open tool calls — never a successor's. + // aborts only its own open tool calls — never a successor's — and + // completes only its own outgoing streams. const lifecycleInferenceIds = new Set([outgoingInferenceId]); - let outgoingIndex = 0; // §10.5 inference/lifecycle: `started` now, exactly one terminal in the // finally below (the one block every exit path shares). Which terminal @@ -11140,55 +11217,24 @@ export class AgentFramework { turnIndex: 0, phase: 'started', }); - // The wrapper guard needs the complete response before it can distinguish - // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded - // turns until completion: otherwise speculative outgoing chunks could - // expose the wrapper before the fail-closed classifier runs. - const proseStream = this.channelRegistry && - turnProseRouting !== 'disabled' && - !agent.toolWrapperProseGuard - ? new ProseStreamRouter({ - mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', - initialTarget: typingChannel, - resolve: (spec) => { - const r = this.channelRegistry!.resolveProseTarget(spec); - return 'channelId' in r ? r.channelId : null; - }, - }) - : null; - // The place a stream's final publish targets (MCPL RFC-011 §6). A - // channel's stream can carry both the route's plain speech and envelope - // text sent to the channel's root, so a channel whose route is a thread - // isn't streamed at all (fail-closed: the stream could not name one - // place); every other stream names the root. - const streamPlace = (channelId: string): null | undefined => { - const target = this.routeTarget(agent.name); - return target && target.channelId === channelId && target.threadId !== null ? undefined : null; - }; - const emitOutgoing = (deltas: { channelId: string; delta: string }[]): void => { - for (const rd of deltas) { - const place = streamPlace(rd.channelId); - if (place === undefined) continue; - this.channelRegistry!.sendOutgoingChunk( - rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta, place, - ); - } - }; + // Outgoing streams (MCPL §14.3) carry only prose already published: the + // speech chain sends each confirmed publication at a channel root to the + // inference that wrote it (streamPublished), and this stream's teardown + // completes them after its last speech (completeOutgoingStreams). Nothing + // is streamed while it is generated, since a later call in the same + // round can still make the words private, held or unsent. // Tool-result-guard publication boundary. While a SUBMITTED batch is // pending (or a guard recovery is running), a refusal can still abandon // this physical round, so its streamed text must not reach any surface - // yet — neither the prose router nor the inference:tokens trace (TTS and - // other trace consumers voice it). Hold both; release in order on a clean - // round (tool-calls / non-guard completion), discard on a guard refusal. - let guardHeld: Array<{ trace: Parameters[0]; text?: string }> = []; + // yet — the inference:tokens trace (TTS and other trace consumers voice + // it). Hold it; release in order on a clean round (tool-calls / non-guard + // completion), discard on a guard refusal. + let guardHeld: Array[0]> = []; const releaseGuardHeld = (): void => { const held = guardHeld; guardHeld = []; - for (const item of held) { - this.emitTrace(item.trace); - if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text)); - } + for (const trace of held) this.emitTrace(trace); }; const discardGuardHeld = (): void => { guardHeld = []; }; @@ -11227,12 +11273,10 @@ export class AgentFramework { // turn's prose is bound for, without re-deriving routing. channelId: typingChannel ?? undefined, }; - const text = event.meta.type === 'text' ? event.content : undefined; if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) { - guardHeld.push({ trace, text }); + guardHeld.push(trace); } else { this.emitTrace(trace); - if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text)); } break; } @@ -11240,15 +11284,14 @@ export class AgentFramework { case 'retrying': { // Membrane is re-issuing after a content-policy refusal. Per the // RetryingEvent contract we must DISCARD everything this call has - // emitted: the tokens above were already streamed to the surface - // as outgoing chunks, and appending a second attempt to them - // would show the human two half-answers spliced together. + // emitted: the tokens above already reached trace consumers, and + // appending a second attempt to them would show the human two + // half-answers spliced together. // - // Re-mint the outgoing id so the partial buffer is orphaned - // rather than continued (delivery is authoritative via - // deliverProse and has not happened yet — nothing has been - // *sent*, only previewed), and reset the prose router so its - // destination bookkeeping starts clean for the new attempt. + // Re-mint the outgoing id so the attempt that stands is a new + // inference (the empty tokens trace below tells consumers to + // drop the partial one). Its words never reached the speech + // chain, so no outgoing channel stream carries them. console.error( `[refusal-retry] agent=${agent.name} membrane retry ` + `${event.attempt}/${event.maxAttempts}` + @@ -11257,9 +11300,7 @@ export class AgentFramework { ); outgoingInferenceId = newOutgoingInferenceId(); lifecycleInferenceIds.add(outgoingInferenceId); - outgoingIndex = 0; discardGuardHeld(); - proseStream?.reset(); this.emitTrace({ type: 'inference:tokens', agentName: agent.name, @@ -11362,7 +11403,7 @@ export class AgentFramework { // This round's words were written before any of its calls ran: // they go where the route stood then, whatever those calls (a // channel_open) or the arrivals at their results (a hold) change. - const roundMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name) }; + const roundMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name), inferenceId: outgoingInferenceId }; for (const call of event.calls) { // RFC-007: register at the one dispatch point for model-issued @@ -11447,7 +11488,7 @@ export class AgentFramework { `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`, ); for (const seg of roundSegments) { - this.chainSpeech(agent.name, 'mid-turn prose delivery', () => this.deliverProse(agent, seg, holdOpts)); + this.chainSpeech(agent.name, 'mid-turn prose delivery', () => this.deliverProse(agent, seg, holdOpts, roundMoment)); } } } else if (turnSilenced) { @@ -11481,7 +11522,7 @@ export class AgentFramework { // the route stood as they were, even though they are published // after the agent is idle, when a next turn may already have // decided its own (the 2026-07-22 Sol DM misroute's shape). - const finalMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name) }; + const finalMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name), inferenceId: outgoingInferenceId }; adoptInjectedRound(); const durationMs = Date.now() - startTime; let response = event.response; @@ -11501,7 +11542,6 @@ export class AgentFramework { // Nothing from the abandoned physical attempt is published or // persisted as assistant speech, including a terminal refusal // after the single recovery retry. - proseStream?.reset(); if (withheld) { const usage = response.details?.usage ?? response.usage; const tokenUsage = usage ? { @@ -11929,7 +11969,7 @@ export class AgentFramework { } else if (turnProseRouting === 'explicit') { console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`); for (const segment of speechSegments) { - this.chainSpeech(agent.name, 'text-only prose delivery', () => this.deliverProse(agent, segment, textOnlyHold)); + this.chainSpeech(agent.name, 'text-only prose delivery', () => this.deliverProse(agent, segment, textOnlyHold, finalMoment)); } } else { // Route along the route the words were written under @@ -12010,7 +12050,7 @@ export class AgentFramework { `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`, ); for (const seg of segments) { - this.chainSpeech(agent.name, 'trailing prose delivery', () => this.deliverProse(agent, seg, trailingHold)); + this.chainSpeech(agent.name, 'trailing prose delivery', () => this.deliverProse(agent, seg, trailingHold, finalMoment)); } } } else if (silenced || segments.length === 0) { @@ -12484,17 +12524,12 @@ export class AgentFramework { // exhausted, abort) so it never sticks after the turn ends. if (frameReachedTerminal) this.channelRegistry?.stopTyping(); - // Spec 14.3: flush any held line-start text, then close each streamed - // channel with its final moderated content — the consumer's signal to - // finalize (end the TTS utterance, settle the rendered message). - if (frameReachedTerminal && proseStream) { - emitOutgoing(proseStream.finish()); - for (const [channelId, text] of proseStream.byChannel()) { - const place = streamPlace(channelId); - if (place === undefined) continue; - this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text, place); - } - } + // Spec 14.3: close each channel this stream's published prose went to, + // with exactly what it carried — the consumer's signal to finalize (end + // the TTS utterance). Chained, so it follows the stream's last speech, + // which may still be publishing. + const inferenceIds = [...lifecycleInferenceIds]; + this.chainSpeech(agent.name, 'outgoing stream completion', () => this.completeOutgoingStreams(inferenceIds)); this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`); if (ownsPhysicalStream) { this.activeStreams.delete(agent.name); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 502298175..9eb0afc2c 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2985,62 +2985,62 @@ export class ChannelRegistry { * process-global default). Public so a multi-segment caller can snapshot it * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */ /** - * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the - * server owning the channel, AS THE MODEL GENERATES. Emitted only when that - * server declared `channels.streaming` in its initialize capabilities — - * servers that never opted in (the whole existing fleet) receive nothing. - * Fire-and-forget and never throws: streaming is an observer surface; the - * authoritative delivery is the eventual channels/publish. + * MCPL Spec 14.3 outgoing streaming: forward prose the agent has published + * to the server owning the channel. Emitted only when that server declared + * `channels.streaming` in its initialize capabilities — servers that never + * opted in receive nothing. The framework streams only text a confirmed + * publish placed at the channel's root (never speculatively: §14.3), so + * every chunk names the root (`threadId: null`). `destination` is the + * publish outcome's own server and channel, never re-resolved from a bare + * channel id. Fire-and-forget and never throws: streaming is an observer + * surface; the authoritative delivery is channels/publish. Says whether the + * chunk went out, so a caller completes only what was streamed. */ sendOutgoingChunk( - channelId: string, + destination: { serverId: string; channelId: string }, conversationId: string, inferenceId: string, index: number, delta: string, - /** The final publish's place (RFC-011 §6): a thread, or null for the root. */ - threadId: string | null = null, - ): void { - const server = this.streamingServerFor(channelId, threadId); - if (!server) return; + ): boolean { + const server = this.streamingServerFor(destination); + if (!server) return false; try { - server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta, threadId }); - } catch { /* observer surface — never disturb the turn */ } + server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: destination.channelId, index, delta, threadId: null }); + return true; + } catch { + /* observer surface — never disturb the turn */ + return false; + } } - /** Spec 14.3 companion: final moderated content per channel at stream end. */ + /** Spec 14.3 companion: the final content of one channel's stream, at its end. */ sendOutgoingComplete( - channelId: string, + destination: { serverId: string; channelId: string }, conversationId: string, inferenceId: string, text: string, - /** The final publish's place (RFC-011 §6): a thread, or null for the root. */ - threadId: string | null = null, ): void { - const server = this.streamingServerFor(channelId, threadId); + const server = this.streamingServerFor(destination); if (!server) return; try { server.sendChannelsOutgoingComplete({ inferenceId, conversationId, - channelId, + channelId: destination.channelId, content: [{ type: 'text', text }], - threadId, + threadId: null, }); } catch { /* observer surface — never disturb the turn */ } } - private streamingServerFor(channelId: string, threadId: string | null) { - // The map is keyed `${serverId}:${channelId}`; stream targets arrive as - // bare descriptor ids (what resolveProseTarget returns). Scan like the - // resolver does, and fail closed on cross-server ambiguity — the same - // never-guess rule that governs delivery. - const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); - if (matches.length !== 1) return null; + private streamingServerFor(destination: { serverId: string; channelId: string }) { + const found = this.findExactEntry(destination); + if ('error' in found) return null; // §14.3 fail-closed: nothing streams that delivery would refuse — and // delivery publishes only where the place is declared (RFC-011 §6). - if (publishPlaceRefusal(declaredPublishTarget(matches[0]!.descriptor), threadId)) return null; - const server = this.serverRegistry.getServer(matches[0]!.serverId); + if (publishPlaceRefusal(declaredPublishTarget(found.entry.descriptor), null)) return null; + const server = this.serverRegistry.getServer(found.entry.serverId); // §5.4: the GRANT gates streaming, not the raw advertisement. The old // `capabilities?.channels?.streaming` check was doubly wrong: undefined // for the boolean `channels: true` shape (masking discord-mcpl's latent diff --git a/src/mcpl/prose-grammar.ts b/src/mcpl/prose-grammar.ts index 557a5b2ce..863f56fed 100644 --- a/src/mcpl/prose-grammar.ts +++ b/src/mcpl/prose-grammar.ts @@ -1,9 +1,7 @@ /** - * Explicit-prose routing grammar — the SINGLE definition shared by the - * delivery path (framework.deliverProse*) and the outgoing-stream router - * (prose-stream-router.ts). Extracted from framework.ts verbatim so the two - * parsers cannot drift: a streamed chunk and its eventual delivery must agree - * on what is a prefix, what is a modifier, and what is body. + * Explicit-prose routing grammar — the single definition the delivery path + * (framework.deliverProse*, deliverHybridProse) reads. Outgoing streams carry + * only what delivery published, so nothing else parses prose. * * Grammar (docs/explicit-prose-routing.md): * `>>target [!] [body…]` — target = channel spec or `skip_reply` diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts deleted file mode 100644 index e14ad8c3b..000000000 --- a/src/mcpl/prose-stream-router.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Incremental prose router: turns a stream of text deltas into a stream of - * (channelId, delta) pairs AS THE MODEL GENERATES, so downstream surfaces - * (voice synthesis, streamed message rendering, future platforms) can act on - * speech before the turn completes. This is the emission half of MCPL Spec - * 14.3 (`channels/outgoing/chunk`) — the notification has existed in the - * wire types since the spec landed; this class is what finally produces it. - * - * Correctness contract: routing decisions here MUST agree with the delivery - * path (deliverProse/parseProsePrefix). Both use prose-grammar.ts; this - * router additionally holds back exactly as much text as needed to make each - * line's decision — the start of a line is buffered only until it either - * cannot be a `>>` prefix (flush as body) or its prefix token (+ optional - * ` !` modifier) completes (switch target, swallow the prefix). Body text - * after the decision point streams through with zero added latency. - * - * Suppression is fail-closed: text whose destination is unknown (explicit - * mode before the first `>>`, unresolvable/ambiguous targets, `>>skip_reply`) - * emits NOTHING — the delivery path will bounce/retain it; a stream consumer - * must never see text that delivery would refuse to send. - * - * Two modes, mirroring proseRouting: - * - explicit: parse `>>` prefixes; initial target null (suppressed). - * - locus: NO prefix parsing (locus mode's `>>` lines are plain text); - * everything streams to the frozen turn locus. - */ - -export interface RoutedDelta { - channelId: string; - delta: string; -} - -type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; - -export class ProseStreamRouter { - private target: string | null; - private state: State = 'line-start'; - private held = ''; // text held while a line's decision is pending - private token = ''; // the >>spec token being accumulated - private sawBang = false; - private accumulated = new Map(); - - constructor( - private opts: { - /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */ - mode: 'explicit' | 'hybrid' | 'locus'; - /** Initial destination: the frozen turn locus (locus mode) or null. */ - initialTarget: string | null; - /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */ - resolve: (spec: string) => string | null; - }, - ) { - this.target = opts.initialTarget; - if (opts.mode === 'locus') this.state = 'body'; - } - - /** Per-channel full text emitted so far (for outgoing/complete). */ - byChannel(): ReadonlyMap { - return this.accumulated; - } - - /** - * Drop everything routed so far and return to the constructed state. - * - * For a membrane refusal retry (RetryingEvent): the abandoned attempt's - * prose must not survive into the new one, and `accumulated` feeds - * outgoing/complete — leaving it would finalize text the model never - * actually said in the attempt that stands. - */ - reset(): void { - this.target = this.opts.initialTarget; - this.state = this.opts.mode === 'locus' ? 'body' : 'line-start'; - this.held = ''; - this.token = ''; - this.sawBang = false; - this.accumulated = new Map(); - } - - feed(delta: string): RoutedDelta[] { - const out: RoutedDelta[] = []; - for (const ch of delta) this.step(ch, out); - return coalesce(out); - } - - /** End of stream: flush any held line-start text (it can no longer become a - * complete prefix; an unterminated `>>tok` at EOF is routing metadata with - * no body — dropped, matching delivery which would deliver empty body). */ - finish(): RoutedDelta[] { - const out: RoutedDelta[] = []; - if (this.state === 'hybrid-indent') this.emit(this.held, out); - if (this.state === 'maybe-prefix') { - const full = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; - if (arrows !== full) this.emit(this.held, out); - } - // prefix-token / after-token at EOF: prefix without body — nothing to emit. - this.held = ''; - this.state = 'line-start'; - return coalesce(out); - } - - private step(ch: string, out: RoutedDelta[]): void { - switch (this.state) { - case 'body': - if (ch === '\n' && this.opts.mode !== 'locus') { - this.emit('\n', out); - this.state = 'line-start'; - } else { - this.emit(ch, out); - } - return; - - case 'line-start': - if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) { - this.state = 'hybrid-indent'; - this.held = ch; - return; - } - if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; } - this.state = 'body'; - this.step(ch, out); - return; - - case 'hybrid-indent': - if (ch === ' ' || ch === '\t') { this.held += ch; return; } - if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; } - this.emit(this.held, out); - this.held = ''; - this.state = 'body'; - this.step(ch, out); - return; - - case 'maybe-prefix': { - const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; - if (ch === '>' && arrows.length < prefix.length) { - this.held += '>'; - return; - } - if (arrows === prefix) { - if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) { - this.held = ''; - this.state = 'prefix-gap'; - return; - } - if (ch === '\n' || ch === ' ' || ch === '\t') { - if (this.opts.mode === 'explicit') this.emit(this.held, out); - this.held = ''; - this.state = ch === '\n' ? 'line-start' : 'body'; - if (ch !== '\n') this.step(ch, out); - return; - } - this.state = 'prefix-token'; - this.token = ch; - this.held = ''; - return; - } - this.emit(this.held, out); - this.held = ''; - this.state = 'body'; - this.step(ch, out); - return; - } - - case 'prefix-gap': - if (ch === ' ' || ch === '\t') return; - if (ch === '\n') { this.target = null; this.state = 'line-start'; return; } - this.state = 'prefix-token'; - this.token = ch; - return; - - case 'prefix-token': - if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; } - if (ch === '\n') { this.applyTarget(); this.state = 'line-start'; return; } - this.token += ch; - return; - - case 'after-token': - // Grammar: `([ \t]+!)?[ \t]*` — one optional `!` immediately after the - // first whitespace run is the continue-turn modifier, not body. - if (ch === ' ' || ch === '\t') return; - if (ch === '\n') { this.state = 'line-start'; return; } - if (ch === '!' && !this.sawBang) { this.sawBang = true; return; } - this.state = 'body'; - this.step(ch, out); - return; - } - } - - private applyTarget(): void { - const spec = this.token; - this.token = ''; - if (spec === 'skip_reply') { this.target = null; return; } - this.target = this.opts.resolve(spec); - } - - private emit(text: string, out: RoutedDelta[]): void { - if (this.target === null || !text) return; - out.push({ channelId: this.target, delta: text }); - this.accumulated.set(this.target, (this.accumulated.get(this.target) ?? '') + text); - } -} - -function coalesce(deltas: RoutedDelta[]): RoutedDelta[] { - const out: RoutedDelta[] = []; - for (const d of deltas) { - const last = out[out.length - 1]; - if (last && last.channelId === d.channelId) last.delta += d.delta; - else out.push({ ...d }); - } - return out; -} diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 6dabe19d3..70e0ea8a4 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -141,11 +141,13 @@ function stubChannelRegistry(framework: AgentFramework, opts: { home?: boolean } locusCalls++; return `chan-live-${locusCalls}`; }, - routeSpeech: async (_agent: string, text: string, target?: string | null | { channelId: string }) => { + routeSpeech: async (_agent: string, text: string, target?: string | null | { channelId: string; threadId?: string | null }) => { const locus = target && typeof target === 'object' ? target.channelId : target ?? null; + const threadId = target && typeof target === 'object' ? target.threadId : undefined; routed.push({ text, locus }); - // Mirror the real registry's outcome shape (delivery receipts read it). - return locus ? { delivered: true, channelId: locus } : null; + // Mirror the real registry's outcome shape (delivery receipts read it), + // the thread included when the publish was placed in one. + return locus ? { delivered: true, channelId: locus, ...(threadId ? { threadId } : {}) } : null; }, getDefaultPublishChannel: () => null, // Hybrid `>>>#name` targets resolve to `chan-name` and deliver through @@ -1108,6 +1110,23 @@ describe('speech follows the moment its words were written', () => { }; } + /** Record the outgoing stream (MCPL §14.3): what each chunk and completion carried. */ + function recordStreams(framework: AgentFramework, refuse: (channelId: string, delta: string) => boolean = () => false) { + const chunks: Array<{ channelId: string; delta: string; threadId: unknown }> = []; + const completes: Array<{ channelId: string; text: string; threadId: unknown }> = []; + const registry = registryOf(framework); + registry.sendOutgoingChunk = (destination: { channelId: string }, _c: string, _i: string, _n: number, delta: string) => { + if (refuse(destination.channelId, delta)) return false; + chunks.push({ channelId: destination.channelId, delta, threadId: null }); + return true; + }; + registry.sendOutgoingComplete = (destination: { channelId: string }, _c: string, _i: string, text: string) => { + completes.push({ channelId: destination.channelId, text, threadId: null }); + }; + const streamedTo = (channelId: string): string => chunks.filter((c) => c.channelId === channelId).map((c) => c.delta).join(''); + return { chunks, completes, streamedTo }; + } + /** An addressed arrival from another conversation, at the next tool call: it holds an inferred route. */ function addressedArrivalElsewhere(): void { module.toolDelayMs = 25; @@ -1487,4 +1506,365 @@ describe('speech follows the moment its words were written', () => { // ---- outgoing streams (review finding 1) -------------------------------- + it('a mid-turn hold stops the stream, and completion carries only what streamed', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Before the hold.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Held words.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + addressedArrivalElsewhere(); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(streams.streamedTo('chan-A'), 'Before the hold.'); + assert.ok(streams.chunks.every((c) => !c.delta.includes('Held')), JSON.stringify(streams.chunks)); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Before the hold.', threadId: null }]); + assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Held words.', 'ambiguous']]); + + await framework.stop(); + }); + + it('a thread placement is never streamed, held or not; root publication in the same channel streams alone', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Into the thread.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Held words.' }, + ] as ContentBlock[])); + let framework = await createFramework(); + let routed = stubChannelRegistry(framework, { home: false }); + registryOf(framework).publishTarget = () => 'exact'; + let streams = recordStreams(framework); + addressedArrivalElsewhere(); + triggerFromThread(framework, 'chan-A', 't1'); + await framework.runUntilIdle(); + assert.deepEqual(routed, [{ text: 'Into the thread.', locus: 'chan-A' }], 'published into the thread'); + assert.deepEqual(streams.chunks, [], 'the thread placement and the held words: nothing streams'); + assert.deepEqual(streams.completes, []); + await framework.stop(); + + // Hybrid: after the route moves on, a `>>>` envelope to that channel's + // root is a root publication, and its stream names the root alone. + rmSync(tempDir, { recursive: true, force: true }); + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + membrane = new MockMembrane(); + module = new RobotModule(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Into the thread.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>chan-A Root words for chan-A.' }, + ] as ContentBlock[])); + framework = await createFramework('hybrid'); + routed = stubChannelRegistry(framework, { home: false }); + registryOf(framework).publishTarget = () => 'exact'; + opensChannels(framework); + streams = recordStreams(framework); + triggerFromThread(framework, 'chan-A', 't1'); + await framework.runUntilIdle(); + assert.equal(streams.streamedTo('chan-A'), 'Root words for chan-A.'); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Root words for chan-A.', threadId: null }]); + await framework.stop(); + }); + + it('a stream follows the route when channel_open moves it, and each channel completes with its own words', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'To A.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'chan-B' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'To B.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'To A.', locus: 'chan-A' }, { text: 'To B.', locus: 'chan-B' }]); + assert.equal(streams.streamedTo('chan-A'), 'To A.'); + assert.equal(streams.streamedTo('chan-B'), 'To B.'); + assert.deepEqual(streams.completes.map((c) => [c.channelId, c.text]).sort(), [['chan-A', 'To A.'], ['chan-B', 'To B.']]); + + await framework.stop(); + }); + + it('held words never resurface in a stream: a hold, then a channel_open, then speech', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Before the hold.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Held words.' }, + { type: 'tool_use', id: 'c2', name: 'channel_open', input: { channelId: 'chan-B' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'After the open.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + const streams = recordStreams(framework); + addressedArrivalElsewhere(); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Before the hold.', locus: 'chan-A' }, { text: 'After the open.', locus: 'chan-B' }]); + assert.equal(streams.streamedTo('chan-A'), 'Before the hold.'); + assert.equal(streams.streamedTo('chan-B'), 'After the open.'); + assert.deepEqual(streams.completes.map((c) => [c.channelId, c.text]).sort(), [['chan-A', 'Before the hold.'], ['chan-B', 'After the open.']]); + + await framework.stop(); + }); + + it('each round is its own segment: a later round\'s prefix names its own destination, and a later skip_reply streams nothing', async () => { + // Mira-1782's probe: the router stayed in `body` across provider rounds, + // so round 2's `>>>#beta` streamed into #alpha as text. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha First.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#beta Second.' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>skip_reply Private words.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'First.', locus: 'chan-alpha' }, { text: 'Second.', locus: 'chan-beta' }]); + assert.equal(streams.streamedTo('chan-alpha'), 'First.'); + assert.equal(streams.streamedTo('chan-beta'), 'Second.'); + assert.ok(streams.chunks.every((c) => !c.delta.includes('>>>') && !c.delta.includes('Private')), JSON.stringify(streams.chunks)); + + await framework.stop(); + }); + + it('an envelope delivery couldn\'t make streams nothing, nor do the words held after it; a newer prefix\'s publication does', async () => { + // Mira-1782's probe: delivery held the words after a failed `>>>#alpha` + // as drafts, while the stream kept carrying them to #alpha. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha First.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Later words.' }, + ] as ContentBlock[])); + let framework = await createFramework('hybrid'); + stubChannelRegistry(framework, { home: false }); + // One bounce, and no bounce-wake turn after it. + (framework as unknown as { proseBounceStreaks: Map }).proseBounceStreaks.set('assistant', 100); + registryOf(framework).deliverSpeech = async () => ({ status: 'failed', reason: 'refused', at: Date.now() }); + module.toolDelayMs = 50; // the failure is known before round 2 is written + let streams = recordStreams(framework); + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + assert.ok(heldDrafts(framework).some((d) => d.text === 'Later words.' && d.reason === 'no-destination')); + assert.equal(streams.streamedTo('chan-alpha'), '', 'only confirmed publication streams'); + await framework.stop(); + + // A newer prefix, parsed before the older envelope's failure is known, stands. + rmSync(tempDir, { recursive: true, force: true }); + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + membrane = new MockMembrane(); + module = new RobotModule(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha First.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#beta Second.' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'More for beta.' }, + ] as ContentBlock[])); + framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + (framework as unknown as { proseBounceStreaks: Map }).proseBounceStreaks.set('assistant', 100); + const registry = registryOf(framework); + const deliver = registry.deliverSpeech as (...args: unknown[]) => Promise; + let calls = 0; + registry.deliverSpeech = async (...args: unknown[]) => { + if (calls++ === 0) { + await new Promise((r) => setTimeout(r, 100)); + return { status: 'failed', reason: 'refused', at: Date.now() }; + } + return deliver(...args); + }; + streams = recordStreams(framework); + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + assert.deepEqual(routed, [{ text: 'Second.', locus: 'chan-beta' }, { text: 'More for beta.', locus: 'chan-beta' }]); + assert.equal(streams.streamedTo('chan-beta'), 'Second.More for beta.'); + await framework.stop(); + }); + + it('a send-silenced turn streams nothing of the later rounds it holds back', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Sending it now.' }, + { type: 'tool_use', id: 'c1', name: 'robot--send_message', input: { text: 'the note' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Postscript: sent it.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework).map((d) => d.reason), ['explicit-send', 'explicit-send']); + // Neither round was published, so neither streams — not even the words + // written before the round's own send made them held. + assert.deepEqual(streams.chunks, []); + + await framework.stop(); + }); + + it('a private-think round neither streams nor pins later speech', async () => { + // Mira-1782's probe: a later call in the same round (a private `think`) + // made words private that a speculative stream had already carried. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Private words.' }, + { type: 'tool_use', id: 'think-1', name: 'think', input: { content: 'private reasoning' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Public words.' }, + ] as ContentBlock[])); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'private-think.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test', proseRouting: 'hybrid', sameRoundThinkTextPolicy: 'private' }], + modules: [module], + }); + module.framework = framework; + const routed = stubChannelRegistry(framework, { home: false }); + registryOf(framework).getChannelTools = () => [{ name: 'think', description: 'private reasoning', inputSchema: { type: 'object', properties: {} } }]; + registryOf(framework).handleChannelToolCall = async () => ({ success: true, data: { noted: true } }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Public words.', locus: 'chan-A' }]); + assert.equal(streams.streamedTo('chan-alpha'), ''); + assert.equal(streams.streamedTo('chan-A'), 'Public words.'); + + await framework.stop(); + }); + + it('completion follows the stream\'s last speech, under the inference that wrote it', async () => { + // A trailing reply is published after the agent is idle: its chunk and + // the completion still come in that order, with one inference id. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Narrating.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Done.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + slowFirst(framework, 'routeSpeech', 40); + const events: Array<[string, string, string]> = []; + const registry = registryOf(framework); + registry.sendOutgoingChunk = (d: { channelId: string }, _c: string, inferenceId: string, _n: number, delta: string) => { + events.push(['chunk', inferenceId, delta]); + return true; + }; + registry.sendOutgoingComplete = (d: { channelId: string }, _c: string, inferenceId: string, text: string) => { + events.push(['complete', inferenceId, text]); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + await new Promise((r) => setTimeout(r, 80)); + + assert.deepEqual(events.map(([kind, , text]) => [kind, text]), [ + ['chunk', 'Narrating.'], + ['chunk', 'Done.'], + ['complete', 'Narrating.Done.'], + ]); + assert.equal(new Set(events.map(([, id]) => id)).size, 1, 'one physical stream, one inference'); + + await framework.stop(); + }); + + it('an aborted stream still completes what its published speech streamed', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Narrating.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Never written.' }, + ] as ContentBlock[])); + module.toolDelayMs = 300; + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + const idle = framework.runUntilIdle(); + const deadline = Date.now() + 2000; + while (streams.chunks.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 5)); + assert.ok(streams.chunks.length > 0, 'the narration was published and streamed'); + assert.ok(framework.abortInference('assistant', 'test abort'), 'the turn was still running'); + await idle; + await new Promise((r) => setTimeout(r, 50)); + + assert.equal(streams.streamedTo('chan-A'), 'Narrating.'); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Narrating.', threadId: null }]); + + await framework.stop(); + }); + + it('completion never replays a chunk the registry refused to stream', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Refused.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Streamed.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + // As when the channel's declaration or streaming grant is briefly absent. + const streams = recordStreams(framework, (_channelId, delta) => delta.startsWith('Refused')); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(streams.streamedTo('chan-A'), 'Streamed.'); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Streamed.', threadId: null }]); + + await framework.stop(); + }); }); diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts deleted file mode 100644 index fa177c918..000000000 --- a/test/prose-stream-router.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// ProseStreamRouter: streamed routing must agree with the delivery path's -// grammar (prose-grammar.parseProsePrefix + the envelope splitter) regardless -// of how the token stream is chopped. The last test is the property that -// matters most: char-by-char feeding ≡ one-shot feeding. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { ProseStreamRouter, type RoutedDelta } from '../src/mcpl/prose-stream-router.js'; - -const RESOLVE: Record = { - '#general': 'discord:g:100', - '#ops': 'discord:g:200', - '@sol': 'discord:dm:300', -}; - -function explicit(): ProseStreamRouter { - return new ProseStreamRouter({ - mode: 'explicit', - initialTarget: null, - resolve: (spec) => RESOLVE[spec] ?? null, - }); -} - -function run(r: ProseStreamRouter, text: string, chunk = text.length): RoutedDelta[] { - const out: RoutedDelta[] = []; - for (let i = 0; i < text.length; i += chunk) out.push(...r.feed(text.slice(i, i + chunk))); - out.push(...r.finish()); - return out; -} - -function textFor(deltas: RoutedDelta[], ch: string): string { - return deltas.filter((d) => d.channelId === ch).map((d) => d.delta).join(''); -} - -test('locus mode: pure passthrough, >> lines are plain text', () => { - const r = new ProseStreamRouter({ mode: 'locus', initialTarget: 'discord:g:100', resolve: () => null }); - const out = run(r, 'hello\n>>#ops not a prefix here\nbye'); - assert.equal(textFor(out, 'discord:g:100'), 'hello\n>>#ops not a prefix here\nbye'); -}); - -test('explicit: unprefixed leading text is suppressed (delivery would bounce it)', () => { - const out = run(explicit(), 'no destination yet\n>>#general now routed'); - assert.equal(textFor(out, 'discord:g:100'), 'now routed'); - assert.equal(out.every((d) => !d.delta.includes('no destination')), true); -}); - -test('explicit: multi-envelope switching, prefixes swallowed', () => { - const out = run(explicit(), '>>#general hello there\nstill general\n>>#ops aside for ops\n>>@sol dm line'); - assert.equal(textFor(out, 'discord:g:100'), 'hello there\nstill general\n'); - assert.equal(textFor(out, 'discord:g:200'), 'aside for ops\n'); - assert.equal(textFor(out, 'discord:dm:300'), 'dm line'); -}); - -test('quoted `>> arrow` and lone `>` stay body text', () => { - const out = run(explicit(), '>>#general look:\n>> quoted arrow\n> markdown quote\ndone'); - assert.equal(textFor(out, 'discord:g:100'), 'look:\n>> quoted arrow\n> markdown quote\ndone'); -}); - -test('skip_reply suppresses; later envelope resumes', () => { - const out = run(explicit(), '>>skip_reply private thought\n>>#ops public line'); - assert.equal(out.every((d) => !d.delta.includes('private')), true); - assert.equal(textFor(out, 'discord:g:200'), 'public line'); -}); - -test('unresolvable target suppresses that envelope only', () => { - const out = run(explicit(), '>>#nonexistent to nowhere\n>>#general to somewhere'); - assert.equal(out.every((d) => !d.delta.includes('nowhere')), true); - assert.equal(textFor(out, 'discord:g:100'), 'to somewhere'); -}); - -test('` !` continuation modifier is swallowed; `!` as body survives', () => { - const out = run(explicit(), '>>#general ! next turn continues\n>>#ops !bang body'); - assert.equal(textFor(out, 'discord:g:100'), 'next turn continues\n'); - // grammar: `([ \t]+!)?` — the first ! after whitespace is the modifier, - // remainder is body (matches parseProsePrefix on ">>#ops !bang body"). - assert.equal(textFor(out, 'discord:g:200'), 'bang body'); -}); - -test('prefix on its own line routes the following lines', () => { - const out = run(explicit(), '>>#general\nbody on next line\nand more'); - assert.equal(textFor(out, 'discord:g:100'), 'body on next line\nand more'); -}); - -test('byChannel accumulates exactly the emitted text', () => { - const r = explicit(); - run(r, '>>#general one\n>>#ops two'); - assert.equal(r.byChannel().get('discord:g:100'), 'one\n'); - assert.equal(r.byChannel().get('discord:g:200'), 'two'); -}); - -test('PROPERTY: char-by-char ≡ one-shot, across every chunk size', () => { - const text = 'lead-in ignored\n>>#general alpha\nbeta > gamma\n>> not a prefix\n>>#ops ! delta\n>>skip_reply hidden\n>>#general {{unsent}} tail'; - const oneShot = run(explicit(), text); - for (const size of [1, 2, 3, 5, 7, 11]) { - const chunked = run(explicit(), text, size); - for (const ch of Object.values(RESOLVE)) { - assert.equal(textFor(chunked, ch), textFor(oneShot, ch)); - } - } -}); - -function hybrid(): ProseStreamRouter { - return new ProseStreamRouter({ - mode: 'hybrid', - initialTarget: 'world:commons', - resolve: (spec) => RESOLVE[spec] ?? (spec === 'world:commons' ? 'world:commons' : null), - }); -} - -test('hybrid: unprefixed prose stays on the frozen locus', () => { - const out = run(hybrid(), 'ordinary world speech'); - assert.equal(textFor(out, 'world:commons'), 'ordinary world speech'); -}); - -test('hybrid: triple-arrow target switches publication and consumes the envelope', () => { - const out = run(hybrid(), '>>>#general cafe body'); - assert.equal(textFor(out, 'discord:g:100'), 'cafe body'); - assert.equal(out.every(d => !d.delta.includes('>>>')), true); - assert.equal(textFor(out, 'world:commons'), ''); -}); - -test('hybrid: permissive whitespace and cross-surface canonical ids', () => { - const out = run(hybrid(), ' >>> #ops spaced\n\t>>> world:commons home'); - assert.equal(textFor(out, 'discord:g:200'), 'spaced\n'); - assert.equal(textFor(out, 'world:commons'), 'home'); -}); - -test('hybrid: double arrows and mid-line triple arrows remain ordinary locus prose', () => { - const text = '>>#ops old syntax is prose\nlook >>>#ops quoted'; - const out = run(hybrid(), text); - assert.equal(textFor(out, 'world:commons'), text); - assert.equal(textFor(out, 'discord:g:200'), ''); -}); - -test('hybrid: missing target suppresses that envelope; a later valid envelope resumes', () => { - const out = run(hybrid(), '>>>#missing nowhere\nplain remains on failed target\n>>>world:commons restored'); - assert.equal(out.every(d => !d.delta.includes('nowhere') && !d.delta.includes('plain remains')), true); - assert.equal(textFor(out, 'world:commons'), 'restored'); -}); - -test('hybrid PROPERTY: char-by-char equals one-shot', () => { - const text = 'locus first\n>>>#general alpha\nbeta\n>>> #ops delta\n>>>world:commons home'; - const one = run(hybrid(), text); - for (const size of [1, 2, 3, 4, 7, 11]) { - const chunked = run(hybrid(), text, size); - for (const ch of ['world:commons', ...Object.values(RESOLVE)]) { - assert.equal(textFor(chunked, ch), textFor(one, ch)); - } - } -}); diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 18aca2cc9..98c1fbfff 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -284,36 +284,43 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(published.length, 1); }); - it('streams only where it would publish, carrying the same place (RFC-011 §6)', () => { - const chunks: Array<{ channelId: string; threadId?: unknown }> = []; - const completes: Array<{ channelId: string; threadId?: unknown }> = []; - const server = { + it('streams only to a declared channel, at its root, on the server the publish resolved (RFC-011 §6)', () => { + const chunks: Array<{ server: string; channelId: string; threadId?: unknown }> = []; + const completes: Array<{ server: string; channelId: string; threadId?: unknown }> = []; + const serverFor = (name: string) => ({ grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), - sendChannelsOutgoingChunk: (p: { channelId: string; threadId?: unknown }) => chunks.push({ channelId: p.channelId, threadId: p.threadId }), - sendChannelsOutgoingComplete: (p: { channelId: string; threadId?: unknown }) => completes.push({ channelId: p.channelId, threadId: p.threadId }), - }; + sendChannelsOutgoingChunk: (p: { channelId: string; threadId?: unknown }) => chunks.push({ server: name, channelId: p.channelId, threadId: p.threadId }), + sendChannelsOutgoingComplete: (p: { channelId: string; threadId?: unknown }) => completes.push({ server: name, channelId: p.channelId, threadId: p.threadId }), + }); + const servers: Record> = { discord: serverFor('discord'), slack: serverFor('slack') }; const registry = new ChannelRegistry( - { getServer: () => server } as unknown as McplServerRegistry, + { getServer: (id: string) => servers[id] } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, {}, ); const channels = (registry as unknown as { channels: Map }).channels; - const seedIt = (id: string, target: 'exact' | 'root' | null) => channels.set(`discord:${id}`, { - serverId: 'discord', - descriptor: { id, type: 'discord', label: id, ...(target ? { capabilities: { publish: { target } } } : {}) }, + const seedIt = (serverId: string, id: string, target: 'exact' | 'root' | null) => channels.set(`${serverId}:${id}`, { + serverId, + descriptor: { id, type: serverId, label: id, ...(target ? { capabilities: { publish: { target } } } : {}) }, open: true, }); - seedIt('exact', 'exact'); - seedIt('root', 'root'); - seedIt('none', null); - for (const [channelId, threadId] of [['exact', null], ['exact', 't-1'], ['root', null], ['root', 't-1'], ['none', null]] as const) { - registry.sendOutgoingChunk(channelId, 'agent', 'inf-1', 0, 'Hello', threadId); - registry.sendOutgoingComplete(channelId, 'agent', 'inf-1', 'Hello', threadId); + seedIt('discord', 'exact', 'exact'); + seedIt('discord', 'root', 'root'); + seedIt('discord', 'none', null); + // The same id on two servers: the destination's server decides, never a scan of bare ids. + seedIt('slack', 'root', 'root'); + for (const [serverId, channelId] of [['discord', 'exact'], ['discord', 'root'], ['discord', 'none'], ['slack', 'root'], ['discord', 'missing']] as const) { + registry.sendOutgoingChunk({ serverId, channelId }, 'agent', 'inf-1', 0, 'Hello'); + registry.sendOutgoingComplete({ serverId, channelId }, 'agent', 'inf-1', 'Hello'); } - const expected = [{ channelId: 'exact', threadId: null }, { channelId: 'exact', threadId: 't-1' }, { channelId: 'root', threadId: null }]; - assert.deepEqual(chunks, expected, 'no stream into a thread where there are none, nor to an undeclared channel'); + const expected = [ + { server: 'discord', channelId: 'exact', threadId: null }, + { server: 'discord', channelId: 'root', threadId: null }, + { server: 'slack', channelId: 'root', threadId: null }, + ]; + assert.deepEqual(chunks, expected, 'never to an undeclared or unregistered channel; always the root'); assert.deepEqual(completes, expected); }); diff --git a/test/tool-wrapper-prose-guard-integration.test.ts b/test/tool-wrapper-prose-guard-integration.test.ts index e740fc07f..0543c5ad9 100644 --- a/test/tool-wrapper-prose-guard-integration.test.ts +++ b/test/tool-wrapper-prose-guard-integration.test.ts @@ -231,7 +231,11 @@ describe('tool wrapper prose guard integration', () => { (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, + // A chunk names whether its words had been published when it was sent. + sendOutgoingChunk: (_destination: unknown, _agent: string, _id: string, _index: number, delta: string) => { + outgoing.push(routed.includes(delta) ? delta : `unpublished: ${delta}`); + return true; + }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); (framework as unknown as Record).channelEventModule = { getChannelId: () => 'world:test' }; @@ -240,7 +244,7 @@ describe('tool wrapper prose guard integration', () => { const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); assert.deepEqual(routed, ['Ordinary answer.']); - assert.deepEqual(outgoing, [], 'guarded turns buffer ordinary prose until completion'); + assert.deepEqual(outgoing, ['Ordinary answer.'], 'guarded turns stream ordinary prose only once it is published'); assert.ok(all.some((m) => m.content.some((b) => b.type === 'text' && b.text === 'Ordinary answer.'))); assert.ok(!all.some((m) => m.metadata?.kind === 'tool-wrapper-prose-contained')); }); @@ -452,7 +456,7 @@ describe('tool wrapper prose guard integration', () => { }); - it('idle-disposed ephemeral stops typing and finalizes every started outgoing stream', async () => { + it('idle-disposed ephemeral stops typing, and the stalled stream\'s unpublished words never stream', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-wrapper-guard-idle-terminal-')); dirs.push(dir); const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: new LateCompletionAfterIdleMembrane().asMembrane(), agents: [], modules: [] }); const events: string[] = []; @@ -473,8 +477,9 @@ describe('tool wrapper prose guard integration', () => { await assert.rejects(run, /stalled/); await new Promise((resolve) => setTimeout(resolve, 180)); assert.ok(events.includes('stop:(all)'), 'disposer-owned frame stops typing'); - assert.ok(events.some((event) => event.startsWith('chunk:')), 'probe actually opened an outgoing stream'); - assert.ok(events.some((event) => event.startsWith('complete:')), 'every started outgoing stream receives a terminal complete'); + // Outgoing streams carry only published prose: the provider's partial + // tokens, and the late answer of a run already disposed, never were. + assert.ok(!events.some((event) => event.startsWith('chunk:') || event.startsWith('complete:')), JSON.stringify(events)); await framework.stop(); }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 6f3becb79..38526c56f 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -50,7 +50,9 @@ function declaringRegistry(target: 'exact' | 'root' | undefined) { resolveLocus: () => null, routeSpeech: async (_a: string, text: string, to: unknown) => { published.push({ text, to }); - return { delivered: true, channelId: 'x' }; + // As the real registry reports a delivery: where it went, its thread included. + const dest = (to ?? {}) as { serverId?: string; channelId?: string; threadId?: string | null }; + return { delivered: true, serverId: dest.serverId ?? 'x', channelId: dest.channelId ?? 'x', ...(dest.threadId ? { threadId: dest.threadId } : {}) }; }, getDescriptor: () => undefined, getChannelTools: () => [], @@ -417,15 +419,15 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it('a thread route is not streamed; a root route streams with threadId null (RFC-011 §6)', async () => { + it('a thread placement is not streamed; a root publication streams after it is confirmed, at the root (RFC-011 §6)', async () => { const framework = await makeFramework(); const { registry, published } = declaringRegistry('exact'); - const streamed: Array<{ kind: string; channelId: string; threadId: unknown }> = []; + const streamed: Array<{ kind: string; serverId: string; channelId: string; text: string; published: number }> = []; Object.assign(registry as Record, { - sendOutgoingChunk: (channelId: string, _a: string, _i: string, _n: number, _d: string, threadId: unknown) => - streamed.push({ kind: 'chunk', channelId, threadId }), - sendOutgoingComplete: (channelId: string, _a: string, _i: string, _t: string, threadId: unknown) => - streamed.push({ kind: 'complete', channelId, threadId }), + sendOutgoingChunk: (d: { serverId: string; channelId: string }, _a: string, _i: string, _n: number, delta: string) => + streamed.push({ kind: 'chunk', serverId: d.serverId, channelId: d.channelId, text: delta, published: published.length }), + sendOutgoingComplete: (d: { serverId: string; channelId: string }, _a: string, _i: string, text: string) => + streamed.push({ kind: 'complete', serverId: d.serverId, channelId: d.channelId, text, published: published.length }), }); internals(framework).channelRegistry = registry; @@ -442,9 +444,10 @@ describe('Trunk channel routing (item-3 redux)', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'at the root' }])); framework.pushEvent({ ...(channelIncoming('zulip:stream:7', 'at root') as unknown as Record), serverId: 'zulip' } as unknown as ProcessEvent); await framework.runUntilIdle(); - assert.ok(streamed.some((e) => e.kind === 'chunk'), 'the root route streams'); - assert.ok(streamed.every((e) => e.channelId === 'zulip:stream:7' && e.threadId === null), JSON.stringify(streamed)); - assert.equal(streamed.filter((e) => e.kind === 'complete').length, 1); + assert.deepEqual(streamed, [ + { kind: 'chunk', serverId: 'zulip', channelId: 'zulip:stream:7', text: 'at the root', published: 2 }, + { kind: 'complete', serverId: 'zulip', channelId: 'zulip:stream:7', text: 'at the root', published: 2 }, + ], 'after its confirmed publish, on the server it resolved to'); await framework.stop(); }); From 228705138e676371aa24acd247d2509975fec195 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 23:00:32 -0400 Subject: [PATCH 29/65] docs(push-handler): handlePushEvent's step list matches the handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nell-1783's nit on the #256 integration merge (room-203 #52368): the docstring still listed main's seven steps, with the gate as step 4, while the handler's inline steps (after ed400ea and ff29945) read 1–8 — inferenceId 4, event and envelope 5, gate 6, trace 7, respond 8. It was out of step on both sides before the merge. Comment only. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/push-handler.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index b2edd0e1b..9acfeb487 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -205,10 +205,14 @@ export class PushHandler { * 1. Validate feature set * 2. Convert content blocks; reject visibly-empty content * 3. Deduplicate by eventId - * 4. Optionally check shouldTriggerInference callback - * 5. Push event to queue - * 6. Emit trace - * 7. Respond with accepted + inferenceId + * 4. Generate inferenceId + * 5. Build the event and its host source envelope (accepted now for an + * ordinary push; only built for a coalesced one, whose coalescer + * freezes it) + * 6. Optionally check shouldTriggerInference, with that envelope; then + * hand a coalesced push to its coalescer, or queue an ordinary one + * 7. Emit trace + * 8. Respond with accepted + inferenceId */ async handlePushEvent( serverId: string, From 4180ccd856be4394751e406ae3f40238c9ae89f8 Mon Sep 17 00:00:00 2001 From: Nell-1783 Date: Wed, 7 Oct 2026 23:21:05 -0400 Subject: [PATCH 30/65] fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stop() closes the process queue first and the MCPL connections only after awaiting stream settlement, maintenance and the loop. Anything arriving in between reached ProcessQueueImpl.push, which throws "Queue is closed", from async callbacks with nothing to catch it. #256's aborted-stream test exposed it (Petra-1012's job-979; Basil-1786's and Basil-1007's runs, one in an anima-dnd composition at startup). Mira-1782 and I each traced it to dispatchToolCallEvent: its robot--move outlived the framework, the successful result's pushEvent threw, the .catch misreported that as the module's failure (markDispatchFailure, tool:failed), and its own pushEvent threw again, unhandled. dispatchToolCallEvent, stop()'s ordering and pushEvent are the same on main (02adf27): the race predates #256. Two outcomes, one boundary (agreed in room-203 with Mira-1782, Petra-1012 and Basil-1007): - pushEvent drops a `tool-result` once the queue is closed, with a log line: the stream that asked is gone and the tool has already run, so there is no one to refuse it to. Every other event type still throws, so fresh input to a stopped framework is refused visibly to its caller (an API request, a module) instead of silently accepted. tune-out's two tool-result pushes go through pushEvent like every other tool's (one sat in a .then with no catch). - The MCPL `push-event` and `channels-incoming` listeners check the stopped state after the awareness barrier and before the handler, so a fresh post-stop item is refused whole (-32603, "the host is stopping") and never recorded as accepted on its way to a queue error. They also catch whatever the handler throws (the host stopping while it runs, or a fault), answered -32603 too ("the host stopped while handling this request", or the handler's message), logged as a failed request that may have admitted part of itself first (Mira-1782: an earlier message of a batch), never as a per-message rejection. A notification is logged. The two are told apart by an explicit outcome ({kind: 'refused'} or {kind: 'failed', error}), never by whether an error was passed: a handler that throws `undefined` or rejects with nothing is a failed request on a live host ("the handler failed"), not a stopping refusal (Mira-1782). Thrown out of the listener, it was an unhandled rejection, and the server's request waited for its timeout (Petra-1012's reading). - Queue checks use optional chaining for prototype-built harnesses. Tests: stop-with-work-in-flight (new) — a tool that succeeds after stop() (dropped and logged, no tool:failed), one whose module throws (its error result dropped), a dispatch that rejects (reported failed, its failure result dropped), fresh input still throwing, a push and an incoming message after stop() answered -32603 and never handed to the handler, a push held at the awareness barrier refused once it lifts, a throwing handler answered with its error, and a handler rejecting with nothing answered "the handler failed" and logged as failed, never refused. The first seven fail against 2287051's code; the last fails against this commit's first version (it fabricated "the host is stopping"). The aborted-stream test now waits for its own robot--move after stop(), so it owns the work it started; it failed 3/3 against 2287051's code with an unhandled "Queue is closed" and passes 5/5 with the fix, run alone with no --test-force-exit and a preload that prints any unhandled rejection's stack. Review follow-up in room-203; Mira-1782 reviews. --- changelog.d/stop-with-work-in-flight.fixed.md | 19 ++ src/framework.ts | 86 +++++- test/present-while-acting.test.ts | 11 + test/stop-with-work-in-flight.test.ts | 282 ++++++++++++++++++ 4 files changed, 389 insertions(+), 9 deletions(-) create mode 100644 changelog.d/stop-with-work-in-flight.fixed.md create mode 100644 test/stop-with-work-in-flight.test.ts diff --git a/changelog.d/stop-with-work-in-flight.fixed.md b/changelog.d/stop-with-work-in-flight.fixed.md new file mode 100644 index 000000000..9029b04f0 --- /dev/null +++ b/changelog.d/stop-with-work-in-flight.fixed.md @@ -0,0 +1,19 @@ +- **Late tool results, and MCPL pushes or incoming messages, at `stop()` no + longer end in an unhandled rejection.** `stop()` closes the process queue + before it closes MCPL connections, and nothing caught the "Queue is + closed" that this meant for late arrivals. The race is older than the + speech-route work. + - A tool's result that arrives after `stop()` is logged and dropped. The + stream that asked for it is gone, and the tool has already run. Before, + its push threw, the throw was reported as the tool's own failure, and + that failure's push threw again, unhandled. tune-out's results now go + through the same `pushEvent` as every other tool's. + - A server's `push/event` or `channels/incoming` that arrives once the + host is stopping is refused before its handler runs, with a JSON-RPC + error (`-32603`, "the host is stopping"). It is never answered + "accepted". + - A handler that throws, because the host stopped while it ran or because + of a fault, is answered with an error too. Part of the request may + already have been admitted. The error is never thrown out of the + connection's listener. A notification is logged. + - Direct input to `pushEvent` after `stop()` still throws to its caller. diff --git a/src/framework.ts b/src/framework.ts index 9277d2dd1..34bb8fb3e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2496,10 +2496,58 @@ export class AgentFramework { : undefined; } + /** + * A server's push or incoming message the host couldn't handle, answered + * with a JSON-RPC error when it is a request (a notification has no one to + * answer, and is logged), rather than thrown out of the connection's + * listener, where it was an unhandled rejection and the server's request + * waited for its timeout. + * - `refused`: the listener turned it away before its handler ran; the + * host is stopping, and none of it was taken. + * - `failed`: the handler threw (the host stopped while it ran, or a + * fault; whatever it threw, `undefined` included). The request failed, + * but the handler may have admitted part of it first (an earlier message + * of a batch), so nothing here claims that none of it was taken. + */ + private refuseServerItem( + method: string, + serverId: string, + responder: { respondError?: (code: number, message: string) => void } | undefined, + outcome: { kind: 'refused' } | { kind: 'failed'; error: unknown }, + ): void { + // Optional chaining: prototype-built harnesses leave the queue unset. + const reason = outcome.kind === 'refused' + ? 'the host is stopping' + : this.queue?.isClosed + ? 'the host stopped while handling this request' + : outcome.error instanceof Error + ? outcome.error.message + : `the handler failed${outcome.error === undefined ? '' : `: ${String(outcome.error)}`}`; + console.error(outcome.kind === 'refused' + ? `[mcpl] ${method} from ${serverId} refused: ${reason}` + : `[mcpl] ${method} from ${serverId} failed: ${reason} (part of it may already have been admitted)`); + try { + responder?.respondError?.(-32603, reason); + } catch { + // The connection is going too; there is no one left to tell. + } + } + /** * Push a process event to the queue. + * + * After stop() the queue is closed and this throws, so a direct caller + * learns the framework is stopped — except for a tool's result. A call + * still running at stop() completes later, into a callback with nothing + * to catch the throw (an unhandled rejection), and nothing could take its + * result anyway: the stream that asked is gone, and the tool has already + * run. That result is dropped, and the drop is logged. */ pushEvent(event: ProcessEvent): void { + if (event.type === 'tool-result' && this.queue?.isClosed) { + console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.moduleName}) arrived after stop — dropped`); + return; + } this.queue.push(event); this.emitTrace({ type: 'process:received', processEvent: event }); } @@ -14076,7 +14124,9 @@ export class AgentFramework { void this.tuneOutCoordinator .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record) .then((result) => { - this.queue.push({ + // Through pushEvent, like every other tool's result, so one that + // arrives after stop() is dropped rather than rejected unhandled. + this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, agentName, @@ -16069,9 +16119,19 @@ export class AgentFramework { params: PushEventParams, responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void }, ) => { - const barrier = this.discordAwarenessBarrier; - if (barrier) await barrier.promise; - await this.pushHandler?.handlePushEvent(connection.id, params, responder as never); + try { + const barrier = this.discordAwarenessBarrier; + if (barrier) await barrier.promise; + // Checked here, after the barrier and before anything records the + // push as accepted: a stopped host refuses it. + if (this.queue?.isClosed) { + this.refuseServerItem('push/event', connection.id, responder, { kind: 'refused' }); + return; + } + await this.pushHandler?.handlePushEvent(connection.id, params, responder as never); + } catch (error) { + this.refuseServerItem('push/event', connection.id, responder, { kind: 'failed', error }); + } }); // Handle server-initiated inference requests (Step 6) @@ -16170,11 +16230,19 @@ export class AgentFramework { // Handle incoming channel messages (Step 7) connection.on('channels-incoming', async ( params: ChannelsIncomingParams, - responder?: { respond: (result: unknown) => void }, + responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void }, ) => { - const barrier = this.discordAwarenessBarrier; - if (barrier) await barrier.promise; - await this.channelRegistry?.handleIncoming(connection.id, params, responder as never); + try { + const barrier = this.discordAwarenessBarrier; + if (barrier) await barrier.promise; + if (this.queue?.isClosed) { + this.refuseServerItem('channels/incoming', connection.id, responder, { kind: 'refused' }); + return; + } + await this.channelRegistry?.handleIncoming(connection.id, params, responder as never); + } catch (error) { + this.refuseServerItem('channels/incoming', connection.id, responder, { kind: 'failed', error }); + } }); // Handle host-level admin commands from a surface (e.g. Discord /undo) @@ -17156,7 +17224,7 @@ export class AgentFramework { } : { success: false, error: r.error, isError: false }; } - this.queue.push({ + this.pushEvent({ type: 'tool-result', callId: call.id, agentName, diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 70e0ea8a4..1c9b0b76f 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1829,6 +1829,15 @@ describe('speech follows the moment its words were written', () => { const framework = await createFramework(); stubChannelRegistry(framework, { home: false }); const streams = recordStreams(framework); + // The abort doesn't stop robot--move: the test owns that call, and waits + // for it after stop() (its late result meets the stopped framework). + const handle = module.handleToolCall.bind(module); + let toolSettled: Promise = Promise.resolve(); + module.handleToolCall = (call) => { + const running = handle(call); + toolSettled = running.catch(() => undefined); + return running; + }; triggerFromChannel(framework, 'chan-A'); const idle = framework.runUntilIdle(); @@ -1843,6 +1852,8 @@ describe('speech follows the moment its words were written', () => { assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Narrating.', threadId: null }]); await framework.stop(); + await toolSettled; + await new Promise((r) => setImmediate(r)); }); it('completion never replays a chunk the registry refused to stream', async () => { diff --git a/test/stop-with-work-in-flight.test.ts b/test/stop-with-work-in-flight.test.ts new file mode 100644 index 000000000..b346cada2 --- /dev/null +++ b/test/stop-with-work-in-flight.test.ts @@ -0,0 +1,282 @@ +/** + * stop() with work still in flight. + * + * stop() closes the process queue first and closes MCPL connections later, + * so for a while work started before stop() keeps completing, and servers + * keep sending. Two outcomes, one boundary (found through #256's aborted- + * stream test, where a robot--move outlived the framework; the race itself + * is older than #256): + * - a tool's result that arrives after stop() is logged and dropped: the + * stream that asked is gone, and the tool has already run. Before, its + * push threw "Queue is closed", the throw was misreported as the tool's + * failure, and that failure's push threw again, unhandled; + * - a server's push or incoming message the host can't take any more is + * answered with a JSON-RPC error, never "accepted", and never thrown + * out of the connection's listener. + * Fresh input to a stopped framework still throws to its caller. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ContentBlock } from '@animalabs/membrane'; +import type { + Module, + ModuleContext, + ProcessEvent, + ProcessState, + EventResponse, + ToolCall, + ToolDefinition, + ToolResult, +} from '../src/index.js'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +/** A tool that settles only when the test says so, so the test owns the work it started. */ +class SlowModule implements Module { + readonly name = 'slow'; + private settle: { resolve: (r: ToolResult) => void; reject: (e: Error) => void } | null = null; + readonly started: Promise; + private markStarted!: () => void; + + constructor() { + this.started = new Promise((r) => { this.markStarted = r; }); + } + + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + + getTools(): ToolDefinition[] { + return [{ name: 'work', description: 'Slow work', inputSchema: { type: 'object', properties: {} } }]; + } + + handleToolCall(_call: ToolCall): Promise { + return new Promise((resolve, reject) => { + this.settle = { resolve, reject }; + this.markStarted(); + }); + } + + /** Settle the running call; resolves once its completion callbacks have run. */ + async finish(outcome: 'success' | 'failure'): Promise { + if (outcome === 'success') this.settle!.resolve({ success: true, data: { done: true } }); + else this.settle!.reject(new Error('the work failed')); + await new Promise((r) => setTimeout(r, 20)); + } + + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (event.type === 'external-message') { + return { + addMessages: [{ participant: 'User', content: [{ type: 'text', text: String((event as { content?: unknown }).content) }] }], + requestInference: true, + }; + } + return {}; + } +} + +describe('stop() with work still in flight', () => { + let tempDir: string; + let rejections: unknown[]; + let logged: string[]; + const onRejection = (reason: unknown): void => { rejections.push(reason); }; + const consoleError = console.error; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'af-stop-in-flight-')); + rejections = []; + logged = []; + process.on('unhandledRejection', onRejection); + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + }); + + afterEach(() => { + console.error = consoleError; + process.off('unhandledRejection', onRejection); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const dropped = (): string[] => logged.filter((line) => line.includes('arrived after stop')); + + async function frameworkWithRunningTool( + opts: { dispatch?: 'rejects' } = {}, + ): Promise<{ framework: AgentFramework; module: SlowModule; failed: string[] }> { + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Working on it.' }, + { type: 'tool_use', id: 'c1', name: 'slow--work', input: {} }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Never written.' }] as ContentBlock[])); + const module = new SlowModule(); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [module], + }); + const failed: string[] = []; + framework.onTrace((e) => { + if (e.type === 'tool:failed') failed.push(String((e as { tool?: unknown }).tool)); + }); + if (opts.dispatch === 'rejects') { + // The module registry turns a module's throw into an error result; this + // is the dispatch itself rejecting (the host got no result at all). + const registry = (framework as unknown as { moduleRegistry: { handleToolCall(call: ToolCall): Promise } }).moduleRegistry; + const handle = registry.handleToolCall.bind(registry); + registry.handleToolCall = (call) => handle(call).then(() => { throw new Error('the dispatch was lost'); }); + } + framework.start(); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); + await module.started; + return { framework, module, failed }; + } + + it('a tool that succeeds after stop() has its result dropped, not thrown or misreported as a failure', async () => { + const { framework, module, failed } = await frameworkWithRunningTool(); + await framework.stop(); + await module.finish('success'); + + assert.deepEqual(rejections, []); + assert.deepEqual(failed, [], 'the tool succeeded; nothing reports it as a failure'); + assert.equal(dropped().length, 1, logged.join('\n')); + }); + + it('a tool that fails after stop() has its error result dropped, not thrown', async () => { + const { framework, module, failed } = await frameworkWithRunningTool(); + await framework.stop(); + await module.finish('failure'); + + assert.deepEqual(rejections, []); + assert.deepEqual(failed, [], 'the registry answers a module throw with an error result'); + assert.equal(dropped().length, 1, logged.join('\n')); + }); + + it('a dispatch that rejects after stop() is reported as failed, and its failure result dropped, not thrown', async () => { + const { framework, module, failed } = await frameworkWithRunningTool({ dispatch: 'rejects' }); + await framework.stop(); + await module.finish('success'); + + assert.deepEqual(rejections, []); + assert.equal(failed.length, 1, 'the host got no result: a dispatch failure'); + assert.equal(dropped().length, 1, logged.join('\n')); + }); + + it('fresh input to a stopped framework still throws to its caller', async () => { + const { framework, module } = await frameworkWithRunningTool(); + await framework.stop(); + assert.throws( + () => framework.pushEvent({ type: 'external-message', source: 'test', content: 'too late', metadata: {} } as unknown as ProcessEvent), + /Queue is closed/, + ); + await module.finish('success'); + assert.deepEqual(rejections, []); + }); + + describe('a server sending while the host stops', () => { + async function wiredFramework() { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const connection = Object.assign(new EventEmitter(), { id: 'srv' }); + (framework as unknown as { wireMcplEvents(c: unknown): void }).wireMcplEvents(connection); + return { framework, connection }; + } + + function responder() { + const answers: Array<{ result?: unknown; error?: { code: number; message: string } }> = []; + return { + answers, + respond: (result: unknown) => { answers.push({ result }); }, + respondError: (code: number, message: string) => { answers.push({ error: { code, message } }); }, + }; + } + + const settle = (): Promise => new Promise((r) => setTimeout(r, 10)); + + it('a push or incoming message after stop() is answered with an error, never accepted', async () => { + const { framework, connection } = await wiredFramework(); + let handled = 0; + (framework as unknown as { pushHandler: unknown }).pushHandler = { handlePushEvent: async () => { handled++; } }; + await framework.stop(); + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e1', payload: { content: [{ type: 'text', text: 'late' }] } }, push); + const incoming = responder(); + connection.emit('channels-incoming', { messages: [{ channelId: 'c', messageId: 'm', content: [] }] }, incoming); + await settle(); + + assert.equal(handled, 0, 'never handed to the push handler, which would record it as accepted'); + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); + assert.deepEqual(incoming.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); + assert.deepEqual(rejections, []); + }); + + it('a push held at the awareness barrier while the host stops is refused once the barrier lifts', async () => { + const { framework, connection } = await wiredFramework(); + let lift!: () => void; + (framework as unknown as { discordAwarenessBarrier: unknown }).discordAwarenessBarrier = { + promise: new Promise((r) => { lift = r; }), + }; + let handled = 0; + (framework as unknown as { pushHandler: unknown }).pushHandler = { handlePushEvent: async () => { handled++; } }; + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e2', payload: { content: [{ type: 'text', text: 'waiting' }] } }, push); + await framework.stop(); + lift(); + await settle(); + + assert.equal(handled, 0); + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); + assert.deepEqual(rejections, []); + }); + + it('a handler that throws is answered with its error, not rejected into the listener; a notification is only logged', async () => { + const { framework, connection } = await wiredFramework(); + (framework as unknown as { pushHandler: unknown }).pushHandler = { + handlePushEvent: async () => { throw new Error('the handler broke'); }, + }; + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e3', payload: { content: [{ type: 'text', text: 'x' }] } }, push); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e4', payload: { content: [{ type: 'text', text: 'y' }] } }); + await settle(); + + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the handler broke' } }]); + assert.deepEqual(rejections, []); + await framework.stop(); + }); + + it('a handler that rejects with nothing is a failed request on a live host, not a stopping refusal', async () => { + const { framework, connection } = await wiredFramework(); + (framework as unknown as { pushHandler: unknown }).pushHandler = { + // eslint-disable-next-line prefer-promise-reject-errors + handlePushEvent: () => Promise.reject(), + }; + const fw = framework as unknown as { channelRegistry: unknown }; + const registry = fw.channelRegistry; + fw.channelRegistry = { handleIncoming: async () => { throw undefined; } }; + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e5', payload: { content: [{ type: 'text', text: 'x' }] } }, push); + const incoming = responder(); + connection.emit('channels-incoming', { messages: [{ channelId: 'c', messageId: 'm', content: [] }] }, incoming); + await settle(); + + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the handler failed' } }]); + assert.deepEqual(incoming.answers, [{ error: { code: -32603, message: 'the handler failed' } }]); + assert.ok(logged.some((line) => line.includes('push/event from srv failed: the handler failed')), logged.join('\n')); + assert.ok(!logged.some((line) => line.includes('refused')), 'a live host refuses nothing'); + assert.deepEqual(rejections, []); + fw.channelRegistry = registry; + await framework.stop(); + }); + }); +}); From 315d12c2ed8ebf31cc6fac9b1ba4a17ba26ec57a Mon Sep 17 00:00:00 2001 From: Rupert-1859 Date: Thu, 8 Oct 2026 14:28:00 -0400 Subject: [PATCH 31/65] fix(drafts): refuse a dismissal while its draft's attempt is out; the receipt keeps a dismissed draft's own uncertain attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slimepriestess's approving review of #247 at 1d6d2bb noted that a `dismiss` issued while that draft's resend is in flight is accepted (the dismiss path doesn't consult draftsInFlight, while resend and {{unsent}} do), and that if the send then comes back unknown, draftState reports `dismissed`, so the receipt's one-word state hides a possibly-posted draft. Two routes record a dismissal: the drafts tool's `dismiss`, and dismissLatestBounce (`>>skip_reply {{unsent}}`, its hybrid form, and the held hybrid path beside a send). A round's calls are dispatched without awaiting each other (`drafts` is fire-and-forget), and a round's prose is routed alongside its tools, so either route can land while a resend's publish, or an {{unsent}} delivery, is out. Neither remedy as worded fits: - consulting draftsInFlight would also refuse a draft only queued in a resend batch, whose dismissal the batch deliberately honours when it reaches it (test: "a draft dismissed while the batch waits is not published"); - ranking an uncertain attempt above `dismissed` inside draftState would make a draft with an unknown attempt impossible to dismiss: it would stay `unconfirmed` and listed, its only way out a confirmDuplicate resend. So: - draftsSending: the drafts in draftsInFlight whose attempt is out right now. It is set around the awaited publish in the resend loop and in deliverWithUnsent, cleared as each publish resolves, and cleared again in the batch's finally as a backstop. The tool's dismiss is refused up front, dismissing nothing, when any draft it names is sending, and the resident is told to wait for that result; dismissLatestBounce leaves the draft and tells the resident privately (takeUnsent's busy-notice pattern). No dismissal is then recorded while that draft's attempt is out, so the resend's own reply ("The draft stays held.", "that needs confirmDuplicate: true") can't contradict a mid-send dismissal, and no "dismissed" reply is given over words that go out. A queued draft can still be dismissed. - The turn-end receipt names a dismissed draft whose own attempt may have been posted (uncertainAttempt; not inherited risk: a dismissed copy was never sent itself, and its source's risk is reported where that attempt is) with its riskText, and counts it toward "nothing confirmed". This also covers the ordinary, knowing case: an unknown resend followed by a deliberate dismiss used to leave "[delivered] nothing — draft d dismissed by you". - The changelog fragment says both. Tests (prose-drafts.test.ts): - the race through real round dispatch, `[resend d, dismiss d]` in one round, delivered and no-receipt; - a dismissal naming a sending and a queued draft dismisses nothing; - the prose route's busy notice; - a tool dismissal while an {{unsent}} delivery of that draft is out (the ownership harness's registry stub now holds deliverSpeech like publish); - the knowing case's receipt, as a third turn of the receipt test. All five fail on 1d6d2bb's source, and the fourth also fails when only deliverWithUnsent's sending mark is removed. The new assertions don't pin destinationText's rendering, which #256 changes. Design worked out with Humphrey-1858, who reviewed it. His probes showed the resend replies contradicting a mid-send dismissal (failed: "The draft stays held."; unknown: "that needs confirmDuplicate: true" for a draft a resend then refuses; delivered: a "dismissed" reply over words that went out), which is why the dismissal is refused rather than only the receipt changing, and his probe U is the {{unsent}} test. --- changelog.d/prose-drafts.added.md | 14 +++- src/framework.ts | 60 ++++++++++++-- test/prose-drafts.test.ts | 130 ++++++++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 16 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 7e0a0a73c..85c6607ad 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -29,8 +29,12 @@ `confirmDuplicate` its resend needs), and the turn-end `[delivered]` receipt names every draft held in the turn by its state at turn end: still held ("held as drafts d-… (not sent …)"), delivered by the resident's resend - (listed where it landed), unconfirmed, or dismissed. A draft no notice - reached (a crash, an aborted turn) is named at the next turn's start. + (listed where it landed), unconfirmed, or dismissed. A dismissed draft + whose own attempt may already have been posted is named with that risk, + and counts like an unconfirmed one: a receipt that names one, with nothing + confirmed delivered, reads "nothing confirmed", not "nothing". A draft no + notice reached (a crash, an aborted turn) is named at the next turn's + start. - A draft is never published except by its resident's explicit resend (or `{{unsent}}`), and only that resident can list or act on it. Its notices go into the resident's own history, which residents sharing one message slot @@ -43,7 +47,11 @@ instead of sending again; a draft with any attempt whose outcome is unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend owns its drafts until it finishes, re-checking each before it is sent. - Unused fields may be passed as null. + Dismissing a draft (with the tool or `>>skip_reply {{unsent}}`) is + refused while that draft's attempt is out, since a dismissal can't call + back words already on their way; the resident is told to wait for that + result. A draft only queued in a resend can still be dismissed, and is + then not sent. Unused fields may be passed as null. - Prose segments also break at XML tool mode's refused attempts and their notices (`tool_attempt`, `tool_notice`): the words before an all-refused call and after its notice are two messages, routed or held separately. diff --git a/src/framework.ts b/src/framework.ts index bd582fa12..8534ce4c6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9205,6 +9205,12 @@ export class AgentFramework { /** Drafts with a delivery attempt in flight, `${agent}\u0000${id}`: a * parallel resend (or `{{unsent}}`) of the same draft is refused. */ private draftsInFlight = new Set(); + /** The drafts in draftsInFlight whose attempt is out right now (journaled, + * its publish awaited). A dismissal of one is refused, and the resident + * told to wait for that result, since a dismissal can't call back words + * already on their way. A draft only queued in a resend batch can still + * be dismissed: the batch honours that when it reaches the draft. */ + private draftsSending = new Set(); /** * The resident's `drafts` tool: list, read, resend and dismiss its own held @@ -9301,6 +9307,14 @@ export class AgentFramework { } if (action === 'dismiss') { + // Refuse up front, before anything is dismissed, rather than half-way. + const sending = drafts.find((d) => this.draftsSending.has(`${agentName}\u0000${d.id}`)); + if (sending) { + return refuse( + `${sending.id} is being sent right now, and dismissing it can't call those words back; ` + + 'wait for that result before dismissing it. Nothing was dismissed.', + ); + } const lines: string[] = []; for (const d of drafts) { const state = draftState(d); @@ -9409,7 +9423,10 @@ export class AgentFramework { stopped = true; continue; } + const sendingKey = `${agentName}\u0000${d.id}`; + this.draftsSending.add(sendingKey); const outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId }); + this.draftsSending.delete(sendingKey); let recorded = true; try { this.proseDrafts.recordOutcome(agentName, d.id, attemptId, outcome); @@ -9450,7 +9467,10 @@ export class AgentFramework { : ' The draft stays held.')); } } finally { - for (const claim of claims) this.draftsInFlight.delete(claim); + for (const claim of claims) { + this.draftsInFlight.delete(claim); + this.draftsSending.delete(claim); + } } const allDelivered = !stopped; return allDelivered ? ok(lines.join('\n')) : refuse(lines.join('\n')); @@ -9534,8 +9554,15 @@ export class AgentFramework { for (const d of unconfirmed) { notes.push(`draft ${d.id} is unconfirmed: ${this.riskText(d)} — check that channel before sending it again (resend needs confirmDuplicate: true)`); } + // A dismissal sets a draft aside; it doesn't unsay that the draft's own + // attempt may already have been posted. const dismissed = inState('dismissed'); - if (dismissed.length > 0) notes.push(`draft${dismissed.length === 1 ? '' : 's'} ${idList(dismissed)} dismissed by you`); + const dismissedAtRisk = dismissed.filter((d) => uncertainAttempt(d)); + const dismissedQuietly = dismissed.filter((d) => !uncertainAttempt(d)); + for (const d of dismissedAtRisk) notes.push(`draft ${d.id} dismissed by you, but ${this.riskText(d)}`); + if (dismissedQuietly.length > 0) { + notes.push(`draft${dismissedQuietly.length === 1 ? '' : 's'} ${idList(dismissedQuietly)} dismissed by you`); + } for (const d of inState('delivered')) { const attempt = d.attempts.find((a) => a.outcome?.status === 'delivered')!; const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; @@ -9560,11 +9587,12 @@ export class AgentFramework { : `${suppressed} plain-speech segment(s) suppressed`); } const suppressedNote = notes.join(' · '); - // Nothing is confirmed delivered, but an unconfirmed draft may have been. + // Nothing is confirmed delivered, but an unconfirmed draft (or a dismissed + // one whose own attempt is uncertain) may have been. const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` - : `[delivered] nothing${unconfirmed.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; + : `[delivered] nothing${unconfirmed.length > 0 || dismissedAtRisk.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( 'user', @@ -9766,12 +9794,18 @@ export class AgentFramework { } } const flight = unsent ? `${agent.name}\u0000${unsent.id}` : undefined; - if (flight) this.draftsInFlight.add(flight); + if (flight) { + this.draftsInFlight.add(flight); + this.draftsSending.add(flight); + } let outcome: PublishOutcome; try { outcome = await registry.deliverSpeech(agent.name, body, channelId); } finally { - if (flight) this.draftsInFlight.delete(flight); + if (flight) { + this.draftsInFlight.delete(flight); + this.draftsSending.delete(flight); + } } if (unsent && attemptId) { try { @@ -9879,10 +9913,22 @@ export class AgentFramework { } } - /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce draft aside. */ + /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce + * draft aside — unless it is being sent right now, which a dismissal + * can't call back: the resident is told instead. */ private dismissLatestBounce(agent: Agent): void { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return; + if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) { + const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.'; + try { + this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (err) { + console.error('[drafts] in-flight-dismiss notice failed:', err); + } + return; + } try { this.proseDrafts.dismiss(agent.name, draft.id); this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index bfea62ed2..09ec8f432 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -668,6 +668,46 @@ describe('held prose drafts, end to end', () => { } }); + it('a dismissal in the same round as its draft\'s resend is refused until that result is in: it can\'t call back words on their way', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('words that land'), explicitSend('s1'), text('words in doubt'), call('l1', 'channel_list', {})], 'tool_use'), + createMockResponse([]), + ]); + const sure = h.drafts().find((d) => d.text === 'words that land')!; + const unsure = h.drafts().find((d) => d.text === 'words in doubt')!; + // The resend is dispatched first, so its attempt is out when the dismissal runs. + const raced = (id: string) => [ + drafts(`r-${id}`, { action: 'resend', draftIds: [id], destination: ROOM }), + drafts(`x-${id}`, { action: 'dismiss', draftIds: [id] }), + ]; + const waits = (id: string) => new RegExp( + `${id} is being sent right now, and dismissing it can't call those words back; wait for that result before dismissing it\\. Nothing was dismissed\\.`); + + await h.turn([createMockResponse(raced(sure.id), 'tool_use'), createMockResponse([])]); + let [resent, dismissal] = h.toolResults(); + assert.match(resent!, /delivered to/); + assert.match(dismissal!, waits(sure.id), 'no "dismissed" reply over words that went out'); + assert.equal(draftState(h.store().get('scout', sure.id)!), 'delivered'); + + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse(raced(unsure.id), 'tool_use'), createMockResponse([])]); + [resent, dismissal] = h.toolResults(); + assert.match(dismissal!, waits(unsure.id)); + assert.match(resent!, /NOT confirmed .* Check the channel before sending it again \(that needs confirmDuplicate: true\)/, + 'the resend\'s advice holds: the draft is still open'); + assert.equal(draftState(h.store().get('scout', unsure.id)!), 'unconfirmed'); + + // With the result in, the resident can set it aside knowingly. + await h.turn([createMockResponse([drafts('x2', { action: 'dismiss', draftIds: [unsure.id] })], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, new RegExp(`${unsure.id}: dismissed`)); + assert.equal(draftState(h.store().get('scout', unsure.id)!), 'dismissed'); + } finally { + await h.close(); + } + }); + it('refuses unusable calls before sending anything', async () => { const h = await harness(); try { @@ -805,14 +845,17 @@ describe('drafts resend ownership', () => { modules: [], }); const pending: Array<{ text: string; resolve: (o: unknown) => void }> = []; + const held = (text: string, channelId: string) => + new Promise((resolve) => pending.push({ + text, + resolve: (o) => resolve({ destination: { serverId: 'discord', channelId }, at: Date.now(), ...(o as object) }), + })); const stub: Record = { resolveProseTarget: (spec: string) => ({ channelId: spec }), resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'discord', channelId } }), - publish: (_agent: string, text: string, target: { channelId: string }) => - new Promise((resolve) => pending.push({ - text, - resolve: (o) => resolve({ destination: { serverId: 'discord', channelId: target.channelId }, at: Date.now(), ...(o as object) }), - })), + publish: (_agent: string, text: string, target: { channelId: string }) => held(text, target.channelId), + // An envelope's delivery, `{{unsent}}` included. + deliverSpeech: (_agent: string, text: string, channelId: string) => held(text, channelId), getChannelTools: () => [], }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(stub, { @@ -821,6 +864,7 @@ describe('drafts resend ownership', () => { const internals = framework as unknown as { proseDrafts: ProseDraftStore; handleDraftsTool(agent: string, input: unknown): Promise<{ success: boolean; data?: Array<{ text: string }>; error?: string }>; + deliverProseEnvelope(agent: unknown, text: string, hold: { notice: 'later' }): Promise; }; const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => r.success ? r.data!.map((b) => b.text).join('') : r.error!; @@ -891,6 +935,68 @@ describe('drafts resend ownership', () => { await t.close(); } }); + + it('a dismissal naming a draft whose attempt is out dismisses nothing, not even the queued one beside it', async () => { + const t = await setup(); + try { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'A is out; B is queued'); + const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id, t.a.id] })); + assert.match(refused, new RegExp(`${t.a.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); + assert.equal(t.internals.proseDrafts.get('scout', t.b.id)!.dismissedAt, undefined); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + const result = t.say(await batch); + assert.match(result, new RegExp(`${t.a.id}: delivery to .+ NOT confirmed`)); + assert.equal(draftState(t.internals.proseDrafts.get('scout', t.a.id)!), 'unconfirmed'); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), new RegExp(`${t.a.id}: dismissed`), + 'with the result in, it can be dismissed'); + } finally { + await t.close(); + } + }); + + it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.tick(); + const agent = t.framework.getAgent('scout')!; + const envelope = (text: string) => t.internals.deliverProseEnvelope(agent, text, { notice: 'later' }); + await envelope('>>skip_reply {{unsent}}'); + assert.equal(t.internals.proseDrafts.get('scout', bounce!.id)!.dismissedAt, undefined); + const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.'), words.join('\n')); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + await envelope('>>skip_reply {{unsent}}'); + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed', 'with the result in, it is set aside'); + } finally { + await t.close(); + } + }); + + it('a dismissal is refused while an {{unsent}} delivery of that draft is out', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const agent = t.framework.getAgent('scout')!; + const delivering = t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}}', { notice: 'later' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['bounced words'], 'the bounce is out through {{unsent}}'); + const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })); + assert.match(refused, new RegExp(`${bounce!.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await delivering; + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'unconfirmed'); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })), new RegExp(`${bounce!.id}: dismissed`), + 'with the result in, it can be dismissed'); + } finally { + await t.close(); + } + }); }); describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end state', () => { @@ -1000,7 +1106,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end it('the turn-end receipt reports each draft as it stands: resent, dismissed, unconfirmed or still held', async () => { const h = await harness(); try { - const ids = ['d-rcpta', 'd-rcptb', 'd-rcptc', 'd-rcptd']; + const ids = ['d-rcpta', 'd-rcptb', 'd-rcptc', 'd-rcptd', 'd-rcpte']; fixIds(h, ids); await h.turn([ createMockResponse([text('resend me'), explicitSend('s1'), text('dismiss me'), call('l1', 'channel_list', {}), text('keep me'), call('l2', 'channel_list', {})], 'tool_use'), @@ -1023,6 +1129,18 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( `^\\[delivered\\] nothing confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); + + // Dismissed knowingly after an unknown resend: the dismissal sets it + // aside, and the receipt still says its attempt may have been posted. + await h.turn([ + createMockResponse([text('dismissed after doubt'), explicitSend('s3')], 'tool_use'), + createMockResponse([drafts('r3', { action: 'resend', draftIds: [ids[4]], destination: ROOM })], 'tool_use'), + createMockResponse([drafts('x2', { action: 'dismiss', draftIds: [ids[4]] })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( + `^\\[delivered\\] nothing confirmed — draft ${ids[4]} dismissed by you, but ${ids[4]}'s attempt to #room \\(Guild One\\) \\(.*${ROOM}\\) at .+ ` + + 'may already have been posted: .+$')); } finally { await h.close(); } From a2accc1d8de1686de26ef629eb518938edff5e0d Mon Sep 17 00:00:00 2001 From: Ogden-1862 Date: Thu, 8 Oct 2026 14:28:43 -0400 Subject: [PATCH 32/65] fix(mcpl): digest a body as the store keeps it, so an untouched stored copy matches its storedBodyDigest slimepriestess's review of #252 (pullrequestreview-5460863368) found an untouched image copy that permanently failed its storedBodyDigest: storage takes an image's media type from its bytes' signature and re-encodes its base64, so the digest of the blocks handed to storage didn't match the copy read back. Writing bodies through the store directly (context-manager 0.13.0 over chronicle 0.4.0, read back live and after reopening) showed the class is wider than images: - every inline media block (an image whose source isn't a URL; any document, audio or video) is kept as a blob and comes back as { type, source: { type: 'base64', data, mediaType } } alone: its base64 re-encoded from the decoded bytes (padding, whitespace, URL-safe alphabet, unused bits), an image's media type taken from its signature, and its other fields (sourceUrl, tokenEstimate, filename, duration) dropped; - the store writes strings as UTF-8: a lone surrogate comes back as U+FFFD, and an entry whose key has one is dropped. sourceBodyDigest now hashes each block as the store keeps it. storedBlock maps inline media to its stored form; its base64 is re-encoded only when it isn't already canonical, so a stored copy's data is never decoded in full, and the store's signature test is copied (context-manager doesn't export it). canonicalJson writes strings well-formed and drops entries whose keys aren't. The normalization is inside the shared digest, not only in how storedBodyDigest is taken, so every copy of a body hashes alike: as delivered, as handed to storage, as injected into a live turn (#253's injectedEvidence hashes those very blocks), and as read back. Had only the stored digest been taken over the stored form, the injected copy would have failed it instead of the read-back one. An undecorated body's two digests stay equal whatever its media, and a legacy copy's recovered digest matches the delivered one. Docs: canonicalJson says it expects JSON-shaped values (review item 3): any other object is written by its own enumerable entries, which the store's own write matches (a Date as {}, a typed array by its indices, as probed) but a deferred write recovered from its JSON file doesn't. sourceBodyDigest says to check copies read back with blobs resolved (item 2). tsconfig gains ES2024.String for isWellFormed/toWellFormed, which Node has from 20, the engines floor. Tests: the reviewer's reproduction through the real framework's push lane (a PNG labeled image/jpeg, base64 broken by a newline), and a store contract test that writes nineteen bodies through a real ContextManager and checks that each hashes as it was handed over, live and after reopening, and which ones the store hands back changed. Its rows of image bytes the store has no signature for (SVG, BMP, AVIF) are labeled image/png and expected back unchanged, so a context-manager that learns to sniff one fails the test rather than letting the copied sniff drift. All three new tests fail at 88a24e6 and pass here. Reviewed independently by Lottie-1863 (room-310 #57630), who found that the store blobs an image of any non-URL source, not only a base64 one, and asked for the unsniffed rows. --- changelog.d/ingestion-body-digests.added.md | 20 ++-- src/framework.ts | 4 +- src/mcpl/inbound-source.ts | 96 ++++++++++++++++-- test/inbound-source.test.ts | 102 ++++++++++++++++++++ tsconfig.json | 2 +- 5 files changed, 205 insertions(+), 19 deletions(-) diff --git a/changelog.d/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md index f97bc691a..6e181ed8c 100644 --- a/changelog.d/ingestion-body-digests.added.md +++ b/changelog.d/ingestion-body-digests.added.md @@ -3,15 +3,21 @@ - `metadata.sourceBodyDigest` is the version identity of the body as delivered, taken at ingestion, after MCPL conversion and before any host decoration or storage sharding. It is the SHA-256 (hex) of `[blocks]` - as canonical JSON (keys sorted at every level, `undefined` dropped), the - framing an undecorated, unsharded stored copy already hashes to. + as canonical JSON (keys sorted at every level, `undefined` dropped), with + the blocks as the store keeps them: inline media as the store hands it + back (base64 re-encoded from its bytes, an image's media type taken from + its bytes' signature, only `type` and `source` kept) and strings + well-formed (a lone surrogate as U+FFFD). So the body hashes alike as + delivered, as handed to storage and as read back, and an undecorated, + unsharded stored copy hashes to it. - `metadata.storedBodyDigest` uses the same function over exactly the blocks handed to storage, decorations included, taken where each path stores. Both are always the host's own: values an adapter supplies are replaced. - A copy that no longer hashes to its `storedBodyDigest` was changed after it - arrived (`editMessage` keeps metadata). `sourceBodyDigest(blocks)` is - exported from the package root as the one implementation consumers share; - it carries the framing, so check a copy with it rather than hashing your - own serialization. + A stored copy, read back with its blobs resolved, that no longer hashes to + its `storedBodyDigest` was changed after it arrived (`editMessage` keeps + metadata). `sourceBodyDigest(blocks)` is exported from the package root as + the one implementation consumers share; it carries the framing and the + store's normalization, so check a copy with it rather than hashing your own + serialization. diff --git a/src/framework.ts b/src/framework.ts index 81957b3bf..5e44cc63c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7837,7 +7837,9 @@ export class AgentFramework { * An inbound item's metadata with its stored copy's own digest * (`storedBodyDigest`): the delivered-body digest's function over exactly * the blocks handed to storage here — every decoration included, before - * storage shards them. Taken at each storage site, never earlier, because + * storage shards them. That function hashes blocks as the store keeps them + * (inline media re-encoded and relabeled from its bytes), so the copy read + * back hashes to this too. Taken at each storage site, never earlier, because * a path can still decorate the body after ingestion stamped it (the * closed-channel invitation; room-220 #48282). An edit through * editMessage keeps metadata but not this hash. diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index 4f51a1860..b7c39c107 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -156,9 +156,18 @@ export function conversationKey(source: InboundSource): string | undefined { } /** - * JSON with object keys sorted at every level and `undefined` values dropped, - * so a value hashes alike however its keys were ordered (content read back - * from the store has its keys in a different order than it was written). + * JSON with object keys sorted at every level, written as the store keeps a + * value: `undefined` values are dropped, a string is well-formed (the store + * writes UTF-8, so each lone surrogate comes back as U+FFFD), and an entry + * whose key isn't well-formed is dropped (the store doesn't keep it). So a + * value hashes alike however its keys were ordered (content read back from + * the store has them in a different order than it was written), and alike + * before and after the store. + * + * It expects JSON-shaped values, as the MCPL lanes deliver them (parsed from + * JSON). Any other object is written by its own enumerable entries, unlike + * JSON.stringify: a Date becomes `{}`, as the store's own write keeps it, but + * a deferred write recovered from its JSON file brings it back as a string. * * sourceBodyDigest's serializer, kept out of the package API: serialization * and framing both stay inside sourceBodyDigest, so consumers don't @@ -168,33 +177,100 @@ export function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (value && typeof value === 'object') { const entries = Object.entries(value as Record) - .filter(([, v]) => v !== undefined) + .filter(([k, v]) => v !== undefined && k.isWellFormed()) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`; } + if (typeof value === 'string') return JSON.stringify(value.toWellFormed()); return JSON.stringify(value) ?? 'null'; } +/** + * A block as the store hands it back. Context-manager keeps inline media as + * blobs (blob-manager.ts): an image whose source isn't a URL, and every + * document, audio or video block, comes back as `{ type, source: { type: + * 'base64', data, mediaType } }` and nothing else, its data re-encoded from + * the decoded bytes, and an image's media type taken from its bytes' + * signature where they have one. Any other block comes back as it was given. + */ +function storedBlock(block: unknown): unknown { + const media = block as { type?: unknown; source?: { type?: unknown; data?: unknown; mediaType?: unknown } } | null; + const blob = media?.type === 'image' + ? media.source?.type !== 'url' + : media?.type === 'document' || media?.type === 'audio' || media?.type === 'video'; + const source = media?.source; + if (!blob || typeof source?.data !== 'string') return block; + const data = canonicalBase64(source.data); + const mediaType = media!.type === 'image' + ? sniffRasterImageMediaType(Buffer.from(data.slice(0, 32), 'base64')) ?? source.mediaType + : source.mediaType; + return { type: media!.type, source: { type: 'base64', data, mediaType } }; +} + +/** + * Base64 as the store writes it back from the decoded bytes: padded, with no + * whitespace, the standard alphabet and zero unused bits. Data already in + * that form, as every stored copy's is, is returned without decoding it all. + */ +function canonicalBase64(data: string): string { + if (data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(data)) { + const last = data.slice(-4); + if (Buffer.from(last, 'base64').toString('base64') === last) return data; + } + return Buffer.from(data, 'base64').toString('base64'); +} + +/** + * The store's own test for raster image types with an unambiguous byte + * signature, copied from context-manager's blob-manager.ts (it isn't + * exported): the store relabels an image whose bytes match one of these. + */ +function sniffRasterImageMediaType(bytes: Uint8Array): string | null { + if (bytes.length >= 8 && + bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && + bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) { + return 'image/png'; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + if (bytes.length >= 6) { + const signature = Buffer.from(bytes.subarray(0, 6)).toString('ascii'); + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif'; + } + if (bytes.length >= 12 && + Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && + Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') { + return 'image/webp'; + } + return null; +} + /** * The version identity of a delivered body (agreed with the receipts lane, * room-220 #46752–#47210): SHA-256 hex of `canonicalJson([blocks])` over the * ContentBlock[] the framework stores for the body — after MCPL conversion, - * before any host decoration is added and before storage shards it. The - * one-element array keeps the framing an undecorated, unsharded stored copy - * has always hashed to, so a stamped copy and an older copy of the same body - * share a version. + * before any host decoration is added and before storage shards it — with + * each block as the store keeps it (storedBlock, canonicalJson). So a body + * hashes alike as delivered, as handed to storage, as injected into a live + * turn, and as read back from the store. The one-element array keeps the + * framing an undecorated, unsharded stored copy has always hashed to, so a + * stamped copy and an older copy of the same body share a version. * * Ingestion stamps it as `metadata.sourceBodyDigest`, and the same function * over exactly the blocks handed to storage (decorations included) as * `metadata.storedBodyDigest`, on both MCPL lanes. Both live outside the * frozen admission envelope: they describe the body actually delivered (a * materialization, a correction), not the admission. Neither proves later - * presence or completeness; a copy that no longer hashes to its + * presence or completeness; a stored copy that no longer hashes to its * storedBodyDigest was changed after delivery (editMessage keeps metadata). + * Check a copy read back with its blobs resolved (getAllMessages, getMessage, + * or getMessageWindow without `resolveBlobs: false`): a blob reference never + * matches. * * Exported from the package root, so a consumer checks a copy against either * field with this same function rather than a copy of it. */ export function sourceBodyDigest(blocks: readonly unknown[]): string { - return createHash('sha256').update(canonicalJson([blocks])).digest('hex'); + return createHash('sha256').update(canonicalJson([blocks.map(storedBlock)])).digest('hex'); } diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 8a8b797a6..65232a7ca 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -18,6 +18,7 @@ import { conversationKey, // The package's public digest: consumers import it from the root. sourceBodyDigest, + PassthroughStrategy, type InboundSource, type Module, type ModuleContext, @@ -28,6 +29,7 @@ import { type ToolCall, type ToolResult, } from '../src/index.js'; +import { ContextManager } from '@animalabs/context-manager'; import { MockMembrane } from './helpers/mock-membrane.js'; import { canonicalJson } from '../src/mcpl/inbound-source.js'; import { createHash } from 'node:crypto'; @@ -36,6 +38,9 @@ import { fixture, eventually, TS } from './helpers/coalescing-fixture.js'; const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); const ROOM = 'discord:g1:room'; const RAW_DM = '1548000000000000001'; +/** Bytes that open with PNG's signature, which is all the store reads of an + * image. 14 bytes, so their base64 is padded and has a '/' in it. */ +const PNG = Buffer.from('89504e470d0a1a0afbefbeffffff', 'hex'); async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): Promise { const deadline = Date.now() + timeoutMs; @@ -256,6 +261,29 @@ describe('inbound source envelope', () => { assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the invitation decorates the stored copy'); }); + it('an image the store relabels or re-encodes still matches both its digests once read back', async () => { + // slimepriestess's review of #252: the store takes an image's media type + // from its bytes and re-encodes its base64, so a digest of the blocks as + // handed to storage failed against the untouched copy read back. + const png = PNG.toString('base64'); + const pushes: Record = { + 'img-labeled': { type: 'image', data: png, mimeType: 'image/png' }, + 'img-mislabeled': { type: 'image', data: png, mimeType: 'image/jpeg' }, + 'img-wrapped': { type: 'image', data: `${png.slice(0, 11)}\n${png.slice(11)}`, mimeType: 'image/png' }, + }; + for (const [eventId, block] of Object.entries(pushes)) command({ op: 'push', eventId, content: [block] }); + await waitFor(() => Object.keys(pushes).every((id) => !!storedWith((m) => m.eventId === id)), 'images stored'); + for (const eventId of Object.keys(pushes)) { + const message = storedWith((m) => m.eventId === eventId)!; + const meta = message.metadata as Record; + const image = message.content.find((b) => b.type === 'image'); + assert.deepEqual(image, { type: 'image', source: { type: 'base64', data: png, mediaType: 'image/png' } }, + `${eventId}: stored as the bytes' own type, in canonical base64`); + assert.equal(sourceBodyDigest(message.content), meta.storedBodyDigest, `${eventId}: the untouched copy matches its stored digest`); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([image]), `${eventId}: the delivered body hashes as the image stored for it`); + } + }); + it('keeps a stored envelope when the channel is renamed later', async () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-2', mode: 'ambient', text: 'before rename' }); await waitFor(() => !!storedWith((m) => m.messageId === 'm-2'), 'first message stored'); @@ -443,3 +471,77 @@ describe('inbound source envelope under RFC-006 coalescing', () => { assert.deepEqual(observed, [stored]); }); }); + +describe('sourceBodyDigest hashes a body as the store keeps it', () => { + // Every agent stores through context-manager over chronicle. Whatever the + // store changes on the way in (an image's label, base64's spelling, fields + // it doesn't keep, a lone surrogate), the body read back must hash as it + // did when it was handed over, live and after the store is reopened. + const png = PNG.toString('base64'); + const image = (data: string, mediaType = 'image/png', extra: Record = {}) => + ({ type: 'image', source: { type: 'base64', data, mediaType }, ...extra }); + const media = (type: string, data: string, mediaType: string, extra: Record = {}) => + ({ type, source: { type: 'base64', data, mediaType }, ...extra }); + /** Each body, and whether the store hands it back changed. */ + const bodies: Record = { + 'a labeled image': { blocks: [image(png)], changed: false }, + 'PNG bytes labeled image/jpeg': { blocks: [image(png, 'image/jpeg')], changed: true }, + 'JPEG bytes labeled image/png': { blocks: [image(Buffer.from('ffd8ffe000104a464946', 'hex').toString('base64'))], changed: true }, + 'base64 with a newline in it': { blocks: [image(`${png.slice(0, 11)}\n${png.slice(11)}`)], changed: true }, + 'base64 with whitespace around it': { blocks: [image(` ${png}\n`)], changed: true }, + 'base64 without its padding': { blocks: [image(png.replace(/=+$/, ''))], changed: true }, + 'base64 with non-zero unused bits': { blocks: [image(png.replace(/8=$/, '9='))], changed: true }, + 'base64 in the URL-safe alphabet': { blocks: [image(png.replace(/\//g, '_'))], changed: true }, + // Bytes the store has no signature for keep their label. Labeled + // image/png, so a context-manager that learns to sniff one fails here. + 'SVG bytes labeled image/png': { blocks: [image(Buffer.from('').toString('base64'))], changed: false }, + 'BMP bytes labeled image/png': { blocks: [image(Buffer.from('424d3a0000000000000036000000', 'hex').toString('base64'))], changed: false }, + 'AVIF bytes labeled image/png': { blocks: [image(Buffer.from('0000001c6674797061766966000000006d696631', 'hex').toString('base64'))], changed: false }, + 'an image whose source is neither base64 nor a URL': { blocks: [{ type: 'image', source: { type: 'file', data: png, mediaType: 'image/png' } }], changed: true }, + 'an image with fields the store drops': { blocks: [image(png, 'image/png', { sourceUrl: 'https://cdn.example/a.png', tokenEstimate: 85 })], changed: true }, + 'an image by URL, its fields kept': { blocks: [{ type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png' }, sourceUrl: 'https://cdn.example/a.png' }], changed: false }, + 'audio, wrapped, with a duration': { blocks: [media('audio', 'SUQz\nAwAAAAAA', 'audio/mpeg', { duration: 3 })], changed: true }, + 'a document with a filename': { blocks: [media('document', 'JVBERi0xLjQ=', 'application/pdf', { filename: 'a.pdf' })], changed: true }, + 'text with lone surrogates': { blocks: [{ type: 'text', text: 'cut \ud83d here, \ude00 there, \ud83d\ud83d twice' }], changed: true }, + 'a key that is not well-formed': { blocks: [{ type: 'text', text: 'x', rawItem: { 'k\ud800': 1, kept: 2 } }], changed: true }, + 'a relabeled image with a text decoration': { blocks: [image(png, 'image/jpeg'), { type: 'text', text: '[invitation]' }], changed: true }, + }; + + it('every body hashes alike as handed to the store and as read back, live and after reopening', async () => { + const dir = mkdtempSync(join(tmpdir(), 'stored-digest-')); + const path = join(dir, 'store.chronicle'); + try { + const strategy = () => new PassthroughStrategy(); + const ids = new Map(); + let cm = await ContextManager.open({ path, strategy: strategy() }); + for (const [name, { blocks }] of Object.entries(bodies)) { + ids.set(name, cm.addMessage('user', blocks as never, { storedBodyDigest: sourceBodyDigest(blocks) })); + } + const check = (when: string) => { + for (const [name, { blocks, changed }] of Object.entries(bodies)) { + const back = cm.getMessage(ids.get(name)!)!; + if (changed) assert.notDeepEqual(back.content, blocks, `${when}, ${name}: the store hands it back changed`); + else assert.deepEqual(back.content, blocks, `${when}, ${name}: the store hands it back as it was`); + assert.equal(sourceBodyDigest(back.content), (back.metadata as Record).storedBodyDigest, `${when}, ${name}: it still hashes as handed over`); + } + }; + check('live'); + cm.close(); + cm = await ContextManager.open({ path, strategy: strategy() }); + check('reopened'); + cm.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('what the store keeps still changes the digest', () => { + const digest = (blocks: unknown[]) => sourceBodyDigest(blocks); + assert.notEqual(digest([image(png)]), digest([image(Buffer.from([...PNG, 0]).toString('base64'))]), 'other bytes'); + const svg = Buffer.from('').toString('base64'); + assert.notEqual(digest([image(svg, 'image/svg+xml')]), digest([image(svg, 'image/png')]), 'a label the store keeps'); + assert.notEqual(digest([media('audio', 'SUQzAwAAAAAA', 'audio/mpeg')]), digest([media('audio', 'SUQzAwAAAAAA', 'audio/wav')]), 'an audio label'); + assert.notEqual(digest([{ type: 'text', text: 'a' }]), digest([{ type: 'text', text: 'b' }]), 'other text'); + assert.equal(digest([image(png, 'image/jpeg')]), digest([image(png)]), 'a label the store replaces is not part of the body'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c10092cff..beb230fc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": ["ES2022", "ES2024.String"], "outDir": "./dist", "rootDir": ".", "strict": true, From 1bbf3f7322bd63678e4a4c6bda2f438817dfb25c Mon Sep 17 00:00:00 2001 From: Crispin-1869 Date: Thu, 8 Oct 2026 14:58:05 -0400 Subject: [PATCH 33/65] docs(routing): Breaking names scheduled wakes' lost fallback; comments stop describing it slimepriestess approved #256 at 4180ccd (review 5461110034) and asked for one adoption note in the Breaking fragment: a turn woken only by pushes that name no conversation, such as heartbeat-mcpl's heartbeats (origin {source: 'heartbeat', reason}) and reminders ({source: 'reminder', ...}), has no speech route now, so plain speech that used to reach the most recent inbound channel piles up as drafts. The Breaking fragments named only connectors that must declare, and heartbeat-mcpl isn't a publishing connector, so an operator whose resident answers a scheduled check-in in plain speech had no warning. The behaviour itself is #256 as designed (speech-routes.changed.md already says it); this is the adoption note. speech-routes.breaking.md gains a bullet for operators whose residents speak on scheduled wakes. Such a turn has no route, unless the resident is a conversation fork (its home is its route). That covers heartbeat-mcpl's message-mode heartbeats and reminders, timers and self-wakes. Their speech is held as no-destination drafts, which the turn's [delivered] receipt names. Migration: the routine names its place on every wake, either with channel_open before speaking (the route lasts for that turn only, as Stanley-1868 pointed out) or with an explicit send (channel_publish with a channelId, or the connector's own tool). The heartbeat's or reminder's own message can say where, and drafts can resend held words. Silent ticks are unaffected: their prose was already private. Their other suggested fix, a home conversation that heartbeat-mcpl names in its origin, doesn't work as things stand. A push's conversation is on the pushing server (pushSource -> channelSource with event.serverId; ensureChannelRegistered(event.serverId, ...)), so a Discord channel named by heartbeat-mcpl would become heartbeat's own host-minted, undeclared channel: untargetable. Comments that still described the removed last-inbound fallback as current behaviour now describe the current rule. They contradicted the note above: - framework.ts: derivePushEventChannel said heartbeats and timers "correctly keep the global fallback". They name no conversation, so they are no route candidate. - framework.ts: the gate wake's addressed channel and the batch's trigger channel were described as the reply's locus, and ambient-only batches and non-channel wakes as falling back to it. They are the request's channelId and provenance, and the route comes from routeCandidates. - event-gate.ts: WakeProvenance.routeChannelId, wakeProvenance and the debounce flush said the same about ambient-only batches. - types/agent.ts: InferenceRequest.channelId, addressed and wakeChannelId, a public type, said the framework routes speech to channelId and that non-channel wakes "fall back to the global default channel". - types/trace.ts: inference:started's channelId cited turnLocusPins (gone) and "heartbeats with no default channel". - channel-registry.ts: two orphaned doc blocks, attached to nothing, described the most-recent-incoming locus and the process-global default. They were already orphaned before #256, and are deleted. Comment and changelog only; no code changes. Review follow-up in room-313 (Crispin-1869 acts; Stanley-1868 reviews). --- changelog.d/speech-routes.breaking.md | 15 +++++++++++++++ src/framework.ts | 26 ++++++++++++++------------ src/gate/event-gate.ts | 22 ++++++++++------------ src/mcpl/channel-registry.ts | 12 ------------ src/types/agent.ts | 21 +++++++++++---------- src/types/trace.ts | 7 ++++--- 6 files changed, 54 insertions(+), 49 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index fe775818e..49e0c646a 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -14,3 +14,18 @@ - **Unchanged:** fork homes, explicit sends naming a channel, and `resolveProseTarget`/`resolveDestination`. No sibling package version changes. +- **Operators whose residents speak on scheduled wakes:** a turn woken only + by events that name no conversation now has no speech route, unless the + resident is a conversation fork, whose home is its route. That covers + heartbeat-mcpl's message-mode heartbeats and its reminders, whose `origin` + names no conversation, as well as timers and self-wakes. Their plain speech + used to go to the most recent inbound channel. It is now held as + `no-destination` drafts, which the turn's `[delivered]` receipt names. + - **Migration:** a routine that should post names its place on every + wake. Before speaking, the resident can `channel_open` the channel, + which sets the speech route for that turn only, or it can send + explicitly with `channel_publish` and a `channelId`, or with the + connector's own send tool. The heartbeat's or reminder's own message can + say where. Held words can be resent with `drafts`. + - **Unaffected:** heartbeat-mcpl's silent ticks, whose prose was already + private. diff --git a/src/framework.ts b/src/framework.ts index 34bb8fb3e..07ef7ede7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1802,12 +1802,11 @@ export class AgentFramework { ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}), ...(provenance?.at ? { wakeAt: provenance.at } : {}), // A batch that contains an ADDRESSED message (mention / reply / - // DM) routes like the direct path does: the reply goes to that - // message's channel. Without it the turn froze on the - // process-global most-recent-inbound channel, which an ambient - // message elsewhere could retarget between the DM's arrival and - // the debounce firing (2026-09-30). Ambient-only batches set no - // locus and keep the legacy fallback. + // DM) marks the wake addressed, with that message's channel as + // the request's channelId, as the direct path does; an + // ambient-only batch sets neither. Neither is the speech route: a + // true new turn infers that from the batch's route candidates + // below (shelf-355). // // The wake is broadcast to every agent, conversation forks // included, and a fork speaks only in its home channel: the @@ -8746,7 +8745,8 @@ export class AgentFramework { * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the * fix works even against a discord-mcpl build that predates `mcplChannelId`. * Returns undefined for push events with no channel provenance (heartbeats, - * timers), which correctly keep the global fallback. + * reminders, timers): they name no conversation, so they are no route + * candidate, and a turn they alone wake infers no speech route (shelf-355). */ private derivePushEventChannel( origin: Record | undefined, @@ -8986,17 +8986,19 @@ export class AgentFramework { // requests[0] in that mixed batch bypassed the turn lock because a // restart existed, then treated the continuation as a fresh turn. const trigger = budgetRestart ?? requests[0]; - // Route this turn's auto-published speech to the channel that triggered - // it (item-3 redux). A batched wake may carry several triggering channels - // (messages arrived in >1 channel while the agent was busy/idle): + // The channel that triggered this turn (the trigger's channelId), and + // the provenance below. Neither is the speech route, which a true new + // turn infers from every request's route candidates (shelf-355). A + // batched wake may carry several triggering channels (messages arrived + // in >1 channel while the agent was busy/idle): // 1. the most recent ADDRESSED channel wins (mention / reply / DM — // someone explicitly spoke TO the agent); // 2. else the most recent channel-bearing request (ambient message in - // an open channel — legacy last-inbound semantics). + // an open channel). // Ambient chatter must not outrank an addressed message just by being // newest (2026-07-21 Cairn lounge misroute, turn-start variant). // Non-channel wakes (heartbeats, module events, reactions — which never - // carry channelId) leave both undefined → global fallback. + // carry channelId) leave both undefined: the turn has no trigger channel. // Track the winning REQUEST, not just its channel: channel, addressed // and counterparty must come from the same message, or a batch of // "ambient from A, then addressed from B" would report B's channel diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 2b89a9864..8c70017b7 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -143,14 +143,12 @@ export interface WakeProvenance { counterparty?: string; /** True when the chosen event was `chat:addressed`. */ addressed?: boolean; - /** Speech locus for the wake: the routable channel of the chosen event, - * set ONLY when that event addressed the agent (mention / reply / DM) and - * the host resolved its registered channel. A batched wake used to carry - * no locus at all, so its reply fell back to the process-global - * most-recent-inbound channel — which any ambient message elsewhere, - * arriving a second after the DM that caused the wake, could retarget - * (2026-09-30: a DM reply published into a guild channel). Ambient-only - * batches still set nothing and keep the legacy fallback. */ + /** The routable channel of the chosen event, set ONLY when that event + * addressed the agent (mention / reply / DM) and the host resolved its + * registered channel: the framework marks the wake addressed there. + * Ambient-only batches set nothing. It is not the speech route, which the + * framework infers from `routeCandidates` (shelf-355); no wake falls back + * to the most recent inbound channel any more. */ routeChannelId?: string; /** Timestamp (ms) of the chosen event, so a consumer coalescing several * requests can order by event recency, not by flush order. */ @@ -248,7 +246,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin // A resolved route counts as naming a channel only for an ADDRESSED // event: a push whose origin declares only `mcplChannelId` (no raw // channel id, no author) is still a routable addressed message, and - // skipping it would leave its wake on the most-recent-inbound fallback. + // skipping it would hide that the wake was addressed there. // An ambient one stays out: the route is never reported for it, so as // the pick it would carry no telemetry at all and hide an earlier event // that names its channel and author. @@ -1682,9 +1680,9 @@ export class EventGate { // ADDRESSED event first, else the newest event naming a channel or an // author; channel + author + addressed from that ONE event. When that // event addressed the agent and its registered channel is known, - // `routeChannelId` names the turn's speech locus too — the reply goes - // back to whoever addressed the agent, not to whichever channel last saw - // any traffic. Ambient-only batches carry telemetry only. + // `routeChannelId` marks the wake addressed there. The turn's speech + // route comes from `routeCandidates`, every conversation in the batch + // (shelf-355), never from whichever channel last saw any traffic. const provenance = wakeProvenance(events); for (const agentName of this.getAgentNamesFn()) { this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 2797e1051..d8f6e6764 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2993,18 +2993,6 @@ export class ChannelRegistry { }; } - /** - * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN). - * Publish the agent's plain-text speech to the current conversational locus - * (the most recent incoming channel, tracked cross-surface here in the host). - * Called by the framework on a text-only turn — replaces the per-surface - * sticky auto-post that used to live in discord-mcpl. Returns null when there - * is no locus / the channel or its server can't be resolved (in which case - * the speech simply stays in chronicle + module surfaces). - */ - /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel → - * process-global default). Public so a multi-segment caller can snapshot it - * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */ /** * MCPL Spec 14.3 outgoing streaming: forward prose the agent has published * to the server owning the channel. Emitted only when that server declared diff --git a/src/types/agent.ts b/src/types/agent.ts index 02e79a51a..d9ba89faf 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -321,20 +321,21 @@ export interface InferenceRequest { timestamp: number; /** * The MCPL channel whose message triggered this inference, if any (composite - * id, e.g. `discord:guild:channel` / `discord:dm:id`). The framework routes - * the turn's auto-published plain-text speech here so a single TRUNK agent - * replies in the channel it is answering — not the process-global most-recent- - * inbound locus, which a concurrent message elsewhere can hijack (item-3 - * redux). Undefined for non-channel wakes (heartbeat, timers, module events), - * which correctly fall back to the global default channel. + * id, e.g. `discord:guild:channel` / `discord:dm:id`). It is not where the + * turn's plain speech goes: that is the turn's speech route, inferred from + * `routeCandidates` (src/speech-routes.ts). Undefined for non-channel wakes + * (heartbeat, timers, module events), which name no conversation; a turn + * they alone wake has no inferred route, and nothing falls back to the most + * recent inbound channel. */ channelId?: string; /** * True when the triggering message explicitly addressed the agent (mention, * reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake * batch spans several channels, an addressed channel outranks ambient - * chatter for the turn's frozen speech locus: overheard conversation must - * not capture the agent's voice just by being newest. + * chatter for the turn's trigger channel, as addressed candidates do for its + * speech route: overheard conversation must not capture the agent's voice + * just by being newest. */ addressed?: boolean; /** @@ -349,8 +350,8 @@ export interface InferenceRequest { * Where the wake came from, for telemetry ONLY — never a speech locus. * Set by the EventGate for batched wakes (composite channel id of the * chosen event when the event carried one; push-event raw ids are not - * used). Routing keeps reading `channelId`, set from registered ids: by - * the direct channel paths, and by a gate wake only when its chosen event + * used). `channelId`, by contrast, is set from registered ids: by the + * direct channel paths, and by a gate wake only when its chosen event * addressed the agent and the host resolved that event's registered * channel (ambient-only batches leave it unset). */ diff --git a/src/types/trace.ts b/src/types/trace.ts index f053e04af..eb0012db0 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -41,9 +41,10 @@ export type TraceEvent = type: 'inference:started'; agentName: string; /** - * The turn-frozen outbound locus (see turnLocusPins) — the channel this - * turn's plain prose will be routed to. Omitted for turns with no locus - * (heartbeats with no default channel). Observability only, like every + * The channel of the turn's speech route (src/speech-routes.ts) — where + * this turn's plain prose will be routed. Omitted when the turn has no + * channel route: no route at all (such as a heartbeat's turn), a held + * turn, or a local surface. Observability only, like every * trace field; lets external taps (e.g. a TTS relay) tag the whole * activation with its channel without re-deriving routing. */ From 2c83659be4de90c04b93efd85c42695f41ae8c6b Mon Sep 17 00:00:00 2001 From: Crispin-1869 Date: Thu, 8 Oct 2026 14:58:20 -0400 Subject: [PATCH 34/65] fix(streams): a later message on a channel opens a paragraph in its stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slimepriestess's review of #256 at 4180ccd (review 5461110034, observation 3) found a seam in outgoing streams. streamPublished sent each published segment's text as its chunk delta and appended it with `channel.text += text`, so two messages a resident published to one channel within one physical stream, "Let me look." and then "Found it.", streamed and completed as "Let me look.Found it.". discord-mcpl's voice output (voice.ts handleChunk) appends deltas into one utterance and sends each straight to TTS (utt.tts.sendText(delta), which voice-kit's ElevenLabs provider passes on verbatim), so the voice heard the same run-on text the completion carried. Each publish is its own message. So within one stream, a later publish on a channel whose stream has already carried text opens its delta with "\n\n". Whether to separate is decided from what that channel actually streamed, the stream.channels entry, which exists only after sendOutgoingChunk returned true, as Stanley-1868 proposed. A chunk that didn't go out therefore leaves no stray separator, and a first message on another channel gets none. The completion is still exactly the deltas concatenated (MCPL §14.3: "Per-channel deltas concatenated in index order reconstruct that channel's streamed text"). The published messages themselves are unchanged. Messages in different physical streams were already separate utterances, one inferenceId each, and stay unchanged. Tests (present-while-acting): - New: "each later message on a channel opens a paragraph in its stream, and the completion is the deltas joined". Two rounds publish "Let me look." and "Found it." to chan-A. Published words are unchanged, the deltas are ['Let me look.', '\n\nFound it.'], and the completion equals the deltas concatenated. - "completion follows the stream's last speech" now expects '\n\nDone.' and 'Narrating.\n\nDone.'. - "an envelope delivery couldn't make streams nothing..." now has its stub's routeSpeech outcome name the resolved server ('stub'), as the real registry's does (ChannelRegistry.routeSpeech returns outcome.destination.serverId). Before, the outcome had no serverId while deliverSpeech's said 'stub', so the envelope's publish and the route's keyed different stream entries, and the test asserted 'Second.More for beta.', the very join this commit removes. Stanley-1868 pointed out that the assertion now read like the bug, and proposed this fix. The test now expects 'Second.\n\nMore for beta.' with exactly one chan-beta completion, which guards that an envelope publish (deliverSpeech) and a route publish (routeSpeech) share one stream entry. The change is local to that test, so the receipt tests that a stub-wide serverId changes are untouched. - All three fail with the separator removed (the delta reverted to `text`). "completion never replays a chunk the registry refused to stream" passes either way, and guards the no-stray-separator rule. targeted-publish.changed.md says so in the outgoing-streams bullet. Review follow-up in room-313 (Crispin-1869 acts; Stanley-1868 reviews). --- changelog.d/targeted-publish.changed.md | 10 +++--- src/framework.ts | 19 +++++++---- test/present-while-acting.test.ts | 45 +++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/changelog.d/targeted-publish.changed.md b/changelog.d/targeted-publish.changed.md index 9ff1653b0..893dd4133 100644 --- a/changelog.d/targeted-publish.changed.md +++ b/changelog.d/targeted-publish.changed.md @@ -27,10 +27,12 @@ `skip_reply`), held (an explicit send's silence) or unsent, so held, private, failed and unconfirmed words never stream, and a voice surface speaks only what was posted. A thread placement isn't streamed, so a - channel's stream names one place. Each physical stream completes, after its - last speech, exactly what it streamed. Voice therefore follows each - published round rather than overlapping generation; speech still lands - between tool rounds. + channel's stream names one place. Each publish is its own message, so + within one stream a later one on the same channel opens its delta with a + paragraph break (`\n\n`), and the stream's text keeps the messages apart. + Each physical stream completes, after its last speech, exactly what it + streamed. Voice therefore follows each published round rather than + overlapping generation; speech still lands between tool rounds. - **Publishing rechecks after opening.** When a publish has to open a closed channel first, it checks the destination again before sending: a channel removed, or a declaration withdrawn, while the open was pending refuses the diff --git a/src/framework.ts b/src/framework.ts index 07ef7ede7..f2782396c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9936,7 +9936,11 @@ export class AgentFramework { * surface speaks only what was published, where it was published — * never held, private, failed or unconfirmed words. The chunk names the * outcome's own server and channel, and the root; a thread placement isn't - * streamed, so a channel's stream names one place. + * streamed, so a channel's stream names one place. Each publish is its own + * message, so a later one on a channel that has already streamed opens with + * a paragraph break: deltas concatenated (what a voice consumer speaks) and + * the completion keep the messages apart instead of running "Let me + * look.Found it." together. */ private streamPublished( agentName: string, @@ -9951,15 +9955,18 @@ export class AgentFramework { stream = { conversationId: agentName, index: 0, channels: new Map() }; streams.set(moment.inferenceId, stream); } + const key = `${published.serverId}\u0000${published.channelId}`; + // Only what this channel actually streamed counts: a chunk that didn't go + // out leaves no entry, so it never earns the next one a separator. + const channel = stream.channels.get(key); + const delta = channel ? `\n\n${text}` : text; const sent = this.channelRegistry.sendOutgoingChunk( { serverId: published.serverId, channelId: published.channelId }, - agentName, moment.inferenceId, stream.index++, text, + agentName, moment.inferenceId, stream.index++, delta, ); if (!sent) return; - const key = `${published.serverId}\u0000${published.channelId}`; - const channel = stream.channels.get(key); - if (channel) channel.text += text; - else stream.channels.set(key, { serverId: published.serverId, channelId: published.channelId, text }); + if (channel) channel.text += delta; + else stream.channels.set(key, { serverId: published.serverId, channelId: published.channelId, text: delta }); } /** diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 1c9b0b76f..41788c249 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1704,6 +1704,14 @@ describe('speech follows the moment its words were written', () => { const routed = stubChannelRegistry(framework, { home: false }); (framework as unknown as { proseBounceStreaks: Map }).proseBounceStreaks.set('assistant', 100); const registry = registryOf(framework); + // routeSpeech's outcome names the resolved server, as the real registry's + // does, so the envelope's publish (deliverSpeech) and the route's + // (routeSpeech) are one stream entry on chan-beta. + const route = registry.routeSpeech as (...args: unknown[]) => Promise | null>; + registry.routeSpeech = async (...args: unknown[]) => { + const outcome = await route(...args); + return outcome ? { ...outcome, serverId: 'stub' } : outcome; + }; const deliver = registry.deliverSpeech as (...args: unknown[]) => Promise; let calls = 0; registry.deliverSpeech = async (...args: unknown[]) => { @@ -1717,7 +1725,10 @@ describe('speech follows the moment its words were written', () => { triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); assert.deepEqual(routed, [{ text: 'Second.', locus: 'chan-beta' }, { text: 'More for beta.', locus: 'chan-beta' }]); - assert.equal(streams.streamedTo('chan-beta'), 'Second.More for beta.'); + assert.equal(streams.streamedTo('chan-beta'), 'Second.\n\nMore for beta.'); + assert.deepEqual(streams.completes.filter((c) => c.channelId === 'chan-beta'), + [{ channelId: 'chan-beta', text: 'Second.\n\nMore for beta.', threadId: null }], + 'one stream entry, one completion, for the envelope and the route alike'); await framework.stop(); }); @@ -1808,14 +1819,42 @@ describe('speech follows the moment its words were written', () => { assert.deepEqual(events.map(([kind, , text]) => [kind, text]), [ ['chunk', 'Narrating.'], - ['chunk', 'Done.'], - ['complete', 'Narrating.Done.'], + ['chunk', '\n\nDone.'], + ['complete', 'Narrating.\n\nDone.'], ]); assert.equal(new Set(events.map(([, id]) => id)).size, 1, 'one physical stream, one inference'); await framework.stop(); }); + it('each later message on a channel opens a paragraph in its stream, and the completion is the deltas joined', async () => { + // Two rounds publish two messages to chan-A. A voice consumer speaks the + // deltas as they come (discord-mcpl's voice output appends them into one + // utterance), so they must not run "Let me look.Found it." together. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Let me look.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Found it.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Let me look.', locus: 'chan-A' }, { text: 'Found it.', locus: 'chan-A' }], + 'each message is published as written, with no separator'); + assert.deepEqual(streams.chunks.map((c) => c.delta), ['Let me look.', '\n\nFound it.']); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Let me look.\n\nFound it.', threadId: null }]); + assert.equal(streams.completes[0]!.text, streams.streamedTo('chan-A'), 'the completion is exactly the deltas, concatenated'); + + await framework.stop(); + }); + it('an aborted stream still completes what its published speech streamed', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Narrating.' }, From d981e494806b69ef1b2a4ed17b281d14c24edab2 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Fri, 9 Oct 2026 13:07:10 -0400 Subject: [PATCH 35/65] test(mcpl): pin every branch of the copied raster sniffer with a mislabeled fixture slimepriestess approved #252 at a2accc1 with one non-blocking ask: the sniffer that sourceBodyDigest uses (sniffRasterImageMediaType, copied from context-manager's unexported blob-manager code) was pinned by real-store round-trip fixtures only for png and jpeg (and svg as unsniffed), so a context-manager change to its gif or webp branch would silently bring back the stored-digest mismatch for those formats. Three rows join the real-store table, each labeled as something else so the store relabels it on the way in: GIF87a bytes labeled image/png, GIF89a bytes labeled image/jpeg, and WebP (RIFF....WEBP) bytes labeled image/png. Each must come back changed and still hash as handed over, live and after the store is reopened, against the installed @animalabs/context-manager 0.13.0. Red checks, each a one-line edit to the copied sniffer, rebuilt and run: - the gif branch removed fails at "GIF87a bytes labeled image/png"; - only GIF87a kept fails at "GIF89a bytes labeled image/jpeg"; - the webp branch removed fails at "WebP bytes labeled image/png". Restored, inbound-source passes 22/22. The existing SVG, BMP and AVIF rows already pin the other direction (a store that starts sniffing a new type). Test-only; no changelog fragment. Co-Authored-By: Claude Opus 5.5 --- test/inbound-source.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 65232a7ca..b50b14a70 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -487,6 +487,11 @@ describe('sourceBodyDigest hashes a body as the store keeps it', () => { 'a labeled image': { blocks: [image(png)], changed: false }, 'PNG bytes labeled image/jpeg': { blocks: [image(png, 'image/jpeg')], changed: true }, 'JPEG bytes labeled image/png': { blocks: [image(Buffer.from('ffd8ffe000104a464946', 'hex').toString('base64'))], changed: true }, + // One fixture per branch of the copied sniffer, each labeled as something + // else, so a context-manager that stops sniffing one fails here. + 'GIF87a bytes labeled image/png': { blocks: [image(Buffer.from('GIF87a\x01\x00\x01\x00\x00\x00\x00', 'latin1').toString('base64'))], changed: true }, + 'GIF89a bytes labeled image/jpeg': { blocks: [image(Buffer.from('GIF89a\x01\x00\x01\x00\x00\x00\x00', 'latin1').toString('base64'), 'image/jpeg')], changed: true }, + 'WebP bytes labeled image/png': { blocks: [image(Buffer.from('RIFF\x1a\x00\x00\x00WEBPVP8L\x0d\x00\x00\x00', 'latin1').toString('base64'))], changed: true }, 'base64 with a newline in it': { blocks: [image(`${png.slice(0, 11)}\n${png.slice(11)}`)], changed: true }, 'base64 with whitespace around it': { blocks: [image(` ${png}\n`)], changed: true }, 'base64 without its padding': { blocks: [image(png.replace(/=+$/, ''))], changed: true }, From 378c2b33423ee5a227d9d53f413707cf5bf95c85 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sat, 10 Oct 2026 02:16:33 -0400 Subject: [PATCH 36/65] test(drafts): pin that a draft's sending mark ends with its own attempt, and with a publish that throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill pass on #247's post-approval commit 315d12c removed each of its pieces one at a time. Two survived: the resend loop's delete of the sending mark right after each publish, and the batch's finally backstop. Nothing tested either. - Without the per-publish delete, a draft delivered early in a batch stays "being sent right now" until the whole batch ends, so dismissing it is refused with the wrong reason instead of "already delivered — nothing to dismiss". - Without the backstop, a publish that throws leaves its draft marked as sending for the life of the process, and every later dismissal of it is refused. Both tests pass on 1d6d2bb's source too (no sending marks there): they pin that 315d12c's marks don't outlive their attempt. With them, all eight of 315d12c's pieces are caught when removed (D1 tool refusal, D2/D3 the loop's mark and unmark, D4 the backstop, D5 the {{unsent}} mark, D6 the bounce refusal, D7/D8 the receipt), on Node 24.21. Co-Authored-By: Claude Opus 5.5 --- test/prose-drafts.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 09ec8f432..defe3a294 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -956,6 +956,36 @@ describe('drafts resend ownership', () => { } }); + it("a draft whose own result is in isn't being sent any more, though the rest of its batch is", async () => { + const t = await setup(); + try { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.tick(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['B words'], 'A is in; B is out'); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), + new RegExp(`${t.a.id}: already delivered — nothing to dismiss`)); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); + await batch; + } finally { + await t.close(); + } + }); + + it('a publish that throws leaves its draft dismissable, not marked as being sent', async () => { + const t = await setup(); + try { + const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.publish = async () => { throw new Error('transport gone'); }; + await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }).catch(() => undefined); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), + new RegExp(`${t.a.id}: dismissed`)); + } finally { + await t.close(); + } + }); + it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { const t = await setup(); try { From e0418535606a07a6545b82cf3aa5d6af0d71faed Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 10 Oct 2026 08:33:56 -0400 Subject: [PATCH 37/65] fix(routing): no guessed server for a shared channel id; the fork rule on every wake path; thread named in failure markers From the house's haiku-review run on this PR's own commits (a2accc1..8f29e62, room-203 #67143), with the verdicts in #67165. What is real and fixed here: A/B. A shared channel id was given a server by registration order. getChannelServerId returned findChannelEntry's first match, and two of this PR's paths used it to fill a missing server: homeRoute (fork homes are bare channel ids) and candidateForChannel (a gate candidate whose event named no server). On an id several servers register, the route then carried whichever server registered first, against resolveDestination's own rule ("a shared id is refused, never resolved to whichever server registered it first"), and its conversation key differed from the same channel's key on the true server, so one channel could count as two conversations. - ChannelRegistry.getChannelServerId names a server only when exactly one registered the id (findExactEntry); null for a shared or unknown id. - candidateForChannel: no server from the event or a sole registrant -> the candidate is unroutable (competes, never the route; reason 'unresolved'). - publishTargetOf distinguishes: a declared target as before; else 'unresolved' when the channel isn't exactly one registration (unknown, withdrawn, or shared without a server), undefined only for a registered channel that declares nothing. routeRefusal maps 'unresolved' through, so a fork home on a shared id is held as unresolved rather than mislabelled as undeclared. The 'unresolved' notice says the channel "doesn't resolve to exactly one registered channel". - The other getChannelServerId callers now skip a shared id rather than act through the first registrant: the refusal reaction with its existing "no MCPL server" log, and the refusal-rewind announcement quietly, as it already does for any channel with no server. C. channel_publish gained serverId in this PR, but the fork guard compared only channelId, so a fork could name its home's id on another server. The guard (channel_publish and channel_close) now refuses a serverId other than the home's sole registrant; a home no single server registers can't be named with a server at all. F. The fork rule ("no agent takes a channel a fork owns; a fork takes only its home") was applied to batched gate wakes only. The push-event path, the coalesced push path and the channels/incoming broadcast now apply it to each woken agent's route candidate and typing channel (mayTakeGateRoute, renamed mayTakeRoute, plus mayTakeCandidate for candidates). H. suspendsRoute's doc said it fires "only for a conversational message"; the function sees no tags. The doc now says the caller passes only conversational arrivals. K. onRouteFailure carries the route's threadId (deliverSpeech's failure path, from the outcome's destination or the target), and the [discord-send-failed] marker names "thread T in #channel (id)", says to check the thread when the outcome is unknown, and stores threadId in its metadata. Tests (L1-L4 were gaps the run found; L5 was already covered): - speech-routes: a thread route isn't suspended by its own thread and is by its channel root (L1); equal-time candidates give the later one as reply edge, inferred and at a home (L2); a label equal to its id adds nothing (L3); 'unresolved' maps through, and an undeclared channel is still 'untargetable'. - targeted-publish: the #legacy assertion is anchored, so a doubled # would fail (L4). - publish-outcome: getChannelServerId unique/shared/unknown; a failed thread delivery reports its thread to onRouteFailure, a root one none; and, framework-level over the speech-route fixture with an exact forum channel, the marker names "thread t1 in #forum (Guild One) (discord:g1:forum)", says to check the thread, and stores threadId. - speech-route-turns: candidateForChannel fills only a sole registrant and marks a shared id unroutable, keeps the event's own server; a shared fork home gets no server; publishTargetOf reads 'unresolved' for it. - conversation-routing: a fork naming its home id on another server is refused; naming the home's own server passes. - batched-wake-locus: a push into a fork-owned channel gives the trunk no typing channel and no candidate (control: a free channel gives both); a coalesced push batch likewise for its typing channel and its candidate; a channels/incoming broadcast gives only the owning fork its channel. Red-first (Anima VPS, node 24.21): these tests against 8f29e62's source fail where they should (speech-routes 1, publish-outcome 3, speech-route-turns 1, conversation-routing 1, batched-wake-locus 2 of the then 9), and the gap tests L1-L4 pass there, pinning existing behaviour. Remove-each-piece: all 18 mutants of this commit's pieces (the unique lookup, the unroutable candidate, 'unresolved' in publishTargetOf and routeRefusal, the fork serverId guard, the six new fork-rule sites (typing and candidate on the push, coalesced and broadcast paths), the threadId on both sides of onRouteFailure, routeConversation's thread, both >= tie rules, the label-equals-id case and the # prefix) fail at least one focused test. M. Leftovers: trunk-channel-routing's header described the deleted activeChannelResolver; it now describes the route plumbing it tests. The deleted getDefaultPublishChannel is no longer stubbed in eight test files. trunk-channel-routing's declaringRegistry and deferred-wake-integrity's stub gain resolveDestination, since publishTargetOf now asks it when a channel declares nothing (the deferred-wake stub answers as the registry does for an unregistered locus). Changelog: speech-routes.changed gains the shared-id, fork-rule and thread marker entries and the fork serverId rule; speech-routes.breaking records getChannelServerId's narrower answer. Not changed (verdicts in #67165): D (the non-streaming tool_results_ready round is unreachable from the framework's own loop), E (a mid-turn hold lists the route and its newest competitor; the hold itself is right), G, I, J and L5 (not real). Co-Authored-By: Claude Opus 5.5 --- changelog.d/speech-routes.breaking.md | 4 +- changelog.d/speech-routes.changed.md | 18 +++- src/framework.ts | 97 ++++++++++++++----- src/mcpl/channel-registry.ts | 18 ++-- src/speech-routes.ts | 18 ++-- test/batched-wake-locus.test.ts | 81 ++++++++++++++++ test/conversation-routing.test.ts | 20 +++- test/deferred-wake-integrity.test.ts | 3 +- test/explicit-prose-routing.test.ts | 1 - test/present-while-acting.test.ts | 1 - test/publish-outcome.test.ts | 76 +++++++++++++++ test/same-round-think-policy.test.ts | 1 - test/silent-heartbeat-rows.test.ts | 2 +- test/silent-heartbeat.test.ts | 2 +- test/speech-route-turns.test.ts | 38 ++++++++ test/speech-routes.test.ts | 36 +++++++ test/targeted-publish.test.ts | 3 +- test/tool-result-guard.test.ts | 4 +- ...ol-wrapper-prose-guard-integration.test.ts | 16 +-- test/trunk-channel-routing.test.ts | 22 +++-- 20 files changed, 398 insertions(+), 63 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index 49e0c646a..7083679ab 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -4,7 +4,9 @@ `activeChannelResolver` option. The new `speechRouteResolver(agentName)` option replaces that option, and the framework supplies it. - **Changed:** `resolveLocus(agent)` returns only a conversation fork's - home channel, or null. `buildChannelContext(agent)` advertises the + home channel, or null. `getChannelServerId(id)` names a server only when + exactly one registered the id, and returns null for an id several + servers share, where it used to return the first registrant. `buildChannelContext(agent)` advertises the agent's own route and reply edge, and nothing for a held, surface-only or absent route. - **Migration:** read a route from the framework's turn rather than the diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index fbd15acfd..36950451f 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -48,7 +48,9 @@ Without `channelId` it goes to the caller's current route. With no route (held, a local surface, or none) it is refused with the reason, and it no longer uses the most recent inbound channel. A new optional `serverId` - disambiguates a channel id that several servers register. Its receipt + disambiguates a channel id that several servers register; a conversation + fork may name a server only as its home's sole registrant, since the same + id on another server is another conversation. Its receipt names `serverId`, `channelId` and `channelLabel`. A failed or unknown outcome is an error that keeps its `status` and the attempted destination; `unknown` warns that the message may already have been posted. @@ -64,3 +66,17 @@ host's source envelope too, the one its coalescer then freezes, and an `inboundSource` key in a connector's origin or metadata is never read as one. +- **A channel id no single server registers is never given a server by + guess.** A fork's home, or a wake candidate whose event named no server, + takes the server only from the id's sole registrant. On an id that several + servers share, the turn records the conversation as `unresolved`, and its + speech is held as drafts rather than published to whichever server + registered first. `unresolved` also now covers a route whose channel was + withdrawn, which used to read as undeclared (`untargetable`). +- **A fork's channel is its own on every wake path.** A push event, a + coalesced push and a `channels/incoming` broadcast now give no other agent + a speech route or a typing indicator in a channel a conversation fork owns, + and a fork takes neither in a channel other than its home. Batched gate + wakes already worked this way. +- The `[discord-send-failed]` marker for a reply on a thread route names the + thread with its channel, and an unconfirmed one says to check the thread. diff --git a/src/framework.ts b/src/framework.ts index b290a13dc..5bd46fc27 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1813,7 +1813,7 @@ export class AgentFramework { // route applies to a fork only when it IS that home, and never to // another agent when a fork owns that channel. Otherwise a fork // bound elsewhere would show typing where it isn't replying. - ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId) + ...(provenance?.routeChannelId && framework.mayTakeRoute(agentName, provenance.routeChannelId) ? { channelId: provenance.routeChannelId, addressed: true } : {}), // The batch's conversations as speech-route candidates, under the @@ -1822,7 +1822,7 @@ export class AgentFramework { ...(provenance?.routeCandidates?.length ? { routeCandidates: provenance.routeCandidates - .filter((c) => c.kind === 'surface' || c.unroutable || framework.mayTakeGateRoute(agentName, c.channelId)) + .filter((c) => c.kind === 'surface' || c.unroutable || framework.mayTakeRoute(agentName, c.channelId)) .map((c): RouteCandidate => c.kind === 'surface' ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } : c.unroutable @@ -7501,17 +7501,20 @@ export class AgentFramework { const candidate = isConversational(event.tags, event.metadata) ? this.candidateFromSource(source, addressed) : undefined; for (const agentName of targetAgents) { if (!this.agents.has(agentName)) continue; + // The broadcast reaches forks too: the fork rule keeps each agent's + // typing and route candidate to a channel it may take. + const takesChannel = this.mayTakeRoute(agentName, event.channelId); this.pendingRequests.push({ agentName, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - channelId: event.channelId, + ...(takesChannel ? { channelId: event.channelId } : {}), counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed, // This message's conversation, as a speech-route candidate: the // turn's route is inferred from every candidate of its batch. - ...(candidate ? { routeCandidates: [candidate] } : {}), + ...(candidate && this.mayTakeCandidate(agentName, candidate) ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -8468,15 +8471,16 @@ export class AgentFramework { ? this.candidateFromSource(occ.event.event.inboundSource, addressedHere) : undefined; for (const agentName of await this.coalescedAudience(occ)) { + const takes = candidate && this.mayTakeCandidate(agentName, candidate) ? candidate : undefined; this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', source: occ.serverId, timestamp: Date.now(), - channelId, + channelId: channelId && this.mayTakeRoute(agentName, channelId) ? channelId : undefined, counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, addressed: addressedHere, - ...(candidate ? { routeCandidates: [candidate] } : {}), + ...(takes ? { routeCandidates: [takes] } : {}), coalescingSubject: subject, coalescingBatch: true, }); @@ -8696,14 +8700,20 @@ export class AgentFramework { ? undefined : this.candidateFromSource(source, pushAddressed); for (const agentName of targetAgents) { + // The fork rule, as for a gate batch: no agent takes a channel a fork + // owns, and a fork takes only its home. + const typingChannel = triggerChannel?.channelId && this.mayTakeRoute(agentName, triggerChannel.channelId) + ? triggerChannel.channelId + : undefined; + const candidate = pushCandidate && this.mayTakeCandidate(agentName, pushCandidate) ? pushCandidate : undefined; this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', source: event.serverId, timestamp: Date.now(), - channelId: triggerChannel?.channelId, + channelId: typingChannel, addressed: pushAddressed, - ...(pushCandidate ? { routeCandidates: [pushCandidate] } : {}), + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(silentHeartbeat ? { suppressProse: true, ephemeralSystemPrompt: @@ -8719,12 +8729,12 @@ export class AgentFramework { } /** - * Whether a batched gate wake's addressed route may become `agentName`'s - * speech locus. The gate broadcasts to every agent, conversation forks - * included. A fork speaks only in its home channel, and a channel bound to - * a fork is that fork's conversation, so no other agent takes it. + * Whether a wake's channel may become `agentName`'s speech route or typing + * channel: for a gate batch, a push event or a coalesced push alike. A + * fork speaks only in its home channel, and a channel bound to a fork is + * that fork's conversation, so no other agent takes it. */ - private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean { + private mayTakeRoute(agentName: string, routeChannelId: string): boolean { const home = this.conversationAgentHomes.get(agentName); if (home !== undefined) return home === routeChannelId; for (const boundChannel of this.conversationAgentHomes.values()) { @@ -8733,6 +8743,11 @@ export class AgentFramework { return true; } + /** mayTakeRoute for a route candidate: a local surface is never a fork's. */ + private mayTakeCandidate(agentName: string, candidate: RouteCandidate): boolean { + return candidate.conversation.kind === 'surface' || this.mayTakeRoute(agentName, candidate.conversation.channelId); + } + /** * Derive the MCPL composite channel id (the outbound routing locus) for a * push event from its server-defined `origin`, if it carries one. @@ -9915,7 +9930,12 @@ export class AgentFramework { }; } - /** A route candidate for a registered channel id (gate and coalesced wakes). */ + /** + * A route candidate for a registered channel id (gate and coalesced wakes). + * The server is the event's own, else the id's sole registrant. An id no + * single server holds (shared across servers, or withdrawn since) gets no + * guessed server: the candidate competes for the turn but is unroutable. + */ private candidateForChannel( channelId: string, addressed: boolean, @@ -9937,10 +9957,15 @@ export class AgentFramework { addressed, ...(messageId ? { messageId } : {}), at, + ...(server ? {} : { unroutable: true as const }), }; } - /** A conversation fork's home as its standing route (it always wins). */ + /** + * A conversation fork's home as its standing route (it always wins). Homes + * are bare channel ids: the server is the id's sole registrant, and a + * shared id gets none, so the route is unresolved rather than guessed. + */ private homeRoute(agentName: string): SpeechRoute | null { const home = this.conversationAgentHomes.get(agentName) ?? this.channelRegistry?.resolveLocus(agentName) ?? null; if (!home) return null; @@ -10106,8 +10131,15 @@ export class AgentFramework { } /** A channel's declared publish target (MCPL RFC-011), read live from the registry. */ - private publishTargetOf(channel: { serverId?: string; channelId: string }): 'exact' | 'root' | undefined { - return this.channelRegistry?.publishTarget(channel); + private publishTargetOf(channel: { serverId?: string; channelId: string }): 'exact' | 'root' | 'unresolved' | undefined { + const registry = this.channelRegistry; + if (!registry) return undefined; + const declared = registry.publishTarget(channel); + if (declared) return declared; + // No declaration read: a registered channel that declares none, or a + // channel that isn't exactly one registration (unknown, withdrawn, or a + // shared id named without its server). Only the second is unresolved. + return 'error' in registry.resolveDestination(channel) ? 'unresolved' : undefined; } /** @@ -10155,7 +10187,7 @@ export class AgentFramework { return `${where} is a thread, but its connector declares the channel has no threads, so publication from ` + `here can't target it.${tail}`; case 'unresolved': - return `${where} couldn't be resolved to a registered channel.${tail || ' Use an explicit send tool to reach it.'}`; + return `${where} doesn't resolve to exactly one registered channel.${tail || ' Use an explicit send tool to reach it.'}`; } } @@ -15104,7 +15136,7 @@ export class AgentFramework { // addMessage() alone does not request inference, so this never wakes // her (matching the `discord-send-failed-skip` gate intent: context // yes, wake no). - onRouteFailure: ({ channelId, reason, textLen, outcome }) => { + onRouteFailure: ({ channelId, threadId, reason, textLen, outcome }) => { try { // Render a human-readable channel name when we can — a bare // snowflake in the marker is unresolvable for the agent (the @@ -15114,11 +15146,14 @@ export class AgentFramework { const label = channelId ? this.channelRegistry?.getDescriptor(channelId)?.label : undefined; - const where = channelId + const channel = channelId ? label && label !== channelId ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` : channelId : 'the channel'; + // A thread route failed in its thread: say so, so a check or a + // resend looks there rather than at the channel's root. + const where = threadId ? `thread ${threadId} in ${channel}` : channel; this.addMessage( 'user', [{ @@ -15126,10 +15161,10 @@ export class AgentFramework { text: outcome === 'unknown' // A request went out and no valid receipt came back: it may // have been posted (in part or whole). Never claim it wasn't. - ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the channel before sending it again. It is saved in your archive.` + ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the ${threadId ? 'thread' : 'channel'} before sending it again. It is saved in your archive.` : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason, ...(outcome ? { outcome } : {}) }, + { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', ...(threadId ? { threadId } : {}), reason, ...(outcome ? { outcome } : {}) }, ); } catch (err) { console.error('onRouteFailure: failed to record send-failure marker:', err); @@ -16932,7 +16967,7 @@ export class AgentFramework { return; } if (call.name === 'channel_publish' || call.name === 'channel_close') { - const input = (call.input ?? {}) as { channelId?: unknown }; + const input = (call.input ?? {}) as { channelId?: unknown; serverId?: unknown }; if (!supplied(input.channelId)) { call = { ...call, input: { ...input, channelId: home } }; } else if (!named(input.channelId)) { @@ -16943,6 +16978,22 @@ export class AgentFramework { reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId as string} is not allowed.`, who); return; } + // The home is a bare channel id, so a server named with it must be + // the one server that registered it: the same id on another server + // is another conversation, and a shared id names no server at all. + if (supplied(input.serverId)) { + const homeServer = this.channelRegistry?.getChannelServerId(home) ?? null; + if (input.serverId !== homeServer) { + const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing'; + reject( + homeServer + ? `This conversation is bound to channel ${home} on server ${homeServer}; ${verb} it on server ${String(input.serverId)} is not allowed.` + : `This conversation is bound to channel ${home}, which isn't registered by exactly one server, so it can't be named with a server. Nothing was ${call.name === 'channel_publish' ? 'sent' : 'closed'}.`, + who, + ); + return; + } + } } } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index d8f6e6764..653f4a7f6 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -601,6 +601,8 @@ interface ChannelRegistryOptions { onRouteFailure?: (info: { conversationId: string; channelId: string | null; + /** The thread the speech was for, when its route was a thread. */ + threadId?: string; reason: string; textLen: number; /** `failed`: nothing was posted; `unknown`: the request was dispatched @@ -2312,9 +2314,12 @@ export class ChannelRegistry { .map((e) => e.descriptor); } - /** Server id owning a registered channel (by descriptor id), or null. */ + /** The server that registered a channel id, or null when none did or more + * than one did. A shared id names no server: resolveDestination refuses it + * too, so a caller never fills one in by registration order. */ getChannelServerId(channelId: string): string | null { - return this.findChannelEntry(channelId)?.serverId ?? null; + const found = this.findExactEntry({ channelId }); + return 'error' in found ? null : found.entry.serverId; } /** @@ -3363,7 +3368,7 @@ export class ChannelRegistry { * "this turn is pinned to no locus": fail loudly rather than guess. */ locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, ): Promise { - const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => { + const fail = (channelId: string | null, reason: string, outcome: PublishOutcome, threadId?: string | null): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ @@ -3374,7 +3379,7 @@ export class ChannelRegistry { textLen: text.length, outcome: status, }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status }); + this.onRouteFailure?.({ conversationId, channelId, ...(threadId ? { threadId } : {}), reason, textLen: text.length, outcome: status }); return outcome; }; @@ -3395,11 +3400,12 @@ export class ChannelRegistry { // A route that knows its server publishes exactly there: the same // channel id on another server is a different conversation. - const target = typeof locusChannelId === 'string' ? { channelId: locusChannelId } : locusChannelId; + const target: { serverId?: string; channelId: string; threadId?: string | null } = + typeof locusChannelId === 'string' ? { channelId: locusChannelId } : locusChannelId; const outcome = await this.publish(conversationId, text, target); const channelId = outcome.destination?.channelId ?? target.channelId; if (outcome.status !== 'delivered') { - return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome); + return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome, outcome.destination?.threadId ?? target.threadId); } console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${outcome.destination!.serverId}, delivered=true)`); diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 06810e9a0..f035b2238 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -110,13 +110,17 @@ export interface TurnRoute { export type UnroutableReason = 'untargetable' | 'thread' | 'unresolved'; -/** A channel's declared publish target (RFC-011), as the framework reads it. */ -export type PublishTargetOf = (channel: { serverId?: string; channelId: string }) => 'exact' | 'root' | undefined; +/** A channel's declared publish target (RFC-011), as the framework reads it; + * `unresolved` when the channel doesn't resolve to exactly one registered + * channel (none, or an id more than one server registers, named without + * its server). */ +export type PublishTargetOf = (channel: { serverId?: string; channelId: string }) => 'exact' | 'root' | 'unresolved' | undefined; /** Why a channel conversation can't be a route, or undefined when it can. */ export function routeRefusal(c: ConversationRef, targetOf: PublishTargetOf): UnroutableReason | undefined { if (c.kind !== 'channel') return undefined; const declared = targetOf(c); + if (declared === 'unresolved') return 'unresolved'; if (!declared) return 'untargetable'; if (c.threadId && declared !== 'exact') return 'thread'; return undefined; @@ -246,10 +250,12 @@ export function isConversational(tags: readonly string[] | undefined, metadata?: /** * Whether a mid-turn arrival suspends the turn's route: only an inferred - * route (origin 'trigger') not already held, and only for a conversational - * message from a DIFFERENT conversation that addressed the resident, or that - * continues a conversation the resident explicitly sent into this turn. - * Ambient chatter never does; deliberate routes never are. + * route (origin 'trigger') not already held, and only for an arrival from a + * DIFFERENT conversation that addressed the resident, or that continues a + * conversation the resident explicitly sent into this turn. Ambient chatter + * never does; deliberate routes never are. It sees no tags: the caller passes + * only conversational arrivals (isConversational), so a reaction or a system + * marker never reaches it. */ export function suspendsRoute( turn: TurnRoute, diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index bf81bc339..d0e58d306 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -235,4 +235,85 @@ describe('batched wake routing with conversation forks', () => { // Telemetry provenance still reaches every agent. assert.equal(byAgent.get('fork-other')?.counterparty, 'discord:user:5'); }); + + type Wake = { agentName: string; channelId?: string; routeCandidates?: Array<{ conversation: { channelId?: string } }> }; + const pushInto = (channelId: string, eventId: string) => ({ + type: 'mcpl:push-event', serverId: 'discord', featureSet: 'chat', eventId, + content: [{ type: 'text', text: 'are you there?' }], timestamp: new Date().toISOString(), + triggerInference: true, tags: ['chat:addressed'], + origin: { mcplChannelId: channelId, messageId: `${eventId}-m`, authorId: '5' }, + }); + + it('a push event follows the fork rule: no agent takes a fork-owned channel as its route or typing', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Wake[]; + handleMcplPushEvent(event: unknown): unknown; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + internals.handleMcplPushEvent(pushInto(ROOM, 'e1')); + const trunk = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.ok(trunk, 'the trunk is still woken'); + assert.equal(trunk!.channelId, undefined, 'no typing in a fork-owned channel'); + assert.equal(trunk!.routeCandidates, undefined, 'and no route into it'); + // Control: the same push into a channel no fork owns. + internals.pendingRequests.length = 0; + internals.handleMcplPushEvent(pushInto(GENERAL, 'e2')); + const free = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.equal(free?.channelId, GENERAL); + assert.deepEqual(free?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL]); + }); + + it('a coalesced push batch follows the fork rule for its typing channel and route candidate', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Wake[]; + wakeForCoalescedBatch(occ: unknown): Promise; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + const batch = (channelId: string, eventId: string) => ({ + serverId: 'discord', binding: 'discord', scope: { kind: 'channel', id: channelId }, key: 'k', eventId, + timestamp: new Date().toISOString(), retract: false, deferred: false, initial: false, tags: ['chat:addressed'], + event: { + lane: 'push', + event: { + ...pushInto(channelId, eventId), + // The envelope the host froze at acceptance: the batch's conversation. + inboundSource: { kind: 'channel', serverId: 'discord', channelId, messageId: `${eventId}-m`, acceptedAt: Date.now() }, + }, + }, + }); + await internals.wakeForCoalescedBatch(batch(ROOM, 'c1')); + const trunk = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.ok(trunk, 'the trunk is still woken'); + assert.equal(trunk!.channelId, undefined, 'no typing in a fork-owned channel'); + assert.equal(trunk!.routeCandidates, undefined, 'and no route into it'); + internals.pendingRequests.length = 0; + await internals.wakeForCoalescedBatch(batch(GENERAL, 'c2')); + const free = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.equal(free?.channelId, GENERAL, 'a free channel is taken'); + assert.deepEqual(free?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'as a route candidate too'); + }); + + it('a channel-incoming broadcast follows the fork rule for every agent it wakes', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Wake[]; + handleMcplChannelIncoming(event: unknown): Promise; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + internals.conversationAgentHomes.set('fork-other', GENERAL); + await internals.handleMcplChannelIncoming({ + type: 'mcpl:channel-incoming', serverId: 'discord', channelId: ROOM, messageId: 'm-1', + author: { id: '5', name: 'Five' }, content: [{ type: 'text', text: '@agent hi' }], + timestamp: new Date().toISOString(), triggerInference: true, tags: ['chat:addressed'], + }); + const byAgent = new Map(internals.pendingRequests.map((r) => [r.agentName, r])); + assert.equal(byAgent.get('fork-room')?.channelId, ROOM, 'the fork that owns the channel takes it'); + assert.deepEqual(byAgent.get('fork-room')?.routeCandidates?.map((c) => c.conversation.channelId), [ROOM]); + for (const other of ['fork-other', 'trunk']) { + assert.equal(byAgent.get(other)?.channelId, undefined, `${other} gets no typing there`); + assert.equal(byAgent.get(other)?.routeCandidates, undefined, `${other} gets no route there`); + } + }); }); diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index cd55d3c4d..36145545d 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -458,6 +458,7 @@ describe('Conversation routing', () => { channelRegistry: { handleChannelToolCall(name: string, input: unknown, origin?: unknown): Promise<{ success: boolean }>; stopAll(): void; + getChannelServerId?(id: string): string | null; } | null; }; @@ -466,7 +467,12 @@ describe('Conversation routing', () => { // channel_* tools). Stub the downstream boundary — the unit under test is // the fence ABOVE it, and a home-defaulted publish must get past the fence // to be observable at tool:started. - fw.channelRegistry = { handleChannelToolCall: async () => ({ success: true }), stopAll: () => {} }; + fw.channelRegistry = { + handleChannelToolCall: async () => ({ success: true }), + stopAll: () => {}, + // The home's sole registrant (a fork's home is a bare channel id). + getChannelServerId: (id: string) => (id === 'slack:C7' ? 'slack-a' : null), + } as typeof fw.channelRegistry; // (a) omitted channelId → rewritten to the home channel, passes the fence fw.dispatchChannelToolCall(forkName, { id: 'p1', name: 'channel_publish', input: { content: 'hello home' } }); @@ -488,6 +494,18 @@ describe('Conversation routing', () => { 'foreign publish never reaches tool:started', ); + // (c) the home's id on another server → rejected: the same id there is + // another conversation + fw.dispatchChannelToolCall(forkName, { id: 'p3', name: 'channel_publish', input: { channelId: 'slack:C7', serverId: 'slack-b', content: 'elsewhere' } }); + const otherServer = failures.find((f) => f.callId === 'p3'); + assert.ok(otherServer, 'the home id named on another server is rejected'); + assert.ok(otherServer!.error.includes('on server slack-a; publishing to it on server slack-b is not allowed'), otherServer!.error); + + // (d) the home with its own server → passes the fence + fw.dispatchChannelToolCall(forkName, { id: 'p4', name: 'channel_publish', input: { channelId: 'slack:C7', serverId: 'slack-a', content: 'here' } }); + assert.ok(!failures.some((f) => f.callId === 'p4'), "naming the home's own server passes"); + assert.equal(started.filter((t) => t.tool === 'channel_publish').length, 2); + await framework.stop(); }); }); diff --git a/test/deferred-wake-integrity.test.ts b/test/deferred-wake-integrity.test.ts index 6aa5cdc5d..0b5994e34 100644 --- a/test/deferred-wake-integrity.test.ts +++ b/test/deferred-wake-integrity.test.ts @@ -119,10 +119,11 @@ function stubChannelRegistry(framework: AgentFramework) { routeSpeech: async (_agent: string, text: string, locus?: string | null) => { routed.push({ text, locus: locus ?? null }); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // As the registry answers for a locus no server registered. + resolveDestination: () => ({ error: 'no registered channel' }), }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { get: (target, prop: string) => (prop in target ? target[prop] : () => undefined), diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index af0367b9d..9d964406f 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -94,7 +94,6 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals }, resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), resolveLocus: () => 'world:commons', - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 41788c249..4ceb41b7b 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -149,7 +149,6 @@ function stubChannelRegistry(framework: AgentFramework, opts: { home?: boolean } // the thread included when the publish was placed in one. return locus ? { delivered: true, channelId: locus, ...(threadId ? { threadId } : {}) } : null; }, - getDefaultPublishChannel: () => null, // Hybrid `>>>#name` targets resolve to `chan-name` and deliver through // the registry's outcome-returning form, recorded like routeSpeech. resolveProseTarget: (spec: string) => ({ channelId: spec.startsWith('#') ? `chan-${spec.slice(1)}` : spec, label: spec }), diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 98c1fbfff..4d507e063 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -66,6 +66,33 @@ function registryWith(servers: Record ({ delivered: true, messageId: 'posted-1', threadId: params.threadId }); +describe('ChannelRegistry server lookup and route failures', () => { + it('names the server of an id only one server registered; a shared or unknown id names none', () => { + const { registry, seed } = registryWith({ alpha: { publish: ok }, beta: { publish: ok } }); + seed('alpha', 'solo', '#solo'); + seed('alpha', 'shared', '#shared'); + seed('beta', 'shared', '#shared'); + assert.equal(registry.getChannelServerId('solo'), 'alpha'); + assert.equal(registry.getChannelServerId('shared'), null, 'never whichever server registered it first'); + assert.equal(registry.getChannelServerId('nowhere'), null); + }); + + it('a failed delivery into a thread reports the thread with its channel', async () => { + const failures: Array> = []; + const { registry, seed } = registryWith( + { discord: { publish: async () => ({ delivered: false, reason: 'thread t-1 is archived' }) } }, + failures, + ); + seed('discord', 'forum', '#forum', 'exact'); + await registry.deliverSpeech('agent', 'hello', { serverId: 'discord', channelId: 'forum', threadId: 't-1' }); + assert.equal(failures.length, 1); + assert.equal(failures[0]!.channelId, 'forum'); + assert.equal(failures[0]!.threadId, 't-1'); + await registry.deliverSpeech('agent', 'hello', { serverId: 'discord', channelId: 'forum' }); + assert.equal('threadId' in failures[1]!, false, 'a root delivery names no thread'); + }); +}); + describe('ChannelRegistry.publish outcomes', () => { it('confirms only delivered:true, naming the destination from the registry', async () => { const { registry, seed } = registryWith({ discord: { publish: ok } }); @@ -383,3 +410,52 @@ test('the resident\'s failure marker never claims an uncertain delivery did not rmSync(dir, { recursive: true, force: true }); } }); + +test('a failure marker for a thread route names the thread, and says to check it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-thread-')); + const commandPath = join(dir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { + STATUS_PATH: join(dir, 'status.jsonl'), + COMMAND_PATH: commandPath, + CHANNELS: JSON.stringify([{ id: 'discord:g1:forum', label: '#forum (Guild One)', publishTarget: 'exact' }]), + }, + }], + modules: [], + }); + try { + await framework.start(); + const registry = (framework as unknown as { channelRegistry: { listChannelsRaw(): unknown[] } }).channelRegistry; + const until = async (cond: () => boolean, what: string) => { + const deadline = Date.now() + 15_000; + while (!cond()) { if (Date.now() > deadline) throw new Error(`timed out: ${what}`); await new Promise((r) => setTimeout(r, 20)); } + }; + await until(() => registry.listChannelsRaw().length >= 1, 'registration'); + const markers = () => framework.getAgent('scout')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'discord-send-failed'); + appendFileSync(commandPath, JSON.stringify({ op: 'publish-mode', mode: 'no-receipt' }) + '\n'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'reply in the thread' }])); + appendFileSync(commandPath, JSON.stringify({ + op: 'incoming', channelId: 'discord:g1:forum', threadId: 't1', messageId: 'm-1', mode: 'addressed', text: 'hello?', + }) + '\n'); + await until(() => markers().length === 1, 'marker'); + await framework.runUntilIdle(); + const [marker] = markers(); + const text = (marker!.content[0] as { text: string }).text; + assert.match(text, /to thread t1 in #forum \(Guild One\) \(discord:g1:forum\) was not confirmed/); + assert.match(text, /check the thread before sending it again/); + assert.equal((marker!.metadata as { threadId?: string }).threadId, 't1'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index e12f7e869..5d5831419 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -126,7 +126,6 @@ function makeChannelRegistry(framework: AgentFramework) { routeSpeech: async (_agent: string, text: string) => { routed.push(text); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', }; diff --git a/test/silent-heartbeat-rows.test.ts b/test/silent-heartbeat-rows.test.ts index deb060c2a..c3acdba82 100644 --- a/test/silent-heartbeat-rows.test.ts +++ b/test/silent-heartbeat-rows.test.ts @@ -108,7 +108,7 @@ async function make(opts: { modules: [mod], }); (framework as any).channelRegistry = new Proxy({ - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', + resolveLocus: () => 'world:commons', routeSpeech: async () => ({ delivered: opts.deliver ?? true, channelId: 'world:commons' }), startTyping: () => {}, stopTyping: () => {}, getChannelTools: () => [], getDescriptor: () => undefined, diff --git a/test/silent-heartbeat.test.ts b/test/silent-heartbeat.test.ts index 94842051b..9226f7156 100644 --- a/test/silent-heartbeat.test.ts +++ b/test/silent-heartbeat.test.ts @@ -45,7 +45,7 @@ async function make() { const routed: string[] = []; const typing: string[] = []; (framework as any).channelRegistry = new Proxy({ - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', + resolveLocus: () => 'world:commons', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, startTyping: (channel: string) => { typing.push(channel); }, stopTyping: () => {}, diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index 3e205f94d..86fa23ab0 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -13,6 +13,7 @@ import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { InferenceRequest, ProcessEvent, Module, ModuleContext, ProcessState, EventResponse, ToolDefinition, ToolCall, ToolResult } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { RouteCandidate, SpeechRoute } from '../src/speech-routes.js'; type Route = { route: { kind: string; channelId?: string; surface?: string; origin?: string } | null; hold?: unknown }; @@ -133,6 +134,43 @@ describe('speech route across a logical turn', () => { await framework.stop(); }); + it('a channel id no single server registers gets no guessed server: unroutable as a candidate, unresolved as a home', async () => { + const framework = await makeFramework(); + const registrants: Record = { solo: ['alpha'], shared: ['alpha', 'beta'] }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + getChannelServerId: (id: string) => (registrants[id]?.length === 1 ? registrants[id]![0] : null), + resolveDestination: (c: { serverId?: string; channelId: string }) => + (c.serverId || registrants[c.channelId]?.length === 1) + ? { destination: { serverId: c.serverId ?? registrants[c.channelId]![0], channelId: c.channelId } } + : { error: 'shared or unknown' }, + // As the registry reads it: a declaration only for exactly one registration. + publishTarget: (c: { serverId?: string; channelId: string }) => + (c.serverId || registrants[c.channelId]?.length === 1 ? 'root' : undefined), + getDescriptor: () => undefined, + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const f = framework as unknown as { + candidateForChannel(channelId: string, addressed: boolean, at: number, serverId?: string): RouteCandidate; + homeRoute(agentName: string): SpeechRoute | null; + publishTargetOf(c: { serverId?: string; channelId: string }): string | undefined; + conversationAgentHomes: Map; + }; + const solo = f.candidateForChannel('solo', true, 1); + assert.equal(solo.conversation.kind === 'channel' ? solo.conversation.serverId : '', 'alpha', 'a sole registrant is the server'); + assert.equal(solo.unroutable, undefined); + const shared = f.candidateForChannel('shared', true, 1); + assert.equal(shared.conversation.kind === 'channel' ? shared.conversation.serverId : '', undefined, 'never the first registrant'); + assert.equal(shared.unroutable, true, 'it competes, but is no route'); + const named = f.candidateForChannel('shared', true, 1, 'beta'); + assert.equal(named.conversation.kind === 'channel' ? named.conversation.serverId : '', 'beta', "the event's own server stands"); + assert.equal(named.unroutable, undefined); + f.conversationAgentHomes.set('scout', 'shared'); + const home = f.homeRoute('scout'); + assert.equal(home?.kind === 'channel' ? home.serverId : '', undefined, 'a shared home gets no server'); + assert.equal(f.publishTargetOf({ channelId: 'shared' }), 'unresolved', 'and is unresolved, not undeclared'); + assert.equal(f.publishTargetOf({ channelId: 'solo' }), 'root'); + await framework.stop(); + }); + it('the [delivered] receipt names each destination as delivered: server, label then, and thread', async () => { const framework = await makeFramework(); const scout = framework.getAgent('scout')!; diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 93520f1ee..7bc66fd77 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -9,6 +9,7 @@ import { describeConversation, inferTurnRoute, isConversational, + routeRefusal, suspendsRoute, type ConversationRef, type RouteCandidate, @@ -96,6 +97,31 @@ describe('inferTurnRoute', () => { 'the addressed pool decides, so its newest message is the reply edge'); }); + it('candidates of one conversation at the same moment: the later one is the reply edge', () => { + const turn = inferTurnRoute([ + candidate(channel('room'), true, 4, 'first'), + candidate(channel('room'), true, 4, 'second'), + ], undefined, rootEverywhere); + assert.equal(turn.route?.kind === 'channel' ? turn.route.replyTo : undefined, 'second', + 'a tie goes to the candidate that arrived later in the wake'); + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'room', origin: 'home' }; + const atHome = inferTurnRoute([ + candidate(channel('room'), true, 4, 'first'), + candidate(channel('room'), true, 4, 'second'), + ], home, rootEverywhere); + assert.equal(atHome.route?.kind === 'channel' ? atHome.route.replyTo : undefined, 'second', 'and so for a home'); + }); + + it('a channel that resolves to no single registration is unresolved, not undeclared', () => { + const turn = inferTurnRoute([candidate(channel('shared'), true, 1)], undefined, () => 'unresolved'); + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'unresolved'); + const home: SpeechRoute = { kind: 'channel', channelId: 'shared', origin: 'home' }; + assert.equal(inferTurnRoute([], home, () => 'unresolved').unroutable?.reason, 'unresolved', + 'a fork home on a shared id is held as unresolved'); + assert.equal(routeRefusal(channel('room'), declaredNowhere), 'untargetable'); + }); + it('the same channel id on two servers, or two threads of one channel, are different conversations', () => { const servers = inferTurnRoute([ candidate({ kind: 'channel', serverId: 'a', channelId: 'shared' }, true, 1), @@ -175,6 +201,14 @@ describe('suspendsRoute', () => { assert.equal(suspendsRoute(routeA, { conversation: channel('A'), addressed: true }, notEngaged), false); }); + it('a thread route is its thread: the same thread never suspends it, the channel root does', () => { + const inThread: TurnRoute = { + route: { kind: 'channel', serverId: 'discord', channelId: 'forum', threadId: 't1', replyTo: 'm1', origin: 'trigger' }, + }; + assert.equal(suspendsRoute(inThread, { conversation: channel('forum', { threadId: 't1' }), addressed: true }, notEngaged), false); + assert.equal(suspendsRoute(inThread, { conversation: channel('forum'), addressed: true }, notEngaged), true); + }); + it('ambient chatter does only in a conversation the resident engaged this turn', () => { const arrival = { conversation: channel('lounge'), addressed: false }; assert.equal(suspendsRoute(routeA, arrival, notEngaged), false); @@ -211,6 +245,8 @@ describe('conversation identity and wording', () => { it('describes a conversation by a usable address', () => { assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), '#room (discord:g1:room)'); assert.equal(describeConversation(channel('discord:dm:7')), 'discord:dm:7'); + assert.equal(describeConversation(channel('discord:dm:7', { label: 'discord:dm:7' })), 'discord:dm:7', + 'a label that is only the id adds nothing'); assert.equal(describeConversation(channel('forum', { threadId: 't1' })), 'forum (thread t1)'); assert.equal(describeConversation({ kind: 'surface', surface: 'tui' }), 'tui (the local surface that messaged you)'); }); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts index a81f046c9..25bb68452 100644 --- a/test/targeted-publish.test.ts +++ b/test/targeted-publish.test.ts @@ -133,7 +133,8 @@ describe('targeted publish (MCPL RFC-011)', () => { assert.deepEqual(publishes(), []); assert.deepEqual(drafts().map((d) => [d.text, d.reason]), [['hello legacy', 'no-destination']]); const notice = texts().find((t) => t.startsWith('[routing]')) ?? ''; - assert.match(notice, /#legacy \(Guild One\) \(discord:g1:legacy\): its connector doesn't declare where a post lands \(MCPL RFC-011\)/); + assert.match(notice, /(?:^|[^#])#legacy \(Guild One\) \(discord:g1:legacy\): its connector doesn't declare where a post lands \(MCPL RFC-011\)/, + 'the label once, with its own # and no second one'); assert.match(notice, /publication from here is unavailable until it does/); assert.match(notice, /may reach it; consult them: `mcpl--discord--reply_message`\./, 'a send-named tool, not the unrelated one'); assert.doesNotMatch(notice, /ring/); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 054691106..1ec7ca4e2 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -321,7 +321,7 @@ test('native Membrane observes the first refusal even when guard is enabled by a routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const tokenTraces: string[] = []; @@ -539,7 +539,7 @@ function fakeRegistry(framework: AgentFramework) { routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: () => {}, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); return routed; diff --git a/test/tool-wrapper-prose-guard-integration.test.ts b/test/tool-wrapper-prose-guard-integration.test.ts index 0543c5ad9..a7031841a 100644 --- a/test/tool-wrapper-prose-guard-integration.test.ts +++ b/test/tool-wrapper-prose-guard-integration.test.ts @@ -123,7 +123,7 @@ async function run(enabled?: boolean, allowedTools?: string[]) { resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, speech: string) => { routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -174,7 +174,7 @@ describe('tool wrapper prose guard integration', () => { (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_c: string, _a: string, _id: string, _i: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -191,7 +191,7 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; @@ -210,7 +210,7 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', maxStreamTokens: 1, toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; @@ -236,7 +236,7 @@ describe('tool wrapper prose guard integration', () => { outgoing.push(routed.includes(delta) ? delta : `unpublished: ${delta}`); return true; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); (framework as unknown as Record).channelEventModule = { getChannelId: () => 'world:test' }; framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); @@ -266,7 +266,7 @@ describe('tool wrapper prose guard integration', () => { }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -420,7 +420,7 @@ describe('tool wrapper prose guard integration', () => { routeSpeech: async () => ({ delivered: true, channelId: 'world:test' }), sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => outgoing.push(['chunk', delta]), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => outgoing.push(['complete', text]), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], resolveProseTarget: () => ({ channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'ephemeral-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); @@ -467,7 +467,7 @@ describe('tool wrapper prose guard integration', () => { routeSpeech: async () => ({ delivered: true, channelId: 'world:test' }), sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => events.push(`chunk:${delta}`), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => events.push(`complete:${text}`), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], resolveProseTarget: () => ({ channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'idle-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 38526c56f..83894cd20 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -1,19 +1,22 @@ /** * Item-3 redux: single-TRUNK output routing. * - * These tests cover the FRAMEWORK-side plumbing that feeds the ChannelRegistry's - * `activeChannelResolver` (the routing DECISION itself is covered in - * channel-registry-routing.test.ts): + * These tests cover the FRAMEWORK-side plumbing that gives a trunk turn its + * speech route (shelf-355; the route DECISION itself is unit-tested in + * speech-routes.test.ts): * - derivePushEventChannel() reconstructs the MCPL composite channel for a * push event (Discord DMs arrive this way), preferring an explicit * origin.mcplChannelId. - * - a channel-incoming turn records its triggering channel per-agent. - * - a DM push-event turn records the reconstructed DM channel per-agent. - * - a batched wake picks the MOST-RECENT triggering channel. + * - a channel-incoming turn, and a DM push-event turn, take their + * triggering conversation as the turn's route; a reaction is no candidate. + * - the route belongs to the current turn, and a no-trigger turn has none. + * - threads are routes only where the channel's connector posts into named + * threads (MCPL RFC-011 exact), and a channel that declares no publish + * target is no route at all. * * connectome-host runs every agent as a single trunk (it never sets - * `conversations`), so no fork/home exists — the active triggering channel is - * the only thing that keeps a reply in the channel it is answering. + * `conversations`), so no fork/home exists: the turn's triggering + * conversation is what keeps a reply in the channel it is answering. */ import { describe, it, beforeEach, afterEach } from 'node:test'; @@ -47,6 +50,9 @@ function declaringRegistry(target: 'exact' | 'root' | undefined) { const published: Array<{ text: string; to: unknown }> = []; const registry = new Proxy({ publishTarget: () => target, + // Every channel it is asked about is one registration. + resolveDestination: (c: { serverId?: string; channelId: string }) => + ({ destination: { serverId: c.serverId ?? 'discord', channelId: c.channelId } }), resolveLocus: () => null, routeSpeech: async (_a: string, text: string, to: unknown) => { published.push({ text, to }); From 4e19f02c5ed0ec76fcc6566187fc8c72afeeb0d5 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 10 Oct 2026 17:45:16 -0400 Subject: [PATCH 38/65] fix(routing): routing names each conversation with its server; a shared-id refusal lists the servers Oscar-1808's review run over #256 (wf-96, sr-1, its third finding; recurred in three runs of the 2026-10-10 reruns; room-203 #67707): describeConversation, documented as "a usable address for a conversation, as a resident would write it", dropped serverId, though conversationKey includes it. Two conversations that share a channel id and label on different servers read identically in the hold, routing and no-destination notices ("#general (general); #general (general)"), and the resident's natural bare send to `general` is then refused as ambiguous. The refusal itself ("registered by more than one MCPL server; the destination must name its server") named no server either, so nothing on screen gave the resident a serverId to name. The decision: every conversation the routing layer names to a resident names its server when it is known, in the one form the [delivered] receipt already used: the label, then `server / channel-id` (as the source headers write it), then the thread. Always, rather than only where the id is shared today: a notice is read later than it is written, registrations change in between, and the receipt's own rule (the server is never left to what else is on screen) applies to every address equally. describeConversation now produces exactly what AgentFramework.placeText produced, and placeText delegates to it, so notices, holds, the mid-turn hold, the route announcement, the channel_publish held-route refusal and the receipt share one formatter. A conversation whose server is unknown (an unroutable candidate on a shared id) is named by its id alone, as before without a thread; with one it now reads `(chan, thread t)`, as placeText wrote it, where it used to read `chan (thread t)`. Basil-1007's review found two more formatters, and two label lookups, that kept "every conversation names its server" from being true: - The [discord-send-failed] marker wrote `#label (channelId)` with the label by first match across servers, and onRouteFailure carried no server. onRouteFailure's payload now carries the destination's serverId and label (deliverSpeech takes them from the outcome's destination, else the route's server), the mcpl:speech-route-failed trace carries the server, the marker's metadata records it, and the marker names the conversation through describeConversation: `#forum (Guild One) (discord / discord:g1:forum, thread t1)`. Its label is the destination's own, else that server's (getChannelLabel), never another server's. - ChannelRegistry.placeRefusalText (a connector that declares no place, or no threads) wrote `${label} (${id})`. It now takes the place (server, id, label) and names it through describeConversation; the registry passes the entry's own, and channel_publish passes its resolved destination, so the `can't be published to exactly` fallback for a missing descriptor is gone. - (Found while checking the claim after Basil's review.) The `[channels] Now open:` notice for a channel opened without the agent asking wrote `${label} (${channelId})` too, and the framework's onChannelAutoOpened handler dropped the callback's serverId. It now passes the server with each channel (the connector-reply path passes its own), and recordChannelAutoOpenNotice names each through describeConversation. - (Also found then.) channel_publish's failure and uncertain-delivery results wrote `${channelId} (${label})`; they now name the destination through describeConversation. The gate's batched-wake line keyed its count by channel id alone and wrote `#${label} (${channelId})`: two servers' channels with one id merged into one line. It now counts per server and channel, and names each through describeConversation. - candidateForChannel and homeRoute took the label from getDescriptor, the first registrant across servers, beside a server that could be another one. Both now take getChannelLabel(server, id) when the server is known, and no label when it isn't: for an unroutable shared id any label would be a guess. The shared-id refusal in ChannelRegistry.findExactEntry now lists the servers that register the id, sorted: "channel id "X" is registered by more than one MCPL server (alpha, beta); the destination must name its server". Tests: - speech-routes: the address forms (label, server / id, thread; a label equal to the id adds nothing; an unknown server gives the id alone), and the same id and label on two servers reading as two addresses. - speech-route-turns: a hold between two servers' conversations with one channel id and label names each one as `#general (alpha / general)` and `#general (beta / general)`. - publish-outcome: the shared-id refusal names both servers; the undeclared-connector refusal names its channel as `#legacy (discord / legacy)`; the thread route's failure marker names its server, label and thread, and records serverId in its metadata. - event-gate: a debounced wake over the same channel id and name on two servers reads `- 1 message in #general (alpha / C1)` and `- 2 messages in #general (beta / C1)`. - channel-registry-routing: channel_publish's failed and uncertain results name `(discord / chanA)`. - publish-outcome: a policy admission through the real registry's onChannelAutoOpened callback is announced as `#general (beta / general)`. - speech-route-turns (the shared-id candidate test): with a registry whose bare-id descriptor is the first registrant's, a candidate named for beta takes beta's label, a shared candidate with no server and a shared home take none, and a sole registrant's candidate takes its label. The speech-routes changelog fragment (changed) gains a line saying so. - Existing expectations of the old server-less forms updated in present-while-acting (the mid-turn hold, its note, the engaged follow-up, setSpeechTarget: false, the engagement cases) and targeted-publish (the thread route announcement, the #legacy notice). Red-first (Anima VPS, node 24.21): with e041853's three source files and these tests, speech-routes fails 2, speech-route-turns 1 (the hold; the receipt test passes, its format being the one adopted), publish-outcome 1. Remove-each-piece: describeConversation without the server fails speech-routes 2 and speech-route-turns 2 (the hold and the receipt, which now shares the formatter); the refusal without the server list fails publish-outcome 1; the candidate's or the home's label by first match fails speech-route-turns 1 each; the marker without its server, the registry not passing the server, and the place refusal without its server fail publish-outcome 1 each; the auto-open handler dropping the server, or the notice's old formatter, fail publish-outcome 1 each; the gate keyed without the server fails event-gate 1; channel_publish's failure text without the server fails channel-registry-routing 1. Out of this change: the closed-channel invitation names a channel as `#label in "Guild"`, beside a reply token proseTargetFor already makes server-safe; it is an invitation, not a routing address. (channel_list already returns each channel's serverId as a field.) Co-Authored-By: Claude Opus 5.5 --- changelog.d/speech-routes.changed.md | 10 +++ src/framework.ts | 89 ++++++++++++++------------- src/gate/event-gate.ts | 29 +++++---- src/mcpl/channel-registry.ts | 73 ++++++++++++++++++---- src/speech-routes.ts | 21 +++++-- test/channel-registry-routing.test.ts | 4 +- test/event-gate.test.ts | 18 ++++++ test/present-while-acting.test.ts | 14 ++--- test/publish-outcome.test.ts | 48 +++++++++++++-- test/speech-route-turns.test.ts | 39 +++++++++++- test/speech-routes.test.ts | 18 ++++-- test/targeted-publish.test.ts | 4 +- 12 files changed, 275 insertions(+), 92 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 36950451f..a3acd11e9 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -80,3 +80,13 @@ wakes already worked this way. - The `[discord-send-failed]` marker for a reply on a thread route names the thread with its channel, and an unconfirmed one says to check the thread. +- **Routing names each conversation with its server.** Routing notices, + holds, the route announcement, `channel_publish`'s refusals and failures, + the `[discord-send-failed]` marker, the `[channels] Now open` notice, the + gate's batched-wake lines and the `[delivered]` receipt all write a + conversation one way: its label, then `server / channel-id`, then its + thread. The label is that server's own for the channel, never another + server's for the same id, and a batched wake counts each server's channel + apart. Two servers' channels with the same id and label no longer read + alike. A refusal for a channel id that several servers register lists + those servers, so the sender has a `serverId` to give. diff --git a/src/framework.ts b/src/framework.ts index 5bd46fc27..76d5fc022 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9322,21 +9322,17 @@ export class AgentFramework { return AgentFramework.placeText(d); } - /** - * A destination in words, self-contained: its label as recorded, then the - * server and channel it resolved to — written `server / channel-id`, as - * the source headers write them — and its thread. The server is always - * named, never inferred from what else is on screen: the same channel id - * (and label) can exist on another server. - */ + /** A destination in words, as every conversation is named to a resident + * (describeConversation): its label as recorded, then `server / channel-id` + * and its thread. */ private static placeText(d: { serverId?: string; channelId: string; label?: string; threadId?: string | null }): string { - const id = d.serverId ? `${d.serverId} / ${d.channelId}` : d.channelId; - const where = d.threadId ? `${id}, thread ${d.threadId}` : id; - if (d.label && d.label !== d.channelId) { - const label = d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`; - return `${label} (${where})`; - } - return d.serverId || d.threadId ? `(${where})` : d.channelId; + return describeConversation({ + kind: 'channel', + ...(d.serverId ? { serverId: d.serverId } : {}), + channelId: d.channelId, + ...(d.threadId ? { threadId: d.threadId } : {}), + ...(d.label ? { label: d.label } : {}), + }); } /** One line per draft: id, state, when, why, size, preview. */ @@ -9640,10 +9636,7 @@ export class AgentFramework { }; const placeRefusal = publishPlaceRefusal(registry.publishTarget(destination), destination.threadId ?? null); if (placeRefusal) { - const descriptor = registry.getDescriptor(destination.channelId); - const why = descriptor - ? ChannelRegistry.placeRefusalText(placeRefusal, descriptor, destination.threadId ?? null) - : `${destination.channelId} can't be published to exactly`; + const why = ChannelRegistry.placeRefusalText(placeRefusal, destination, destination.threadId ?? null); const consult = this.connectorToolsText(destination.serverId); return refuse(`${why}.${consult ? ` ${consult}` : ''} Nothing was sent.`); } @@ -9945,7 +9938,10 @@ export class AgentFramework { threadId?: string, ): RouteCandidate { const server = serverId || this.channelRegistry?.getChannelServerId(channelId) || undefined; - const label = this.channelRegistry?.getDescriptor(channelId)?.label; + // The label is the server's own for the id: another server's channel + // with the same id may carry another. With no server, any label would be + // a guess, so there is none. + const label = server ? this.channelRegistry?.getChannelLabel(server, channelId) : undefined; return { conversation: { kind: 'channel', @@ -9969,8 +9965,8 @@ export class AgentFramework { private homeRoute(agentName: string): SpeechRoute | null { const home = this.conversationAgentHomes.get(agentName) ?? this.channelRegistry?.resolveLocus(agentName) ?? null; if (!home) return null; - const label = this.channelRegistry?.getDescriptor(home)?.label; const serverId = this.channelRegistry?.getChannelServerId(home) ?? undefined; + const label = serverId ? this.channelRegistry?.getChannelLabel(serverId, home) : undefined; return { kind: 'channel', ...(serverId ? { serverId } : {}), @@ -10340,12 +10336,18 @@ export class AgentFramework { */ private recordChannelAutoOpenNotice( agentName: string | undefined, - channels: Array<{ channelId: string; label?: string }>, + channels: Array<{ serverId?: string; channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', ): void { if (channels.length === 0) return; + // Named as every conversation is (describeConversation), server included. const shown = channels - .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId)) + .map((c) => describeConversation({ + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.label ? { label: c.label } : {}), + })) .join(', '); const plural = channels.length > 1; const causeText = @@ -15136,24 +15138,27 @@ export class AgentFramework { // addMessage() alone does not request inference, so this never wakes // her (matching the `discord-send-failed-skip` gate intent: context // yes, wake no). - onRouteFailure: ({ channelId, threadId, reason, textLen, outcome }) => { + onRouteFailure: ({ serverId, channelId, label, threadId, reason, textLen, outcome }) => { try { - // Render a human-readable channel name when we can — a bare - // snowflake in the marker is unresolvable for the agent (the - // 2026-07-21 incident read as "a stale artifact", not a live - // failure). The marker is `system: true`, so it is never - // conversational and never influences routing. - const label = channelId - ? this.channelRegistry?.getDescriptor(channelId)?.label - : undefined; - const channel = channelId - ? label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId + // Name the conversation the words were bound for as every + // conversation is named (describeConversation): its label, its + // server and channel, and its thread, so a check or a resend + // looks in the right place. A bare snowflake in the marker is + // unresolvable for the agent (the 2026-07-21 incident read as "a + // stale artifact", not a live failure); the label is the + // destination's own, or else its server's, never another + // server's by first match. The marker is `system: true`, so it is + // never conversational and never influences routing. + const ownLabel = label ?? (serverId && channelId ? this.channelRegistry?.getChannelLabel(serverId, channelId) : undefined); + const where = channelId + ? describeConversation({ + kind: 'channel', + ...(serverId ? { serverId } : {}), + channelId, + ...(threadId ? { threadId } : {}), + ...(ownLabel ? { label: ownLabel } : {}), + }) : 'the channel'; - // A thread route failed in its thread: say so, so a check or a - // resend looks there rather than at the channel's root. - const where = threadId ? `thread ${threadId} in ${channel}` : channel; this.addMessage( 'user', [{ @@ -15164,7 +15169,7 @@ export class AgentFramework { ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the ${threadId ? 'thread' : 'channel'} before sending it again. It is saved in your archive.` : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', ...(threadId ? { threadId } : {}), reason, ...(outcome ? { outcome } : {}) }, + { system: true, kind: 'discord-send-failed', ...(serverId ? { serverId } : {}), channelId: channelId ?? '', ...(threadId ? { threadId } : {}), reason, ...(outcome ? { outcome } : {}) }, ); } catch (err) { console.error('onRouteFailure: failed to record send-failure marker:', err); @@ -15174,8 +15179,8 @@ export class AgentFramework { // open forced by delivery) must be announced in the window, with the // opt-out named. Routed here so registry-driven opens and framework- // driven opens produce the same durable notice. - onChannelAutoOpened: ({ conversationId, source, channels }) => { - this.recordChannelAutoOpenNotice(conversationId, channels, source); + onChannelAutoOpened: ({ conversationId, serverId, source, channels }) => { + this.recordChannelAutoOpenNotice(conversationId, channels.map((c) => ({ serverId, ...c })), source); }, }, ); @@ -16866,7 +16871,7 @@ export class AgentFramework { ); this.recordChannelAutoOpenNotice( agentName, - [{ channelId: channelId ?? target, label }], + [{ ...(serverId ? { serverId } : {}), channelId: channelId ?? target, label }], 'opened-by-reply', ); } else if (status === 'open-failed') { diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 8c70017b7..4973966a8 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -12,7 +12,7 @@ */ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; -import { isConversational } from '../speech-routes.js'; +import { describeConversation, isConversational } from '../speech-routes.js'; import { readInboundSource } from '../mcpl/inbound-source.js'; import { dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; @@ -1644,22 +1644,29 @@ export class EventGate { const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`); if (referenced.length > 0) { - const byChannel = new Map(); + const byChannel = new Map(); for (const e of referenced) { - // The registered id when known, so this line names the channel the - // same way routing notices and send tools do (a push event's own - // channelId is the adapter's raw id). - const key = e.routeChannelId ?? e.channelId ?? '(unknown channel)'; - const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel }; + // The registered id when known (a push event's own channelId is the + // adapter's raw id), counted per server: the same id on another + // server is another conversation. + const channelId = e.routeChannelId ?? e.channelId; + const key = `${e.serverId ?? ''}\u0000${channelId ?? ''}`; + const entry = byChannel.get(key) ?? { + ...(e.serverId ? { serverId: e.serverId } : {}), + ...(channelId ? { channelId } : {}), + count: 0, oldest: e.timestamp, label: e.channelLabel, + }; entry.count += 1; entry.oldest = Math.min(entry.oldest, e.timestamp); entry.label = entry.label ?? e.channelLabel; byChannel.set(key, entry); } - for (const [channelId, { count, oldest, label }] of byChannel) { - // Prefer the human-readable name; keep the id parenthesized so the - // agent can still address tools that want the composite id. - const channel = label ? `#${label} (${channelId})` : channelId; + for (const { serverId, channelId, count, oldest, label } of byChannel.values()) { + // Named the way routing notices and send tools name a conversation + // (describeConversation): its label, then `server / channel-id`. + const channel = channelId + ? describeConversation({ kind: 'channel', ...(serverId ? { serverId } : {}), channelId, ...(label ? { label } : {}) }) + : '(unknown channel)'; const age = Math.round((Date.now() - oldest) / 1000); lines.push( `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` + diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 653f4a7f6..7ac3a2f48 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -42,6 +42,7 @@ import { isVisiblyEmptyContent } from './visible-content.js'; import { CapabilityGrant } from './capability-grant.js'; import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; import { McplRequestError } from './server-connection.js'; +import { describeConversation } from '../speech-routes.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -601,6 +602,10 @@ interface ChannelRegistryOptions { onRouteFailure?: (info: { conversationId: string; channelId: string | null; + /** The server the speech was for, when its route named or resolved one. */ + serverId?: string; + /** That server's label for the channel, as the destination resolved. */ + label?: string; /** The thread the speech was for, when its route was a thread. */ threadId?: string; reason: string; @@ -3127,8 +3132,12 @@ export class ChannelRegistry { }; } if (matches.length > 1) { + // Name the servers: a resident told only that the id is shared would + // have nothing to put in serverId. + const servers = matches.map((e) => e.serverId).sort(); return { - error: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`, + error: `channel id "${target.channelId}" is registered by more than one MCPL server (${servers.join(', ')}); ` + + 'the destination must name its server', }; } return { entry: matches[0]! }; @@ -3191,7 +3200,7 @@ export class ChannelRegistry { } const refusal = publishPlaceRefusal(declaredPublishTarget(entry.descriptor), place); if (refusal) { - return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(refusal, entry.descriptor, place), at: at() }; + return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(refusal, ChannelRegistry.entryPlace(entry), place), at: at() }; } if (!entry.open) { @@ -3235,7 +3244,7 @@ export class ChannelRegistry { destination = destinationOf(entry); const withdrawn = publishPlaceRefusal(declaredPublishTarget(entry.descriptor), place); if (withdrawn) { - return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(withdrawn, entry.descriptor, place), at: at() }; + return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(withdrawn, ChannelRegistry.entryPlace(entry), place), at: at() }; } } @@ -3337,19 +3346,29 @@ export class ChannelRegistry { }; } - /** Why a publish's place was refused, in words a resident can act on. */ + /** Why a publish's place was refused, in words a resident can act on. The + * channel is named as every conversation is (describeConversation), its + * server included. */ static placeRefusalText( refusal: 'undeclared' | 'no-threads', - descriptor: ChannelDescriptor, + place: { serverId: string; channelId: string; label?: string }, threadId: string | null, ): string { - const where = descriptor.label && descriptor.label !== descriptor.id ? `${descriptor.label} (${descriptor.id})` : descriptor.id; + const where = describeConversation({ + kind: 'channel', serverId: place.serverId, channelId: place.channelId, ...(place.label ? { label: place.label } : {}), + }); return refusal === 'undeclared' ? `${where}'s connector doesn't declare where a post lands (MCPL RFC-011), so it may choose a thread itself; ` + 'the framework doesn\'t publish there' : `${where} has no threads (its connector declares root), so a post into thread ${threadId} is refused`; } + /** A registered channel's place: its own server, id and label. */ + private static entryPlace(entry: ChannelEntry): { serverId: string; channelId: string; label?: string } { + const label = entry.descriptor.label; + return { serverId: entry.serverId, channelId: entry.descriptor.id, ...(typeof label === 'string' && label ? { label } : {}) }; + } + /** * Plain speech for one turn segment: publish `text` to the turn's frozen * locus and report the PublishOutcome. A failure (or an uncertain outcome) @@ -3368,18 +3387,30 @@ export class ChannelRegistry { * "this turn is pinned to no locus": fail loudly rather than guess. */ locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, ): Promise { - const fail = (channelId: string | null, reason: string, outcome: PublishOutcome, threadId?: string | null): PublishOutcome => { + const fail = ( + where: { serverId?: string; channelId: string | null; label?: string; threadId?: string | null }, + reason: string, + outcome: PublishOutcome, + ): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; + const { serverId, channelId, label, threadId } = where; console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, + ...(serverId ? { serverId } : {}), channelId: channelId ?? '', reason, textLen: text.length, outcome: status, }); - this.onRouteFailure?.({ conversationId, channelId, ...(threadId ? { threadId } : {}), reason, textLen: text.length, outcome: status }); + this.onRouteFailure?.({ + conversationId, channelId, + ...(serverId ? { serverId } : {}), + ...(label ? { label } : {}), + ...(threadId ? { threadId } : {}), + reason, textLen: text.length, outcome: status, + }); return outcome; }; @@ -3388,14 +3419,14 @@ export class ChannelRegistry { // with a live re-resolution. if (locusChannelId === undefined) { const reason = 'caller passed no locus (routing bug: every speech path must snapshot the turn locus)'; - return fail(null, reason, { status: 'failed', reason, at: Date.now() }); + return fail({ channelId: null }, reason, { status: 'failed', reason, at: Date.now() }); } if (!locusChannelId) { // The turn froze with no locus (no home, no triggering channel, no // global inbound ever seen) — the agent was told its prose stays in // the archive; honor that. const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)'; - return fail(null, reason, { status: 'failed', reason, at: Date.now() }); + return fail({ channelId: null }, reason, { status: 'failed', reason, at: Date.now() }); } // A route that knows its server publishes exactly there: the same @@ -3405,7 +3436,16 @@ export class ChannelRegistry { const outcome = await this.publish(conversationId, text, target); const channelId = outcome.destination?.channelId ?? target.channelId; if (outcome.status !== 'delivered') { - return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome, outcome.destination?.threadId ?? target.threadId); + // The server and label are the destination's own when it resolved, so + // the marker names the conversation the words were bound for. + const serverId = outcome.destination?.serverId ?? target.serverId; + const label = outcome.destination?.label; + return fail({ + ...(serverId ? { serverId } : {}), + channelId, + ...(label ? { label } : {}), + threadId: outcome.destination?.threadId ?? target.threadId, + }, outcome.reason ?? 'delivery not confirmed', outcome); } console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${outcome.destination!.serverId}, delivered=true)`); @@ -3542,8 +3582,15 @@ export class ChannelRegistry { // Not confirmed: say which, and where it was attempted. `unknown` may // already be posted (a partial multi-part send, a timeout): never invite // a blind retry. - const shown = `${where.channelId}${'channelLabel' in where && where.channelLabel ? ` (${where.channelLabel})` : ''}` + - `${where.threadId ? `, thread ${where.threadId}` : ''}`; + // Named as every conversation is (describeConversation): label, then + // `server / channel-id`, then thread. + const shown = describeConversation({ + kind: 'channel', + ...(where.serverId ? { serverId: where.serverId } : {}), + channelId: where.channelId, + ...(where.threadId ? { threadId: where.threadId } : {}), + ...('channelLabel' in where && where.channelLabel ? { label: where.channelLabel } : {}), + }); return { success: false, error: outcome.status === 'unknown' diff --git a/src/speech-routes.ts b/src/speech-routes.ts index f035b2238..e5448256b 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -146,13 +146,24 @@ export function routeConversation(route: SpeechRoute): ConversationRef { }; } -/** A usable address for a conversation, as a resident would write it. */ +/** + * A conversation as the framework names it to a resident, self-contained: its + * label, then the server and channel id, written `server / channel-id` as the + * source headers write them, and its thread. The server is named whenever it + * is known, never left to what else is on screen: the same channel id (and + * label) can exist on another server, and a bare id is then no address a + * resident can send to. Notices, holds and the [delivered] receipt all name + * conversations this one way. + */ export function describeConversation(c: ConversationRef): string { if (c.kind === 'surface') return `${c.surface} (the local surface that messaged you)`; - const where = `${c.channelId}${c.threadId ? `, thread ${c.threadId}` : ''}`; - if (!c.label || c.label === c.channelId) return c.threadId ? `${c.channelId} (thread ${c.threadId})` : c.channelId; - const label = c.label.startsWith('#') || c.label.startsWith('DM') ? c.label : `#${c.label}`; - return `${label} (${where})`; + const id = c.serverId ? `${c.serverId} / ${c.channelId}` : c.channelId; + const where = c.threadId ? `${id}, thread ${c.threadId}` : id; + if (c.label && c.label !== c.channelId) { + const label = c.label.startsWith('#') || c.label.startsWith('DM') ? c.label : `#${c.label}`; + return `${label} (${where})`; + } + return c.serverId || c.threadId ? `(${where})` : c.channelId; } /** diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 1e280bee6..bfec5f790 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -457,7 +457,7 @@ test('channel_publish reports failed and unknown outcomes with the attempted des seedRegistered(refused.registry, 'discord', 'chanA'); const failed = await refused.registry.handleChannelToolCall('channel_publish', { channelId: 'chanA', content: 'x' }, { kind: 'agent', agentName: 'scout' }); assert.equal(failed.success, false); - assert.match(failed.error!, /^Not sent to chanA \(chanA\): .*Nothing was posted\.$/); + assert.match(failed.error!, /^Not sent to \(discord \/ chanA\): .*Nothing was posted\.$/); assert.deepEqual( { status: (failed.data as { status: string }).status, serverId: (failed.data as { serverId: string }).serverId, channelId: (failed.data as { channelId: string }).channelId }, { status: 'failed', serverId: 'discord', channelId: 'chanA' }, @@ -467,7 +467,7 @@ test('channel_publish reports failed and unknown outcomes with the attempted des seedRegistered(silent.registry, 'discord', 'chanA'); const unknown = await silent.registry.handleChannelToolCall('channel_publish', { channelId: 'chanA', content: 'x' }, { kind: 'agent', agentName: 'scout' }); assert.equal(unknown.success, false); - assert.match(unknown.error!, /^Delivery to chanA \(chanA\) was not confirmed — it may or may not have been posted: .*Check the channel before sending again\.$/); + assert.match(unknown.error!, /^Delivery to \(discord \/ chanA\) was not confirmed — it may or may not have been posted: .*Check the channel before sending again\.$/); assert.equal((unknown.data as { status: string }).status, 'unknown'); }); diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 790e33b9e..843cb0d7d 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -347,6 +347,24 @@ describe('debounce', () => { assert.deepStrictEqual(d.behavior, { debounce: 100 }); }); + it('a batched wake counts each server\'s channel apart and names its server', async () => { + const path = writeConfig('debounce-servers.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, messages } = makeGate(path); + // The same channel id and name on two servers: two conversations. + gate.evaluate(event({ serverId: 'alpha', channelId: 'C1', metadata: { channelName: 'general' } })); + gate.evaluate(event({ serverId: 'beta', channelId: 'C1', metadata: { channelName: 'general' } })); + gate.evaluate(event({ serverId: 'beta', channelId: 'C1', metadata: { channelName: 'general' } })); + await new Promise(r => setTimeout(r, 160)); + const text = (messages[0].content as Array<{ text: string }>)[0].text; + assert.ok(text.includes('- 1 message in #general (alpha / C1) '), text); + assert.ok(text.includes('- 2 messages in #general (beta / C1) '), text); + }); + it('batches events and fires after delay', async () => { const path = writeConfig('debounce-fire.json', { policies: [ diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 4ceb41b7b..149d07c51 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -424,14 +424,14 @@ describe('present while acting', () => { const [held] = heldDrafts(framework); assert.equal(held?.text, 'Answering the person who addressed me.'); assert.equal(held?.reason, 'ambiguous'); - assert.match(held?.note ?? '', /chan-A; discord:dm:antra/); + assert.match(held?.note ?? '', /\(srv \/ chan-A\); \(srv \/ discord:dm:antra\)/); // Turn-start announcement + the mid-turn hold notice. assert.equal(notices.length, 2, 'the hold produced exactly one routing notice'); const wired = membrane.lastStream!.receivedToolResultOptions .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); assert.ok(wired.some((t) => t.includes('quick question')), 'the addressed message itself was injected'); assert.ok( - wired.some((t) => t.includes('[routing] discord:dm:antra addressed you mid-turn') && t.includes('instead of going to chan-A')), + wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn') && t.includes('instead of going to (srv / chan-A)')), 'the hold notice was injected alongside it', ); @@ -476,7 +476,7 @@ describe('present while acting', () => { // The notice names the actual cause: a continued conversation, not an address. const wired = membrane.lastStream!.receivedToolResultOptions .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); - assert.ok(wired.some((t) => t.includes('[routing] discord:guild:portables, where you sent a message this turn, continued mid-turn'))); + assert.ok(wired.some((t) => t.includes('[routing] (srv / discord:guild:portables), where you sent a message this turn, continued mid-turn'))); assert.ok(!wired.some((t) => t.includes('addressed you mid-turn'))); await framework.stop(); @@ -868,7 +868,7 @@ describe('present while acting', () => { assert.deepEqual(routed, [{ text: 'Still talking to chan-A.', locus: 'chan-A' }]); const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); - assert.ok(results.includes('Opened for reading. Your plain speech still goes to chan-A.')); + assert.ok(results.includes('Opened for reading. Your plain speech still goes to (srv / chan-A).'), results); await framework.stop(); }); @@ -1357,19 +1357,19 @@ describe('speech follows the moment its words were written', () => { name: 'a delivered send, followed up in its own thread', record: (f) => engage(f).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), arrivalThread: 't1', - notice: 'chan-T (thread t1), where you sent a message this turn, continued mid-turn', + notice: '(srv / chan-T, thread t1), where you sent a message this turn, continued mid-turn', }, { name: 'an unconfirmed send, followed up in its own thread', record: (f) => engage(f).noteSendEngagement('assistant', 'unknown', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), arrivalThread: 't1', - notice: 'chan-T (thread t1), where you tried to send a message this turn (its delivery wasn\'t confirmed), continued mid-turn', + notice: '(srv / chan-T, thread t1), where you tried to send a message this turn (its delivery wasn\'t confirmed), continued mid-turn', }, { name: 'a connector send tool, whose place in the channel the host can\'t know', record: (f) => engage(f).noteChannelEngagement('assistant', 'srv', 'chan-T'), arrivalThread: 't2', - notice: 'chan-T (thread t2) continued mid-turn, in a channel you sent something into this turn (where in the channel isn\'t known)', + notice: '(srv / chan-T, thread t2) continued mid-turn, in a channel you sent something into this turn (where in the channel isn\'t known)', }, ]; for (const c of cases) { diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 4d507e063..8b7622bb9 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -210,7 +210,8 @@ describe('ChannelRegistry.publish outcomes', () => { seed('beta', 'discord:g1:room', '#room (Beta)'); const shared = await registry.publish('agent', 'x', { channelId: 'discord:g1:room' }); assert.equal(shared.status, 'failed'); - assert.match(shared.reason ?? '', /more than one MCPL server/); + assert.match(shared.reason ?? '', /more than one MCPL server \(alpha, beta\); the destination must name its server/, + 'the refusal names the servers, so the sender has a serverId to give'); assert.equal(published.length, 0, 'never routed through whichever server came first'); const exact = await registry.publish('agent', 'x', { serverId: 'beta', channelId: 'discord:g1:room' }); assert.equal(exact.status, 'delivered'); @@ -255,7 +256,8 @@ describe('ChannelRegistry.publish outcomes', () => { (registry as unknown as { openChannelNow: (e: unknown) => Promise }).openChannelNow = async () => { opened.push('legacy'); }; const outcome = await registry.publish('agent', 'x', { channelId: 'legacy' }); assert.equal(outcome.status, 'failed'); - assert.match(outcome.reason ?? '', /doesn't declare where a post lands/); + assert.match(outcome.reason ?? '', /^#legacy \(discord \/ legacy\)'s connector doesn't declare where a post lands/, + 'the refusal names the channel as every conversation is named, its server included'); assert.equal(outcome.destination?.channelId, 'legacy'); assert.deepEqual(published, [], 'the connector would choose the place itself'); assert.deepEqual(opened, [], 'refused before any side effect'); @@ -411,7 +413,43 @@ test('the resident\'s failure marker never claims an uncertain delivery did not } }); -test('a failure marker for a thread route names the thread, and says to check it', async () => { +test('a channel opened without the agent asking is announced by its server too', async () => { + const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-open-')); + const commandPath = join(dir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { STATUS_PATH: join(dir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + try { + await framework.start(); + // The real registry's callback, as a policy admission calls it: one + // server, its channels with their own labels. + const registry = (framework as unknown as { channelRegistry: { + onChannelAutoOpened(info: { serverId: string; source: 'subscription-policy'; channels: Array<{ channelId: string; label?: string }> }): void; + } }).channelRegistry; + registry.onChannelAutoOpened({ serverId: 'beta', source: 'subscription-policy', channels: [{ channelId: 'general', label: '#general' }] }); + const notice = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((t) => t.startsWith('[channels] Now open:')); + assert.match(notice ?? '', /^\[channels\] Now open: #general \(beta \/ general\) — your subscription policy just admitted it\./, + 'named as every conversation is, its server included'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a failure marker for a thread route names its server and thread, and says to check the thread', async () => { const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-thread-')); const commandPath = join(dir, 'commands.jsonl'); writeFileSync(commandPath, ''); @@ -451,9 +489,11 @@ test('a failure marker for a thread route names the thread, and says to check it await framework.runUntilIdle(); const [marker] = markers(); const text = (marker!.content[0] as { text: string }).text; - assert.match(text, /to thread t1 in #forum \(Guild One\) \(discord:g1:forum\) was not confirmed/); + assert.match(text, /to #forum \(Guild One\) \(discord \/ discord:g1:forum, thread t1\) was not confirmed/, + 'named as every conversation is: label, server / channel, thread'); assert.match(text, /check the thread before sending it again/); assert.equal((marker!.metadata as { threadId?: string }).threadId, 't1'); + assert.equal((marker!.metadata as { serverId?: string }).serverId, 'discord'); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index 86fa23ab0..1709fdb11 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -146,7 +146,10 @@ describe('speech route across a logical turn', () => { // As the registry reads it: a declaration only for exactly one registration. publishTarget: (c: { serverId?: string; channelId: string }) => (c.serverId || registrants[c.channelId]?.length === 1 ? 'root' : undefined), - getDescriptor: () => undefined, + // The first registrant's label, as a bare-id lookup finds it: never the + // label of a conversation whose server is another one. + getDescriptor: () => ({ label: '#alpha-room' }), + getChannelLabel: (serverId: string, id: string) => (registrants[id]?.includes(serverId) ? `#${serverId}-room` : undefined), } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); const f = framework as unknown as { candidateForChannel(channelId: string, addressed: boolean, at: number, serverId?: string): RouteCandidate; @@ -160,17 +163,51 @@ describe('speech route across a logical turn', () => { const shared = f.candidateForChannel('shared', true, 1); assert.equal(shared.conversation.kind === 'channel' ? shared.conversation.serverId : '', undefined, 'never the first registrant'); assert.equal(shared.unroutable, true, 'it competes, but is no route'); + assert.equal(shared.conversation.kind === 'channel' ? shared.conversation.label : '', undefined, + 'and no label: with no server, any label would be a guess'); const named = f.candidateForChannel('shared', true, 1, 'beta'); assert.equal(named.conversation.kind === 'channel' ? named.conversation.serverId : '', 'beta', "the event's own server stands"); + assert.equal(named.conversation.kind === 'channel' ? named.conversation.label : '', '#beta-room', 'with its own label, not the first registrant\'s'); assert.equal(named.unroutable, undefined); + assert.equal(solo.conversation.kind === 'channel' ? solo.conversation.label : '', '#alpha-room'); f.conversationAgentHomes.set('scout', 'shared'); const home = f.homeRoute('scout'); assert.equal(home?.kind === 'channel' ? home.serverId : '', undefined, 'a shared home gets no server'); + assert.equal(home?.kind === 'channel' ? home.label : '', undefined, 'nor a label'); assert.equal(f.publishTargetOf({ channelId: 'shared' }), 'unresolved', 'and is unresolved, not undeclared'); assert.equal(f.publishTargetOf({ channelId: 'solo' }), 'root'); await framework.stop(); }); + it('a hold between two servers\' conversations with one channel id and label names each server', async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + i.channelRegistry = new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + getDescriptor: () => ({ label: '#general' }), + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const from = (serverId: string, messageId: string, at: number): RouteCandidate => ({ + conversation: { kind: 'channel', serverId, channelId: 'general', label: '#general' }, addressed: true, messageId, at, + }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'which general?' }])); + await i.startAgentStream(scout, { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + routeCandidates: [from('alpha', 'm1', 1), from('beta', 'm2', 2)], + }); + await framework.runUntilIdle(); + const notice = scout.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((t) => t.startsWith('[routing]')); + assert.match(notice ?? '', /^\[routing\] More than one conversation is waiting for you: /); + for (const address of ['#general (alpha / general)', '#general (beta / general)']) { + assert.ok(notice?.includes(address), `${address} is an address the resident can send to, server included: ${notice}`); + } + await framework.stop(); + }); + it('the [delivered] receipt names each destination as delivered: server, label then, and thread', async () => { const framework = await makeFramework(); const scout = framework.getAgent('scout')!; diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 7bc66fd77..8d0ed27ed 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -242,12 +242,20 @@ describe('conversation identity and wording', () => { assert.notEqual(conversationKey({ kind: 'surface', surface: 'x' }), conversationKey(channel('x'))); }); - it('describes a conversation by a usable address', () => { - assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), '#room (discord:g1:room)'); - assert.equal(describeConversation(channel('discord:dm:7')), 'discord:dm:7'); - assert.equal(describeConversation(channel('discord:dm:7', { label: 'discord:dm:7' })), 'discord:dm:7', + it('describes a conversation by a usable address: its label, then server / channel id, then its thread', () => { + assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), '#room (discord / discord:g1:room)'); + assert.equal(describeConversation(channel('discord:dm:7')), '(discord / discord:dm:7)'); + assert.equal(describeConversation(channel('discord:dm:7', { label: 'discord:dm:7' })), '(discord / discord:dm:7)', 'a label that is only the id adds nothing'); - assert.equal(describeConversation(channel('forum', { threadId: 't1' })), 'forum (thread t1)'); + assert.equal(describeConversation(channel('forum', { threadId: 't1' })), '(discord / forum, thread t1)'); + assert.equal(describeConversation({ kind: 'channel', channelId: 'general', label: 'general' }), 'general', + 'a conversation whose server is unknown is named by its id alone'); assert.equal(describeConversation({ kind: 'surface', surface: 'tui' }), 'tui (the local surface that messaged you)'); }); + + it('the same channel id and label on two servers read as two addresses, each naming its server', () => { + const on = (serverId: string) => describeConversation({ kind: 'channel', serverId, channelId: 'general', label: '#general' }); + assert.equal(on('alpha'), '#general (alpha / general)'); + assert.equal(on('beta'), '#general (beta / general)'); + }); }); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts index 25bb68452..6ad67a8fa 100644 --- a/test/targeted-publish.test.ts +++ b/test/targeted-publish.test.ts @@ -119,7 +119,7 @@ describe('targeted publish (MCPL RFC-011)', () => { it('plain speech answering a thread goes into that thread, and its receipt says so', async () => { await turn([say('answering the thread')], ROOM, { threadId: 't-1' }); assert.deepEqual(publishes().map((p) => [p.event, p.channelId, p.threadId, p.text]), [['publish', ROOM, 't-1', 'answering the thread']]); - assert.ok(texts().some((t) => t.startsWith('[routing] Your plain speech now lands in #room (Guild One) (discord:g1:room, thread t-1)'))); + assert.ok(texts().some((t) => t.startsWith('[routing] Your plain speech now lands in #room (Guild One) (discord / discord:g1:room, thread t-1)'))); assert.ok(texts().some((t) => t.startsWith('[delivered]') && t.includes('thread t-1')), texts().filter((t) => t.startsWith('[delivered]')).join(' | ')); }); @@ -133,7 +133,7 @@ describe('targeted publish (MCPL RFC-011)', () => { assert.deepEqual(publishes(), []); assert.deepEqual(drafts().map((d) => [d.text, d.reason]), [['hello legacy', 'no-destination']]); const notice = texts().find((t) => t.startsWith('[routing]')) ?? ''; - assert.match(notice, /(?:^|[^#])#legacy \(Guild One\) \(discord:g1:legacy\): its connector doesn't declare where a post lands \(MCPL RFC-011\)/, + assert.match(notice, /(?:^|[^#])#legacy \(Guild One\) \(discord \/ discord:g1:legacy\): its connector doesn't declare where a post lands \(MCPL RFC-011\)/, 'the label once, with its own # and no second one'); assert.match(notice, /publication from here is unavailable until it does/); assert.match(notice, /may reach it; consult them: `mcpl--discord--reply_message`\./, 'a send-named tool, not the unrelated one'); From 2c198b5df51875834a25cbe1298d0b9678ea6543 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 14:43:58 -0400 Subject: [PATCH 39/65] fix(drafts): notices count on the branch they're on, the catch-up names every draft it marks, and the store keeps what it needs sw-af247 (the house's haiku review of 02adf27..378c2b3), part 1: the drafts store and its notices. - I2: a draft counts as named while a message that named it is on the current branch. The 'noticed' record now carries the naming message's id (the catch-up's own message, or the one settleTurnDrafts finds), and the turn-start catch-up names every open draft none of whose naming messages is present (cm.getMessage), so a rollback or branch move past its notice names it again, once. A record written without a message id names nothing, so its draft is named once more. - I4 and I29: the catch-up names every draft it marks, newest first: the newest eight with state and preview, the rest by id ("and N more: ..."). unnoticed() returns newest first, as open() does. - I27: settleTurnDrafts reads only the window it scans (the last 500 messages, DRAFT_NOTICE_SCAN), not a copy of the whole history, and returns each draft's naming message. - I6: the store keeps each resident's 100 newest drafts and, beyond them, any draft something happened to (held, named, attempted, answered, dismissed) in the last 7 days; at a checkpoint every 500 entries it lets the rest go, and opening replays only the checkpoint and what follows. The latest bounce is tracked by id, so {{unsent}} means nothing once it is let go, even if an older bounce is kept; newId never reuses that id. The drafts tool's description and its unknown-id refusal say so. Retention is a constructor option, for tests. - I10: a draft whose held record reached the store before its sync failed is in the reconciled journal, so hold() counts it as held (and logs the failure); only the segments after it are reported not held. Tests (prose-drafts): the hold-failure test now expects 'x' held and one not held; new: notices counted per branch (store), the checkpoint and retention across a reopen (store, mock Date), the catch-up naming ten drafts, and a rollback past a receipt naming its draft again, once. --- src/framework.ts | 92 +++++++++++++------- src/prose-drafts.ts | 173 +++++++++++++++++++++++++++++++------- test/prose-drafts.test.ts | 142 ++++++++++++++++++++++++++++--- 3 files changed, 337 insertions(+), 70 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 8534ce4c6..052ddd180 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -85,7 +85,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, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -4676,10 +4676,13 @@ export class AgentFramework { inputSchema: { type: 'object' }, }; + /** How long, in days, a draft beyond a resident's newest is kept (KEEP_IDLE_MS). */ + private static readonly DRAFT_KEEP_DAYS = Math.round(KEEP_IDLE_MS / 86_400_000); + /** Synthesized drafts tool — present whenever MCPL channels are * configured. A resident's held drafts (src/prose-drafts.ts): plain speech - * that was not sent, never published except by its resident's resend and - * kept until resent or dismissed. Nullable + * that was not sent, never published except by its resident's resend, and + * kept as that module's retention says. Nullable * fields, cast like save_recent_image's: a caller whose provider presents * every property as required can still say "not in use". */ private static readonly DRAFTS_TOOL = { @@ -4687,8 +4690,9 @@ export class AgentFramework { description: 'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in the ' + 'same round holds plain speech back, because a routing prefix bounced, or because it had no destination. ' + - 'Drafts are never published except by your own resend, only you can list or act on them, and they stay ' + - 'until you resend or dismiss them. list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + + `Drafts are never published except by your own resend, and only you can list or act on them. Your ${KEEP_NEWEST} newest ` + + `drafts are always kept; an older one is let go after ${AgentFramework.DRAFT_KEEP_DAYS} days in which nothing happened to it. ` + + 'list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + 'resend: publish drafts verbatim, in the order given, to one destination ("#channel", "@person" or a ' + 'channel id) — an explicit send, like send_message; it stops at the first draft not confirmed delivered. ' + 'A draft already delivered returns its receipt instead of sending again. If an earlier attempt may ' + @@ -9136,38 +9140,51 @@ export class AgentFramework { } /** - * Turn start (or after a crash): name any open drafts no notice has named - * yet, privately, before the turn's compile. + * Turn start (or after a crash): name, privately and before the turn's + * compile, any open drafts no notice on the current branch names: none + * reached the history (a crash, an aborted turn), or a rollback or branch + * move took the notices away. Every draft it marks is named by id, newest + * first: the newest eight with their state and a preview, the rest by id. */ private noticeUnnoticedDrafts(agent: Agent): void { - const pending = this.proseDrafts.unnoticed(agent.name); + const cm = agent.getContextManager(); + const pending = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null); if (pending.length === 0) return; const shown = pending.slice(0, 8).map((d) => `${d.id} (${this.draftStateText(d)}; held ${this.draftTime(d.heldAt)}, ` + `${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); - const more = pending.length > shown.length ? ` · and ${pending.length - shown.length} more` : ''; + const rest = pending.slice(shown.length).map((d) => d.id); + const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : ''; const text = `[drafts] ${agent.name}: ${pending.length === 1 ? 'a held draft' : `${pending.length} held drafts`} of yours ` + - `${pending.length === 1 ? 'has' : 'have'} not been named to you yet: ${shown.join(' · ')}${more}. ` + + `${pending.length === 1 ? 'is' : 'are'} unnamed on this branch of your history: ${shown.join(' · ')}${more}. ` + 'drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; + let mid: string; try { - const mid = agent.getContextManager().addMessage( + mid = cm.addMessage( 'user', [{ type: 'text', text }], { system: true, kind: 'prose-drafts', draftOwner: agent.name, draftIds: pending.map((d) => d.id) } as MessageMetadata, ); this.emitTrace({ type: 'message:added', messageId: mid, source: 'prose-drafts' }); - this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id)); } catch (err) { console.error('[drafts] catch-up notice failed:', err); + return; + } + try { + this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id), mid); + } catch (err) { + // Named again by the next turn's catch-up. + console.error('[drafts] could not record the catch-up notice:', err); } } /** * Logical turn end, after the turn's deferred notices are flushed: record * as noticed exactly the drafts a stored message names (the receipt, a - * held-draft notice, a bounce notice). One whose notice never reached the - * history stays unnoticed, for the next turn's catch-up. + * held-draft notice, a bounce notice), each with the message that names + * it. One whose notice never reached the history stays unnoticed, for the + * next turn's catch-up. */ private settleTurnDrafts(agent: Agent): void { const held = this.turnDrafts.get(agent.name); @@ -9175,29 +9192,43 @@ export class AgentFramework { this.turnDraftFailures.delete(agent.name); this.turnProsePrivate.delete(agent.name); if (!held?.length) return; - const named = this.draftsNamedInHistory(agent, held.map((d) => d.id)); - if (named.length === 0) return; - try { - this.proseDrafts.markNoticed(agent.name, named); - } catch (err) { - // Unmarked drafts are named again by the next turn's catch-up. - console.error('[drafts] could not record notice:', err); + const byMessage = new Map(); + for (const [draftId, messageId] of this.draftsNamedInHistory(agent, held.map((d) => d.id))) { + byMessage.set(messageId, [...(byMessage.get(messageId) ?? []), draftId]); + } + for (const [messageId, ids] of byMessage) { + try { + this.proseDrafts.markNoticed(agent.name, ids, messageId); + } catch (err) { + // Unmarked drafts are named again by the next turn's catch-up. + console.error('[drafts] could not record notice:', err); + } } } + /** How far back draftsNamedInHistory looks: this turn's notices are near + * the end. One beyond it leaves its draft for the next turn's catch-up. */ + private static readonly DRAFT_NOTICE_SCAN = 500; + /** Which of these drafts a stored notice or receipt OF THIS RESIDENT names * (draft ids are unique only per resident, and residents can share one - * history), scanning the recent history, where this turn's notices are. */ - private draftsNamedInHistory(agent: Agent, ids: string[]): string[] { + * history), each with the newest message that names it, reading only the + * recent history, where this turn's notices are. */ + private draftsNamedInHistory(agent: Agent, ids: string[]): Map { const wanted = new Set(ids); - const named = new Set(); - const messages = agent.getContextManager().getAllMessages(); - for (let i = messages.length - 1, scanned = 0; i >= 0 && scanned < 500 && named.size < wanted.size; i--, scanned++) { + const named = new Map(); + const cm = agent.getContextManager(); + const total = cm.getMessageCount(); + const start = Math.max(0, total - AgentFramework.DRAFT_NOTICE_SCAN); + const { messages } = cm.getMessageWindow(start, total - start, { resolveBlobs: false }); + for (let i = messages.length - 1; i >= 0 && named.size < wanted.size; i--) { const meta = messages[i]!.metadata as { draftIds?: unknown; draftOwner?: unknown } | undefined; if (meta?.draftOwner !== agent.name || !Array.isArray(meta.draftIds)) continue; - for (const id of meta.draftIds) if (typeof id === 'string' && wanted.has(id)) named.add(id); + for (const id of meta.draftIds) { + if (typeof id === 'string' && wanted.has(id) && !named.has(id)) named.set(id, String(messages[i]!.id)); + } } - return ids.filter((id) => named.has(id)); + return new Map(ids.filter((id) => named.has(id)).map((id) => [id, named.get(id)!])); } /** Page size for drafts(action: "list"). */ @@ -9277,7 +9308,10 @@ export class AgentFramework { else missing.push(id); } if (missing.length > 0) { - return refuse(`No draft of yours with id ${missing.join(', ')}. drafts(action: "list") shows your open drafts.`); + return refuse( + `No draft of yours with id ${missing.join(', ')}: there was none, or it was let go (a draft beyond your ${KEEP_NEWEST} newest ` + + `is kept for ${AgentFramework.DRAFT_KEEP_DAYS} days after anything last happened to it). drafts(action: "list") shows your open drafts.`, + ); } if (action === 'read') { diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 7001db0fc..c290a749b 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -22,7 +22,17 @@ * rollback, suppression and branch moves never touch them. A draft written * in a turn that is later undone stays usable (the words were genuinely * written and held back), and a delivered draft stays delivered. Its - * originating branch is recorded as provenance only. + * originating branch is recorded as provenance only. The notices that name + * a draft are conversation, so they do follow the branch: a draft counts as + * named while a message naming it is on the current branch, and one a + * rollback or branch move has left unnamed is named again. + * + * Drafts are kept, not hoarded. Each resident's KEEP_NEWEST newest drafts + * are always kept; an older one is let go once nothing has happened to it + * (held, named, attempted, answered, dismissed) for KEEP_IDLE_MS. The words + * stay in the resident's history either way. The journal checkpoints what + * it keeps every CHECKPOINT_EVERY entries, so opening it replays only what + * came after. * * A delivery attempt is journaled durably BEFORE its request leaves the * host, and its outcome after: an attempt with no recorded outcome (a crash @@ -119,7 +129,10 @@ export interface Draft { source: DraftSource; attempts: DraftAttempt[]; dismissedAt?: number; - /** When an in-band notice named this draft. */ + /** The messages whose notices named this draft, by id. It counts as named + * while one of them is on the current branch. */ + noticedIn?: string[]; + /** When a notice last named it. */ noticedAt?: number; } @@ -152,41 +165,126 @@ export function uncertainAttempt(draft: Draft): DraftAttempt | undefined { } type DraftEntry = - | { op: 'held'; draft: Omit } + | { op: 'held'; draft: Omit } | { op: 'attempt'; agent: string; id: string; attemptId: string; at: number; destination: PublishDestination; via: DraftAttempt['via']; confirmedDuplicate?: true } | { op: 'outcome'; agent: string; id: string; attemptId: string; outcome: DraftAttemptOutcome } | { op: 'dismissed'; agent: string; id: string; at: number } - | { op: 'noticed'; agent: string; ids: string[]; at: number }; + /** A notice naming these drafts is in the history as message `messageId`. + * (A record without one names nowhere the drafts can be found again, so + * they are named once more.) */ + | { op: 'noticed'; agent: string; ids: string[]; at: number; messageId?: string }; + +/** What a checkpoint keeps: every kept draft, in hold order, and each + * resident's latest bounce, which `{{unsent}}` means whatever became of it. */ +interface DraftSnapshot { + v: 1; + drafts: Draft[]; + latestBounces: Record; +} /** Unambiguous lowercase alphabet for short ids (no i/l/o/0/1). */ const ID_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; +/** Each resident's newest drafts, by hold order, that are always kept. */ +export const KEEP_NEWEST = 100; +/** How long an older draft is kept after the last thing that happened to it. */ +export const KEEP_IDLE_MS = 7 * 24 * 60 * 60 * 1000; +/** Entries the journal takes between checkpoints. */ +export const CHECKPOINT_EVERY = 500; + +/** The store's retention, KEEP_NEWEST, KEEP_IDLE_MS and CHECKPOINT_EVERY + * unless given (tests give smaller ones). */ +export interface DraftRetention { + keepNewest: number; + keepIdleMs: number; + checkpointEvery: number; +} + +/** The last time anything happened to a draft: held, named, attempted, + * answered or dismissed. */ +function lastActivity(draft: Draft): number { + let last = Math.max(draft.heldAt, draft.noticedAt ?? 0, draft.dismissedAt ?? 0); + for (const attempt of draft.attempts) last = Math.max(last, attempt.at, attempt.outcome?.at ?? 0); + return last; +} + export class ProseDraftStore { - private readonly journal: RecordJournal; + private readonly journal: RecordJournal; /** agent → draft id → draft, in journal (hold) order. */ private drafts = new Map>(); + /** agent → the id of its latest bounce, kept or not. */ + private latestBounces = new Map(); + private readonly retention: DraftRetention; - constructor(store: JsStore) { - this.journal = new RecordJournal(store, { type: PROSE_DRAFT_RECORD_TYPE }); + constructor(store: JsStore, retention: Partial = {}) { + this.journal = new RecordJournal(store, { type: PROSE_DRAFT_RECORD_TYPE }); + this.retention = { + keepNewest: retention.keepNewest ?? KEEP_NEWEST, + keepIdleMs: retention.keepIdleMs ?? KEEP_IDLE_MS, + checkpointEvery: retention.checkpointEvery ?? CHECKPOINT_EVERY, + }; this.reload(); + this.checkpointIfDue(); } - /** Rebuild the projection from the journal (at open, and to reconcile). */ + /** Rebuild the projection from the journal (at open, and to reconcile): + * the latest checkpoint's drafts, then the entries after it. */ reload(): void { - const { entries } = this.journal.load(); + const { snapshot, entries } = this.journal.load(); this.drafts = new Map(); + this.latestBounces = new Map(Object.entries(snapshot?.latestBounces ?? {})); + for (const draft of snapshot?.drafts ?? []) this.agentDrafts(draft.agent).set(draft.id, draft); for (const { entry } of entries) this.reduce(entry); } + /** + * Write a checkpoint once CHECKPOINT_EVERY entries have been appended since + * the last one, letting go of the drafts that aren't kept (see the module + * header). The projection drops them only once the checkpoint is written, + * so a failed one changes nothing, and the next write tries again. + */ + private checkpointIfDue(): void { + const { keepNewest, keepIdleMs, checkpointEvery } = this.retention; + if (this.journal.entriesSinceCheckpoint < checkpointEvery) return; + const now = Date.now(); + const kept = new Map>(); + for (const [agent, drafts] of this.drafts) { + const all = [...drafts.values()]; + const keep = new Map(); + all.forEach((draft, i) => { + if (i >= all.length - keepNewest || now - lastActivity(draft) < keepIdleMs) keep.set(draft.id, draft); + }); + if (keep.size > 0) kept.set(agent, keep); + } + const snapshot: DraftSnapshot = { + v: 1, + drafts: [...kept.values()].flatMap((m) => [...m.values()]), + latestBounces: Object.fromEntries(this.latestBounces), + }; + try { + this.journal.checkpoint(snapshot); + } catch (err) { + console.error('[drafts] checkpoint failed; the next write tries again:', err); + if (this.journal.needsReconcile) this.reload(); + return; + } + this.drafts = kept; + } + private reduce(entry: DraftEntry): void { if (entry.op === 'held') { this.agentDrafts(entry.draft.agent).set(entry.draft.id, { ...entry.draft, attempts: [] }); + if (entry.draft.reason === 'bounced') this.latestBounces.set(entry.draft.agent, entry.draft.id); return; } if (entry.op === 'noticed') { + if (entry.messageId === undefined) return; for (const id of entry.ids) { const draft = this.drafts.get(entry.agent)?.get(id); - if (draft && draft.noticedAt === undefined) draft.noticedAt = entry.at; + if (!draft) continue; + draft.noticedIn ??= []; + if (!draft.noticedIn.includes(entry.messageId)) draft.noticedIn.push(entry.messageId); + draft.noticedAt = Math.max(draft.noticedAt ?? 0, entry.at); } return; } @@ -221,6 +319,7 @@ export class ProseDraftStore { throw err; } this.reduce(entry); + this.checkpointIfDue(); } private agentDrafts(agent: string): Map { @@ -232,12 +331,16 @@ export class ProseDraftStore { return map; } + /** A new id, unique among the resident's kept drafts and never its latest + * bounce's. One let go can be given again: the notices that named it are + * by then at least KEEP_IDLE_MS old and KEEP_NEWEST drafts back. */ private newId(agent: string): string { const existing = this.agentDrafts(agent); + const latestBounce = this.latestBounces.get(agent); for (;;) { let id = 'd-'; for (let i = 0; i < 5; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; - if (!existing.has(id)) return id; + if (!existing.has(id) && id !== latestBounce) return id; } } @@ -245,7 +348,9 @@ export class ProseDraftStore { * Hold words as drafts, in order, each journaled durably before this * returns. If the journal refuses a write (after reconciling), the drafts * already written stand and the rest are reported as not held: their words - * remain in the resident's history only. + * remain in the resident's history only. A draft whose record reached the + * store before its write failed (its sync did) is in the reconciled + * journal, so it is held, and counted so. */ hold( agent: string, @@ -270,7 +375,14 @@ export class ProseDraftStore { try { this.write({ op: 'held', draft }, true); } catch (err) { - return { held, notHeld: { count: segments.length - i, error: err instanceof Error ? err.message : String(err) } }; + const error = err instanceof Error ? err.message : String(err); + const landed = this.get(agent, draft.id); + if (landed) { + console.error(`[drafts] ${agent}: ${landed.id} reached the journal, but its write then failed (${error}); it is held`); + held.push(landed); + } + const count = segments.length - i - (landed ? 1 : 0); + return { held, ...(count > 0 ? { notHeld: { count, error } } : {}) }; } held.push(this.get(agent, draft.id)!); } @@ -289,21 +401,26 @@ export class ProseDraftStore { .reverse(); } - /** Open drafts no in-band notice has named yet (a crash before the notice). */ - unnoticed(agent: string): Draft[] { - return this.open(agent).filter((d) => d.noticedAt === undefined).reverse(); + /** + * Open drafts, newest first, that no notice on the current branch names: + * none was recorded (a crash before the notice), or a rollback or branch + * move took away every message that named them. `onBranch` says whether a + * message, by id, is on the current branch. + */ + unnoticed(agent: string, onBranch: (messageId: string) => boolean): Draft[] { + return this.open(agent).filter((d) => !d.noticedIn?.some(onBranch)); } /** - * Record that a notice naming these drafts is in the resident's history. - * The notice is conversation state, buffered until the store syncs, while - * this record reaches the OS at once: so the store is synced FIRST - * (afterCommittedState), and a notice that never became durable can't - * leave a record that suppresses the crash catch-up. + * Record that message `messageId`, a notice naming these drafts, is in the + * resident's history. The notice is conversation state, buffered until the + * store syncs, while this record reaches the OS at once: so the store is + * synced FIRST (afterCommittedState), and a notice that never became + * durable can't leave a record that suppresses the crash catch-up. */ - markNoticed(agent: string, ids: string[]): void { + markNoticed(agent: string, ids: string[], messageId: string): void { if (ids.length === 0) return; - this.write({ op: 'noticed', agent, ids, at: Date.now() }, true, true); + this.write({ op: 'noticed', agent, ids, at: Date.now(), messageId }, true, true); } dismiss(agent: string, id: string): void { @@ -349,14 +466,12 @@ export class ProseDraftStore { * that draft is still open (held or unconfirmed — the caller must not * resend an unconfirmed one without the resident's confirmation). * Latest-wins, like the clipboard it replaces: once the latest bounce is - * delivered or dismissed there is nothing to substitute, even if older - * bounces are still held (they stay in the collection, by id). + * delivered, dismissed or let go there is nothing to substitute, even if + * older bounces are still held (they stay in the collection, by id). */ latestBounce(agent: string): Draft | undefined { - let latest: Draft | undefined; - for (const draft of this.drafts.get(agent)?.values() ?? []) { - if (draft.reason === 'bounced') latest = draft; - } + const id = this.latestBounces.get(agent); + const latest = id === undefined ? undefined : this.get(agent, id); if (!latest) return undefined; const state = draftState(latest); return state === 'held' || state === 'unconfirmed' ? latest : undefined; diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index defe3a294..cf175e9c8 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -90,17 +90,19 @@ describe('ProseDraftStore', () => { } }); - it('reports words it could not hold, and stays consistent with its journal', () => { + it('reports words it could not hold, and stays consistent with its journal', (t) => { const { dir, store, drafts } = open(); try { const real = store.sync.bind(store); let fail = true; (store as unknown as { sync: () => void }).sync = () => { if (fail) throw new Error('EIO'); real(); }; + t.mock.method(console, 'error', () => {}); const result = drafts.hold('a', [{ text: 'x', source }, { text: 'y', source }], 'explicit-send'); - assert.equal(result.held.length, 0); - assert.deepEqual(result.notHeld, { count: 2, error: 'EIO' }); // The first record reached the store before its barrier failed: the - // journal was reconciled, so the projection shows what it holds. + // journal was reconciled, so the projection holds it, and so does the + // answer. Only the second is reported not held. + assert.deepEqual(result.held.map((d) => d.text), ['x']); + assert.deepEqual(result.notHeld, { count: 1, error: 'EIO' }); assert.deepEqual(drafts.open('a').map((d) => d.text), ['x']); fail = false; assert.equal(drafts.hold('a', [{ text: 'z', source }], 'explicit-send').held.length, 1); @@ -110,6 +112,74 @@ describe('ProseDraftStore', () => { } }); + it('a draft counts as named while a message that named it is on the branch, newest first', () => { + const { dir, store, drafts } = open(); + try { + const { held } = drafts.hold('a', [{ text: 'one', source }, { text: 'two', source }, { text: 'three', source }], 'explicit-send'); + const [one, two, three] = held; + drafts.markNoticed('a', [one!.id, two!.id], 'm-1'); + drafts.markNoticed('a', [two!.id], 'm-2'); + const unnamed = (present: string[]) => drafts.unnoticed('a', (id) => present.includes(id)).map((d) => d.text); + assert.deepEqual(unnamed(['m-1', 'm-2']), ['three'], 'only the draft no notice named'); + assert.deepEqual(unnamed(['m-2']), ['three', 'one'], 'm-1 rolled away: one is unnamed, two is still named by m-2; newest first'); + assert.deepEqual(unnamed([]), ['three', 'two', 'one']); + assert.deepEqual(drafts.get('a', three!.id)!.noticedIn, undefined); + // The record outlives a reopen; one written without a message id (an + // older build's) names nothing, so its draft is named once more. + store.appendJson(PROSE_DRAFT_RECORD_TYPE, { op: 'noticed', agent: 'a', ids: [three!.id], at: 1 }); + const again = new ProseDraftStore(store); + assert.deepEqual(again.unnoticed('a', (id) => id === 'm-2').map((d) => d.text), ['three', 'one']); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('checkpoints what it keeps: the newest drafts, recent ones, and the latest bounce as {{unsent}} means it', (t) => { + const { dir, store } = open(); + const path = join(dir, 'store'); + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const hour = 60 * 60 * 1000; + const retention = { keepNewest: 2, keepIdleMs: hour, checkpointEvery: 9 }; + let drafts = new ProseDraftStore(store, retention); + try { + const [older] = drafts.hold('a', [{ text: 'an older bounce', source }], 'bounced').held; + const [latest] = drafts.hold('a', [{ text: 'the latest bounce', source }], 'bounced').held; + const [settled] = drafts.hold('a', [{ text: 'resent long ago', source }], 'explicit-send').held; + const attempt = drafts.beginAttempt('a', settled!.id, dest(), 'resend', false); + drafts.recordOutcome('a', settled!.id, attempt, { status: 'delivered', messageId: 'm-1', at: Date.now(), destination: dest() }); + drafts.hold('b', [{ text: "another resident's", source }], 'explicit-send'); + t.mock.timers.tick(2 * hour); + // Something happens to the older bounce now: it isn't idle. + drafts.beginAttempt('a', older!.id, dest(), 'resend', false); + const newest = drafts.hold('a', [{ text: 'new one', source }, { text: 'new two', source }], 'explicit-send').held; + // That was the ninth entry: the checkpoint let go of 'a''s idle + // drafts outside its two newest, the latest bounce among them. + const kept = () => drafts.open('a').map((d) => d.text); + assert.deepEqual(kept(), ['new two', 'new one', 'an older bounce']); + assert.equal(drafts.get('a', settled!.id), undefined, 'a settled, idle draft is let go'); + assert.equal(drafts.get('a', latest!.id), undefined); + assert.equal(drafts.latestBounce('a'), undefined, 'the latest bounce was let go: {{unsent}} has nothing, though an older bounce is kept'); + assert.deepEqual(drafts.open('b').map((d) => d.text), ["another resident's"], 'within its resident\'s newest'); + assert.equal(store.getRecordIdsByType(`${PROSE_DRAFT_RECORD_TYPE}/checkpoint`).length, 1); + // A reopen reads the checkpoint and the entries after it, and gets the same. + drafts.markNoticed('a', [newest[0]!.id], 'm-9'); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + drafts = new ProseDraftStore(reopened, retention); + assert.deepEqual(kept(), ['new two', 'new one', 'an older bounce']); + assert.equal(drafts.latestBounce('a'), undefined); + assert.deepEqual(drafts.get('a', newest[0]!.id)!.noticedIn, ['m-9']); + assert.equal(draftState(drafts.get('a', older!.id)!), 'unconfirmed', 'its attempt survives the checkpoint'); + } finally { + reopened.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('records a notice only after the history holding it is synced (afterCommittedState)', () => { const { dir, store, drafts } = open(); try { @@ -118,9 +188,9 @@ describe('ProseDraftStore', () => { const real = { sync: store.sync.bind(store), appendJson: store.appendJson.bind(store) }; (store as unknown as { sync: () => void }).sync = () => { calls.push('sync'); real.sync(); }; (store as unknown as { appendJson: (t: string, p: unknown) => unknown }).appendJson = (t, p) => { calls.push(`append:${t}`); return real.appendJson(t, p); }; - drafts.markNoticed('a', [held[0]!.id]); + drafts.markNoticed('a', [held[0]!.id], 'm-1'); assert.deepEqual(calls, ['sync', `append:${PROSE_DRAFT_RECORD_TYPE}`, 'sync']); - assert.ok(drafts.get('a', held[0]!.id)!.noticedAt); + assert.deepEqual(drafts.get('a', held[0]!.id)!.noticedIn, ['m-1']); } finally { store.close(); rmSync(dir, { recursive: true, force: true }); @@ -570,7 +640,7 @@ describe('held prose drafts, end to end', () => { h.store().beginAttempt('scout', midSend.id, dest(), 'resend', false); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours have not been named to you yet: /); + assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours are unnamed on this branch of your history: /); assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); held .*"orphaned words"\)/); assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); assert.doesNotMatch(notice, /They were not sent/); @@ -581,6 +651,52 @@ describe('held prose drafts, end to end', () => { } }); + it('the catch-up names every draft it marks: the newest eight with previews, the rest by id', async () => { + const h = await harness(); + try { + const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); + await h.turn([createMockResponse([])]); + const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; + assert.match(notice, /^\[drafts\] scout: 10 held drafts of yours are unnamed on this branch of your history: /); + // Newest first: orphan 9 down to orphan 2 with their previews, then the + // two oldest by id alone. + const previews = [...notice.matchAll(/"(orphan \d)"/g)].map((m) => m[1]); + assert.deepEqual(previews, [9, 8, 7, 6, 5, 4, 3, 2].map((i) => `orphan ${i}`)); + assert.ok(notice.includes(` · and 2 more: ${held[1]!.id}, ${held[0]!.id}.`), notice); + await h.turn([createMockResponse([])]); + assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'all ten named, so none is named again'); + } finally { + await h.close(); + } + }); + + it('a rollback past the notice that named a draft names it again at the next turn, once', async () => { + const h = await harness(); + try { + await h.turn([createMockResponse([text('hello')])]); + const anchor = h.messages().at(-1)!.id; + await h.turn([ + createMockResponse([text('words a send held back'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + assert.ok(h.texts().some((t) => t.includes(`held as draft ${draft!.id}`)), 'the turn-end receipt names it'); + const catchUps = () => h.texts().filter((t) => t.includes('unnamed on this branch of your history')); + assert.deepEqual(catchUps(), []); + // The rollback takes the turn, and its notices, off the branch; the + // draft stays, as drafts do. + await h.framework.rollbackToMessage('scout', { messageId: anchor }); + assert.ok(!h.texts().some((t) => t.includes(draft!.id)), 'nothing on the branch names it now'); + await h.turn([createMockResponse([])]); + assert.equal(catchUps().length, 1); + assert.match(catchUps()[0]!, new RegExp(`a held draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\);.*"words a send held back"`)); + await h.turn([createMockResponse([])]); + assert.equal(catchUps().length, 1, 'named once on this branch'); + } finally { + await h.close(); + } + }); + it('explicit mode: a bounce is a draft that survives a restart, and {{unsent}} delivers and settles it', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); try { @@ -762,12 +878,14 @@ describe('draft notices and turn-end settlement', () => { const agent = framework.getAgent('scout')!; const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named', source }, { text: 'lost', source }], 'explicit-send').held; // Only the first draft's notice reached the history. - agent.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); + const cm = agent.getContextManager(); + const notice = cm.addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); internals.turnDrafts.set('scout', [named, lost]); internals.settleTurnDrafts(agent); - assert.ok(internals.proseDrafts.get('scout', named!.id)!.noticedAt); - assert.equal(internals.proseDrafts.get('scout', lost!.id)!.noticedAt, undefined, 'left for the catch-up'); - assert.deepEqual(internals.proseDrafts.unnoticed('scout').map((d) => d.id), [lost!.id]); + assert.deepEqual(internals.proseDrafts.get('scout', named!.id)!.noticedIn, [String(notice)], 'recorded with the message that names it'); + assert.equal(internals.proseDrafts.get('scout', lost!.id)!.noticedIn, undefined, 'left for the catch-up'); + const onBranch = (id: string) => cm.getMessage(id as never) !== null; + assert.deepEqual(internals.proseDrafts.unnoticed('scout', onBranch).map((d) => d.id), [lost!.id]); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); @@ -805,7 +923,7 @@ describe('draft identity and risk evidence', () => { { system: true, kind: 'prose-drafts', draftOwner: 'other', draftIds: ['d-same2'] } as never); t.internals.turnDrafts.set('scout', [mine]); t.internals.settleTurnDrafts(t.framework.getAgent('scout')!); - assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.noticedAt, undefined); + assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.noticedIn, undefined); } finally { await t.close(); } From 548071d19ca1510acddf962a4befdd3fd35fc791 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 14:49:29 -0400 Subject: [PATCH 40/65] fix(publish): a publish always has a deadline, a refused send opens nothing, and a failed outcome record says what the draft reads sw-af247, part 2: the publish executor and recording its outcomes. - I11: a channels/publish request always has a deadline: the server's requestTimeoutMs, or 60 s (publishDeadlineMs) when that is 0. A publish's outcome is told to the resident and recorded for its draft, so one that is never answered held both open for good. Its expiry is a no-response, which publish() reads as unknown. McplServerConfig.requestTimeoutMs's doc says so. - I35: publish() checks the server and its channels.publish grant before opening a closed channel, so a send that would be refused opens nothing and reports no opened-by-delivery. - I37: when recording an {{unsent}} delivery's outcome fails, the resident is told privately what the draft reads as now. - I38: the resend's line after a failed outcome record reports what the draft reads as now (draftStateNow): a record that failed after reaching the journal was reconciled into it, so it may read delivered, not the "will read as unconfirmed" it promised. - I3 (text): the comment above publish() says channel_publish moves onto it with shelf-355 (#256), not shelf-356. Tests: - mcpl-request-timeout: with requestTimeoutMs 0, channels/publish still times out (publishDeadlineMs shortened), as a no-response. - publish-outcome (I17): a closed destination opens first and sends; a failed open sends nothing; a send the grant forbids opens nothing. - prose-drafts (I5), through the fixture and the real connection: error mode reads unknown with the connector's words, for speech (the marker) and for a resend (the attempt); hang mode, with requestTimeoutMs 1000, reads unknown once the request times out. - prose-drafts (I37, I38): a sync that fails right after an outcome record lands, for a resend and for an {{unsent}} delivery. I32 is re-judged not-worth in the ledger: resolveDestination and publish's lookup run in the same tick, so a destination that doesn't resolve can't be sent to. --- src/framework.ts | 19 +++++- src/mcpl/channel-registry.ts | 39 ++++++----- src/mcpl/server-connection.ts | 12 +++- src/mcpl/types.ts | 4 +- test/mcpl-request-timeout.test.ts | 20 +++++- test/prose-drafts.test.ts | 105 ++++++++++++++++++++++++++++++ test/publish-outcome.test.ts | 43 ++++++++++-- 7 files changed, 215 insertions(+), 27 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 052ddd180..3cba84f6c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9478,7 +9478,9 @@ export class AgentFramework { ...(outcome.messageId ? { messageId: outcome.messageId } : {}), }); const where = AgentFramework.destinationText(outcome.destination ?? destination); - const unrecorded = recorded ? '' : ' (this outcome could not be recorded; the draft will read as unconfirmed)'; + // A record that failed after reaching the journal was reconciled into + // it, so say what the draft reads as now rather than guess. + const unrecorded = recorded ? '' : ` (recording this outcome failed; the draft reads as ${this.draftStateNow(agentName, d.id)} now)`; if (outcome.status === 'delivered') { lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); let engaged = this.turnEngagedChannels.get(agentName); @@ -9846,11 +9848,26 @@ export class AgentFramework { this.proseDrafts.recordOutcome(agent.name, unsent.id, attemptId, outcome); } catch (err) { console.error(`[drafts] ${agent.name}: outcome of ${unsent.id} not recorded:`, err); + const text = + `[prose-routing] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` + + `failed, so the draft reads as ${this.draftStateNow(agent.name, unsent.id)} now. drafts(action: "read") shows it.`; + try { + this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (noticeErr) { + console.error('[drafts] unrecorded-outcome notice failed:', noticeErr); + } } } return outcome; } + /** A draft's state as the journal reads it now: after a write that failed + * and was reconciled, whatever reached the journal counts. */ + private draftStateNow(agentName: string, id: string): DraftState | 'let go' { + const draft = this.proseDrafts.get(agentName, id); + return draft ? draftState(draft) : 'let go'; + } + /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */ private async deliverHybridProse( agent: Agent, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 3e00ff168..222fb2d03 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3077,7 +3077,7 @@ export class ChannelRegistry { * established (PublishOutcome). The delivery executor behind plain speech * (deliverSpeech / routeSpeech) and resident resends of held drafts; * channel_publish (and publishForAgent, which shares its handler) moves - * onto it with shelf-356 — until then that tool reads its own receipt. + * onto it with shelf-355 — until then that tool reads its own receipt. * * Resolution is exact (resolveDestination): `serverId` plus `channelId` * when the caller has the server, otherwise the channel id must be @@ -3086,8 +3086,9 @@ export class ChannelRegistry { * in use"; a supplied one must be a non-empty server id, and an empty or * non-string selector is refused rather than read as omission. It is not * possible to send into a closed channel: a closed but registered - * destination is opened first (`openSource` names why), and a failed open - * sends nothing. + * destination is opened first (`openSource` names why), once its server + * and that server's channels.publish grant are known to take the send, so + * a refused send opens nothing; and a failed open sends nothing. * * Only the connector's `delivered: true` confirms a post. `failed` means * nothing was posted: the attempt stopped before the request was written, @@ -3115,6 +3116,23 @@ export class ChannelRegistry { ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}), }; + // A send that would be refused is refused before anything opens: a + // closed channel opens only for words that can go out. + const server = this.serverRegistry.getServer(entry.serverId); + if (!server) { + return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() }; + } + // §14.1: channels/publish requires channels.publish in the grant. The + // host not sending is the enforcement. + if (!CapabilityGrant.of(server).has('channels.publish')) { + return { + status: 'failed', + destination, + reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`, + at: at(), + }; + } + if (!entry.open) { try { await this.openChannelNow(entry, openSource); @@ -3145,21 +3163,6 @@ export class ChannelRegistry { } } - const server = this.serverRegistry.getServer(entry.serverId); - if (!server) { - return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() }; - } - // §14.1: channels/publish requires channels.publish in the grant. The - // host not sending is the enforcement. - if (!CapabilityGrant.of(server).has('channels.publish')) { - return { - status: 'failed', - destination, - reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`, - at: at(), - }; - } - let result: unknown; try { result = await server.sendChannelsPublish({ diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 18f82d096..0a0025fca 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -240,6 +240,9 @@ export class McplServerConnection extends EventEmitter { /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; + /** The deadline a channels/publish request keeps when requestTimeoutMs is 0 + * (see sendChannelsPublish). */ + private publishDeadlineMs = DEFAULT_REQUEST_TIMEOUT_MS; // Event buffering: control-plane traffic can be released independently from // inference-bearing data-plane traffic. This lets a server finish its own @@ -775,13 +778,20 @@ export class McplServerConnection extends EventEmitter { * Send `channels/publish`. * May be sent as a request (if an ACK is desired) or notification. * When `params.stream` is true, sends as a notification (no ACK). + * + * A request always has a deadline: the server's requestTimeoutMs, or + * publishDeadlineMs when that is 0 (disabled). A publish is a side effect + * whose outcome the resident is told and a held draft's attempt records, + * so one that is never answered would hold both open for good. Its expiry + * is a no-response, which the caller reads as unknown. */ sendChannelsPublish(params: ChannelsPublishParams): Promise { if (params.stream) { this.sendNotification(McplMethod.ChannelsPublish, params as unknown as Record); return Promise.resolve(); } - return this.sendRequest(McplMethod.ChannelsPublish, params as unknown as Record) as Promise; + const timeoutMs = this.requestTimeoutMs > 0 ? this.requestTimeoutMs : this.publishDeadlineMs; + return this.sendRequest(McplMethod.ChannelsPublish, params as unknown as Record, { timeoutMs }) as Promise; } /** Send `channels/outgoing/chunk` notification. */ diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 0f913ea5a..fbc848860 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -374,7 +374,9 @@ export interface McplServerConfig { * accepts a request and never responds would otherwise freeze the awaiting * agent turn forever; with the timeout the pending request rejects with a * descriptive error, which the framework surfaces as a normal isError - * tool_result. Set 0 to disable. Default: 60000 (60 seconds). + * tool_result. Set 0 to disable, except for a channels/publish request, + * which keeps the 60 s default: its outcome is reported to the resident and + * recorded for its draft, so it must end. Default: 60000 (60 seconds). */ requestTimeoutMs?: number; diff --git a/test/mcpl-request-timeout.test.ts b/test/mcpl-request-timeout.test.ts index 2778469e3..8ebf050a6 100644 --- a/test/mcpl-request-timeout.test.ts +++ b/test/mcpl-request-timeout.test.ts @@ -8,7 +8,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { McplServerConnection } from '../src/mcpl/server-connection.js'; +import { McplRequestError, McplServerConnection } from '../src/mcpl/server-connection.js'; import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; // Stdio server: answers initialize and tools/list, but swallows tools/call — @@ -100,6 +100,24 @@ test('requestTimeoutMs: 0 disables the per-request watchdog', async () => { assert.equal(settled, true); }); +test('requestTimeoutMs: 0 leaves channels/publish its own deadline, whose expiry is a no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 0 }), HOST_CAPS); + connection.ready(); + // The default is 60 s; the test shortens it rather than wait. + (connection as unknown as { publishDeadlineMs: number }).publishDeadlineMs = 150; + const started = Date.now(); + await assert.rejects( + connection.sendChannelsPublish({ conversationId: 'c', channelId: 'x', content: [{ type: 'text', text: 'hi' }] }), + (err: Error) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /did not respond to channels\/publish .*within 150ms/); + return true; + }, + ); + assert.ok(Date.now() - started < 5000); +}); + test('the timeout error warns the model the tool may have completed server-side', async () => { connection = await McplServerConnection.connect(config({ requestTimeoutMs: 150 }), HOST_CAPS); connection.ready(); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index cf175e9c8..b264fda27 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -272,6 +272,8 @@ function xmlMode(inner: MockMembrane): MockMembrane { async function harness(opts: { agents?: Array<{ name: string; proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; sameRoundThinkTextPolicy?: 'public' | 'private' }>; xml?: boolean; + /** The fixture server's requestTimeoutMs (the framework's default when unset). */ + requestTimeoutMs?: number; } = {}) { const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-e2e-')); const commandPath = join(dir, 'commands.jsonl'); @@ -301,6 +303,7 @@ async function harness(opts: { command: process.execPath, args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + ...(opts.requestTimeoutMs !== undefined ? { requestTimeoutMs: opts.requestTimeoutMs } : {}), }], modules: [], }); @@ -549,6 +552,48 @@ describe('held prose drafts, end to end', () => { } }); + it('through the real connection, an error response is unconfirmed, with the connector\'s words', async () => { + const h = await harness(); + try { + h.command({ op: 'publish-mode', mode: 'error' }); + await h.turn([createMockResponse([text('words into a failing connector')])]); + assert.equal(h.publishes().filter((p) => p.event === 'publish-error').length, 1, 'the request reached the server'); + await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); + const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; + assert.match(marker, /was not confirmed \(.*connector exploded.*\(delivery uncertain\)\)\. It may or may not have been posted; check the channel/); + // A resend of a held draft records the same outcome. + await h.turn([ + createMockResponse([text('held beside a send'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + const [attempt] = h.store().get('scout', draft!.id)!.attempts; + assert.equal(attempt!.outcome?.status, 'unknown'); + assert.match(attempt!.outcome!.reason!, /connector exploded/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + } finally { + await h.close(); + } + }); + + it('through the real connection, a server that never answers is unconfirmed once the request times out', async () => { + const h = await harness({ requestTimeoutMs: 1_000 }); + try { + h.command({ op: 'publish-mode', mode: 'hang' }); + await h.turn([createMockResponse([text('words into silence')])]); + await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); + assert.equal(h.publishes().length, 0); + const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; + assert.match(marker, /was not confirmed \(MCPL server "discord" did not respond to channels\/publish .*within 1000ms.*\(delivery uncertain\)\)\. It may or may not have been posted/); + } finally { + await h.close(); + } + }); + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { const h = await harness(); try { @@ -697,6 +742,66 @@ describe('held prose drafts, end to end', () => { } }); + /** Make the store's sync fail once, right after a draft outcome record + * reaches it: the record lands, its write fails, and the journal is + * reconciled with it in. */ + const failOutcomeSyncOnce = (h: Awaited>) => { + const store = (h.framework as unknown as { store: JsStore }).store; + const real = { appendJson: store.appendJson.bind(store), sync: store.sync.bind(store) }; + let armed = false; + let spent = false; + (store as unknown as { appendJson: (t: string, p: unknown) => unknown }).appendJson = (t, p) => { + const ref = real.appendJson(t, p); + if (!spent && t === PROSE_DRAFT_RECORD_TYPE && (p as { op?: string }).op === 'outcome') armed = true; + return ref; + }; + (store as unknown as { sync: () => void }).sync = () => { + if (armed && !spent) { + spent = true; + throw new Error('EIO after the outcome landed'); + } + real.sync(); + }; + }; + + it('a resend whose outcome record fails after landing reports what the draft reads as now', async (t) => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('words to resend'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + failOutcomeSyncOnce(h); + t.mock.method(console, 'error', () => {}); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.toolResults()[0]!, new RegExp(`${draft!.id}: delivered to .*\\(recording this outcome failed; the draft reads as delivered now\\)\\.`)); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'delivered'); + } finally { + await h.close(); + } + }); + + it('an {{unsent}} delivery whose outcome record fails tells the resident what the draft reads as now', async (t) => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); + try { + await h.turn([createMockResponse([text('words with nowhere to go')])]); + const [bounced] = h.drafts(); + failOutcomeSyncOnce(h); + t.mock.method(console, 'error', () => {}); + await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); + assert.deepEqual(h.publishes().map((p) => p.text), ['words with nowhere to go']); + await h.until(() => h.texts().some((x) => x.includes('but recording that failed')), 'the notice'); + assert.ok(h.texts().some((x) => x === `[prose-routing] {{unsent}} was draft ${bounced!.id}; its send came back delivered, but recording that ` + + 'failed, so the draft reads as delivered now. drafts(action: "read") shows it.'), JSON.stringify(h.texts().filter((x) => x.startsWith('[prose-routing]')))); + } finally { + await h.close(); + } + }); + it('explicit mode: a bounce is a draft that survives a restart, and {{unsent}} delivers and settles it', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); try { diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index d9d2d79d3..13f298add 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -24,8 +24,12 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; type Publish = (params: { channelId: string }) => Promise; -function registryWith(servers: Record, failures: unknown[] = []) { +function registryWith( + servers: Record Promise }>, + failures: unknown[] = [], +) { const published: Array<{ serverId: string; channelId: string }> = []; + const opened: Array<{ serverId: string; channelId: string }> = []; const mocks = new Map(); for (const [id, s] of Object.entries(servers)) { mocks.set(id, { @@ -35,7 +39,10 @@ function registryWith(servers: Record ({}), + sendChannelsOpen: async (params: { channelId: string }) => { + opened.push({ serverId: id, channelId: params.channelId }); + return s.open ? s.open() : {}; + }, }); } const registry = new ChannelRegistry( @@ -48,9 +55,9 @@ function registryWith(servers: Record; }).channels; - const seed = (serverId: string, id: string, label: string) => - channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open: true }); - return { registry, published, seed }; + const seed = (serverId: string, id: string, label: string, open = true) => + channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open }); + return { registry, published, opened, seed }; } const ok: Publish = async () => ({ delivered: true, messageId: 'posted-1' }); @@ -66,6 +73,32 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(typeof outcome.at, 'number'); }); + it('opens a closed destination first, and a failed open sends nothing', async () => { + const opens = registryWith({ discord: { publish: ok } }); + opens.seed('discord', 'c', '#c', false); + assert.equal((await opens.registry.publish('agent', 'x', { channelId: 'c' })).status, 'delivered'); + assert.deepEqual(opens.opened, [{ serverId: 'discord', channelId: 'c' }]); + assert.deepEqual(opens.published, [{ serverId: 'discord', channelId: 'c' }]); + + const fails = registryWith({ discord: { publish: ok, open: async () => { throw new Error('no such channel'); } } }); + fails.seed('discord', 'c', '#c', false); + const outcome = await fails.registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.reason, 'channel is closed and open failed: no such channel'); + assert.deepEqual(fails.opened, [{ serverId: 'discord', channelId: 'c' }]); + assert.deepEqual(fails.published, [], 'nothing dispatched after a failed open'); + }); + + it('refuses a send the grant forbids before opening anything', async () => { + const { registry, seed, opened, published } = registryWith({ discord: { publish: ok, grant: false } }); + seed('discord', 'c', '#c', false); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason!, /channels\.publish not in "discord"'s effective grant/); + assert.deepEqual(opened, [], 'a refused send opens nothing'); + assert.deepEqual(published, []); + }); + it('treats delivered:false with no posted message as failed', async () => { const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false }) } }); seed('discord', 'c', '#c'); From d80210e460936e3fa2dceea9ff0b25896bd0f092 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 14:56:13 -0400 Subject: [PATCH 41/65] fix(drafts): a refused {{unsent}} holds its envelope, dismissals name their risk, and every stop and wait says what it is sw-af247, part 3: {{unsent}}, dismissals, and what the resident is told. - I19: a deliverable envelope whose {{unsent}} is refused (its draft is being sent, queued in a resend, or unconfirmed) is held as a bounced envelope would be: one bounce draft with the whole text, {{unsent}} written out under the re-bounce rule and its risk carried, named in the refusal notice. A bare {{unsent}} holds nothing new: its words already are that draft. takeUnsent takes the envelope's body and hold. - I26 and I43: {{unsent}} says "being sent right now" only while the draft's attempt is out (draftsSending), and "queued in a resend in progress" for a draft a batch has only claimed. The risk a queued draft's words carry says it was queued. Each draft a resend batch doesn't attempt gives the stop's own reason: an earlier draft not confirmed delivered, dismissed or changed while it waited, no longer found, or its attempt not recorded. - I23: dismissing a draft that may already have been posted names that risk, in the drafts tool's line and, for >>skip_reply {{unsent}}, in a private notice. - I31: when >>skip_reply {{unsent}} can't record the dismissal, the resident is told the bounce is still held and {{unsent}} still names it. - I44: an attempt still out (its draft's latest, while being sent) is not called one whose outcome "was never recorded": riskText, drafts read and a copied draft's inherited risk say it is being sent, its outcome not known yet (attemptIsOut). Tests (prose-drafts): - I8: the resend-ownership tests wait until a publish is pending, bounded at 10 s, instead of a fixed 5 ms. - I14: the publish-throws test checks that publish was entered, that the resend rejects with the stub's error, and that the draft reads unconfirmed before the dismissal, which now names the risk. - New: stop reasons for a dismissed and an unconfirmed draft; drafts read of an attempt still out; {{unsent}} of a bounce queued in a resend, holding the envelope with the risk; >>skip_reply {{unsent}} when the dismissal can't be recorded, then for an unconfirmed bounce. - The re-bounce test's refusal wording is updated. --- src/framework.ts | 140 +++++++++++++++++++++++++++----------- test/prose-drafts.test.ts | 136 ++++++++++++++++++++++++++++++++---- 2 files changed, 222 insertions(+), 54 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3cba84f6c..783d02b31 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -85,7 +85,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, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -9117,7 +9117,7 @@ export class AgentFramework { const risky = uncertainAttempt(draft); if (risky) { return `${draft.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ` + - `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}`; + `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? this.noOutcomeText(draft, risky)}`; } const inherited = draft.inheritedRisk!; const owner = inherited.sourceDraftId ?? inherited.draftId; @@ -9129,6 +9129,18 @@ export class AgentFramework { return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`; } + /** Whether an attempt with no outcome is still out: its draft's latest, + * while that draft is being sent. Otherwise it never got one (a crash). */ + private attemptIsOut(draft: Draft, attempt: DraftAttempt): boolean { + return !attempt.outcome && this.draftsSending.has(`${draft.agent}\u0000${draft.id}`) && + draft.attempts.at(-1)?.attemptId === attempt.attemptId; + } + + /** Why an attempt has no outcome, in a risk's words. */ + private noOutcomeText(draft: Draft, attempt: DraftAttempt): string { + return this.attemptIsOut(draft, attempt) ? "it is being sent right now, so its outcome isn't known yet" : 'its outcome was never recorded'; + } + /** A delivered draft's historical receipt. */ private draftReceipt(draft: Draft): string { const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); @@ -9323,7 +9335,7 @@ export class AgentFramework { ...d.attempts.map((a) => { const o = a.outcome; const status = !o - ? 'outcome never recorded — it may have been posted' + ? (this.attemptIsOut(d, a) ? 'being sent right now — no outcome yet' : 'outcome never recorded — it may have been posted') : o.status === 'delivered' ? `delivered${o.messageId ? `, message ${o.messageId}` : ''}` : o.status === 'unknown' @@ -9360,6 +9372,9 @@ export class AgentFramework { lines.push(`${d.id}: already dismissed.`); continue; } + // A dismissal sets the words aside; it can't take back an attempt + // that may already have been posted, so that is said with it. + const risk = state === 'unconfirmed' ? ` Note that ${this.riskText(d)}.` : ''; try { this.proseDrafts.dismiss(agentName, d.id); } catch (err) { @@ -9368,7 +9383,7 @@ export class AgentFramework { `${err instanceof Error ? err.message : String(err)}. It is still open.`, ); } - lines.push(`${d.id}: dismissed (your history keeps the words).`); + lines.push(`${d.id}: dismissed (your history keeps the words).${risk}`); } this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: drafts.map((d) => d.id) }); return ok(lines.join('\n')); @@ -9424,11 +9439,13 @@ export class AgentFramework { const claims = drafts.map((d) => `${agentName}\u0000${d.id}`); for (const claim of claims) this.draftsInFlight.add(claim); const lines: string[] = []; - let stopped = false; + // Why the batch stopped, said for each draft after it: the resend sends + // in order, so one that can't go stops the rest. + let stopped: string | undefined; try { for (const queued of drafts) { if (stopped) { - lines.push(`${queued.id}: not attempted (an earlier draft was not confirmed delivered).`); + lines.push(`${queued.id}: not attempted (${stopped}).`); continue; } // The current projection, not the batch's snapshot (it may have been @@ -9436,7 +9453,7 @@ export class AgentFramework { const d = this.proseDrafts.get(agentName, queued.id); if (!d) { lines.push(`${queued.id}: no longer found among your drafts — not sent.`); - stopped = true; + stopped = `${queued.id} was no longer found`; continue; } if (draftState(d) === 'delivered') { @@ -9446,7 +9463,7 @@ export class AgentFramework { const refusal = this.resendRefusal(d, confirmDuplicate); if (refusal) { lines.push(`${refusal} It changed while this resend was waiting — not sent.`); - stopped = true; + stopped = draftState(d) === 'dismissed' ? `${d.id} was dismissed while it waited` : `${d.id} changed while it waited`; continue; } let attemptId: string; @@ -9454,7 +9471,7 @@ export class AgentFramework { attemptId = this.proseDrafts.beginAttempt(agentName, d.id, destination, 'resend', draftState(d) === 'unconfirmed'); } catch (err) { lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}).`); - stopped = true; + stopped = `${d.id}'s attempt could not be recorded`; continue; } const sendingKey = `${agentName}\u0000${d.id}`; @@ -9491,7 +9508,7 @@ export class AgentFramework { engaged.add(destination.channelId); continue; } - stopped = true; + stopped = `${d.id} was not confirmed delivered`; const after = this.proseDrafts.get(agentName, d.id); const stillAtRisk = after !== undefined && draftState(after) === 'unconfirmed'; lines.push(outcome.status === 'unknown' @@ -9775,31 +9792,60 @@ export class AgentFramework { } /** - * `{{unsent}}` in an envelope body: the latest bounce draft's words. An - * unconfirmed one (its last attempt may have been posted) is never - * substituted silently — the resident gets the facts and the explicit - * route (drafts resend with confirmDuplicate) instead. + * `{{unsent}}` in an envelope body: the latest bounce draft's words. One + * that is out (being sent, or queued in a resend) or unconfirmed (its last + * attempt may have been posted) is never substituted — nothing is sent, + * and the resident gets the facts and the explicit route (drafts resend + * with confirmDuplicate) instead. The envelope's own words are held, as a + * bounced envelope's would be (unsentWords): one bounce draft with the + * whole text, `{{unsent}}` written out and its risk carried, so nothing + * the resident wrote has to be typed again. A bare `{{unsent}}` holds + * nothing new: its words already are that draft. */ - private takeUnsent(agent: Agent, prefix: '>>' | '>>>'): { draft?: Draft; refused?: true } { + private takeUnsent( + agent: Agent, + body: string, + hold: { round?: number; notice: 'now' | 'later' }, + ): { draft?: Draft; refused?: true } { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return {}; - if (this.draftsInFlight.has(`${agent.name}\u0000${draft.id}`)) { - const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. Nothing was sent; wait for that result.`; - try { - this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); - } catch (err) { - console.error('[drafts] in-flight-unsent notice failed:', err); - } - return { draft, refused: true }; + const key = `${agent.name}\u0000${draft.id}`; + let why: string; + let next: string; + if (this.draftsInFlight.has(key)) { + why = this.draftsSending.has(key) ? 'which is being sent right now' : 'which is queued in a resend in progress'; + next = 'wait for that result'; + } else if (draftState(draft) === 'unconfirmed') { + why = this.riskText(draft); + next = 'check that channel; to send that draft again anyway use ' + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true)`; + } else { + return { draft }; } - if (draftState(draft) !== 'unconfirmed') return { draft }; - const text = - `[prose-routing] {{unsent}} is draft ${draft.id}: ${this.riskText(draft)}. Nothing was sent. Check that channel; ` + - `to send it again anyway use drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true).`; + const words = this.unsentWords(agent, body); + const held = 'draft' in words + ? undefined + : this.holdProseDrafts( + agent, + [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], + 'bounced', + { round: hold.round, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, + )[0]; + const kept = held + ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` + + 'can be resent only with confirmDuplicate: true, after that.' + : ''; + const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` + + `Nothing was sent; ${next}.${kept}`; try { - this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + this.addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'prose-bounce', ...(held ? { draftOwner: agent.name, draftIds: [held.id] } : {}) } as MessageMetadata, + { forAgent: agent.name }, + ); } catch (err) { - console.error('[drafts] unconfirmed-unsent notice failed:', err); + console.error('[drafts] refused-unsent notice failed:', err); } return { draft, refused: true }; } @@ -9899,7 +9945,7 @@ export class AgentFramework { } let body = parsed.body; const usedClipboard = body.includes('{{unsent}}'); - const unsent = usedClipboard ? this.takeUnsent(agent, '>>>') : {}; + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; if (unsent.refused) { this.proseTargetPins.delete(agent.name); this.proseHybridSuppressed.set(agent.name, 'failed'); @@ -9966,26 +10012,35 @@ export class AgentFramework { /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce * draft aside — unless it is being sent right now, which a dismissal - * can't call back: the resident is told instead. */ + * can't call back: the resident is told instead. So are a dismissal that + * can't be recorded (the draft stays, and `{{unsent}}` still names it) + * and one whose draft may already have been posted. */ private dismissLatestBounce(agent: Agent): void { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return; - if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) { - const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` + - 'since that can\'t call back words already on their way; wait for that result.'; + const tell = (text: string, what: string): void => { try { - this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); } catch (err) { - console.error('[drafts] in-flight-dismiss notice failed:', err); + console.error(`[drafts] ${what} notice failed:`, err); } + }; + if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) { + tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.', 'in-flight-dismiss'); return; } + const risk = draftState(draft) === 'unconfirmed' ? this.riskText(draft) : undefined; try { this.proseDrafts.dismiss(agent.name, draft.id); this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); } catch (err) { console.error(`[drafts] ${agent.name}: could not dismiss ${draft.id}:`, err); + tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which could not be set aside ` + + `(${err instanceof Error ? err.message : String(err)}): it is still held, and {{unsent}} still names it.`, 'failed-dismiss'); + return; } + if (risk) tell(`[prose-routing] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss'); } private async deliverProseEnvelope(agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' }): Promise { @@ -10025,7 +10080,7 @@ export class AgentFramework { } const usedClipboard = body.includes('{{unsent}}'); - const unsent = usedClipboard ? this.takeUnsent(agent, '>>') : {}; + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; if (unsent.refused) return; if (usedClipboard) { body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); @@ -10074,7 +10129,9 @@ export class AgentFramework { draftId: previous.id, destination: risky.destination, at: risky.at, - reason: risky.outcome?.reason ?? 'its outcome was never recorded', + reason: risky.outcome?.reason ?? (this.attemptIsOut(previous, risky) + ? "it was being sent when these words were held, so its outcome wasn't known yet" + : 'its outcome was never recorded'), } : previous.inheritedRisk ? { @@ -10084,7 +10141,12 @@ export class AgentFramework { ...(previous.inheritedRisk.at !== undefined ? { at: previous.inheritedRisk.at } : {}), reason: previous.inheritedRisk.reason, } - : { draftId: previous.id, reason: 'it was being sent when these words were held' }; + : { + draftId: previous.id, + reason: this.draftsSending.has(`${name}\u0000${previous.id}`) + ? 'it was being sent when these words were held' + : 'it was queued in a resend when these words were held', + }; return { text: expanded, inheritedRisk }; } diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index b264fda27..9635ad821 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -841,7 +841,7 @@ describe('held prose drafts, end to end', () => { const before = h.publishes().length; await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); assert.equal(h.publishes().length, before, 'neither draft goes out without confirmation'); - assert.ok(h.texts().some((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${fresh.id}: ${fresh.id} includes the words of ${risky!.id}`))); + assert.ok(h.texts().some((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${fresh.id}, and ${fresh.id} includes the words of ${risky!.id}`))); await h.turn([createMockResponse([drafts('r1', { action: 'resend', draftIds: [fresh.id], destination: ROOM, confirmDuplicate: true })], 'tool_use'), createMockResponse([])]); assert.deepEqual(h.publishes().slice(before).map((p) => p.text), ['risky words and a postscript']); assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed', 'the original still needs its own confirmation'); @@ -1091,10 +1091,18 @@ describe('drafts resend ownership', () => { }; const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => r.success ? r.data!.map((b) => b.text).join('') : r.error!; - const tick = () => new Promise((r) => setTimeout(r, 5)); + /** Until a publish is waiting on the test, bounded: what the next step + * needs, rather than a fixed pause that a loaded machine can outrun. */ + const untilPublishing = async () => { + const deadline = Date.now() + 10_000; + while (pending.length === 0) { + if (Date.now() > deadline) throw new Error('no publish reached the stub within 10 s'); + await new Promise((r) => setTimeout(r, 2)); + } + }; const [a, b] = internals.proseDrafts.hold('scout', [{ text: 'A words', source }, { text: 'B words', source }], 'explicit-send').held; return { - dir, framework, internals, pending, say, tick, a: a!, b: b!, + dir, framework, internals, pending, say, untilPublishing, a: a!, b: b!, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, }; }; @@ -1103,7 +1111,7 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); assert.match(t.say(await sending), /delivered to \(chan\), message m-a/); // The channel is gone: destination resolution would now fail. @@ -1125,12 +1133,12 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'the batch is waiting on A'); const concurrent = t.say(await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.b.id], destination: 'chan' })); assert.match(concurrent, new RegExp(`${t.b.id} is being sent right now \\(or is queued in a resend in progress\\)`)); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['B words']); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); const result = t.say(await batch); @@ -1146,7 +1154,7 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); const dismissed = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); assert.match(dismissed, new RegExp(`${t.b.id}: dismissed`)); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); @@ -1163,7 +1171,7 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'A is out; B is queued'); const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id, t.a.id] })); assert.match(refused, new RegExp(`${t.a.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); @@ -1183,9 +1191,9 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['B words'], 'A is in; B is out'); assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), new RegExp(`${t.a.id}: already delivered — nothing to dismiss`)); @@ -1200,10 +1208,108 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; - registry.publish = async () => { throw new Error('transport gone'); }; - await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }).catch(() => undefined); + let entered = 0; + registry.publish = async () => { + entered++; + throw new Error('transport gone'); + }; + await assert.rejects( + t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }), + /transport gone/, + ); + assert.equal(entered, 1, 'the resend reached publish'); + assert.equal(draftState(t.internals.proseDrafts.get('scout', t.a.id)!), 'unconfirmed', 'its attempt has no outcome: it may have been posted'); assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), - new RegExp(`${t.a.id}: dismissed`)); + new RegExp(`${t.a.id}: dismissed \\(your history keeps the words\\)\\. Note that ${t.a.id}'s attempt to .* may already have been posted: its outcome was never recorded\\.`)); + } finally { + await t.close(); + } + }); + + it('a batch that stops says why for each draft after the stop', async () => { + const t = await setup(); + try { + const [c] = t.internals.proseDrafts.hold('scout', [{ text: 'C words', source }], 'explicit-send').held; + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id, c!.id], destination: 'chan' }); + await t.untilPublishing(); + t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + const result = t.say(await batch); + assert.match(result, new RegExp(`${c!.id}: not attempted \\(${t.b.id} was dismissed while it waited\\)\\.`)); + + const [d] = t.internals.proseDrafts.hold('scout', [{ text: 'D words', source }], 'explicit-send').held; + const again = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [c!.id, d!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + assert.match(t.say(await again), new RegExp(`${d!.id}: not attempted \\(${c!.id} was not confirmed delivered\\)\\.`)); + } finally { + await t.close(); + } + }); + + it('an attempt still out reads as being sent, not as one whose outcome was never recorded', async () => { + const t = await setup(); + try { + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.untilPublishing(); + const read = t.say(await t.internals.handleDraftsTool('scout', { action: 'read', draftIds: [t.a.id] })); + assert.match(read, /attempt .* → \(chan\): being sent right now — no outcome yet/); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await sending; + } finally { + await t.close(); + } + }); + + it('{{unsent}} of a bounce queued in a resend says so, and holds the envelope\'s words with the risk', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + const agent = t.framework.getAgent('scout')!; + await t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}} and a postscript', { notice: 'later' }); + assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'nothing more was sent'); + const fresh = t.internals.proseDrafts.latestBounce('scout')!; + assert.notEqual(fresh.id, bounce!.id); + assert.equal(fresh.text, 'bounced words and a postscript'); + assert.equal(fresh.inheritedRisk?.draftId, bounce!.id); + assert.match(fresh.inheritedRisk!.reason, /queued in a resend when these words were held/); + const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is queued in a resend in progress. Nothing was sent; ` + + `wait for that result. Your message is held as draft ${fresh.id}, with {{unsent}} written out; since it includes ` + + `${bounce!.id}'s words, it can be resent only with confirmDuplicate: true, after that.`), words.join('\n')); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); + await batch; + } finally { + await t.close(); + } + }); + + it('>>skip_reply {{unsent}} says when the bounce may already have been posted, or could not be set aside', async () => { + const t = await setup(); + try { + const agent = t.framework.getAgent('scout')!; + const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + // The journal refuses the dismissal: the bounce stays, and the resident is told. + const dismiss = t.internals.proseDrafts.dismiss.bind(t.internals.proseDrafts); + t.internals.proseDrafts.dismiss = () => { throw new Error('EIO'); }; + await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + assert.ok(words().includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which could not be set aside (EIO): ` + + 'it is still held, and {{unsent}} still names it.'), words().join('\n')); + assert.equal(t.internals.proseDrafts.latestBounce('scout')?.id, bounce!.id); + t.internals.proseDrafts.dismiss = dismiss; + await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed'); + assert.ok(words().some((w) => w.startsWith(`[prose-routing] Draft ${bounce!.id} is set aside. Note that ${bounce!.id}'s attempt to (chan) at `) && + w.endsWith('may already have been posted: no receipt.')), words().join('\n')); } finally { await t.close(); } @@ -1214,7 +1320,7 @@ describe('drafts resend ownership', () => { try { const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); const agent = t.framework.getAgent('scout')!; const envelope = (text: string) => t.internals.deliverProseEnvelope(agent, text, { notice: 'later' }); await envelope('>>skip_reply {{unsent}}'); @@ -1237,7 +1343,7 @@ describe('drafts resend ownership', () => { const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; const agent = t.framework.getAgent('scout')!; const delivering = t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}}', { notice: 'later' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['bounced words'], 'the bounce is out through {{unsent}}'); const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })); assert.match(refused, new RegExp(`${bounce!.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); From 9feee54ceb6842b04a49ae52d2cfba934ba23706 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:00:52 -0400 Subject: [PATCH 42/65] fix(envelope): only the framework writes the envelope, conversation keys can't collide, and the texts say what the code does sw-af247, part 4: the inbound envelope, and the texts. - I12: a module's addMessages metadata loses any inboundSource it carries before it is stored (one echoed from a server's event, say); a console or API surface event's messages carry the framework's own, as before. - I36: conversationKey encodes its server-chosen parts as a JSON array, so two distinct (kind, server, channel, thread) never share a key, and an empty thread is not no thread. It is an in-memory comparison key, never stored, so its format can change. - I33: splitProseSegments is gone; the framework trims splitProseRuns' runs inline, and the tests trim them the same way. - I1: the inbound-source header says what the envelope is and that consumers read it through readInboundSource and conversationKey, without naming readers this change doesn't have. - I41: the acceptance observer's comment says it is an internal seam the receipt work (shelf-354) assigns inside the framework; unset here. - I9, I18, I28, I42, and the round's own changes, in the fragments and the routing doc: routeSpeech now returns null unless delivery was confirmed; what {{unsent}} does with a draft that may have been posted, on each route; retention and the let-go refusal; notices counted per branch; the drafts tool's one-time prompt-cache miss; a publish's deadline and a refused send opening nothing; coalesced: true present only when it is; a module's envelope dropped. Tests: - inbound-source (I15): the strict reader refuses a missing, empty or non-string serverId, a bad binding, and non-string threadId, replyTo and eventId, for channel and unscoped envelopes. - inbound-source (I36): keys built from parts holding NUL differ, and an empty thread differs from none; the surface key's new form. - inbound-source (I12): a module relaying a forged envelope stores none; a console message carries the surface stamp, whatever its metadata said. - prose-drafts (I16): a silent turn's prose beside a send is counted private, never held or named. Red check: with suppressProse removed from both turnProseRouting and silenceCause, it fails. With only one removed it passes, since either keeps the prose private. I34 is re-judged not-real in the ledger: Agent.proseRouting is readonly, so the live value is the turn's. --- changelog.d/inbound-source-envelope.added.md | 13 +++--- changelog.d/prose-drafts.added.md | 42 ++++++++++++++------ changelog.d/publish-outcomes.fixed.md | 6 ++- docs/explicit-prose-routing.md | 25 +++++++++--- src/framework.ts | 16 ++++++-- src/mcpl/inbound-source.ts | 28 +++++++------ src/prose-segments.ts | 7 +--- test/inbound-source.test.ts | 42 +++++++++++++++++++- test/prose-drafts.test.ts | 20 +++++++++- test/prose-segments.test.ts | 6 ++- 10 files changed, 154 insertions(+), 51 deletions(-) diff --git a/changelog.d/inbound-source-envelope.added.md b/changelog.d/inbound-source-envelope.added.md index 9afd3c00c..ff472c601 100644 --- a/changelog.d/inbound-source-envelope.added.md +++ b/changelog.d/inbound-source-envelope.added.md @@ -8,12 +8,15 @@ ordinary and the RFC-006 coalesced paths; a coalesced occurrence freezes its envelope at acceptance, so deferral, fan-out and replay deliver it unchanged and a later rename never rewrites it. Adapter-supplied metadata cannot set - the field (an adapter's own `origin.source` string is untouched). - `readInboundSource`, `conversationKey` and `INBOUND_SOURCE_KEY` are exported. + the field, and one a module's messages carry is dropped (an adapter's own + `origin.source` string is untouched). `readInboundSource`, + `conversationKey` (a JSON-encoded key, so no server-chosen part can run + into another) and `INBOUND_SOURCE_KEY` are exported. - **Inbound source envelope: admission facts.** An ordinary `channels/incoming` message or `push/event` now has its envelope frozen at admission, before it is queued or acknowledged, so a rename or rebind while it waits cannot change it. Every channel and unscoped envelope names the admission `lane` - (`channels/incoming` or `push/event`) and whether it came through RFC-006 - coalesced admission (`coalesced: true`): facts a consumer needs to know what - an `eventId` is worth. `readInboundSource` validates every field it returns. + (`channels/incoming` or `push/event`), and one that came through RFC-006 + coalesced admission has `coalesced: true` (others have no `coalesced` + field): facts a consumer needs to know what an `eventId` is worth. + `readInboundSource` validates every field it returns. diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 85c6607ad..f55b6d5a2 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -22,7 +22,12 @@ re-bounce rule (a bare `{{unsent}}` leaves the bounce as the one draft). - Drafts live in typed Chronicle records (`RecordJournal`) that do not follow branch switches: undo, rollback and checkout never change them, and they - survive restarts. Nothing is ever sent from them on its own. + survive restarts. Nothing is ever sent from them on its own. Each + resident's 100 newest drafts are kept, and an older one for 7 days after + anything last happened to it (held, named, attempted, answered, dismissed); + the journal checkpoints what it keeps every 500 entries, so opening it + replays only what follows. A request naming a draft that was let go is + refused, and nothing is sent. - A private notice names held drafts at the next tool boundary when the live stream presents mid-turn messages (a draft holding words copied from one that may already have been posted is named with that risk and the @@ -32,29 +37,42 @@ (listed where it landed), unconfirmed, or dismissed. A dismissed draft whose own attempt may already have been posted is named with that risk, and counts like an unconfirmed one: a receipt that names one, with nothing - confirmed delivered, reads "nothing confirmed", not "nothing". A draft no - notice reached (a crash, an aborted turn) is named at the next turn's - start. + confirmed delivered, reads "nothing confirmed", not "nothing". A draft + counts as named while a message naming it is on the current branch, so one + no notice reached (a crash, an aborted turn), or whose notices a rollback + or branch move took away, is named at the next turn's start: every such + draft by id, the newest eight with a preview. - A draft is never published except by its resident's explicit resend (or `{{unsent}}`), and only that resident can list or act on it. Its notices go into the resident's own history, which residents sharing one message slot already share, as they share the assistant turn the words came from. - New `drafts` tool (whenever MCPL channels are configured): `list`, `read`, - `resend` and `dismiss`, scoped to the calling resident. `resend` publishes + `resend` and `dismiss`, scoped to the calling resident. Every such + resident's tool list gains it, so each one's first request after upgrading + misses the prompt cache once. `resend` publishes drafts verbatim, in order, to one destination through the normal publish path, journaling each attempt durably before the request leaves; it counts as an explicit send. A delivered draft returns its historical receipt instead of sending again; a draft with any attempt whose outcome is unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend - owns its drafts until it finishes, re-checking each before it is sent. - Dismissing a draft (with the tool or `>>skip_reply {{unsent}}`) is - refused while that draft's attempt is out, since a dismissal can't call - back words already on their way; the resident is told to wait for that - result. A draft only queued in a resend can still be dismissed, and is - then not sent. Unused fields may be passed as null. + owns its drafts until it finishes, re-checking each before it is sent, and + a draft it doesn't attempt is told why the batch stopped. Dismissing a + draft (with the tool or `>>skip_reply {{unsent}}`) is refused while that + draft's attempt is out, since a dismissal can't call back words already on + their way; the resident is told to wait for that result. A draft only + queued in a resend can still be dismissed, and is then not sent. A + dismissal of a draft that may already have been posted names that risk. + Unused fields may be passed as null. +- `{{unsent}}` whose draft is being sent, queued in a resend, or unconfirmed + sends nothing, and the envelope's own words are held as a bounce draft + with `{{unsent}}` written out and the risk carried, so nothing the + resident wrote has to be typed again. - Prose segments also break at XML tool mode's refused attempts and their notices (`tool_attempt`, `tool_notice`): the words before an all-refused call and after its notice are two messages, routed or held separately. - `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and - `ChannelRegistry.resolveDestination` are new; routeSpeech is unchanged. + `ChannelRegistry.resolveDestination` are new. `routeSpeech` now delegates + to `deliverSpeech` and returns null unless delivery was confirmed, so a + missing receipt and a channel id two servers share now return null (see + the publish-outcomes entry). - The bounce notice now goes to the bouncing resident rather than the primary. diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md index 659f0742b..6dd88191e 100644 --- a/changelog.d/publish-outcomes.fixed.md +++ b/changelog.d/publish-outcomes.fixed.md @@ -16,4 +16,8 @@ `mcpl:speech-route-failed` trace carry `outcome`. A channel id registered by more than one MCPL server is refused rather than routed through whichever server registered it first, and a supplied but empty or - non-string server selector is refused rather than read as omitted. + non-string server selector is refused rather than read as omitted. A send + refused for its server or grant opens no closed channel. A + `channels/publish` request always has a deadline: with `requestTimeoutMs` + 0 it keeps the 60 s default, since its outcome is told to the resident and + recorded for its draft; its expiry reads as unknown. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index b719b400c..8096ac7fc 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -45,12 +45,25 @@ breaking any loop). `{{unsent}}` means the latest bounce while that draft is open (latest-wins, as the clipboard it replaced was), and `>>skip_reply {{unsent}}` dismisses it. -Earlier bounces stay in the agent's drafts, by id, until resent or dismissed; -drafts survive restarts and branch moves, and a delivery through `{{unsent}}` -is recorded on the draft like any resend. When the latest bounce's last -delivery attempt may already have been posted (an unknown outcome), -`{{unsent}}` is not substituted: the agent is told, and can resend it with the -`drafts` tool's `confirmDuplicate`. +Earlier bounces stay in the agent's drafts, by id, until resent, dismissed or +let go (an agent's 100 newest drafts are always kept, and an older one for 7 +days after anything last happened to it); drafts survive restarts and branch +moves, and a delivery through `{{unsent}}` is recorded on the draft like any +resend. + +When the latest bounce may already have been posted (an attempt's outcome is +unknown), or is being sent or queued in a resend right now, what happens +depends on the route: +- **Delivery** (`>>#channel … {{unsent}} …`): nothing is sent. The agent is + told, and can resend the bounce with the `drafts` tool's `confirmDuplicate` + after checking the channel. The envelope's own words are held as a new + bounce draft, with `{{unsent}}` written out and the risk carried, so that + draft too can be resent only with `confirmDuplicate`. A bare `{{unsent}}` + holds nothing new: its words already are the bounce. +- **Hold** (an envelope that bounces, or one an explicit send held back): the + bounce's words are copied into the new draft with `{{unsent}}` written out, + and the draft carries the same risk (`inheritedRisk`), so it reads + unconfirmed until a confirmed delivery. ## What explicit mode retires (for that agent) diff --git a/src/framework.ts b/src/framework.ts index 783d02b31..6656a87be 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1056,8 +1056,9 @@ export class AgentFramework { private processLoggingPersist: boolean; private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); - /** Told once per inbound acceptance (mcpl/inbound-source.ts); unset unless - * a receipt consumer installs one. */ + /** Told once per inbound acceptance (mcpl/inbound-source.ts). An internal + * seam, unset here: the receipt work (shelf-354) assigns it inside the + * framework, and tests set it by cast. */ private inboundAcceptanceObserver?: InboundAcceptanceObserver; /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved @@ -7129,10 +7130,17 @@ export class AgentFramework { /** The event's surface envelope, when it is console/API input. */ surface?: InboundSource, ): Promise { - // Add messages + // Add messages. Only the framework writes the inbound envelope: one a + // module's metadata carries (echoed from a server's event, say) is + // dropped, and a surface event's messages carry the framework's own. if (response.addMessages) { for (const msg of response.addMessages) { - const metadata = surface ? { ...msg.metadata, [INBOUND_SOURCE_KEY]: surface } : msg.metadata; + let metadata = msg.metadata; + if (metadata && INBOUND_SOURCE_KEY in metadata) { + const { [INBOUND_SOURCE_KEY]: _dropped, ...rest } = metadata; + metadata = rest; + } + if (surface) metadata = { ...metadata, [INBOUND_SOURCE_KEY]: surface }; const id = this.addMessage(msg.participant, msg.content, metadata); this.emitTrace({ type: 'message:added', messageId: id, source: event.type }); } diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index 29c2e65e0..766eb07cf 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -2,18 +2,18 @@ * Where an inbound item came from, decided once at Agent Framework's * ingestion boundary and stored on the message as `metadata.inboundSource`. * - * Every later consumer reads this one value instead of re-deriving identity - * from the lane it happened to arrive on: speech routing compares - * conversations with it, the visible provenance header renders from it, and - * delivery bookkeeping keys on it. It is frozen when the host ACCEPTS the - * item. A coalesced occurrence (RFC-006) carries the value stamped at its - * acceptance through deferral, fan-out and replay, so a later rename, - * reconnect or re-render never rewrites where an old item says it came from. + * A consumer of where an item came from reads this one value + * (readInboundSource) rather than re-deriving identity from the lane the + * item happened to arrive on, and compares conversations with + * conversationKey. It is frozen when the host ACCEPTS the item. A coalesced + * occurrence (RFC-006) carries the value stamped at its acceptance through + * deferral, fan-out and replay, so a later rename, reconnect or re-render + * never rewrites where an old item says it came from. * * The field is framework-owned. It is written after any adapter-supplied - * metadata is spread, so an adapter cannot supply or spoof it (adapter - * `origin` objects already use a free-form `source` string, which is why this - * is not called `source`). + * metadata is spread, so an adapter cannot supply or spoof it, and one a + * module's messages carry is dropped (adapter `origin` objects already use a + * free-form `source` string, which is why this is not called `source`). * * Facts only, no policy: whether an item is a body, an edit or a deletion * stays in its tags (`chat:edited`, `chat:deleted`); `deferred` and @@ -143,12 +143,14 @@ export function readInboundSource(metadata: unknown): InboundSource | undefined /** * The conversation an item belongs to, as a comparable key: server + channel * + thread for a channel item, the surface for console input. Undefined for an - * unscoped push, which belongs to no conversation. + * unscoped push, which belongs to no conversation. The parts are server-chosen + * strings, so they are encoded as a JSON array, never joined with a separator + * one of them could contain: two keys are equal only for the same parts. */ export function conversationKey(source: InboundSource): string | undefined { if (source.kind === 'channel') { - return `channel\u0000${source.serverId}\u0000${source.channelId}\u0000${source.threadId ?? ''}`; + return JSON.stringify(['channel', source.serverId, source.channelId, source.threadId ?? null]); } - if (source.kind === 'surface') return `surface\u0000${source.surface}`; + if (source.kind === 'surface') return JSON.stringify(['surface', source.surface]); return undefined; } diff --git a/src/prose-segments.ts b/src/prose-segments.ts index 6e2b96f7e..d10ce357d 100644 --- a/src/prose-segments.ts +++ b/src/prose-segments.ts @@ -22,7 +22,7 @@ import type { ContentBlock } from '@animalabs/membrane'; * The runs exactly as written: contiguous text blocks joined with a newline, * nothing trimmed. Whitespace-only runs are dropped. Held drafts keep these, * so a draft's words (indentation included) are the resident's own, byte for - * byte. + * byte; speech routes each run trimmed of surrounding whitespace. */ export function splitProseRuns(content: readonly ContentBlock[]): string[] { const runs: string[] = []; @@ -54,8 +54,3 @@ export function splitProseRuns(content: readonly ContentBlock[]): string[] { return runs; } - -/** The runs as speech is routed: each trimmed of surrounding whitespace. */ -export function splitProseSegments(content: readonly ContentBlock[]): string[] { - return splitProseRuns(content).map((run) => run.trim()); -} diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 5516268dc..22814a316 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -50,9 +50,15 @@ class ConsoleModule implements Module { getTools(): ToolDefinition[] { return []; } async handleToolCall(_call: ToolCall): Promise { return { success: true }; } async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + // A module that relays an event's metadata as it came, envelope and all. + if ((event as { type: string }).type === 'relay') { + const relayed = event as unknown as { content: string; metadata: Record }; + return { addMessages: [{ participant: 'Relay', content: [{ type: 'text', text: relayed.content }], metadata: relayed.metadata }] }; + } if (event.type !== 'external-message') return {}; const text = String((event as { content?: unknown }).content); - return { addMessages: [{ participant: 'Operator', content: [{ type: 'text', text }] }] }; + const metadata = (event as { metadata?: Record }).metadata; + return { addMessages: [{ participant: 'Operator', content: [{ type: 'text', text }], ...(metadata ? { metadata } : {}) }] }; } } @@ -156,10 +162,24 @@ describe('inbound source envelope', () => { const message = stored().find((m) => m.participant === 'Operator')!; const source = readInboundSource(message.metadata); assert.deepEqual(source && { kind: source.kind, surface: (source as { surface: string }).surface }, { kind: 'surface', surface: 'tui' }); - assert.equal(conversationKey(source!), 'surface\u0000tui'); + assert.equal(conversationKey(source!), '["surface","tui"]'); assert.equal(observed.length, 1); }); + it("drops an envelope a module's metadata carries: only the framework writes one", async () => { + const forged = { kind: 'channel', lane: 'channels/incoming', serverId: 'evil', binding: 'x', channelId: 'discord:g9:elsewhere', acceptedAt: 1 }; + framework.pushEvent({ type: 'relay', content: 'relayed words', metadata: { inboundSource: forged, note: 'kept' } } as unknown as ProcessEvent); + await waitFor(() => stored().some((m) => m.participant === 'Relay'), 'relayed message stored'); + const relayed = stored().find((m) => m.participant === 'Relay')!; + assert.equal(readInboundSource(relayed.metadata), undefined, 'not the forged envelope'); + assert.equal((relayed.metadata as Record).note, 'kept', 'the rest of its metadata stays'); + // A surface event's message carries the framework's envelope, whatever its metadata said. + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'console words', metadata: { inboundSource: forged } } as ProcessEvent); + await waitFor(() => stored().some((m) => m.participant === 'Operator'), 'console message stored'); + const source = readInboundSource(stored().find((m) => m.participant === 'Operator')!.metadata); + assert.equal(source?.kind, 'surface'); + }); + it('keeps a stored envelope when the channel is renamed later', async () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-2', mode: 'ambient', text: 'before rename' }); await waitFor(() => !!storedWith((m) => m.messageId === 'm-2'), 'first message stored'); @@ -270,6 +290,17 @@ describe('readInboundSource', () => { { ...channel, messageId: 42 }, { ...channel, label: '' }, { ...channel, sourceTimestamp: {} }, + { ...channel, serverId: undefined }, + { ...channel, serverId: '' }, + { ...channel, serverId: 7 }, + { ...channel, binding: undefined }, + { ...channel, binding: { id: 'b' } }, + { ...channel, threadId: 3 }, + { ...channel, threadId: '' }, + { ...channel, replyTo: false }, + { ...channel, eventId: 9 }, + { kind: 'unscoped', lane: 'push/event', serverId: '', binding: 'b', acceptedAt: 1 }, + { kind: 'unscoped', lane: 'push/event', serverId: 's', binding: 'b', acceptedAt: 1, eventId: [] }, { kind: 'unscoped', lane: 'channels/incoming', serverId: 's', binding: 'b', acceptedAt: 1 }, { kind: 'surface', surface: '', acceptedAt: 1 }, { kind: 'mystery', acceptedAt: 1 }, @@ -284,6 +315,13 @@ describe('readInboundSource', () => { assert.notEqual(conversationKey(base), conversationKey({ ...base, serverId: 't' })); assert.notEqual(conversationKey(base), conversationKey({ ...base, threadId: 'th' })); assert.equal(conversationKey(base), conversationKey({ ...base, messageId: 'other', acceptedAt: 2 })); + // The parts are server-chosen: a separator inside one can't make two + // conversations one. + assert.notEqual( + conversationKey({ ...base, channelId: 'c\u0000x', threadId: 'y' }), + conversationKey({ ...base, channelId: 'c', threadId: 'x\u0000y' }), + ); + assert.notEqual(conversationKey({ ...base, threadId: '' }), conversationKey(base), 'an empty thread is not no thread'); }); }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 9635ad821..1068ff601 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -333,14 +333,17 @@ async function harness(opts: { : [], /** One addressed message in #room starts a turn (or, with `request`, a * direct inference request for just that resident); resolves when it settles. */ - turn: async (responses: Array>, agentName = 'scout', via: 'incoming' | 'request' = 'incoming') => { + turn: async (responses: Array>, agentName = 'scout', via: 'incoming' | 'request' | 'silent' = 'incoming') => { for (const r of responses) membrane.pushResponse(r); const before = membrane.calls.length; if (via === 'incoming') { h.command({ op: 'incoming', channelId: ROOM, messageId: `in-${++incoming}`, mode: 'addressed', text: `message ${incoming}` }); } else { + // 'silent': a control-plane wake whose plain prose stays private, as + // a silent heartbeat's does. (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ agentName, reason: 'test', source: 'test', timestamp: Date.now(), + ...(via === 'silent' ? { suppressProse: true } : {}), }); } await until(() => membrane.calls.length > before, 'turn start'); @@ -594,6 +597,21 @@ describe('held prose drafts, end to end', () => { } }); + it("a silent turn's prose stays private beside a send: counted, never held or named", async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('private narration'), explicitSend('s1')], 'tool_use'), + createMockResponse([text('more private words')]), + ], 'scout', 'silent'); + assert.deepEqual(h.drafts(), [], 'nothing was held as a draft'); + assert.ok(!h.texts().some((t) => t.includes('held as draft')), 'and no notice names one'); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere'], 'only the explicit send went out'); + } finally { + await h.close(); + } + }); + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { const h = await harness(); try { diff --git a/test/prose-segments.test.ts b/test/prose-segments.test.ts index 98ffda842..4fd2ff4f7 100644 --- a/test/prose-segments.test.ts +++ b/test/prose-segments.test.ts @@ -1,7 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { ContentBlock } from '@animalabs/membrane'; -import { splitProseRuns, splitProseSegments } from '../src/prose-segments.js'; +import { splitProseRuns } from '../src/prose-segments.js'; + +/** The runs as speech routes them: each trimmed, as the framework does. */ +const splitProseSegments = (content: Parameters[0]): string[] => + splitProseRuns(content).map((run) => run.trim()); const textBlock = (text: string): ContentBlock => ({ type: 'text', text } as ContentBlock); const toolUse = (id: string): ContentBlock => From 7cc9c216cf39e4620f8707159abf8bbb4ad5fd0f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:02:15 -0400 Subject: [PATCH 43/65] test(publish): the refused-before-open case uses a grant that could open the channel but not publish into it So the test tells the new order (server and channels.publish checked before any open) from the old one, which opened first: with an empty grant, the old order failed at channels.lifecycle and opened nothing either, so only the refusal's wording told them apart. --- test/publish-outcome.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 13f298add..846a9835f 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -25,7 +25,7 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; type Publish = (params: { channelId: string }) => Promise; function registryWith( - servers: Record Promise }>, + servers: Record Promise }>, failures: unknown[] = [], ) { const published: Array<{ serverId: string; channelId: string }> = []; @@ -34,7 +34,9 @@ function registryWith( for (const [id, s] of Object.entries(servers)) { mocks.set(id, { id, - grant: new CapabilityGrant(new Set(s.grant === false ? [] : ALL_CAPABILITY_PATHS), []), + grant: new CapabilityGrant(new Set( + s.grant === false ? [] : ALL_CAPABILITY_PATHS.filter((p) => !(typeof s.grant === 'object' && s.grant.without.includes(p))), + ), []), sendChannelsPublish: async (params: { channelId: string }) => { published.push({ serverId: id, channelId: params.channelId }); return s.publish(params); @@ -90,7 +92,8 @@ describe('ChannelRegistry.publish outcomes', () => { }); it('refuses a send the grant forbids before opening anything', async () => { - const { registry, seed, opened, published } = registryWith({ discord: { publish: ok, grant: false } }); + // The grant could open the channel (channels.lifecycle), but not publish into it. + const { registry, seed, opened, published } = registryWith({ discord: { publish: ok, grant: { without: ['channels.publish'] } } }); seed('discord', 'c', '#c', false); const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); assert.equal(outcome.status, 'failed'); From 97d860dea8efa30f5c73965d6ca3db2e4e811447 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:13:16 -0400 Subject: [PATCH 44/65] test(digests): every storage site and store normalization is pinned, and the texts say where a stored copy can be checked sw-af252 (the house's haiku review of #252), its fix round. Text: - I2, I5, I9, I11, I17, I18 (changelog, sourceBodyDigest's, storedBlock's, canonicalJson's and withStoredDigest's docs): - the digests are on every stored MCPL item; console and API input and the host's notices carry none; - canonicalJson mirrors the store's append: an undefined object entry is dropped, an undefined array element is null, a lone surrogate is U+FFFD, and an ill-formed key is dropped; editMessage writes JSON instead; - the copy check is of an unsharded copy: a body the store shards carries the whole body's digests on every shard (bodyGroupId and shardIndex mark them), and no shard hashes to them; - a blob_ref is digested as given, which its copy read back (resolved) doesn't match; the MCPL lanes never store one; - sourceBodyDigest no longer claims a live-turn injection this change neither performs nor tests. Tests (inbound-source): - I1: the conversation router's site and a fork correction's site (a coalesced replacement following its prior into an unbound fork), each with forged digests in the adapter's metadata, store the host's. - I10: a channels/incoming item's forged digests are replaced. - I2: with a strategy whose store shards a two-block push, both shards carry the whole body's digests and neither hashes to them; a one-block body is not sharded and matches its own. - I7: the materialized RFC-006 copy's digests are the host's, over the rendered body, not the deferred placeholder. - I13: a channel message deferred mid-turn and stored at the boundary still hashes to its stored digest. - I3, I4, I12 (the real-store table): a video with a duration; an image without a media type, and with mediaType undefined (both take their bytes' type); a text block with an undefined field; a URL image with a stray data string, kept as given. A document without a media type, and an image without one whose bytes have no signature, are refused at write. A blob_ref is resolved on read and doesn't match its digest. A URL image and a text block are digested as given (plain canonical hash). Red checks: dropping withStoredDigest at the router site, at the fork correction site or on the channels/incoming path fails its test; so do blobbing a URL image and keeping undefined object entries. Runs (on-vps, Node 24.21.0): typecheck clean; inbound-source 28/28. --- changelog.d/ingestion-body-digests.added.md | 34 ++-- src/framework.ts | 6 +- src/mcpl/inbound-source.ts | 41 +++-- test/inbound-source.test.ts | 165 ++++++++++++++++++++ 4 files changed, 214 insertions(+), 32 deletions(-) diff --git a/changelog.d/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md index 6e181ed8c..558bf4832 100644 --- a/changelog.d/ingestion-body-digests.added.md +++ b/changelog.d/ingestion-body-digests.added.md @@ -1,15 +1,18 @@ -- **Delivered-body digests on every stored inbound item.** Both MCPL lanes - stamp two fields, outside the frozen source envelope: +- **Delivered-body digests on every stored MCPL inbound item.** Both MCPL + lanes stamp two fields, outside the frozen source envelope (console and API + input, and the host's own notices, carry neither): - `metadata.sourceBodyDigest` is the version identity of the body as delivered, taken at ingestion, after MCPL conversion and before any host decoration or storage sharding. It is the SHA-256 (hex) of `[blocks]` - as canonical JSON (keys sorted at every level, `undefined` dropped), with - the blocks as the store keeps them: inline media as the store hands it - back (base64 re-encoded from its bytes, an image's media type taken from - its bytes' signature, only `type` and `source` kept) and strings - well-formed (a lone surrogate as U+FFFD). So the body hashes alike as - delivered, as handed to storage and as read back, and an undecorated, - unsharded stored copy hashes to it. + as canonical JSON (keys sorted at every level, `undefined` object entries + dropped and an `undefined` array element written as `null`, as JSON + writes them), with the blocks as the store keeps them: inline media as + the store hands it back (base64 re-encoded from its bytes, an image's + media type taken from its bytes' signature, only `type` and `source` + kept), strings well-formed (a lone surrogate as U+FFFD) and an entry + whose key isn't well-formed dropped, as the store's append drops it. So + the body hashes alike as delivered, as handed to storage and as read + back, and an undecorated, unsharded stored copy hashes to it. - `metadata.storedBodyDigest` uses the same function over exactly the blocks handed to storage, decorations included, taken where each path stores. @@ -17,7 +20,12 @@ Both are always the host's own: values an adapter supplies are replaced. A stored copy, read back with its blobs resolved, that no longer hashes to its `storedBodyDigest` was changed after it arrived (`editMessage` keeps - metadata). `sourceBodyDigest(blocks)` is exported from the package root as - the one implementation consumers share; it carries the framing and the - store's normalization, so check a copy with it rather than hashing your own - serialization. + metadata). That check is of an unsharded copy: a body the store shards + (context-manager's adaptive ingress chunking) carries the whole body's two + digests on every shard (`bodyGroupId` and `shardIndex` mark them), and no + shard hashes to them. A `blob_ref` block is digested as given, while the + store resolves it on read, so a copy holding one doesn't match; the MCPL + lanes never store one. `sourceBodyDigest(blocks)` is exported from the + package root as the one implementation consumers share; it carries the + framing and the store's normalization, so check a copy with it rather than + hashing your own serialization. diff --git a/src/framework.ts b/src/framework.ts index 5e44cc63c..63a72154f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7838,8 +7838,10 @@ export class AgentFramework { * (`storedBodyDigest`): the delivered-body digest's function over exactly * the blocks handed to storage here — every decoration included, before * storage shards them. That function hashes blocks as the store keeps them - * (inline media re-encoded and relabeled from its bytes), so the copy read - * back hashes to this too. Taken at each storage site, never earlier, because + * (inline media re-encoded and relabeled from its bytes), so an unsharded + * copy read back hashes to this too. A body the store shards (adaptive + * ingress chunking) carries this on every shard, with its metadata, and no + * shard hashes to it. Taken at each storage site, never earlier, because * a path can still decorate the body after ingestion stamped it (the * closed-channel invitation; room-220 #48282). An edit through * editMessage keeps metadata but not this hash. diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index b7c39c107..3d53dc307 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -156,13 +156,16 @@ export function conversationKey(source: InboundSource): string | undefined { } /** - * JSON with object keys sorted at every level, written as the store keeps a - * value: `undefined` values are dropped, a string is well-formed (the store - * writes UTF-8, so each lone surrogate comes back as U+FFFD), and an entry - * whose key isn't well-formed is dropped (the store doesn't keep it). So a - * value hashes alike however its keys were ordered (content read back from - * the store has them in a different order than it was written), and alike - * before and after the store. + * JSON with object keys sorted at every level, written as the store's append + * keeps a value (its native UTF-8 write, which the real-store digest table + * checks): an `undefined` object entry is dropped and an `undefined` array + * element is written as `null`, as JSON writes them; a string is + * well-formed (each lone surrogate comes back as U+FFFD); and an entry whose + * key isn't well-formed is dropped (the store doesn't keep it). So a value + * hashes alike however its keys were ordered (content read back from the + * store has them in a different order than it was written), and alike + * before and after the store. editMessage writes JSON instead, so an edited + * copy can keep what an append wouldn't; it has changed anyway. * * It expects JSON-shaped values, as the MCPL lanes deliver them (parsed from * JSON). Any other object is written by its own enumerable entries, unlike @@ -191,7 +194,10 @@ export function canonicalJson(value: unknown): string { * document, audio or video block, comes back as `{ type, source: { type: * 'base64', data, mediaType } }` and nothing else, its data re-encoded from * the decoded bytes, and an image's media type taken from its bytes' - * signature where they have one. Any other block comes back as it was given. + * signature where they have one. Any other block comes back as it was given, + * except a `blob_ref`, which the store resolves to its inline media on read: + * it is digested as given here, so a copy holding one doesn't match its + * digest (the MCPL lanes never store one). */ function storedBlock(block: unknown): unknown { const media = block as { type?: unknown; source?: { type?: unknown; data?: unknown; mediaType?: unknown } } | null; @@ -252,21 +258,22 @@ function sniffRasterImageMediaType(bytes: Uint8Array): string | null { * ContentBlock[] the framework stores for the body — after MCPL conversion, * before any host decoration is added and before storage shards it — with * each block as the store keeps it (storedBlock, canonicalJson). So a body - * hashes alike as delivered, as handed to storage, as injected into a live - * turn, and as read back from the store. The one-element array keeps the - * framing an undecorated, unsharded stored copy has always hashed to, so a - * stamped copy and an older copy of the same body share a version. + * hashes alike as delivered, as handed to storage, and as read back from + * the store. The one-element array keeps the framing an undecorated, + * unsharded stored copy has always hashed to, so a stamped copy and an + * older copy of the same body share a version. * * Ingestion stamps it as `metadata.sourceBodyDigest`, and the same function * over exactly the blocks handed to storage (decorations included) as * `metadata.storedBodyDigest`, on both MCPL lanes. Both live outside the * frozen admission envelope: they describe the body actually delivered (a * materialization, a correction), not the admission. Neither proves later - * presence or completeness; a stored copy that no longer hashes to its - * storedBodyDigest was changed after delivery (editMessage keeps metadata). - * Check a copy read back with its blobs resolved (getAllMessages, getMessage, - * or getMessageWindow without `resolveBlobs: false`): a blob reference never - * matches. + * presence or completeness; an unsharded stored copy that no longer hashes + * to its storedBodyDigest was changed after delivery (editMessage keeps + * metadata). A body the store shards carries the whole body's two digests + * on every shard, and no shard hashes to them. Check a copy read back with + * its blobs resolved (getAllMessages, getMessage, or getMessageWindow + * without `resolveBlobs: false`): a blob reference never matches. * * Exported from the package root, so a consumer checks a copy against either * field with this same function rather than a copy of it. diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index b50b14a70..d632dc1d8 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -30,6 +30,7 @@ import { type ToolResult, } from '../src/index.js'; import { ContextManager } from '@animalabs/context-manager'; +import type { ContentBlock } from '@animalabs/membrane'; import { MockMembrane } from './helpers/mock-membrane.js'; import { canonicalJson } from '../src/mcpl/inbound-source.js'; import { createHash } from 'node:crypto'; @@ -261,6 +262,18 @@ describe('inbound source envelope', () => { assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the invitation decorates the stored copy'); }); + it('a channels/incoming item carries the host\'s digests, never the adapter\'s', async () => { + command({ + op: 'incoming', channelId: ROOM, messageId: 'm-forged', mode: 'ambient', text: 'forged digests', + metadata: { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }, + }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-forged'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-forged')!; + const meta = message.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'forged digests' }]), 'the delivered body, not the adapter\'s value'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored, not the adapter\'s value'); + }); + it('an image the store relabels or re-encodes still matches both its digests once read back', async () => { // slimepriestess's review of #252: the store takes an image's media type // from its bytes and re-encodes its base64, so a digest of the blocks as @@ -411,6 +424,61 @@ describe('readInboundSource', () => { }); }); +describe('stored digests when the store shards a body', () => { + /** A strategy whose store splits a body of more than one block into one + * shard per block, as adaptive ingress chunking does. */ + class ShardingStrategy extends PassthroughStrategy { + chunkIngressMessage(_participant: string, content: ContentBlock[]) { + if (content.length < 2) return null; + return { bodyGroupId: `group-${content.length}-${Date.now()}`, shards: content.map((block, shardIndex) => ({ content: [block], shardIndex })) }; + } + } + + it("every shard carries the whole body's digests, and no shard hashes to them; an unsharded copy matches its own", async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'inbound-shards-')); + const commandPath = join(tempDir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', strategy: new ShardingStrategy() }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [FIXTURE], + env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + try { + await framework.start(); + const command = (c: Record): void => appendFileSync(commandPath, JSON.stringify(c) + '\n'); + const blocks = [{ type: 'text', text: 'first part' }, { type: 'text', text: 'second part' }]; + command({ op: 'push', eventId: 'sharded', content: blocks }); + command({ op: 'push', eventId: 'whole', text: 'one block' }); + const stored = () => framework.getAgent('scout')!.getContextManager().getAllMessages(); + await waitFor(() => stored().some((m) => (m.metadata as { eventId?: string } | undefined)?.eventId === 'whole'), 'both pushes stored'); + const shards = stored().filter((m) => (m.metadata as { eventId?: string } | undefined)?.eventId === 'sharded'); + assert.equal(shards.length, 2, 'stored as two shards'); + assert.ok(shards[0]!.bodyGroupId && shards.every((m) => m.bodyGroupId === shards[0]!.bodyGroupId)); + assert.deepEqual(shards.map((m) => m.shardIndex), [0, 1]); + const whole = sourceBodyDigest(blocks); + for (const shard of shards) { + const meta = shard.metadata as Record; + assert.equal(meta.sourceBodyDigest, whole, 'the whole delivered body'); + assert.equal(meta.storedBodyDigest, whole, 'the whole body handed to storage'); + assert.notEqual(sourceBodyDigest(shard.content), whole, 'a shard alone is not the body'); + } + const one = stored().find((m) => (m.metadata as { eventId?: string } | undefined)?.eventId === 'whole')!; + assert.equal(one.bodyGroupId, undefined, 'a one-block body is not sharded'); + assert.equal(sourceBodyDigest(one.content), (one.metadata as Record).storedBodyDigest, 'an unsharded copy matches'); + } finally { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + describe('inbound source envelope under RFC-006 coalescing', () => { const install = (framework: AgentFramework): InboundSource[] => { const observed: InboundSource[] = []; @@ -452,6 +520,14 @@ describe('inbound source envelope under RFC-006 coalescing', () => { assert.equal(observed.length, 1, 'materialization is not a second acceptance'); const stored = sourceOf(f, 'document_diff'); assert.deepEqual(stored, { ...accepted, materialized: true }); + // The materialized copy is its own storage site: both digests are the + // host's, over the rendered body, not the deferred placeholder. + const copy = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('document_diff'))!; + const meta = copy.metadata as Record; + assert.equal(meta.storedBodyDigest, sourceBodyDigest(copy.content), 'exactly what was stored'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest(copy.content), 'the rendered body, undecorated'); + assert.notEqual(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'fallback_unavailable' }]), 'not the placeholder'); }); it('a channel-scoped coalesced message belongs to its channel, frozen at acceptance', async (t) => { @@ -470,6 +546,46 @@ describe('inbound source envelope under RFC-006 coalescing', () => { assert.equal(stored.sourceTimestamp, TS); assert.deepEqual(observed, [stored]); }); + + it('a channel message deferred mid-turn and stored at the boundary still hashes to its stored digest', async (t) => { + const f = await fixture(); t.after(f.close); + await f.register('chat'); + const internals = f.framework as unknown as { activeTurnTokens: Map; flushDeferredWrites(label: string): Promise }; + internals.activeTurnTokens.set('agent', 100); + await f.send('channels/incoming', { messages: [f.channel('e1', 'deferred_words', { initial: true })] }); + assert.ok(!f.context().includes('deferred_words'), 'held until the boundary'); + internals.activeTurnTokens.delete('agent'); + await internals.flushDeferredWrites('test'); + const copy = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('deferred_words'))!; + const meta = copy.metadata as Record; + assert.equal(meta.storedBodyDigest, sourceBodyDigest(copy.content), 'the copy stored at the boundary matches'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'deferred_words' }]), 'the delivered body, unchanged'); + }); + + it("the conversation router's and a fork correction's storage sites carry the host's digests, never the adapter's", async (t) => { + const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } as never }); t.after(f.close); + await f.register(); + const forged = { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }; + const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): { agentName: string } | undefined; unbind(id: string): void } }).conversationRouter; + const check = (text: string, fork: string) => { + const copy = f.framework.getAgent(fork)!.getContextManager().getAllMessages().find((m) => JSON.stringify(m.content).includes(text)); + assert.ok(copy, `${text} stored in ${fork}`); + const meta = copy.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text }]), `${text}: the delivered body, not the adapter's value`); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(copy.content), `${text}: exactly what was stored, not the adapter's value`); + }; + // Routed into a fresh fork by the conversation router. + await f.send('channels/incoming', { messages: [{ ...f.channel('c1', 'routed_original', { initial: true }), metadata: forged }] }); + const fork = router.getBinding('chat')!.agentName; + check('routed_original', fork); + // A correction follows the prior delivery into that fork, though the + // channel is no longer bound to it. + router.unbind('chat'); + await f.send('channels/incoming', { messages: [{ ...f.channel('c2', 'fork_correction'), metadata: forged }] }); + assert.equal(router.getBinding('chat'), undefined, 'no fresh fork was spawned for the correction'); + check('fork_correction', fork); + }); }); describe('sourceBodyDigest hashes a body as the store keeps it', () => { @@ -510,6 +626,15 @@ describe('sourceBodyDigest hashes a body as the store keeps it', () => { 'text with lone surrogates': { blocks: [{ type: 'text', text: 'cut \ud83d here, \ude00 there, \ud83d\ud83d twice' }], changed: true }, 'a key that is not well-formed': { blocks: [{ type: 'text', text: 'x', rawItem: { 'k\ud800': 1, kept: 2 } }], changed: true }, 'a relabeled image with a text decoration': { blocks: [image(png, 'image/jpeg'), { type: 'text', text: '[invitation]' }], changed: true }, + 'a video with a duration': { blocks: [media('video', 'AAAAGGZ0eXBtcDQy', 'video/mp4', { duration: 2 })], changed: true }, + // No media type: an image takes its bytes' own (an image without a + // signature, or other media, is refused at write: see below). + 'an image without a media type': { blocks: [{ type: 'image', source: { type: 'base64', data: png } }], changed: true }, + 'an image whose media type is undefined': { blocks: [{ type: 'image', source: { type: 'base64', data: png, mediaType: undefined } }], changed: true }, + 'a text block with an undefined field': { blocks: [{ type: 'text', text: 'x', note: undefined }], changed: true }, + // Only a non-URL image is blobbed: a URL image keeps even a stray data + // string as given, and storedBlock must not re-encode it. + 'an image by URL with a stray data string': { blocks: [{ type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png', data: 'not base64 at all' } }], changed: false }, }; it('every body hashes alike as handed to the store and as read back, live and after reopening', async () => { @@ -540,6 +665,46 @@ describe('sourceBodyDigest hashes a body as the store keeps it', () => { } }); + it('a block the store refuses is refused at write, and a blob_ref is digested as given, which its copy read back does not match', async () => { + const dir = mkdtempSync(join(tmpdir(), 'stored-digest-refused-')); + try { + const cm = await ContextManager.open({ path: join(dir, 'store.chronicle'), strategy: new PassthroughStrategy() }); + try { + const svg = Buffer.from('').toString('base64'); + for (const [what, block] of [ + ['a document without a media type', { type: 'document', source: { type: 'base64', data: 'JVBERi0xLjQ=' } }], + ['an image without a media type whose bytes have no signature', { type: 'image', source: { type: 'base64', data: svg } }], + ] as const) { + assert.throws(() => cm.addMessage('user', [block] as never), Error, `${what}: refused at write`); + } + // A blob_ref names media the store already holds; it is resolved to + // inline media on read, so the copy read back hashes differently. + const held = cm.addMessage('user', [image(png)] as never); + const ref = cm.getMessageWindow(0, cm.getMessageCount(), { resolveBlobs: false }).messages + .find((m) => m.id === held)!.content[0] as { type: string }; + assert.equal(ref.type, 'blob_ref'); + const given = [ref]; + const id = cm.addMessage('user', given as never, { storedBodyDigest: sourceBodyDigest(given) }); + const back = cm.getMessage(id)!; + assert.deepEqual(back.content, [image(png)], 'resolved on read'); + assert.notEqual(sourceBodyDigest(back.content), (back.metadata as Record).storedBodyDigest, 'the documented mismatch'); + } finally { + cm.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a block the store keeps as given is digested as given', () => { + const plain = (blocks: unknown[]) => createHash('sha256').update(canonicalJson([blocks])).digest('hex'); + // Only a non-URL image is blobbed, so a URL image's stray data string is + // neither decoded nor re-encoded. + const urlImage = { type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png', data: 'not base64 at all' } }; + assert.equal(sourceBodyDigest([urlImage]), plain([urlImage])); + assert.equal(sourceBodyDigest([{ type: 'text', text: 'x' }]), plain([{ type: 'text', text: 'x' }])); + }); + it('what the store keeps still changes the digest', () => { const digest = (blocks: unknown[]) => sourceBodyDigest(blocks); assert.notEqual(digest([image(png)]), digest([image(Buffer.from([...PNG, 0]).toString('base64'))]), 'other bytes'); From 7ce228ed9f2b789adfb0e23de98587e7bbb3a80b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:32:12 -0400 Subject: [PATCH 45/65] test(digests): the store refuses an undefined array element, and canonicalJson writes one as null Oscar-1808's take-or-leave in his review of 97d860d (room-203 #72199): no test pinned the new text's claim that canonicalJson writes an undefined array element as null, so a canonicalJson that skipped the element passed. He suggested a real-store row, expecting the store's append to write the element as null. The row showed the store doesn't: context-manager 0.13.0's append throws ("undefined cannot be represented as a serde_json::Value"), so no stored copy holds one. So instead: - the refused-at-write test gains a body with ['a', undefined]; - the digested-as-given test asserts the element digests as null, as JSON writes it, which fails for a canonicalJson that skips it (red-checked); - canonicalJson's doc and the changelog say an undefined array element is written as null as JSON writes it, and that the store refuses a body holding one. The doc no longer implies the store writes null. Runs (on-vps, Node 24.21.0): typecheck clean; inbound-source 29/29. --- changelog.d/ingestion-body-digests.added.md | 5 +++-- src/mcpl/inbound-source.ts | 9 +++++---- test/inbound-source.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/changelog.d/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md index 558bf4832..0242f0bf8 100644 --- a/changelog.d/ingestion-body-digests.added.md +++ b/changelog.d/ingestion-body-digests.added.md @@ -5,8 +5,9 @@ delivered, taken at ingestion, after MCPL conversion and before any host decoration or storage sharding. It is the SHA-256 (hex) of `[blocks]` as canonical JSON (keys sorted at every level, `undefined` object entries - dropped and an `undefined` array element written as `null`, as JSON - writes them), with the blocks as the store keeps them: inline media as + dropped, and an `undefined` array element written as `null` as JSON + writes it, though the store refuses a body holding one), with the blocks + as the store keeps them: inline media as the store hands it back (base64 re-encoded from its bytes, an image's media type taken from its bytes' signature, only `type` and `source` kept), strings well-formed (a lone surrogate as U+FFFD) and an entry diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index d11a8f8de..e2bc75645 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -160,10 +160,11 @@ export function conversationKey(source: InboundSource): string | undefined { /** * JSON with object keys sorted at every level, written as the store's append * keeps a value (its native UTF-8 write, which the real-store digest table - * checks): an `undefined` object entry is dropped and an `undefined` array - * element is written as `null`, as JSON writes them; a string is - * well-formed (each lone surrogate comes back as U+FFFD); and an entry whose - * key isn't well-formed is dropped (the store doesn't keep it). So a value + * checks): an `undefined` object entry is dropped; a string is well-formed + * (each lone surrogate comes back as U+FFFD); and an entry whose key isn't + * well-formed is dropped (the store doesn't keep it). An `undefined` array + * element is written as `null`, as JSON writes it: the store's append + * refuses a body holding one, so no stored copy does. So a value * hashes alike however its keys were ordered (content read back from the * store has them in a different order than it was written), and alike * before and after the store. editMessage writes JSON instead, so an edited diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 12de580de..b68e0e495 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -712,6 +712,8 @@ describe('sourceBodyDigest hashes a body as the store keeps it', () => { for (const [what, block] of [ ['a document without a media type', { type: 'document', source: { type: 'base64', data: 'JVBERi0xLjQ=' } }], ['an image without a media type whose bytes have no signature', { type: 'image', source: { type: 'base64', data: svg } }], + // The store's append can't represent an undefined array element. + ['an array with an undefined element', { type: 'text', text: 'x', parts: ['a', undefined] }], ] as const) { assert.throws(() => cm.addMessage('user', [block] as never), Error, `${what}: refused at write`); } @@ -741,6 +743,12 @@ describe('sourceBodyDigest hashes a body as the store keeps it', () => { const urlImage = { type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png', data: 'not base64 at all' } }; assert.equal(sourceBodyDigest([urlImage]), plain([urlImage])); assert.equal(sourceBodyDigest([{ type: 'text', text: 'x' }]), plain([{ type: 'text', text: 'x' }])); + // An undefined array element is written as null, as JSON writes it (the + // store refuses such a body, above, so no stored copy holds one). + assert.equal( + sourceBodyDigest([{ type: 'text', text: 'x', parts: ['a', undefined] }]), + sourceBodyDigest([{ type: 'text', text: 'x', parts: ['a', null] }]), + ); }); it('what the store keeps still changes the digest', () => { From f9f54bf7846b415985d9a2271ab642e888a54c06 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:34:22 -0400 Subject: [PATCH 46/65] fix(routing): conversation keys can't collide, labels show as given, and only the host marks machinery From a review of this PR's own commits. - conversationKey (speech-routes) encodes its parts as a JSON array, as #247's round did for inbound-source's: server-chosen strings can't run into each other, and an unknown server is not an empty one. - describeConversation shows a channel's label as its connector gave it. It no longer adds '#' to a label that doesn't start with '#' or 'DM': a Telegram DM named after a person, or a group named by its title, reads as given. - `system` marks host machinery, and only the host writes it. isConversational is called with an MCPL item's tags alone at all five sites (channels/incoming main path, fork correction, conversation router, coalesced push wake, push lane), and ingestion drops a `system` an adapter's metadata or a push's origin carries, so a stored item's marker is the host's. isConversational's doc says so. - TurnRoute.route's doc says it is where unaddressed speech goes when the turn isn't held, and that a mid-turn hold keeps the suspended route beside `hold`, which wins while set. Tests: - speech-routes: keys from parts holding NUL differ, and an unknown server differs from an empty one; labels show as given (a person, a group title, a DM label, a bare label, and a label with no server beside its channel id); the '#room' case now passes its own '#'. - batched-wake-locus: a push whose origin says system: true, and a channels/incoming message whose metadata does, are each a route candidate, and neither stores the adapter's system. --- src/framework.ts | 16 ++++++++---- src/speech-routes.ts | 44 +++++++++++++++++++-------------- test/batched-wake-locus.test.ts | 24 ++++++++++++++++++ test/speech-routes.test.ts | 20 ++++++++++++++- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index cf1d09efb..a8216644c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7389,6 +7389,9 @@ export class AgentFramework { triggered: event.triggerInference ?? false, serverId: event.serverId, }; + // `system` marks host machinery (isConversational): host-written only, + // so an adapter's is dropped. + delete metadata.system; if (event.threadId) metadata.threadId = event.threadId; if (event.tags) metadata.tags = event.tags; // The inbound source envelope (mcpl/inbound-source.ts): frozen at the @@ -7431,7 +7434,7 @@ export class AgentFramework { this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference) { const addressedHere = isAddressedMessage(event.tags, event.metadata); - const candidate = isConversational(event.tags, event.metadata) + const candidate = isConversational(event.tags) ? this.candidateFromSource(source, addressedHere) : undefined; this.pendingRequests.push({ @@ -7510,7 +7513,7 @@ export class AgentFramework { ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName); // A reaction or system marker can wake a turn, but answers nothing: // it is never a route candidate (the same predicate as mid-turn). - const candidate = isConversational(event.tags, event.metadata) ? this.candidateFromSource(source, addressed) : undefined; + const candidate = isConversational(event.tags) ? this.candidateFromSource(source, addressed) : undefined; for (const agentName of targetAgents) { if (!this.agents.has(agentName)) continue; // The broadcast reaches forks too: the fork rule keeps each agent's @@ -7623,7 +7626,7 @@ export class AgentFramework { if (trigger) { const addressedHere = isAddressedMessage(event.tags, event.metadata); // A fork's home route always wins; the candidate is carried for parity. - const candidate = isConversational(event.tags, event.metadata) + const candidate = isConversational(event.tags) ? this.candidateFromSource(readInboundSource(messageMetadata), addressedHere) : undefined; this.pendingRequests.push({ @@ -8481,7 +8484,7 @@ export class AgentFramework { 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); const addressedHere = isAddressedMessage(occ.tags, origin); - const candidate = isConversational(occ.tags, origin as Record | undefined) + const candidate = isConversational(occ.tags) ? this.candidateFromSource(occ.event.event.inboundSource, addressedHere) : undefined; for (const agentName of await this.coalescedAudience(occ)) { @@ -8640,6 +8643,9 @@ export class AgentFramework { ...(event.tags ? { tags: event.tags } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }; + // `system` marks host machinery (isConversational): host-written only, + // so an origin's is dropped. + delete metadata.system; // `origin.channelId` is the server-internal raw id (a bare Discord // snowflake) — unroutable as a locus and unresolvable by the agent. Store // the composite MCPL id instead wherever we can derive it, so anything @@ -8710,7 +8716,7 @@ export class AgentFramework { // A push that names a conversation (a DM, an addressed-while-closed // message) is a route candidate like its channels/incoming // counterpart; an unscoped push names none. - const pushCandidate = silentHeartbeat || !isConversational(event.tags, event.origin as Record | undefined) + const pushCandidate = silentHeartbeat || !isConversational(event.tags) ? undefined : this.candidateFromSource(source, pushAddressed); for (const agentName of targetAgents) { diff --git a/src/speech-routes.ts b/src/speech-routes.ts index e5448256b..a373d3f01 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -90,7 +90,9 @@ export type SpeechRoute = /** The turn's routing state. */ export interface TurnRoute { - /** Where unaddressed speech goes; null when it has no destination. */ + /** Where unaddressed speech goes when the turn isn't held; null when it + * has no destination. A mid-turn hold keeps the route it suspended here, + * alongside `hold`, which wins while it is set. */ route: SpeechRoute | null; /** Unaddressed speech is held as drafts: these conversations compete. */ hold?: { @@ -126,11 +128,14 @@ export function routeRefusal(c: ConversationRef, targetOf: PublishTargetOf): Unr return undefined; } -/** Identity of a conversation: server, channel and thread, or the surface. */ +/** Identity of a conversation: server, channel and thread, or the surface. + * The parts are server-chosen strings, so they are encoded as a JSON array, + * never joined with a separator one of them could contain: two keys are + * equal only for the same parts (an unknown server is not an empty one). */ export function conversationKey(c: ConversationRef): string { return c.kind === 'surface' - ? `surface:${c.surface}` - : `channel:${c.serverId ?? ''}\u0000${c.channelId}\u0000${c.threadId ?? ''}`; + ? JSON.stringify(['surface', c.surface]) + : JSON.stringify(['channel', c.serverId ?? null, c.channelId, c.threadId ?? null]); } /** The conversation a route speaks into. */ @@ -148,21 +153,20 @@ export function routeConversation(route: SpeechRoute): ConversationRef { /** * A conversation as the framework names it to a resident, self-contained: its - * label, then the server and channel id, written `server / channel-id` as the - * source headers write them, and its thread. The server is named whenever it - * is known, never left to what else is on screen: the same channel id (and - * label) can exist on another server, and a bare id is then no address a - * resident can send to. Notices, holds and the [delivered] receipt all name - * conversations this one way. + * label as its connector gave it (nothing added or withheld for how it is + * spelled: a DM or a group may be named after a person or a title), then the + * server and channel id, written `server / channel-id` as the source headers + * write them, and its thread. The server is named whenever it is known, + * never left to what else is on screen: the same channel id (and label) can + * exist on another server, and a bare id is then no address a resident can + * send to. Notices, holds and the [delivered] receipt all name conversations + * this one way. */ export function describeConversation(c: ConversationRef): string { if (c.kind === 'surface') return `${c.surface} (the local surface that messaged you)`; const id = c.serverId ? `${c.serverId} / ${c.channelId}` : c.channelId; const where = c.threadId ? `${id}, thread ${c.threadId}` : id; - if (c.label && c.label !== c.channelId) { - const label = c.label.startsWith('#') || c.label.startsWith('DM') ? c.label : `#${c.label}`; - return `${label} (${where})`; - } + if (c.label && c.label !== c.channelId) return `${c.label} (${where})`; return c.serverId || c.threadId ? `(${where})` : c.channelId; } @@ -246,11 +250,13 @@ function newestOf(candidates: readonly RouteCandidate[]): RouteCandidate | undef /** * Whether an item is conversational input — something the resident might be - * answering — rather than machinery: a system marker (send-failure notices, - * routing notices) or a reaction or its removal (`chat:reaction`, - * `chat:reaction-remove`, MCPL RFC-001) is not. One - * predicate for every place that asks: turn-start route candidates, mid-turn - * holds, and clearing send suppression. + * answering — rather than machinery: a host system marker (send-failure + * notices, routing notices) or a reaction or its removal (`chat:reaction`, + * `chat:reaction-remove`, MCPL RFC-001) is not. One predicate for every place + * that asks: turn-start route candidates, mid-turn holds, and clearing send + * suppression. `metadata.system` is host-written only: a caller passes an + * MCPL item's tags alone, and ingestion drops a `system` an adapter's + * metadata or origin carries, so no server can mark its item as machinery. */ export function isConversational(tags: readonly string[] | undefined, metadata?: Record): boolean { if (metadata?.system === true) return false; diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index d0e58d306..ded0c26d0 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -316,4 +316,28 @@ describe('batched wake routing with conversation forks', () => { assert.equal(byAgent.get(other)?.routeCandidates, undefined, `${other} gets no route there`); } }); + + it("a server can't mark its item as host machinery: system: true in an origin or in metadata is dropped, and the item is a route candidate", async () => { + const internals = framework as unknown as { + pendingRequests: Wake[]; + handleMcplPushEvent(event: unknown): unknown; + handleMcplChannelIncoming(event: unknown): Promise; + }; + const stored = (text: string) => framework.getAgent('trunk')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes(text)); + const push = pushInto(GENERAL, 'sys-push'); + internals.handleMcplPushEvent({ ...push, content: [{ type: 'text', text: 'pushed as system' }], origin: { ...push.origin, system: true } }); + const pushed = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.deepEqual(pushed?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'the push is a route candidate'); + assert.equal((stored('pushed as system')?.metadata as Record | undefined)?.system, undefined, "the origin's system is not stored"); + internals.pendingRequests.length = 0; + await internals.handleMcplChannelIncoming({ + type: 'mcpl:channel-incoming', serverId: 'discord', channelId: GENERAL, messageId: 'm-sys', + author: { id: '5', name: 'Five' }, content: [{ type: 'text', text: 'incoming as system' }], + timestamp: new Date().toISOString(), triggerInference: true, tags: ['chat:addressed'], metadata: { system: true }, + }); + const incoming = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.deepEqual(incoming?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'the message is a route candidate'); + assert.equal((stored('incoming as system')?.metadata as Record | undefined)?.system, undefined, "the adapter's system is not stored"); + }); }); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 8d0ed27ed..7f8b2ddfd 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -240,10 +240,17 @@ describe('conversation identity and wording', () => { assert.notEqual(conversationKey(channel('x')), conversationKey({ kind: 'channel', serverId: 'other', channelId: 'x' })); assert.notEqual(conversationKey(channel('x')), conversationKey(channel('x', { threadId: 't' }))); assert.notEqual(conversationKey({ kind: 'surface', surface: 'x' }), conversationKey(channel('x'))); + // Server-chosen parts can't run into each other, and an unknown server + // is not an empty one. + assert.notEqual( + conversationKey(channel('a\u0000b', { threadId: 'c' })), + conversationKey(channel('a', { threadId: 'b\u0000c' })), + ); + assert.notEqual(conversationKey({ kind: 'channel', channelId: 'x' }), conversationKey({ kind: 'channel', serverId: '', channelId: 'x' })); }); it('describes a conversation by a usable address: its label, then server / channel id, then its thread', () => { - assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), '#room (discord / discord:g1:room)'); + assert.equal(describeConversation(channel('discord:g1:room', { label: '#room' })), '#room (discord / discord:g1:room)'); assert.equal(describeConversation(channel('discord:dm:7')), '(discord / discord:dm:7)'); assert.equal(describeConversation(channel('discord:dm:7', { label: 'discord:dm:7' })), '(discord / discord:dm:7)', 'a label that is only the id adds nothing'); @@ -253,6 +260,17 @@ describe('conversation identity and wording', () => { assert.equal(describeConversation({ kind: 'surface', surface: 'tui' }), 'tui (the local surface that messaged you)'); }); + it("shows a label as its connector gave it, adding or withholding nothing for how it's spelled", () => { + assert.equal(describeConversation(channel('telegram:42', { label: 'Ada Lovelace' })), 'Ada Lovelace (discord / telegram:42)', + 'a DM named after a person'); + assert.equal(describeConversation(channel('telegram:-100', { label: 'Book Club' })), 'Book Club (discord / telegram:-100)', + 'a group named by its title'); + assert.equal(describeConversation(channel('discord:dm:9', { label: 'DM with antra' })), 'DM with antra (discord / discord:dm:9)'); + assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), 'room (discord / discord:g1:room)', 'no # added'); + assert.equal(describeConversation({ kind: 'channel', channelId: 'c-1', label: 'general' }), 'general (c-1)', + 'a label with no server, beside its channel id'); + }); + it('the same channel id and label on two servers read as two addresses, each naming its server', () => { const on = (serverId: string) => describeConversation({ kind: 'channel', serverId, channelId: 'general', label: '#general' }); assert.equal(on('alpha'), '#general (alpha / general)'); From fdca2b988f62452b39e57592fe8a678220fd9e14 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:50:56 -0400 Subject: [PATCH 47/65] fix(routing): forks own their channels' unroutable candidates, pins keep their server, and only admitted coalesced items are gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a review of this PR's own commits. - the gate wake applies the fork rule to every channel candidate, unroutable ones included: one never routes, but it competes, so a fork's channel would otherwise hold another agent's turn. An agent the rule leaves with no candidate gets none, as on the direct paths, not an empty list. - resolveProseTarget returns the server its target resolved to, and a `>>`/`>>>` pin keeps { serverId, channelId }. The envelope's delivery (deliverWithUnsent) and the speech after it publish by server, so the same id registered by a second server later changes nothing. - the gate's batched-wake notice groups by server, channel and thread, and names each with its thread (describeConversation). - the sleep announcement is unaddressed speech, on the speech chain after the words written before it: a hybrid `>>>` pin wins as for an unprefixed envelope, else speakUnaddressed under the route as it stands when the tool runs (held: an ambiguous draft; no route: a no-destination draft; surface: shown there). Only a publish that was tried can fail. - a coalesced item's trigger decision (the gate's, which records it as a route candidate) is made only once its coalescer first delivers or wakes for an occurrence it admitted. PushHandler and ChannelRegistry pass the decision as decideTrigger instead of making it before the coalescer, and the framework keeps it in a WeakMap by the occurrence's delivery event (settleCoalescedTrigger), since a persisted occurrence can't carry a function. A retry, a retraction or a refused occurrence never reaches the gate. Ordinary items are decided at admission as before. - a module event (external-message, api:message) that names a channel isn't a local surface's: the framework gives the gate that channel, and wakeProvenance makes it an unroutable channel candidate, never a surface one, as surfaceSourceFor decides. - buildChannelContext's defaultOutgoing names the route's thread, reply edge or not (ChannelContext.defaultOutgoing.threadId, documented as an extension of §14.4's shape). - The gate's half of the host-only `system` mark: its conversational flag reads an MCPL item's tags alone. Tests: - batched-wake-locus: a gate batch whose unroutable candidate is a fork's channel routes only to that fork; the trunk and the other fork are woken with no candidate. - event-gate: system: true in a channels/incoming item's metadata leaves it a route candidate; a module event naming a channel is an unroutable channel candidate, and one naming none a surface; a batch notice counts a thread apart from its root and another thread; the two-server notice shows labels as given. Bounded waits (waitForRequests, waitForMessages) for the new tests. - present-while-acting: a `>>>` pin's envelope and the speech after it both publish with the pinned server. - speech-route-turns: the sleep announcement under a held, a routeless and a surface turn publishes nothing and keeps the held and routeless ones as drafts. - mcpl-coalescing: the trigger is decided once for an admitted push and once for an admitted channel message, never for a retry, a retraction or a refused channel-scoped push. - push-gate-metadata and channel-registry-routing: the coalesced gate tests show the gate isn't consulted before admission. - channel-registry-routing: defaultOutgoing names the thread, with and without a reply edge. --- src/framework.ts | 156 +++++++++++++++++--------- src/gate/event-gate.ts | 40 +++++-- src/mcpl/channel-registry.ts | 35 ++++-- src/mcpl/push-handler.ts | 16 ++- src/mcpl/types.ts | 5 +- test/batched-wake-locus.test.ts | 25 ++++- test/channel-registry-routing.test.ts | 13 ++- test/event-gate.test.ts | 76 ++++++++++++- test/mcpl-coalescing.test.ts | 18 +++ test/present-while-acting.test.ts | 33 ++++++ test/push-gate-metadata.test.ts | 10 +- test/speech-route-turns.test.ts | 41 +++++++ 12 files changed, 384 insertions(+), 84 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index a8216644c..45949aa95 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1170,7 +1170,7 @@ export class AgentFramework { * (hybrid) prefix. Written and read on the speech chain, in the order the * words were written. Cleared at a true new turn; a context restart or * framework retry continues the turn and keeps it. */ - private proseTargetPins: Map = new Map(); + private proseTargetPins: Map = new Map(); /** Hybrid router is fail-closed after a `>>>skip_reply` envelope (the * rest is private) or a malformed/unresolved/undelivered one (the rest is * held as drafts, having no destination) until a new valid target. On the @@ -1795,6 +1795,28 @@ export class AgentFramework { emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }), addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined), requestInference: (agentName, reason, source, provenance) => { + // The batch's conversations as speech-route candidates, under the + // same fork rule: a fork takes only its home's, and no other agent + // takes a channel a fork owns. That includes an unroutable + // candidate: it never routes, but it competes, so a fork's + // channel would otherwise hold another agent's turn. + const routeCandidates = (provenance?.routeCandidates ?? []) + .filter((c) => c.kind === 'surface' || framework.mayTakeRoute(agentName, c.channelId)) + .map((c): RouteCandidate => c.kind === 'surface' + ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } + : c.unroutable + ? { + conversation: { + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + }, + addressed: c.addressed, + at: c.at, + unroutable: true, + } + : framework.candidateForChannel(c.channelId, c.addressed, c.at, c.serverId, c.messageId, c.threadId)); framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now(), // gate-requested wakes carry where/who (EventGate wakeProvenance) @@ -1817,30 +1839,8 @@ export class AgentFramework { ...(provenance?.routeChannelId && framework.mayTakeRoute(agentName, provenance.routeChannelId) ? { channelId: provenance.routeChannelId, addressed: true } : {}), - // The batch's conversations as speech-route candidates, under the - // same fork rule: a fork takes only its home's, and no other agent - // takes a channel a fork owns. - ...(provenance?.routeCandidates?.length - ? { - routeCandidates: provenance.routeCandidates - .filter((c) => c.kind === 'surface' || c.unroutable || framework.mayTakeRoute(agentName, c.channelId)) - .map((c): RouteCandidate => c.kind === 'surface' - ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } - : c.unroutable - ? { - conversation: { - kind: 'channel', - ...(c.serverId ? { serverId: c.serverId } : {}), - channelId: c.channelId, - ...(c.threadId ? { threadId: c.threadId } : {}), - }, - addressed: c.addressed, - at: c.at, - unroutable: true, - } - : framework.candidateForChannel(c.channelId, c.addressed, c.at, c.serverId, c.messageId, c.threadId)), - } - : {}), + // None left, as for an agent the fork rule leaves out, is none. + ...(routeCandidates.length > 0 ? { routeCandidates } : {}), }); }, getAgentNames: () => [...framework.agents.keys()].filter( @@ -7303,11 +7303,16 @@ export class AgentFramework { // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry) if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') { + const fields = extractGateFields(event); + const named = fields.metadata.channelId; const decision = this.eventGate.evaluate({ - ...extractGateFields(event), + ...fields, eventType: event.type, serverId: source, - channelId: '', + // A module event that names a channel isn't a local surface's + // conversation (surfaceSourceFor): the gate's wake carries that + // channel, unroutable, never a surface candidate. + channelId: !surface && typeof named === 'string' ? named : '', }); if (!decision.trigger) return; } @@ -8263,10 +8268,32 @@ export class AgentFramework { : this.pushSource(event, { deferred }); } + /** A coalesced item's trigger decision, keyed by the delivery event its + * occurrence carries, until the coalescer first acts on it + * (settleCoalescedTrigger). Not on the event itself: the coalescer + * persists occurrences, and a function can't be. */ + private readonly coalescedTriggers = new WeakMap boolean>(); + + /** + * Make a coalesced item's trigger decision (the gate's, which records the + * item in its batch as a route candidate), once: when the coalescer first + * delivers or wakes for an occurrence it admitted. A refused occurrence, a + * retry it deduplicates, or a retraction is never delivered or woken for, + * so it never reaches the gate. The decision is written onto the event, + * so a persisted occurrence keeps it. + */ + private settleCoalescedTrigger(event: { triggerInference?: boolean }): void { + const decide = this.coalescedTriggers.get(event); + if (!decide) return; + this.coalescedTriggers.delete(event); + event.triggerInference = decide(); + } + private handleCoalescedIncoming( serverId: string, message: ChannelIncomingMessage, event: CoalescedChannelEvent, + decideTrigger?: () => boolean, ): Promise { return this.admitCoalesced(async () => { if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000); @@ -8281,6 +8308,8 @@ export class AgentFramework { // deferral, fan-out and replay deliver it unchanged // (mcpl/inbound-source.ts). const inboundSource = event.inboundSource ?? this.coalescedIncomingSource(serverId, message, event); + const delivery = { ...event, eventId: message.eventId, inboundSource }; + if (decideTrigger) this.coalescedTriggers.set(delivery, decideTrigger); const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -8294,13 +8323,13 @@ export class AgentFramework { tags: event.tags, content: message.content, identity: { messageId: message.messageId, author: message.author, threadId: message.threadId }, - event: { lane: 'channel', event: { ...event, eventId: message.eventId, inboundSource } }, + event: { lane: 'channel', event: delivery }, }); return { messageId: message.messageId, accepted: true, coalesce: result.coalesce }; }); } - private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise { + private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent, decideTrigger?: () => boolean): Promise { return this.admitCoalesced(async () => { if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000); validateCoalesceMember(params.coalesce, 'push'); @@ -8318,6 +8347,8 @@ export class AgentFramework { // The inbound source envelope the gate read, frozen at acceptance (see // the channel lane). const inboundSource = event.inboundSource ?? this.coalescedPushSource(serverId, params, event); + const delivery = { ...event, inboundSource }; + if (decideTrigger) this.coalescedTriggers.set(delivery, decideTrigger); return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -8339,7 +8370,7 @@ export class AgentFramework { ...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}), }, } : {}), - event: { lane: 'push', event: { ...event, inboundSource } }, + event: { lane: 'push', event: delivery }, }); }); } @@ -8411,6 +8442,7 @@ export class AgentFramework { materialized?: McplContentBlock[], assemblingFor?: string, ): Promise { + this.settleCoalescedTrigger(occ.event.event); const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; // The envelope frozen at acceptance travels unchanged; this delivery only @@ -8477,6 +8509,7 @@ export class AgentFramework { private async wakeForCoalescedBatch(occ: CoalescedOccurrence): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); this.cancelCoalescedWakes(subject); + this.settleCoalescedTrigger(occ.event.event); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; const origin = occ.event.event.origin; // The wake carries the batch's channel so the turn freezes its reply @@ -9176,7 +9209,7 @@ export class AgentFramework { } else if (parsed.kind === 'target') { const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!); if (resolved && !('error' in resolved)) { - this.proseTargetPins.set(agent.name, resolved.channelId); + this.proseTargetPins.set(agent.name, { serverId: resolved.serverId, channelId: resolved.channelId }); this.proseHybridSuppressed.delete(agent.name); } else { this.proseTargetPins.delete(agent.name); @@ -10550,13 +10583,14 @@ export class AgentFramework { private async deliverWithUnsent( agent: Agent, body: string, - channelId: string, + /** The envelope's destination, on the server its target resolved to. */ + destination: { serverId: string; channelId: string }, unsent: Draft | undefined, ): Promise { const registry = this.channelRegistry!; let attemptId: string | undefined; if (unsent) { - const target = registry.resolveDestination({ channelId }); + const target = registry.resolveDestination(destination); if (!('error' in target)) { try { attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false); @@ -10574,7 +10608,7 @@ export class AgentFramework { } let outcome: PublishOutcome; try { - outcome = await registry.deliverSpeech(agent.name, body, channelId); + outcome = await registry.deliverSpeech(agent.name, body, destination); } finally { if (flight) { this.draftsInFlight.delete(flight); @@ -10644,14 +10678,15 @@ export class AgentFramework { continue; } if (usedClipboard) body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); - this.proseTargetPins.set(agent.name, resolved.channelId); + const pin = { serverId: resolved.serverId, channelId: resolved.channelId }; + this.proseTargetPins.set(agent.name, pin); this.proseHybridSuppressed.delete(agent.name); if (!body.trim()) { console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`); continue; } try { - const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); + const outcome = await this.deliverWithUnsent(agent, body, pin, unsent.draft); this.recordProseDelivery(agent.name, outcome.status === 'delivered' ? { delivered: true, ...outcome.destination! } : null); if (outcome.status === 'delivered') { this.streamPublished(agent.name, moment, outcome.destination!, body); @@ -10758,7 +10793,7 @@ export class AgentFramework { return; } - let targetChannel: string; + let target: { serverId: string; channelId: string }; let body: string; if (parsed.kind === 'target') { const res = this.channelRegistry!.resolveProseTarget(parsed.target!); @@ -10766,9 +10801,10 @@ export class AgentFramework { this.bounceProse(agent, parsed.body, res.error, res.candidates, '>>', hold); return; } - targetChannel = res.channelId; - // Sticky within the turn: later unprefixed segments follow this target. - this.proseTargetPins.set(name, targetChannel); + target = { serverId: res.serverId, channelId: res.channelId }; + // Sticky within the turn: later unprefixed segments follow this + // target, on its server. + this.proseTargetPins.set(name, target); body = parsed.body; } else { const sticky = this.proseTargetPins.get(name); @@ -10776,7 +10812,7 @@ export class AgentFramework { this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn', undefined, '>>', hold); return; } - targetChannel = sticky; + target = sticky; body = rawText; } @@ -10792,13 +10828,13 @@ export class AgentFramework { } try { - const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft); + const outcome = await this.deliverWithUnsent(agent, body, target, unsent.draft); if (outcome.status === 'delivered') { this.streamPublished(name, moment, outcome.destination!, body); this.proseBounceStreaks.delete(name); } } catch (err) { - console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); + console.error(`[prose] ${name}: delivery to ${target.channelId} failed:`, err); } } @@ -15189,7 +15225,7 @@ export class AgentFramework { (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), + (serverId, params, event, decideTrigger) => this.handleCoalescedPush(serverId, params, event, decideTrigger), // An ordinary push: freeze its envelope and observe the acceptance at // admission, before it is queued. (event) => { @@ -15229,8 +15265,8 @@ export class AgentFramework { (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), { store: this.store, - handleCoalescedIncoming: (serverId, message, event) => - this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent), + handleCoalescedIncoming: (serverId, message, event, decideTrigger) => + this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent, decideTrigger), // An ordinary channels/incoming message: freeze its envelope and // observe the acceptance at admission, before it is queued. acceptInbound: (event) => { @@ -17504,9 +17540,10 @@ export class AgentFramework { : seconds >= 60 ? `${Math.round(seconds / 60)}m` : `${Math.round(seconds)}s`; - // Announce in the TURN's frozen locus (best-effort; never blocks the - // result). The sleep tool runs mid-turn, so the pin is live — same - // authority as every other speech path, no live re-resolution. + // Announce as the turn's speech goes (best-effort; never blocks the + // result): an explicit-mode resident's pinned target, a hybrid one's + // `>>>` pin when it set one, else the turn's route, exactly as other + // unaddressed speech, with no live re-resolution. if (announce && this.channelRegistry) { const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`; const agent = this.agents.get(agentName); @@ -17524,10 +17561,23 @@ export class AgentFramework { } else { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } - } else { - const locus = this.routeTarget(agentName); - this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { - console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); + } else if (agent) { + // Unaddressed speech like any other, after the words written before + // it. A hybrid `>>>` pin set this turn wins, as for an unprefixed + // envelope; else the route as it stands when the tool runs: a held + // turn keeps the words as a draft, a surface shows them, no route + // keeps them with no destination, and only a publish that was tried + // can fail. + const moment: SpeechMoment = { turn: this.turnRoutes.get(agentName) }; + this.chainSpeech(agentName, '[sleep] announcement', async () => { + const pin = agent.proseRouting === 'hybrid' ? this.proseTargetPins.get(agentName) : undefined; + if (pin) { + const outcome = await this.channelRegistry!.routeSpeech(agentName, text, pin); + this.recordProseDelivery(agentName, outcome); + if (outcome?.delivered) this.streamPublished(agentName, moment, outcome, text); + return; + } + await this.speakUnaddressed(agent, text, { notice: 'later' }, moment); }); } } diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 4973966a8..82f888827 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -269,7 +269,10 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin addressed: e.addressed, at: e.timestamp, }); - } else if (e.eventType === 'external-message' || e.eventType === 'api:message') { + } else if ((e.eventType === 'external-message' || e.eventType === 'api:message') && !e.channelId) { + // A local surface only when it names no channel, as surfaceSourceFor + // decides: one that names a channel no registry holds is that + // channel's conversation, unroutable (below), never answered locally. routeCandidates.push({ kind: 'surface', surface: e.serverId || (e.eventType === 'api:message' ? 'api' : 'external'), @@ -1582,7 +1585,13 @@ export class EventGate { routeChannelId: conversation?.channelId ?? this.routeChannelFor(info), ...(messageId ? { messageId } : {}), ...(threadId ? { threadId } : {}), - conversational: isConversational(info.tags, info.metadata), + // An MCPL item's metadata is its server's (an origin, an incoming + // message's metadata): only the host marks machinery, so its tags + // alone decide. A host module's event metadata is the host's. + conversational: isConversational( + info.tags, + info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event' ? undefined : info.metadata, + ), }; const existing = this.debounceTimers.get(policy.name); @@ -1644,28 +1653,37 @@ export class EventGate { const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`); if (referenced.length > 0) { - const byChannel = new Map(); + const byConversation = new Map(); for (const e of referenced) { // The registered id when known (a push event's own channelId is the - // adapter's raw id), counted per server: the same id on another - // server is another conversation. + // adapter's raw id), counted per server and thread: the same id on + // another server, or a thread and its channel's root, is another + // conversation. const channelId = e.routeChannelId ?? e.channelId; - const key = `${e.serverId ?? ''}\u0000${channelId ?? ''}`; - const entry = byChannel.get(key) ?? { + const key = JSON.stringify([e.serverId ?? null, channelId ?? null, e.threadId ?? null]); + const entry = byConversation.get(key) ?? { ...(e.serverId ? { serverId: e.serverId } : {}), ...(channelId ? { channelId } : {}), + ...(e.threadId ? { threadId: e.threadId } : {}), count: 0, oldest: e.timestamp, label: e.channelLabel, }; entry.count += 1; entry.oldest = Math.min(entry.oldest, e.timestamp); entry.label = entry.label ?? e.channelLabel; - byChannel.set(key, entry); + byConversation.set(key, entry); } - for (const { serverId, channelId, count, oldest, label } of byChannel.values()) { + for (const { serverId, channelId, threadId, count, oldest, label } of byConversation.values()) { // Named the way routing notices and send tools name a conversation - // (describeConversation): its label, then `server / channel-id`. + // (describeConversation): its label, then `server / channel-id`, + // then its thread. const channel = channelId - ? describeConversation({ kind: 'channel', ...(serverId ? { serverId } : {}), channelId, ...(label ? { label } : {}) }) + ? describeConversation({ + kind: 'channel', + ...(serverId ? { serverId } : {}), + channelId, + ...(threadId ? { threadId } : {}), + ...(label ? { label } : {}), + }) : '(unknown channel)'; const age = Math.round((Date.now() - oldest) / 1000); lines.push( diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 1ec18a008..1574322e3 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -586,6 +586,10 @@ interface ChannelRegistryOptions { serverId: string, message: ChannelIncomingMessage, event: McplChannelIncomingEvent, + /** The trigger decision, made only once the coalescer admits the + * occurrence for delivery: a refused one, a retry or a retraction is + * never gated (so never a route candidate). */ + decideTrigger?: () => boolean, ) => Promise; /** Chronicle store used for durable desired channel lifecycle state. */ store?: JsStore; @@ -1106,13 +1110,16 @@ export class ChannelRegistry { : this.acceptInbound?.(event); if (inboundSource) event.inboundSource = inboundSource; - // Determine whether to trigger inference + // Whether to trigger inference: decided now for an ordinary message, + // and for a coalesced one only once its coalescer admits it. + let decideTrigger: (() => boolean) | undefined; if (this.shouldTriggerInference) { + const shouldTrigger = this.shouldTriggerInference; const textContent = message.content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map((b) => b.text) .join('\n'); - event.triggerInference = this.shouldTriggerInference( + decideTrigger = () => shouldTrigger( textContent, { // The adapter's own metadata first; the protocol's fields after @@ -1132,6 +1139,7 @@ export class ChannelRegistry { [INBOUND_SOURCE_KEY]: inboundSource, }, ); + if (!coalesced) event.triggerInference = decideTrigger(); } if (coalesced) { @@ -1140,7 +1148,7 @@ export class ChannelRegistry { // the coalescer, which replaces, appends or withdraws. Malformed // `coalesce` is a per-message failure; siblings are unaffected. try { - const result = await this.handleCoalescedIncoming!(serverId, message, event); + const result = await this.handleCoalescedIncoming!(serverId, message, event, decideTrigger); if (result.accepted) markAccepted(); results.push(result); } catch (error) { @@ -1511,8 +1519,11 @@ export class ChannelRegistry { } if (outgoing) { + // Where the speech lands, its thread included, whether or not the + // route has a reply edge. context.defaultOutgoing = { channelId: outgoing, + ...(route?.kind === 'channel' && route.threadId ? { threadId: route.threadId } : {}), }; } @@ -2457,14 +2468,18 @@ export class ChannelRegistry { */ resolveProseTarget( spec: string, - ): { channelId: string; label?: string } | { error: string; candidates?: string[] } { + ): { serverId: string; channelId: string; label?: string } | { error: string; candidates?: string[] } { const entries = [...this.channels.values()]; + // The conversation it resolved to, with its server: a pin keeps that + // server, so the same id registered by another server later changes + // nothing. + const found = (e: ChannelEntry) => ({ serverId: e.serverId, channelId: e.descriptor.id, label: e.descriptor.label }); const trimmed = spec.trim(); if (!trimmed) return { error: 'empty target' }; const exact = entries.filter((e) => e.descriptor.id === trimmed); if (exact.length === 1) { - return { channelId: exact[0]!.descriptor.id, label: exact[0]!.descriptor.label }; + return found(exact[0]!); } if (exact.length > 1) { return { @@ -2477,7 +2492,7 @@ export class ChannelRegistry { (e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed, ); if (raw.length === 1) { - return { channelId: raw[0]!.descriptor.id, label: raw[0]!.descriptor.label }; + return found(raw[0]!); } if (raw.length > 1) { return { @@ -2513,7 +2528,7 @@ export class ChannelRegistry { const id = mention[1]!; const byId = dms.filter((e) => dmMeta(e)?.recipientId === id); if (byId.length === 1) { - return { channelId: byId[0]!.descriptor.id, label: byId[0]!.descriptor.label }; + return found(byId[0]!); } return { error: `no registered DM matches the mention <@${id}>`, @@ -2530,7 +2545,7 @@ export class ChannelRegistry { return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name)); }); if (pool.length === 1) { - return { channelId: pool[0]!.descriptor.id, label: pool[0]!.descriptor.label }; + return found(pool[0]!); } if (pool.length > 1) { return { @@ -2548,7 +2563,7 @@ export class ChannelRegistry { (e) => norm(e.descriptor.label ?? '') === norm(trimmed), ); if (byLabel.length === 1) { - return { channelId: byLabel[0]!.descriptor.id, label: byLabel[0]!.descriptor.label }; + return found(byLabel[0]!); } if (byLabel.length > 1) { return { @@ -2565,7 +2580,7 @@ export class ChannelRegistry { const nameOf = normalizeChannelLabelName; const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed)); if (byName.length === 1) { - return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label }; + return found(byName[0]!); } if (byName.length > 1) { return { diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 9acfeb487..8e771105a 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -183,7 +183,11 @@ export class PushHandler { pushEventFn: (event: McplPushEvent) => void, emitTraceFn: (event: { type: string; [key: string]: unknown }) => void, shouldTriggerInference?: (content: string, metadata: Record) => boolean, - private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent) => Promise, + /** RFC-006: hand a coalesced push to its coalescer. `decideTrigger` + * is the trigger decision, made only once the coalescer admits the + * occurrence for delivery: a refused one, a retry or a retraction is + * never gated (so never a route candidate). */ + private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent, decideTrigger?: () => boolean) => Promise, /** An ordinary (uncoalesced) push was just admitted, before it is queued * or acknowledged: return its source envelope, frozen now * (mcpl/inbound-source.ts). Coalesced work is stamped by its own path. */ @@ -316,8 +320,11 @@ export class PushHandler { : this.acceptInbound?.(pushEvent); if (inboundSource) pushEvent.inboundSource = inboundSource; - // 6. Check shouldTriggerInference callback + // 6. The shouldTriggerInference callback: decided now for an ordinary + // push, and for a coalesced one only once its coalescer admits it. + let decideTrigger: (() => boolean) | undefined; if (this.shouldTriggerInference) { + const shouldTrigger = this.shouldTriggerInference; const textContent = content .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') .map((b) => b.text) @@ -336,7 +343,8 @@ export class PushHandler { ...(params.tags ? { tags: params.tags } : {}), [INBOUND_SOURCE_KEY]: inboundSource, }; - pushEvent.triggerInference = this.shouldTriggerInference(textContent, metadata); + decideTrigger = () => shouldTrigger(textContent, metadata); + if (!coalesced) pushEvent.triggerInference = decideTrigger(); } if (coalesced) { @@ -344,7 +352,7 @@ export class PushHandler { // unread one, appends, or withdraws; it delivers through the same event // the ordinary path would have queued. try { - const result = await this.handleCoalesced!(serverId, params, pushEvent); + const result = await this.handleCoalesced!(serverId, params, pushEvent, decideTrigger); this.emitTraceFn({ type: 'mcpl:push_event', serverId, eventId: params.eventId, featureSet: params.featureSet, coalesce: result.coalesce }); responder?.respond(result); } catch (error) { diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 3bcbadbb3..db1b23320 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -1019,9 +1019,12 @@ export interface ChannelContext { threadId?: string; }; - /** Default channel for outgoing messages */ + /** Default channel for outgoing messages, and the thread in it that the + * agent's plain speech posts into, when it has one (an extension of + * §14.4's shape: a server that doesn't read it loses nothing). */ defaultOutgoing?: { channelId: string; + threadId?: string; }; /** All candidate channels for this inference */ diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index ded0c26d0..9f918563d 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -201,7 +201,7 @@ describe('batched wake routing with conversation forks', () => { ], gate: { config: { - policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 50 } }], + policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, behavior: { debounce: 50 } }], default: 'skip', }, }, @@ -214,6 +214,29 @@ describe('batched wake routing with conversation forks', () => { rmSync(tempDir, { recursive: true, force: true }); }); + it('a batch candidate the host could not resolve follows the fork rule too: no other agent is held by a fork-owned channel', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Array<{ agentName: string; routeCandidates?: Array<{ conversation: { channelId?: string }; unroutable?: true }> }>; + eventGate: { evaluate(info: unknown): unknown }; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + // A push naming the fork's channel by a raw id the host can't resolve: + // an unroutable candidate, which competes but never routes. + internals.eventGate.evaluate({ + content: '@agent hi', eventType: 'mcpl:push-event', serverId: 'discord', channelId: ROOM, + tags: ['chat:addressed'], metadata: { authorId: '5' }, + }); + await waitFor(() => internals.pendingRequests.some((r) => r.agentName === 'trunk'), 'gate wake broadcast'); + const byAgent = new Map(internals.pendingRequests.map((r) => [r.agentName, r])); + assert.deepEqual(byAgent.get('fork-room')?.routeCandidates?.map((c) => [c.conversation.channelId, c.unroutable]), [[ROOM, true]], + 'the fork homed there competes for it'); + for (const other of ['trunk', 'fork-other']) { + assert.equal(byAgent.get(other)?.routeCandidates, undefined, `${other}'s turn is not held by the fork's channel`); + assert.ok(byAgent.has(other), `${other} is still woken`); + } + }); + it('the addressed route applies only to the fork homed there; other forks and the trunk get none', async () => { const internals = framework as unknown as { conversationAgentHomes: Map; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index bfec5f790..ab48a8a97 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -161,6 +161,7 @@ test('a coalesced message is gated with the host envelope its coalescer will fre channelId: 'slack:C1', messageId: 'm-c', acceptedAt: 1, }; let accepted = 0; + let gatedBeforeAdmission = -1; const handed: Array<{ inboundSource?: unknown }> = []; const registry = new ChannelRegistry( { getServer: () => undefined } as unknown as McplServerRegistry, @@ -171,8 +172,11 @@ test('a coalesced message is gated with the host envelope its coalescer will fre shouldTriggerInference: (_content, metadata) => { seen.push(metadata); return false; }, acceptInbound: () => { accepted++; return undefined; }, coalescedSource: () => envelope as never, - handleCoalescedIncoming: async (_serverId, message, event) => { + // The coalescer admits the occurrence, and only then decides its trigger. + handleCoalescedIncoming: async (_serverId, message, event, decideTrigger) => { + gatedBeforeAdmission = seen.length; handed.push(event as { inboundSource?: unknown }); + decideTrigger?.(); return { messageId: message.messageId, accepted: true }; }, }, @@ -187,6 +191,7 @@ test('a coalesced message is gated with the host envelope its coalescer will fre }], } as never); assert.equal(accepted, 0, 'building the envelope accepts nothing: the coalescer observes its own admissions'); + assert.equal(gatedBeforeAdmission, 0, 'the gate is not consulted before the coalescer admits the occurrence'); assert.equal(seen.length, 1); assert.equal(seen[0]!.inboundSource, envelope, 'the gate reads the host envelope'); assert.equal(handed[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate read'); @@ -315,6 +320,8 @@ test('buildChannelContext advertises the agent\'s own route and its reply edge, const routes: Record = { 'conversation-chanA-g1': { kind: 'channel', serverId: 'discord', channelId: 'chanA' }, scout: { kind: 'channel', serverId: 'discord', channelId: 'chanB', replyTo: 'm-7', threadId: 't-1' }, + // A thread route with no reply edge (a channel_open of a thread, say). + opener: { kind: 'channel', serverId: 'discord', channelId: 'chanB', threadId: 't-2' }, held: { kind: 'held', conversations: ['#chanA (chanA)', '#chanB (chanB)'] }, tui: { kind: 'surface', surface: 'tui' }, }; @@ -328,6 +335,10 @@ test('buildChannelContext advertises the agent\'s own route and its reply edge, assert.equal(scout?.defaultOutgoing?.channelId, 'chanB'); assert.deepEqual(scout?.incoming, { channelId: 'chanB', messageId: 'm-7', threadId: 't-1' }, 'the reply edge is the route\'s own triggering message'); + assert.deepEqual(scout?.defaultOutgoing, { channelId: 'chanB', threadId: 't-1' }); + const opener = registry.buildChannelContext('opener'); + assert.deepEqual(opener?.defaultOutgoing, { channelId: 'chanB', threadId: 't-2' }, 'the thread is named without a reply edge too'); + assert.equal(opener?.incoming, undefined); for (const agent of ['held', 'tui', 'nobody']) { const ctx = registry.buildChannelContext(agent); assert.equal(ctx?.defaultOutgoing, undefined, `${agent}: no outgoing channel`); diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 843cb0d7d..9955bb5b6 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -48,6 +48,24 @@ function makeGate(configPath: string, opts?: { return { gate, traces, messages, inferenceRequests }; } +/** Until the gate has added `n` messages, bounded. */ +async function waitForMessages(messages: unknown[], n: number, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (messages.length < n) { + if (Date.now() > deadline) throw new Error(`expected ${n} message(s), saw ${messages.length}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + +/** Until the gate has requested `n` wakes, bounded: what a debounced test waits on. */ +async function waitForRequests(requests: unknown[], n: number, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (requests.length < n) { + if (Date.now() > deadline) throw new Error(`expected ${n} wake request(s), saw ${requests.length}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + function event(overrides?: Partial): GateEventInfo { return { content: 'test message', @@ -361,8 +379,29 @@ describe('debounce', () => { gate.evaluate(event({ serverId: 'beta', channelId: 'C1', metadata: { channelName: 'general' } })); await new Promise(r => setTimeout(r, 160)); const text = (messages[0].content as Array<{ text: string }>)[0].text; - assert.ok(text.includes('- 1 message in #general (alpha / C1) '), text); - assert.ok(text.includes('- 2 messages in #general (beta / C1) '), text); + // The label as the connector gave it: nothing added for its spelling. + assert.ok(text.includes('- 1 message in general (alpha / C1) '), text); + assert.ok(text.includes('- 2 messages in general (beta / C1) '), text); + }); + + it('a batched wake counts a thread apart from its channel root and from another thread, naming each', async () => { + const path = writeConfig('debounce-threads.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, messages } = makeGate(path); + const said = (threadId?: string) => gate.evaluate(event({ + eventType: 'mcpl:channel-incoming', serverId: 'slack', channelId: 'slack:C1', + metadata: { channelName: '#eng', ...(threadId ? { threadId } : {}) }, + })); + said('1700.1'); said('1700.1'); said('1700.2'); said(); + await waitForMessages(messages, 1); + const text = (messages[0].content as Array<{ text: string }>)[0].text; + assert.ok(text.includes('- 2 messages in #eng (slack / slack:C1, thread 1700.1) '), text); + assert.ok(text.includes('- 1 message in #eng (slack / slack:C1, thread 1700.2) '), text); + assert.ok(text.includes('- 1 message in #eng (slack / slack:C1) '), text); }); it('batches events and fires after delay', async () => { @@ -675,6 +714,39 @@ describe('debounce', () => { ]); }); + it("a server can't mark its item as machinery: system: true in an MCPL item's metadata leaves it a route candidate", async () => { + const path = writeConfig('debounce-route-system.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: 'hi', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'm-1', system: true } })); + await waitForRequests(inferenceRequests, 1); + assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:room']); + }); + + it('a module event is a local surface candidate only when it names no channel', async () => { + const path = writeConfig('debounce-route-surface.json', { + policies: [ + { name: 'console', match: { scope: ['external-message'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'external-message', serverId: 'tui', channelId: '', content: 'local words' })); + gate.evaluate(event({ eventType: 'external-message', serverId: 'bridge', channelId: 'discord:g9:nowhere', content: 'relayed words', + metadata: { channelId: 'discord:g9:nowhere' } })); + await waitForRequests(inferenceRequests, 1); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'surface', surface: 'tui' }, + { kind: 'channel', channelId: 'discord:g9:nowhere', serverId: 'bridge', addressed: false, unroutable: true }, + ]); + }); + it('a reaction in the batch is never a route candidate (the same predicate as mid-turn)', async () => { const path = writeConfig('debounce-route-reaction.json', { policies: [ diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 0e70902cc..337e424b5 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -566,6 +566,24 @@ test('G1: malformed content on a coalesced channel item fails that item only', a assert.equal(badPush.error.code, -32602); }); +test('the trigger (the gate) is decided only for an occurrence the coalescer admits: never for a retry, a retraction or a refused one', async (t) => { + const decided: string[] = []; + const f = await fixture({ server: { shouldTriggerInference: (_content: string, metadata: Record) => { + decided.push(String(metadata.eventId ?? metadata.messageId)); + return false; + } } }); t.after(f.close); + await f.register(); + await f.send('push/event', f.params('p1', 'first_words', { initial: true })); + await f.send('push/event', f.params('p1', 'first_words', { initial: true })); + await f.send('push/event', f.params('p2', '', { retract: true })); + const refused = await f.send('push/event', { ...f.params('p3', 'x'), coalesce: { key: 'document', channelId: 'nowhere' } }); + assert.ok(refused.error, 'a channel-scoped push for an unknown channel is refused'); + await f.send('channels/incoming', { messages: [f.channel('c1', 'channel_words', { initial: true })] }); + await f.send('channels/incoming', { messages: [f.channel('c2', '', { retract: true })] }); + // The channel message is named by its platform message id (m). + assert.deepEqual(decided, ['p1', 'm'], 'once for each admitted occurrence, at delivery'); +}); + test('G3/G5: a deferred channel-scoped push renders with its feature set and wakes with its channel', async (t) => { const f = await fixture({ server: { shouldTriggerInference: () => true } }); t.after(f.close); await f.register(); await f.send('push/event', { ...f.params('1', 'fallback', { deferred: true, channelId: 'chat', key: 'activity' }), origin: { authorId: 'u', authorName: 'User' } }); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 149d07c51..d2dd30b4f 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1209,6 +1209,39 @@ describe('speech follows the moment its words were written', () => { await framework.stop(); }); + it('hybrid: a `>>>` pin keeps the server its target resolved to, and later speech publishes there by server', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Pinned words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'More for alpha.' }] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + stubChannelRegistry(framework, { home: false }); + const registry = registryOf(framework); + // The target resolves on one server; a pin that kept only the id would + // re-resolve it against any server that registers the same id later. + registry.resolveProseTarget = (spec: string) => ({ serverId: 'alpha-server', channelId: `chan-${spec.slice(1)}`, label: spec }); + const targets: unknown[] = []; + for (const method of ['deliverSpeech', 'routeSpeech'] as const) { + const real = registry[method] as (...args: unknown[]) => Promise; + registry[method] = async (agent: string, text: string, target: unknown) => { + targets.push(target); + return real(agent, text, target); + }; + } + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(targets, [ + { serverId: 'alpha-server', channelId: 'chan-alpha' }, + { serverId: 'alpha-server', channelId: 'chan-alpha' }, + ], 'the envelope and the speech after it both name the pinned server'); + + await framework.stop(); + }); + it('hybrid: a channel_open that sets the speech route ends a `>>>skip_reply` suppression', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>>skip_reply An aside to myself.' }, diff --git a/test/push-gate-metadata.test.ts b/test/push-gate-metadata.test.ts index d233c675e..15d4dc3c9 100644 --- a/test/push-gate-metadata.test.ts +++ b/test/push-gate-metadata.test.ts @@ -53,12 +53,19 @@ test('a coalesced push is gated with the host envelope its coalescer will freeze let accepted = 0; let built = 0; const coalescedEvents: Array<{ inboundSource?: InboundSource }> = []; + let gatedBeforeAdmission = -1; const handler = new PushHandler( { validateInbound: () => {} } as unknown as FeatureSetManager, () => {}, () => {}, (_content, metadata) => { seen.push(metadata); return false; }, - async (_serverId, _params, event) => { coalescedEvents.push(event); return { accepted: true } as never; }, + // The coalescer admits the occurrence, and only then decides its trigger. + async (_serverId, _params, event, decideTrigger) => { + gatedBeforeAdmission = seen.length; + coalescedEvents.push(event); + decideTrigger?.(); + return { accepted: true } as never; + }, () => { accepted++; return undefined; }, (_serverId, params) => { built++; @@ -77,6 +84,7 @@ test('a coalesced push is gated with the host envelope its coalescer will freeze } as never); assert.equal(built, 1); assert.equal(accepted, 0, 'building a coalesced envelope accepts nothing: the coalescer observes its own admissions'); + assert.equal(gatedBeforeAdmission, 0, 'the gate is not consulted before the coalescer admits the occurrence'); assert.equal(seen.length, 1); assert.deepEqual(seen[0]![INBOUND_SOURCE_KEY], envelope, 'the gate reads the host envelope, not the origin\'s channel or key'); assert.equal(coalescedEvents.length, 1); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index 1709fdb11..c314ad6d8 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -241,4 +241,45 @@ describe('speech route across a logical turn', () => { assert.equal(second, '[delivered] plain speech → #Beta (beta / shared, thread topic)'); await framework.stop(); }); + + it('the sleep announcement is unaddressed speech: a held, a routeless and a surface turn each keep it as such, publishing nothing', async () => { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + gate: { config: { policies: [], default: 'always' } }, + }); + const i = framework as unknown as { + turnRoutes: Map; + channelRegistry: unknown; + speechChains?: Map>; + dispatchSleepToolCall(agent: string, call: unknown): void; + proseDrafts: { open(agent: string): Array<{ reason: string; text: string }> }; + }; + const published: unknown[] = []; + i.channelRegistry = new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + routeSpeech: async (...args: unknown[]) => { published.push(args); return null; }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const sleepUnder = async (turn: Route, id: string) => { + i.turnRoutes.set('scout', turn); + i.dispatchSleepToolCall('scout', { id, name: 'sleep', input: { seconds: 60 } }); + await (i.speechChains?.get('scout') ?? Promise.resolve()); + }; + await sleepUnder({ route: null, hold: { conversations: [ + { kind: 'channel', serverId: 'discord', channelId: 'a' }, + { kind: 'channel', serverId: 'discord', channelId: 'b' }, + ], since: 'turn-start' } }, 's1'); + await sleepUnder({ route: null }, 's2'); + await sleepUnder({ route: { kind: 'surface', surface: 'tui', origin: 'trigger' } }, 's3'); + assert.deepEqual(published, [], 'nothing was published, so no failure can be reported'); + const held = i.proseDrafts.open('scout'); + assert.deepEqual(held.map((d) => d.reason).sort(), ['ambiguous', 'no-destination'], 'held, then routeless; the surface shows it'); + assert.ok(held.every((d) => d.text.startsWith('💤 Going quiet for 1m'))); + await framework.stop(); + }); }); From 535d44f8f7083bbe53f684e18fd7862fc94bb69b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:56:01 -0400 Subject: [PATCH 48/65] fix(routing): an evicted event keeps its route candidate, and a mid-turn hold names every arrival with its own cause From a review of this PR's own commits. - the gate's inference buffer still evicts its oldest event at 100, but keeps each evicted conversation's newest event and newest addressed one (keepEvictedRoute). The flush's wake counts them toward its route candidates and provenance, as if they had stayed, and not toward its notice, which names what is still held. An addressed DM evicted by 100 ambient messages still competes, and still wins. - a mid-turn hold names every conversation that suspends the route in that round (each once, its newest arrival), plus the current one, in the drafts' note and the notice, with one cause per arrival. - each cause uses the arrival candidate's own addressed flag, so a local surface's message (always addressed) reads as addressing the resident whether or not it carries chat:addressed. Test harness: present-while-acting's RobotModule simulated channel arrivals as external messages whose module metadata carried an inboundSource. #247's round drops a module's envelope, so since the merge 66198a60 those arrivals had none, and eight tests failed (the hold never happened). A channel arrival now comes as an mcpl:channel-incoming event carrying its envelope, as the MCPL lane delivers it; a surface arrival is still an external message. The module can push several arrivals in one round (moreInterjections). Tests: - event-gate: the inference buffer evicts the oldest event, an addressed DM, under 100 ambient ones; the flush still makes the DM the addressed candidate and addresses the wake there. - present-while-acting: two addressed arrivals in one round are both named in the hold's note and notice; an untagged mid-turn surface arrival reads as addressing the resident. --- src/framework.ts | 77 +++++++++++++--------- src/gate/event-gate.ts | 42 ++++++++++-- test/event-gate.test.ts | 28 ++++++++ test/present-while-acting.test.ts | 102 +++++++++++++++++++++++++++--- 4 files changed, 204 insertions(+), 45 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 45949aa95..aa8409635 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7047,43 +7047,58 @@ export class AgentFramework { ) { const turn = this.turnRoutes.get(agent.name); const isEngaged = (c: ConversationRef): boolean => this.engagementOf(agent.name, c) !== undefined; - const competing = turn ? [...midTurnInjections].reverse().find((inj) => { - if (!isConversationalInjection(inj.metadata)) return false; - const m = inj.metadata as Record | undefined; - const candidate = this.candidateFromSource( - readInboundSource(m), - isAddressedMessage(m?.tags as string[] | undefined, m), - ); - return candidate !== undefined && suspendsRoute(turn, candidate, isEngaged); - }) : undefined; - if (turn?.route && competing) { - const m = competing.metadata as Record | undefined; - const arrival = this.candidateFromSource(readInboundSource(m), true)!.conversation; - const addressedArrival = isAddressedMessage(m?.tags as string[] | undefined, m); + // Every arrival in this round that suspends the route, each + // conversation once (its newest arrival), oldest first. + const competing: RouteCandidate[] = []; + if (turn) { + const seen = new Set(); + for (const inj of [...midTurnInjections].reverse()) { + if (!isConversationalInjection(inj.metadata)) continue; + const m = inj.metadata as Record | undefined; + const candidate = this.candidateFromSource( + readInboundSource(m), + isAddressedMessage(m?.tags as string[] | undefined, m), + ); + if (!candidate || !suspendsRoute(turn, candidate, isEngaged)) continue; + const key = conversationKey(candidate.conversation); + if (seen.has(key)) continue; + seen.add(key); + competing.unshift(candidate); + } + } + if (turn?.route && competing.length > 0) { const current = routeConversation(turn.route); - const held: TurnRoute = { route: turn.route, hold: { conversations: [current, arrival], since: 'mid-turn' } }; + const arrivals = competing.map((c) => c.conversation); + const held: TurnRoute = { route: turn.route, hold: { conversations: [current, ...arrivals], since: 'mid-turn' } }; this.turnRoutes.set(agent.name, held); this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); - // Name the actual cause, as certain as the host is of it: - // someone addressed the resident, or a conversation continued - // where it sent this turn — there exactly, there maybe (the - // send's delivery is unconfirmed), or somewhere in the channel - // (a connector's own tool chose where). - const engagement = this.engagementOf(agent.name, arrival); - const cause = addressedArrival - ? `${describeConversation(arrival)} addressed you mid-turn` - : engagement === 'delivered' - ? `${describeConversation(arrival)}, where you sent a message this turn, continued mid-turn` - : engagement === 'unknown' - ? `${describeConversation(arrival)}, where you tried to send a message this turn (its delivery wasn't confirmed), continued mid-turn` - : `${describeConversation(arrival)} continued mid-turn, in a channel you sent something into this turn (where in the channel isn't known)`; + // Name each actual cause, as certain as the host is of it: + // someone addressed the resident (the arrival's own addressed + // flag: a local surface's message always addresses it), or a + // conversation continued where it sent this turn — there + // exactly, there maybe (the send's delivery is unconfirmed), or + // somewhere in the channel (a connector's own tool chose where). + const causeOf = (candidate: RouteCandidate): string => { + const arrival = candidate.conversation; + const engagement = this.engagementOf(agent.name, arrival); + return candidate.addressed + ? `${describeConversation(arrival)} addressed you mid-turn` + : engagement === 'delivered' + ? `${describeConversation(arrival)}, where you sent a message this turn, continued mid-turn` + : engagement === 'unknown' + ? `${describeConversation(arrival)}, where you tried to send a message this turn (its delivery wasn't confirmed), continued mid-turn` + : `${describeConversation(arrival)} continued mid-turn, in a channel you sent something into this turn (where in the channel isn't known)`; + }; + const causes = competing.map(causeOf); + const cause = causes.length === 1 ? causes[0]! : `${causes.slice(0, -1).join('; ')}; and ${causes.at(-1)}`; const noticeContent: ContentBlock[] = [{ type: 'text', text: - `[routing] ${cause}, so your unaddressed plain speech could now answer either conversation. ` + + `[routing] ${cause}, so your unaddressed plain speech could now answer ` + + `${competing.length === 1 ? 'either conversation' : 'any of these conversations'}. ` + 'For the rest of this turn it is held as drafts ' + - `instead of going to ${describeConversation(current)}. Answer either one with an explicit send ` + - '(or a channel_open); held words can be resent from your drafts.', + `instead of going to ${describeConversation(current)}. Answer ${competing.length === 1 ? 'either one' : 'any of them'} ` + + 'with an explicit send (or a channel_open); held words can be resent from your drafts.', }]; const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata; try { @@ -7091,7 +7106,7 @@ export class AgentFramework { this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta }); console.error( - `[routing] ${agent.name}: mid-turn ambiguity hold — ${describeConversation(current)} vs ${describeConversation(arrival)}`, + `[routing] ${agent.name}: mid-turn ambiguity hold — ${[current, ...arrivals].map(describeConversation).join(' vs ')}`, ); } catch (err) { console.error('mid-turn ambiguity hold: failed to record routing notice:', err); diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 82f888827..a2055506b 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -643,6 +643,11 @@ export class EventGate { private quiesced = false; /** Lifetime count of buffer-cap evictions (see bufferForInference). */ private droppedBufferedEvents = 0; + /** The route candidates of events the buffer evicted, per conversation: + * its newest event and its newest addressed one. Eviction bounds what + * the buffer holds of each event, never which conversations the flush + * can route to: the flush's candidates are as if nothing was evicted. */ + private evictedRouteEvents = new Map(); // Per-policy stats private stats = new Map(); @@ -1638,7 +1643,13 @@ export class EventGate { // Event delivery // ========================================================================= - private deliverEvents(events: PendingEvent[]): void { + /** + * @param evicted The newest event, and newest addressed one, of each + * conversation the inference buffer evicted: they count toward the + * wake's route candidates and provenance, as if they had stayed, but not + * toward the notice, which names what is still held. + */ + private deliverEvents(events: PendingEvent[], evicted: PendingEvent[] = []): void { if (events.length === 0) return; const policyNames = [...new Set(events.map(e => e.policyName))]; @@ -1708,7 +1719,7 @@ export class EventGate { // `routeChannelId` marks the wake addressed there. The turn's speech // route comes from `routeCandidates`, every conversation in the batch // (shelf-355), never from whichever channel last saw any traffic. - const provenance = wakeProvenance(events); + const provenance = wakeProvenance([...evicted, ...events]); for (const agentName of this.getAgentNamesFn()) { this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance); } @@ -1754,8 +1765,11 @@ export class EventGate { if (this.quiesced) return; if (this.inferring.size === 0 && this.inferenceBuffer.length > 0) { const events = this.inferenceBuffer.splice(0); + const evicted = [...this.evictedRouteEvents.values()] + .flatMap(({ newest, addressed }) => (addressed && addressed !== newest ? [addressed, newest] : [newest])); + this.evictedRouteEvents.clear(); // Defer to avoid re-entrancy inside trace callbacks - queueMicrotask(() => this.deliverEvents(events)); + queueMicrotask(() => this.deliverEvents(events, evicted)); } } @@ -1781,11 +1795,31 @@ export class EventGate { // Inference buffer cap // ========================================================================= + /** Keep an evicted event's route candidate (wakeProvenance's identity of + * it): the conversation's newest, and its newest addressed. */ + private keepEvictedRoute(event: PendingEvent): void { + if (!event.conversational) return; + const surface = !event.routeChannelId && !event.channelId + && (event.eventType === 'external-message' || event.eventType === 'api:message'); + const channel = event.routeChannelId ?? event.channelId ?? (event.authorId ? `user:${event.authorId}` : undefined); + if (!surface && !channel) return; + const key = JSON.stringify(surface ? ['surface', event.serverId ?? null] : [event.serverId ?? null, channel, event.threadId ?? null]); + const kept = { ...event, content: '' }; + const seen = this.evictedRouteEvents.get(key); + this.evictedRouteEvents.set(key, { + newest: !seen || kept.timestamp >= seen.newest.timestamp ? kept : seen.newest, + ...(kept.addressed && (!seen?.addressed || kept.timestamp >= seen.addressed.timestamp) + ? { addressed: kept } + : seen?.addressed ? { addressed: seen.addressed } : {}), + }); + } + /** Called internally when debounce fires during inference. */ private bufferForInference(events: PendingEvent[]): void { for (const event of events) { if (this.inferenceBuffer.length >= MAX_INFERENCE_BUFFER) { - this.inferenceBuffer.shift(); // Drop oldest + // Drop the oldest, keeping its route candidate. + this.keepEvictedRoute(this.inferenceBuffer.shift()!); // Telemetry (issue #122): quiesce stretches buffering from // single-inference minutes to operator-length windows, so silent // eviction is no longer a corner case — count and log it, or the diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 9955bb5b6..431ef966f 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -747,6 +747,34 @@ describe('debounce', () => { ]); }); + it('an event the inference buffer evicts keeps its route candidate: the flush routes as if nothing had been evicted', async () => { + const path = writeConfig('buffer-evict.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.onInferenceStarted('agent'); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:dm:1', content: 'are you there?', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'dm-1' } })); + for (let n = 0; n < 100; n++) { + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:general', content: `chatter ${n}`, + metadata: { authorId: '2', messageId: `g-${n}` } })); + } + const deadline = Date.now() + 5000; + while (gate.inferenceDiagnostics().bufferedEvents < 100) { + if (Date.now() > deadline) throw new Error('the batch never reached the buffer'); + await new Promise((r) => setTimeout(r, 10)); + } + assert.equal(gate.inferenceDiagnostics().droppedBufferedEvents, 1, 'the DM, oldest, was evicted'); + gate.onInferenceEnded('agent'); + await waitForRequests(inferenceRequests, 1); + const addressed = (inferenceRequests[0].routeCandidates ?? []).filter((c) => c.addressed).map((c) => c.channelId); + assert.deepEqual(addressed, ['discord:dm:1'], 'the addressed conversation still competes, and wins'); + assert.equal(inferenceRequests[0].routeChannelId, 'discord:dm:1', 'and the wake is addressed there'); + }); + it('a reaction in the batch is never a route candidate (the same predicate as mid-turn)', async () => { const path = writeConfig('debounce-route-reaction.json', { policies: [ diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index d2dd30b4f..063403abc 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -51,6 +51,8 @@ class RobotModule implements Module { /** Full metadata override for the interjected message (wins over * interjectionChannelId when set) — e.g. reaction tags. */ interjectionMetadata: Record | null = null; + /** More arrivals pushed in the same round, after the interjection. */ + moreInterjections: Array<{ text: string; metadata: Record }> = []; /** Delay tool completion so live routing deterministically precedes it. */ toolDelayMs = 0; @@ -89,17 +91,16 @@ class RobotModule implements Module { await new Promise((r) => setTimeout(r, this.toolDelayMs)); } if (this.interjection) { - const text = this.interjection; + const arrivals = [ + { + text: this.interjection, + metadata: this.interjectionMetadata ?? (this.interjectionChannelId ? { channelId: this.interjectionChannelId } : {}), + }, + ...this.moreInterjections, + ]; this.interjection = null; - this.framework!.pushEvent({ - type: 'external-message', - source: 'test', - content: text, - metadata: this.interjectionMetadata - ?? (this.interjectionChannelId - ? { channelId: this.interjectionChannelId } - : {}), - } as unknown as ProcessEvent); + this.moreInterjections = []; + for (const arrival of arrivals) this.pushArrival(arrival.text, arrival.metadata); // Give the run loop a beat to process the queued message while this // tool round is still pending (pendingAssistantBlocks non-empty), so // it lands in deferredMessages before the tool-result event. @@ -108,6 +109,31 @@ class RobotModule implements Module { return { success: true, data: { ok: true, tool: call.name } }; } + /** One arrival: a channel's message on the MCPL lane, which carries its + * envelope (only the framework writes one, so a module's metadata + * can't), else an external message from the local surface. */ + private pushArrival(text: string, metadata: Record): void { + const { inboundSource, ...rest } = metadata as { inboundSource?: Record } & Record; + if (inboundSource?.kind === 'channel') { + this.framework!.pushEvent({ + type: 'mcpl:channel-incoming', + serverId: inboundSource.serverId, + channelId: inboundSource.channelId, + messageId: inboundSource.messageId ?? 'm-arrival', + ...(inboundSource.threadId ? { threadId: inboundSource.threadId } : {}), + author: { id: 'antra', name: 'Antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: rest, + ...(Array.isArray(rest.tags) ? { tags: rest.tags } : {}), + triggerInference: false, + inboundSource, + } as unknown as ProcessEvent); + return; + } + this.framework!.pushEvent({ type: 'external-message', source: 'test', content: text, metadata } as unknown as ProcessEvent); + } + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { if (event.type === 'external-message') { const text = String((event as { content?: unknown }).content); @@ -438,6 +464,62 @@ describe('present while acting', () => { await framework.stop(); }); + it('a mid-turn hold names every conversation that suspends the route in that round', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Answering someone.' }] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra') }; + module.moreInterjections = [{ + text: 'and here too?', + metadata: { channelId: 'discord:dm:ada', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:ada', 'm-ada') }, + }]; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + const [held] = heldDrafts(framework); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', /\(srv \/ chan-A\); \(srv \/ discord:dm:antra\); \(srv \/ discord:dm:ada\)/, 'all three conversations'); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('(srv / discord:dm:antra) addressed you mid-turn; and (srv / discord:dm:ada) addressed you mid-turn') + && t.includes('any of these conversations')), wired.join('\n')); + + await framework.stop(); + }); + + it('a mid-turn arrival from the local surface reads as addressing the resident, tagged or not', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Answering the console.' }] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'a word from the console'; + module.interjectionMetadata = {}; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('[routing] test (the local surface that messaged you) addressed you mid-turn')), wired.join('\n')); + const [held] = heldDrafts(framework); + assert.equal(held?.text, 'Answering the console.'); + + await framework.stop(); + }); + it('a follow-up in a channel the agent explicitly engaged this turn holds the route', async () => { // 2026-07-31 n=7 (q's #portables reply): the agent explicitly sent into a // channel this turn; someone replies there WITHOUT a mention. Their From 5065142569ae31333f9b00661d5a60aa4867e61d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 16:02:59 -0400 Subject: [PATCH 49/65] fix(routing): a silent wake's channel_open sets no route, typing shows on the route's own server, and failed speech is kept From a review of this PR's own commits. - in a silent wake (suppressProse), channel_open opens the channel and sets no speech route; its result says this wake's plain speech stays private and that a send tool posts there. The restart reset's comment now says what the code does. - typing follows the route's exact channel. startTyping and stopTyping take { serverId, channelId } (a bare id still works), typing state is keyed by server and channel, and an id two servers share, named alone, shows no indicator rather than one on a guessed server. A stop goes only to a server that saw a start. The registry's own stops (removal, close, removeServer) name their server. inference:started carries the route's serverId, and the refusal failure log its locusServer, beside the channel. - unaddressed speech whose publish failed with nothing posted is kept as a bounce draft (as a failed `>>>` delivery's words are), on the route path, the sticky `>>>` pin path and the sleep announcement's pin path. deliverSpeech takes holdFailed, called only for a failed outcome, and the one failure notice names the draft and how to resend it, with draftOwner/draftIds so the turn's settlement counts it named. An unknown outcome keeps no draft, so as not to invite a duplicate. Tests: - speech-route-turns: the continuation test sets its route by running channel_open through the tool path, and its new-turn check wakes from another conversation and sees the route move there. - speech-route-turns: a silent wake's channel_open opens, sets no route, and says so. - channel-registry-routing: with two servers sharing an id, typing shows only on the named server, once; the bare id shows none; a stop goes only where a start went. - prose-drafts: a failed speech publish is held as a bounce draft named in the one failure notice; an unconfirmed one keeps no draft. - prose-drafts: the sticky-target assertion takes the pin's new shape, { serverId, channelId }. --- src/framework.ts | 59 ++++++++--- src/mcpl/channel-registry.ts | 142 +++++++++++++++----------- src/types/trace.ts | 3 + test/channel-registry-routing.test.ts | 30 ++++++ test/prose-drafts.test.ts | 26 ++++- test/speech-route-turns.test.ts | 68 +++++++++--- 6 files changed, 236 insertions(+), 92 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index aa8409635..56b7f073d 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10231,6 +10231,14 @@ export class AgentFramework { return turn && !turn.hold && turn.route?.kind === 'channel' ? turn.route.channelId : null; } + /** The channel route's channel with its server, where typing shows and + * logs name it: the same id on another server is another channel. */ + private routeChannel(agentName: string): { serverId?: string; channelId: string } | null { + const turn = this.turnRoutes?.get(agentName); + if (!turn || turn.hold || turn.route?.kind !== 'channel') return null; + return { ...(turn.route.serverId ? { serverId: turn.route.serverId } : {}), channelId: turn.route.channelId }; + } + /** * The exact place the agent's unaddressed speech is published to now — its * channel route's server, channel and thread (null: the channel root) — or @@ -10366,11 +10374,19 @@ export class AgentFramework { } // The route's exact place: its server, channel and thread (or root). const target = { ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, threadId: route.threadId ?? null }; - const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target); + const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target, { holdFailed: this.holdUndelivered(agent, text, hold) }); this.recordProseDelivery(agent.name, outcome); if (outcome?.delivered) this.streamPublished(agent.name, moment, outcome, text); } + /** deliverSpeech's holdFailed for unaddressed speech: words that provably + * weren't posted are kept as a bounce draft (as a failed `>>>` delivery's + * are), named by the one failure notice. An unknown outcome keeps none, + * so as not to invite a duplicate. */ + private holdUndelivered(agent: Agent, text: string, hold: { round?: number; notice: 'now' | 'later' }): (reason: string) => string | undefined { + return (reason) => this.holdProseDrafts(agent, [text], 'bounced', { round: hold.round, note: `delivery failed: ${reason}`, notice: 'later' })[0]?.id; + } + /** A turn route in words, for logs. */ private static routeText(turn: TurnRoute): string { if (turn.hold) return `held between ${turn.hold.conversations.map(describeConversation).join(' and ')}`; @@ -10744,7 +10760,7 @@ export class AgentFramework { const sticky = this.proseTargetPins.get(agent.name); try { if (sticky) { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, sticky); + const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, sticky, { holdFailed: this.holdUndelivered(agent, envelope, hold) }); this.recordProseDelivery(agent.name, outcome); if (outcome?.delivered) this.streamPublished(agent.name, moment, outcome, envelope); } else { @@ -11304,9 +11320,9 @@ export class AgentFramework { } } - // A context-budget restart normally preserves the turn route, but a - // silent control-plane wake must remain routeless even if an explicit - // tool opened a channel mid-turn. + // A context-budget restart normally preserves the turn route. A silent + // control-plane wake has none: its channel_open sets none (the result + // says so), so this resets only what a restart could carry over. if (trigger?.suppressProse) { this.turnRoutes.set(agent.name, { route: null }); this.proseTargetPins.delete(agent.name); @@ -11317,9 +11333,11 @@ export class AgentFramework { this.emitTrace({ type: 'inference:started', agentName: agent.name, - // The turn's route channel, decided just above (kept across a - // context-budget restart, which skips the decision but re-emits this). + // The turn's route channel and its server, decided just above (kept + // across a context-budget restart, which skips the decision but + // re-emits this). channelId: this.routeChannelId(agent.name) ?? undefined, + ...(this.routeChannel(agent.name)?.serverId ? { serverId: this.routeChannel(agent.name)!.serverId } : {}), ...(trigger?.suppressProse ? { silent: true } : {}), }); // A budget restart continues the same logical inference window. The @@ -11341,7 +11359,7 @@ export class AgentFramework { const earlyTypingChannel = turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : this.routeChannelId(agent.name); + : this.routeChannel(agent.name); if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel); try { @@ -11617,7 +11635,7 @@ export class AgentFramework { ? null : turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : this.routeChannelId(agent.name); + : this.routeChannel(agent.name); if (typingChannel) this.channelRegistry?.startTyping(typingChannel); // MCPL Spec 14.3 outgoing streaming: route text deltas to their @@ -14967,6 +14985,7 @@ export class AgentFramework { category, streak, locus: this.routeChannelId(agentName), + locusServer: this.routeChannel(agentName)?.serverId ?? null, tokens: tokens ?? null, }); if (streak >= 2) { @@ -15318,7 +15337,7 @@ export class AgentFramework { // addMessage() alone does not request inference, so this never wakes // her (matching the `discord-send-failed-skip` gate intent: context // yes, wake no). - onRouteFailure: ({ serverId, channelId, label, threadId, reason, textLen, outcome }) => { + onRouteFailure: ({ conversationId, serverId, channelId, label, threadId, reason, textLen, outcome, draftId }) => { try { // Name the conversation the words were bound for as every // conversation is named (describeConversation): its label, its @@ -15347,9 +15366,18 @@ export class AgentFramework { // A request went out and no valid receipt came back: it may // have been posted (in part or whole). Never claim it wasn't. ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the ${threadId ? 'thread' : 'channel'} before sending it again. It is saved in your archive.` - : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, + : draftId + ? `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). ` + + `Nothing was posted; it is held as draft ${draftId}, and drafts(action: "resend", draftIds: ["${draftId}"], ` + + 'destination: "#channel") sends it.' + : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, }], - { system: true, kind: 'discord-send-failed', ...(serverId ? { serverId } : {}), channelId: channelId ?? '', ...(threadId ? { threadId } : {}), reason, ...(outcome ? { outcome } : {}) }, + { + system: true, kind: 'discord-send-failed', ...(serverId ? { serverId } : {}), channelId: channelId ?? '', + ...(threadId ? { threadId } : {}), reason, ...(outcome ? { outcome } : {}), + // The draft it names counts as named (settleTurnDrafts). + ...(draftId ? { draftOwner: conversationId, draftIds: [draftId] } : {}), + } as MessageMetadata, ); } catch (err) { console.error('onRouteFailure: failed to record send-failure marker:', err); @@ -17251,6 +17279,11 @@ export class AgentFramework { // speech is its own to decide: the open stands, the route and // prose choices don't move. routing = 'Opened. A newer turn has begun since this call, so its speech route is unchanged.'; + } else if (this.activeTurnTriggers.get(agentName)?.suppressProse === true) { + // A silent control-plane wake keeps its plain speech private: + // the open stands, and no route is set. + routing = 'Opened. This is a silent wake, so your plain speech stays private this turn; ' + + 'to post there, use a send tool.'; } else if (openInput?.setSpeechTarget !== false) { const resolved = this.channelRegistry!.resolveDestination({ channelId: opened, @@ -17587,7 +17620,7 @@ export class AgentFramework { this.chainSpeech(agentName, '[sleep] announcement', async () => { const pin = agent.proseRouting === 'hybrid' ? this.proseTargetPins.get(agentName) : undefined; if (pin) { - const outcome = await this.channelRegistry!.routeSpeech(agentName, text, pin); + const outcome = await this.channelRegistry!.routeSpeech(agentName, text, pin, { holdFailed: this.holdUndelivered(agent, text, { notice: 'later' }) }); this.recordProseDelivery(agentName, outcome); if (outcome?.delivered) this.streamPublished(agentName, moment, outcome, text); return; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 1574322e3..fb70b4b16 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -617,6 +617,9 @@ interface ChannelRegistryOptions { /** `failed`: nothing was posted; `unknown`: the request was dispatched * and no valid receipt came back, so it may or may not have been. */ outcome?: 'failed' | 'unknown'; + /** The draft the words were kept as (deliverSpeech's holdFailed), for + * a failure where nothing was posted. */ + draftId?: string; }) => void; /** * Called when channels were opened WITHOUT the agent asking (subscription @@ -749,11 +752,13 @@ export class ChannelRegistry { */ private dmClassified = new Map(); - /** Per-channel typing indicator timers. */ - private typingIntervals = new Map>(); + /** Typing indicator timers, per channel on its server (typingKey): the + * same channel id on another server is another channel. */ + private typingIntervals = new Map }>(); - /** Per-channel typing metadata — carried on the 7s refresh so the target - * server keeps getting the same routing hints (e.g. Zulip topic). */ + /** Typing metadata, keyed like the timers — carried on the 7s refresh so + * the target server keeps getting the same routing hints (e.g. Zulip + * topic). */ private typingMetadata = new Map>(); /** Chronicle-projected desired lifecycle state, keyed by server + channel. @@ -897,7 +902,7 @@ export class ChannelRegistry { for (const channelId of params.removed) { const key = `${serverId}:${channelId}`; const existed = this.channels.delete(key); - this.stopTyping(channelId); + this.stopTyping({ serverId, channelId }); addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' }); } } @@ -1266,93 +1271,90 @@ export class ChannelRegistry { // Typing Indicator Management // ========================================================================== + /** The typing state's key for a channel on its server. */ + private static typingKey(serverId: string, channelId: string): string { + return JSON.stringify([serverId, channelId]); + } + /** * Start sending typing indicators for a channel. * * Sends a typing notification immediately and every 7 seconds thereafter. * Discord typing indicators last ~10s, so 7s keeps them alive. * + * `target` names the channel and, when known, its server: the indicator + * shows only on that server's channel. A bare id resolves only when one + * server registers it, so an id two servers share, named alone, shows no + * indicator rather than one on a guessed server. + * * No-op if already typing on this channel. */ - startTyping(channelId: string, metadata?: Record): void { + startTyping(target: string | { serverId?: string; channelId: string }, metadata?: Record): void { + const { serverId, channelId } = typeof target === 'string' ? { serverId: undefined, channelId: target } : target; + const found = this.findExactEntry({ ...(serverId ? { serverId } : {}), channelId }); + if ('error' in found) return; + const entry = found.entry; + const key = ChannelRegistry.typingKey(entry.serverId, entry.descriptor.id); const metadataChanged = metadata !== undefined && - !shallowEqualRecord(this.typingMetadata.get(channelId), metadata); + !shallowEqualRecord(this.typingMetadata.get(key), metadata); if (metadata) { - this.typingMetadata.set(channelId, metadata); + this.typingMetadata.set(key, metadata); } - if (this.typingIntervals.has(channelId)) { + if (this.typingIntervals.has(key)) { // Already typing. If the caller supplied new routing metadata (e.g. the // relevant Zulip topic just moved because a newer message arrived), // dispatch an immediate refresh so the server sees the new routing // within this request instead of waiting up to TYPING_INTERVAL_MS for // the next tick. - if (metadataChanged) { - const entry = this.findChannelEntry(channelId); - if (entry) { - this.sendTypingNotification(entry.serverId, channelId); - } - } - return; - } - - // Find the channel entry and its server - const entry = this.findChannelEntry(channelId); - if (!entry) { + if (metadataChanged) this.sendTypingNotification(entry.serverId, entry.descriptor.id); return; } // Send typing immediately - this.sendTypingNotification(entry.serverId, channelId); + this.sendTypingNotification(entry.serverId, entry.descriptor.id); // Set up interval — pulls the latest metadata on each tick so mid-stream // updates (e.g. a newer incoming message switching the relevant topic) // take effect on the next refresh. const interval = setInterval(() => { - this.sendTypingNotification(entry.serverId, channelId); + this.sendTypingNotification(entry.serverId, entry.descriptor.id); }, TYPING_INTERVAL_MS); - this.typingIntervals.set(channelId, interval); + this.typingIntervals.set(key, { serverId: entry.serverId, channelId: entry.descriptor.id, interval }); } /** * Stop sending typing indicators. * - * If channelId is specified, stops typing on that channel only. - * If no channelId, stops all typing indicators. + * With a target, stops typing on that channel: on its server when named, + * else on every server where that channel id is typing. With none, stops + * all typing indicators. */ - stopTyping(channelId?: string): void { - if (channelId !== undefined) { - const interval = this.typingIntervals.get(channelId); - if (interval) { - clearInterval(interval); - this.typingIntervals.delete(channelId); - // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip) - // clear the indicator immediately rather than waiting for auto-expire. - // Metadata still carries the routing hint so the stop hits the same - // topic/thread as the start. Guarded by `interval`: matches the - // global-clear branch's semantics, and keeps defensive stopTyping(ch) - // calls from spamming stops at a server that never saw a start. - const entry = this.findChannelEntry(channelId); - if (entry && this.sendTypingFn) { - this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop'); - } + stopTyping(target?: string | { serverId?: string; channelId: string }): void { + const named = target === undefined + ? undefined + : typeof target === 'string' ? { serverId: undefined, channelId: target } : target; + for (const [key, typing] of [...this.typingIntervals]) { + if (named && (typing.channelId !== named.channelId || (named.serverId && typing.serverId !== named.serverId))) continue; + clearInterval(typing.interval); + this.typingIntervals.delete(key); + // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip) + // clear the indicator immediately rather than waiting for auto-expire. + // Metadata still carries the routing hint so the stop hits the same + // topic/thread as the start. Only where typing was started: a + // defensive stopTyping(ch) never sends a stop to a server that never + // saw a start. + this.sendTypingFn?.(typing.serverId, typing.channelId, this.typingMetadata.get(key), 'stop'); + this.typingMetadata.delete(key); + } + if (named) { + for (const key of [...this.typingMetadata.keys()]) { + const [serverId, channelId] = JSON.parse(key) as [string, string]; + if (channelId === named.channelId && (!named.serverId || serverId === named.serverId)) this.typingMetadata.delete(key); } - this.typingMetadata.delete(channelId); } else { - // Clear all typing intervals and dispatch stop for each known channel - const channels = Array.from(this.typingIntervals.keys()); - for (const interval of this.typingIntervals.values()) { - clearInterval(interval); - } - this.typingIntervals.clear(); - if (this.sendTypingFn) { - for (const id of channels) { - const entry = this.findChannelEntry(id); - if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop'); - } - } this.typingMetadata.clear(); } } @@ -1548,7 +1550,7 @@ export class ChannelRegistry { for (const [key, entry] of this.channels) { if (entry.serverId !== serverId) continue; this.channels.delete(key); - this.stopTyping(entry.descriptor.id); + this.stopTyping({ serverId: entry.serverId, channelId: entry.descriptor.id }); } // Desired state and migration markers deliberately survive disconnects. } @@ -1558,10 +1560,11 @@ export class ChannelRegistry { */ stopAll(): void { // Clear all typing intervals - for (const interval of this.typingIntervals.values()) { - clearInterval(interval); + for (const typing of this.typingIntervals.values()) { + clearInterval(typing.interval); } this.typingIntervals.clear(); + this.typingMetadata.clear(); // Clear channels map this.channels.clear(); @@ -2313,7 +2316,7 @@ export class ChannelRegistry { // tick would be noise. if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return; if (this.sendTypingFn) { - this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId)); + this.sendTypingFn(serverId, channelId, this.typingMetadata.get(ChannelRegistry.typingKey(serverId, channelId))); } // TODO: When server-connection exposes a public sendNotification or // sendTyping method, wire it here directly instead of using a callback. @@ -2834,7 +2837,7 @@ export class ChannelRegistry { } this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource); - this.stopTyping(input.channelId); + this.stopTyping({ serverId: entry.serverId, channelId: input.channelId }); const server = this.serverRegistry.getServer(entry.serverId); if (!server) { @@ -3404,6 +3407,13 @@ export class ChannelRegistry { * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means * "this turn is pinned to no locus": fail loudly rather than guess. */ locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, + opts: { + /** Keep words that provably weren't posted (a failed outcome) and + * return the draft's id, which the failure notice then names. Not + * called for an unknown outcome: those words may have been posted, + * and a draft would invite a duplicate. */ + holdFailed?: (reason: string) => string | undefined; + } = {}, ): Promise { const fail = ( where: { serverId?: string; channelId: string | null; label?: string; threadId?: string | null }, @@ -3412,6 +3422,14 @@ export class ChannelRegistry { ): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; const { serverId, channelId, label, threadId } = where; + let draftId: string | undefined; + if (status === 'failed' && opts.holdFailed) { + try { + draftId = opts.holdFailed(reason); + } catch (err) { + console.error(`[routeSpeech] ${conversationId}: could not keep the undelivered words as a draft:`, err); + } + } console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', @@ -3428,6 +3446,7 @@ export class ChannelRegistry { ...(label ? { label } : {}), ...(threadId ? { threadId } : {}), reason, textLen: text.length, outcome: status, + ...(draftId ? { draftId } : {}), }); return outcome; }; @@ -3488,8 +3507,9 @@ export class ChannelRegistry { conversationId: string, text: string, locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, + opts: { holdFailed?: (reason: string) => string | undefined } = {}, ): Promise<{ delivered: boolean; serverId: string; channelId: string; label?: string; threadId?: string; messageId?: string } | null> { - const outcome = await this.deliverSpeech(conversationId, text, locusChannelId); + const outcome = await this.deliverSpeech(conversationId, text, locusChannelId, opts); if (outcome.status !== 'delivered') return null; // The destination as resolved at delivery — server, channel, the label it // had then, and the thread — so a receipt names exactly where it went. diff --git a/src/types/trace.ts b/src/types/trace.ts index eb0012db0..cea7ba6fc 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -49,6 +49,9 @@ export type TraceEvent = * activation with its channel without re-deriving routing. */ channelId?: string; + /** The server of that channel route, when known: the same channel id + * on another server is another channel. */ + serverId?: string; /** True for a control-plane wake that must not surface typing/prose. */ silent?: boolean; }) diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index ab48a8a97..19512ed07 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -375,6 +375,36 @@ test('a route that names its server publishes there, even when another server re assert.equal(published.length, 1); }); +test('typing shows on the route\'s own server when another server registers the same channel id, and nowhere for the id alone', () => { + const typed: Array<[string, string, string]> = []; + const servers: Record = { + a: { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []) }, + b: { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []) }, + }; + const registry = new ChannelRegistry( + { getServer: (id: string) => servers[id] } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { sendTypingFn: (serverId, channelId, _metadata, op) => { typed.push([serverId, channelId, op ?? 'start']); } }, + ); + seedRegistered(registry, 'a', 'shared'); + seedRegistered(registry, 'b', 'shared'); + try { + registry.startTyping('shared'); + assert.deepEqual(typed, [], 'an id two servers share, named alone, shows no indicator'); + registry.startTyping({ serverId: 'b', channelId: 'shared' }); + registry.startTyping({ serverId: 'b', channelId: 'shared' }); + assert.deepEqual(typed, [['b', 'shared', 'start']], "only the route's server, once"); + registry.stopTyping({ serverId: 'a', channelId: 'shared' }); + assert.equal(typed.length, 1, 'no stop to a server that never saw a start'); + registry.stopTyping({ serverId: 'b', channelId: 'shared' }); + assert.deepEqual(typed.at(-1), ['b', 'shared', 'stop']); + } finally { + registry.stopTyping(); + } +}); + test('routeSpeech surfaces a failure when there is no locus at all', async () => { const { registry, failures } = makeRegistry({ delivered: true }); const res = await registry.routeSpeech('cairn', 'into the void', registry.resolveLocus('cairn')); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 0a18ae176..d436a750f 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -612,6 +612,28 @@ describe('held prose drafts, end to end', () => { } }); + it('unaddressed speech that provably was not posted is kept as a draft, named by its one failure notice; an unconfirmed one is not', async () => { + const h = await harness(); + try { + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([text('words that could not go')])]); + const [kept] = h.drafts(); + assert.equal(kept?.text, 'words that could not go'); + assert.equal(kept?.reason, 'bounced'); + assert.match(kept?.note ?? '', /^delivery failed: /); + const markers = () => h.texts().filter((t) => t.startsWith('[discord-send-failed]')); + assert.equal(markers().length, 1); + assert.match(markers()[0]!, new RegExp(`Nothing was posted; it is held as draft ${kept!.id}, and drafts\\(action: "resend"`)); + + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([text('words that may have gone')])]); + assert.deepEqual(h.drafts().map((d) => d.id), [kept!.id], 'an unconfirmed delivery keeps no draft, so as not to invite a duplicate'); + assert.match(markers().at(-1)!, /was not confirmed .* It may or may not have been posted/); + } finally { + await h.close(); + } + }); + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { const h = await harness(); try { @@ -1426,8 +1448,8 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end ['words for the room', `written for >>>${ROOM}`], ['more for the room', undefined], ]); - assert.equal((h.framework as unknown as { proseTargetPins: Map }).proseTargetPins.get('scout'), ROOM, - 'the named destination is the sticky target again, as live routing would leave it'); + assert.deepEqual((h.framework as unknown as { proseTargetPins: Map }).proseTargetPins.get('scout'), { serverId: 'discord', channelId: ROOM }, + 'the named destination, on its server, is the sticky target again, as live routing would leave it'); assert.match(h.texts().find((t) => t.startsWith('[delivered]'))!, / · 3 plain-speech segment\(s\) kept private \(skip_reply\)$/); // A live `>>>skip_reply` in an earlier round binds a later silenced one. diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index c314ad6d8..842dc2ddb 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -13,6 +13,7 @@ import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { InferenceRequest, ProcessEvent, Module, ModuleContext, ProcessState, EventResponse, ToolDefinition, ToolCall, ToolResult } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { ContentBlock } from '@animalabs/membrane'; import type { RouteCandidate, SpeechRoute } from '../src/speech-routes.js'; type Route = { route: { kind: string; channelId?: string; surface?: string; origin?: string } | null; hold?: unknown }; @@ -60,6 +61,23 @@ describe('speech route across a logical turn', () => { modules, }); + /** A channel subsystem whose channels declare an MCPL RFC-011 publish + * target (a route is only ever a place the framework publishes to + * exactly), and whose channel_open succeeds, recording what it opened. */ + const channelsThatOpen = (opens: string[] = []) => new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + routeSpeech: async () => ({ delivered: true, channelId: 'x' }), + resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'discord', channelId } }), + handleChannelToolCall: async (name: string, input: { channelId?: string }) => { + if (name !== 'channel_open') return { success: true }; + opens.push(input.channelId!); + return { success: true, data: { channelId: input.channelId, opened: true } }; + }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const fromChannel = (channelId: string): InferenceRequest => ({ agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId }, addressed: true, messageId: 'm1', at: Date.now() }], @@ -71,22 +89,20 @@ describe('speech route across a logical turn', () => { const scout = framework.getAgent('scout')!; // A channel subsystem whose channels declare an MCPL RFC-011 publish // target: a route is only ever a place the framework publishes to exactly. - i.channelRegistry = new Proxy({ - publishTarget: () => 'root', - resolveLocus: () => null, - routeSpeech: async () => ({ delivered: true, channelId: 'x' }), - getDescriptor: () => undefined, - getChannelTools: () => [], - } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + i.channelRegistry = channelsThatOpen(); - membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }])); + // The turn starts routed to its wake, and the resident opens another + // channel during it, through the tool. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'one' }, + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'opened' }])); await i.startAgentStream(scout, fromChannel('chan-A')); await framework.runUntilIdle(); - assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'chan-A'); - - // The resident opened another channel during this logical turn. - const opened: Route = { route: { kind: 'channel', channelId: 'chan-open', origin: 'open' } }; - i.turnRoutes.set('scout', opened); + const opened = i.turnRoutes.get('scout')!; + assert.equal(opened.route?.channelId, 'chan-open'); + assert.equal(opened.route?.origin, 'open', 'channel_open set the route'); // A context-budget restart, a guard retry and a framework retry continue // the same logical turn: none of them re-decides the route, even with @@ -102,11 +118,31 @@ describe('speech route across a logical turn', () => { assert.equal(i.turnRoutes.get('scout'), opened, `${trigger.reason} (attempt ${attempt}) kept the route`); } - // A true new turn decides from its own wake: none here, so no route. + // A true new turn decides from its own wake: another conversation's. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); - await i.startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await i.startAgentStream(scout, fromChannel('chan-C')); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'chan-C', 'the new turn routes to its own wake'); + assert.equal(i.turnRoutes.get('scout')?.route?.origin, 'trigger'); + await framework.stop(); + }); + + it("a silent wake's channel_open opens the channel and sets no route, and its result says so", async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + const opens: string[] = []; + i.channelRegistry = channelsThatOpen(opens); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + await i.startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now(), suppressProse: true }); await framework.runUntilIdle(); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'and no route set'); + const result = JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); + assert.match(result, /This is a silent wake, so your plain speech stays private this turn; to post there, use a send tool\./); await framework.stop(); }); From 5036af4636e4f4b83e71e81ba1d1fe7e2ef83745 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 16:13:17 -0400 Subject: [PATCH 50/65] fix(publish): one reading of a thread id, acceptance after the trigger filter, a grant rechecked after an open, and traces that follow outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a review of this PR's own commits. - readThreadId (speech-routes) reads an agent-supplied thread id one way for channel_publish, channel_open and drafts resend: absent or null is the root, a string is trimmed, and a blank one or a non-string is refused with nothing sent, opened or set. A blank thread never becomes a route. - on both ordinary lanes, the envelope is still built before the trigger filter reads it, but the acceptance is observed only once the filter has returned, right before the item is queued, as a coalesced item's is on admission. PushHandler and ChannelRegistry take two callbacks, inboundSource (build) and inboundAccepted (observe), in place of acceptInbound. A filter that throws leaves nothing accepted. - publish() rechecks the server and its channels.publish grant after opening a closed channel, as well as before, and sends through the server object it rechecked. - publishForAgent traces once its outcome is in, as deliverSpeech does: speech-routed only for a confirmed delivery, with the server, channel and thread; speech-route-failed otherwise, with its outcome. - mcpl:speech-routed, mcpl:speech-route-failed and prose:draft-resent carry the thread (threadId, null for the root), typed in trace.ts. - the lazy placeholder's comments say what it is: a closed entry naming the channel for the push's envelope, which declares no publish target, so a reply to it is held as a draft until the server registers the channel with a declaration. - sendOutgoingChunk's doc says the effective grant's channels.streaming decides (§5.4), for a confirmed root publish on a channel that declares where posts land. Tests: - targeted-publish: a blank thread is refused by channel_publish, drafts resend and channel_open, with nothing sent, attempted or opened; a padded one is trimmed, for a publish and an open that becomes the route. - push-gate-metadata and channel-registry-routing: a throwing trigger filter accepts and queues nothing; one that returns accepts once as it queues. - channel-registry-routing: a grant losing channels.publish while the open waits refuses the send; as plain speech, a declaration withdrawn during the open reports the refusal, the words are kept (holdFailed) and the failure names their draft. - channel-registry-routing: a thread publish's routed and failed traces name the thread; publishForAgent traces routed only for a delivery, and a refused one as failed. - publish-outcome: a root publish whose receipt omits threadId is unknown, keeps its message id, and doesn't claim the root. - publish-outcome: a streamed publish is a Notification only with no threadId; a thread, or null for the root, is a Request. --- src/framework.ts | 49 +++++----- src/mcpl/channel-registry.ts | 128 ++++++++++++++++++-------- src/mcpl/push-handler.ts | 22 +++-- src/speech-routes.ts | 13 +++ src/types/trace.ts | 6 ++ test/channel-registry-routing.test.ts | 84 ++++++++++++++++- test/publish-outcome.test.ts | 27 ++++++ test/push-gate-metadata.test.ts | 28 ++++++ test/targeted-publish.test.ts | 25 +++++ 9 files changed, 309 insertions(+), 73 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 56b7f073d..c496598f6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -90,6 +90,7 @@ import { describeConversation, inferTurnRoute, isConversational, + readThreadId, routeConversation, routeRefusal, suspendsRoute, @@ -9740,13 +9741,9 @@ export class AgentFramework { } // The place inside the channel (MCPL RFC-011): the thread named with it, // else the root — never a thread borrowed from where the words were held. - if (present(input.threadId) && (typeof input.threadId !== 'string' || !input.threadId.trim())) { - return refuse('threadId must be a thread id of the destination channel, or null for its root.'); - } - const destination: PublishDestination = { - ...target.destination, - threadId: present(input.threadId) ? (input.threadId as string).trim() : null, - }; + const thread = readThreadId(input.threadId); + if ('error' in thread) return refuse(`${thread.error} Nothing was sent.`); + const destination: PublishDestination = { ...target.destination, threadId: thread.threadId }; const placeRefusal = publishPlaceRefusal(registry.publishTarget(destination), destination.threadId ?? null); if (placeRefusal) { const why = ChannelRegistry.placeRefusalText(placeRefusal, destination, destination.threadId ?? null); @@ -9823,6 +9820,7 @@ export class AgentFramework { status: outcome.status, serverId: outcome.destination?.serverId ?? destination.serverId, channelId: outcome.destination?.channelId ?? destination.channelId, + threadId: outcome.destination?.threadId ?? destination.threadId ?? null, ...(outcome.messageId ? { messageId: outcome.messageId } : {}), }); const where = AgentFramework.destinationText(outcome.destination ?? destination); @@ -15260,16 +15258,14 @@ export class AgentFramework { (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), triggerFilter, (serverId, params, event, decideTrigger) => this.handleCoalescedPush(serverId, params, event, decideTrigger), - // An ordinary push: freeze its envelope and observe the acceptance at - // admission, before it is queued. - (event) => { - const source = this.pushSource(event); - this.noteInboundAccepted(source); - return source; - }, + // An ordinary push: freeze its envelope at admission; it is accepted + // (the last argument) once its trigger filter returns, before it is + // queued. + (event) => this.pushSource(event), // A coalesced push: its envelope before it is gated; its coalescer // observes the acceptance. (serverId, params, event) => this.coalescedPushSource(serverId, params, event), + (source) => this.noteInboundAccepted(source), ); // Server-initiated inference router (Step 6) @@ -15301,13 +15297,11 @@ export class AgentFramework { store: this.store, handleCoalescedIncoming: (serverId, message, event, decideTrigger) => this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent, decideTrigger), - // An ordinary channels/incoming message: freeze its envelope and - // observe the acceptance at admission, before it is queued. - acceptInbound: (event) => { - const source = this.channelEventSource(event, 'channels/incoming'); - this.noteInboundAccepted(source); - return source; - }, + // An ordinary channels/incoming message: freeze its envelope at + // admission; accepted once its trigger filter returns, before it is + // queued. + inboundSource: (event) => this.channelEventSource(event, 'channels/incoming'), + inboundAccepted: (source) => this.noteInboundAccepted(source), // A coalesced message: its envelope before it is gated; its // coalescer observes the acceptance. coalescedSource: (serverId, message, event) => this.coalescedIncomingSource(serverId, message, event), @@ -17215,10 +17209,11 @@ export class AgentFramework { // opened, so an invalid choice can't open a channel or become a route. if (call.name === 'channel_open') { const input = (call.input ?? {}) as { serverId?: unknown; threadId?: unknown; setSpeechTarget?: unknown }; + const thread = readThreadId(input.threadId); const invalid = supplied(input.serverId) && !named(input.serverId) ? 'serverId must name a server (leave it out to resolve the channel id alone).' - : supplied(input.threadId) && !named(input.threadId) - ? 'threadId must be a thread id (or left out for the channel root).' + : 'error' in thread + ? thread.error : supplied(input.setSpeechTarget) && typeof input.setSpeechTarget !== 'boolean' ? 'setSpeechTarget must be true or false (left out, it is true).' : undefined; @@ -17293,7 +17288,8 @@ export class AgentFramework { const openedServer = destination?.serverId ?? openInput?.serverId; // A channel named alone means its root — never a thread // borrowed from an earlier route; a thread is named with it. - const threadId = typeof openInput?.threadId === 'string' && openInput.threadId ? openInput.threadId : undefined; + const read = readThreadId(openInput?.threadId); + const threadId = 'threadId' in read && read.threadId ? read.threadId : undefined; const route: SpeechRoute = { kind: 'channel', ...(openedServer ? { serverId: openedServer } : {}), @@ -17333,8 +17329,9 @@ export class AgentFramework { console.error(`[routing] ${agentName}: channel_open -> speech route ${opened}${threadId ? ` thread ${threadId}` : ''} (announced in tool result)`); } } else { - const ignored = typeof openInput?.threadId === 'string' && openInput.threadId - ? ` threadId ${openInput.threadId} chooses where speech goes, so with setSpeechTarget: false it was not used.` + const read = readThreadId(openInput?.threadId); + const ignored = 'threadId' in read && read.threadId + ? ` threadId ${read.threadId} chooses where speech goes, so with setSpeechTarget: false it was not used.` : ''; routing = `Opened for reading.${ignored} ${unchanged()}`; } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index fb70b4b16..7aad68540 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -42,7 +42,7 @@ import { isVisiblyEmptyContent } from './visible-content.js'; import { CapabilityGrant } from './capability-grant.js'; import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; import { McplRequestError } from './server-connection.js'; -import { describeConversation } from '../speech-routes.js'; +import { describeConversation, readThreadId } from '../speech-routes.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -560,11 +560,18 @@ export interface PublishOutcome { interface ChannelRegistryOptions { /** * An ordinary (uncoalesced) `channels/incoming` message has just been - * admitted, before it is queued or acknowledged: return its source - * envelope, frozen now (mcpl/inbound-source.ts). The framework observes the - * acceptance here; coalesced work is stamped and observed by its own path. + * admitted: return its source envelope, frozen now + * (mcpl/inbound-source.ts), before the trigger filter reads it. Building + * it accepts nothing (inboundAccepted); coalesced work is stamped and + * observed by its own path. */ - acceptInbound?: (event: McplChannelIncomingEvent) => InboundSource | undefined; + inboundSource?: (event: McplChannelIncomingEvent) => InboundSource | undefined; + /** + * An ordinary message is accepted: once its trigger filter has returned, + * right before it is queued, as a coalesced one is on admission. A filter + * that throws leaves nothing accepted. + */ + inboundAccepted?: (source: InboundSource) => void; /** * A `channels/incoming` message carrying `coalesce`: its source envelope, * built — not accepted — before it is gated. Its coalescer freezes and @@ -778,7 +785,8 @@ export class ChannelRegistry { private legacyPolicies = new Map(); private migratedLegacyPolicies = new Set(); private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming']; - private acceptInbound?: ChannelRegistryOptions['acceptInbound']; + private inboundSource?: ChannelRegistryOptions['inboundSource']; + private inboundAccepted?: ChannelRegistryOptions['inboundAccepted']; private coalescedSource?: ChannelRegistryOptions['coalescedSource']; constructor( @@ -796,7 +804,8 @@ export class ChannelRegistry { }, ) { this.handleCoalescedIncoming = options?.handleCoalescedIncoming; - this.acceptInbound = options?.acceptInbound; + this.inboundSource = options?.inboundSource; + this.inboundAccepted = options?.inboundAccepted; this.coalescedSource = options?.coalescedSource; this.serverRegistry = serverRegistry; this.featureSetManager = featureSetManager; @@ -1020,7 +1029,7 @@ export class ChannelRegistry { // channels/register authorization). A server whose channel genuinely // exists registers it first; discord's DM case goes through // ensureChannelRegistered on push/event, which creates a CLOSED - // routable entry rather than an open self-attested one. + // placeholder that names the channel, not an open self-attested one. const incomingKey = `${serverId}:${message.channelId}`; if (!this.channels.has(incomingKey)) { this.emitTraceFn({ @@ -1106,13 +1115,13 @@ export class ChannelRegistry { // The source envelope is the host's, built here, at admission, before // the message is gated, queued or acknowledged: a rename or rebind // while it waits cannot change where it says it came from, and the - // gate reads the same envelope the direct path does. An ordinary - // message's acceptance is observed now; a coalesced one's envelope is - // only built here, and its coalescer freezes it and observes the - // acceptance if it admits the occurrence. + // gate reads the same envelope the direct path does. Building it + // accepts nothing: an ordinary message is accepted once its trigger + // filter returns (below), a coalesced one by its coalescer if it + // admits the occurrence. const inboundSource = coalesced ? this.coalescedSource?.(serverId, message, event) - : this.acceptInbound?.(event); + : this.inboundSource?.(event); if (inboundSource) event.inboundSource = inboundSource; // Whether to trigger inference: decided now for an ordinary message, @@ -1167,9 +1176,11 @@ export class ChannelRegistry { continue; } - // Push to the processing queue + // Push to the processing queue, accepted now that its trigger filter + // has returned. // Cast through unknown because McplChannelIncomingEvent matches the // CustomEvent `${string}:${string}` type pattern but lacks an index signature. + if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(event as unknown as ProcessEvent); // Collect per-message result @@ -1191,19 +1202,18 @@ export class ChannelRegistry { } /** - * Ensure a channel is registered so it can serve as an outbound routing - * locus. A push event from a closed channel must never mutate lifecycle - * state: direct-address events are intentionally usable without subscribing. + * Ensure a channel a push names has an entry: a CLOSED placeholder that + * names the channel (its id and label), so the push's envelope and its + * route candidate name a known conversation. A push event from a closed + * channel must never mutate lifecycle state: direct-address events are + * intentionally usable without subscribing. * - * Mirrors the lazy-registration inside handleIncoming(), but for channels - * that only ever arrive as push/events rather than channels/incoming — the - * motivating case is Discord DMs, which discord-mcpl forwards via push/event - * with the channel closed (`channelIsOpen:false`). Such a channel is never - * registered, so routeSpeech() can't resolve it and the agent's reply is - * silently dropped even though the inbound message proves the channel is - * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative - * channels/register or channels/changed overwrites this descriptor with the - * richer one. + * The placeholder declares no publish target (MCPL RFC-011), so nothing + * is published to it: a reply to it is held as a draft until the server + * registers the channel with a declaration (channels/register or + * channels/changed), which overwrites this descriptor with the richer + * one. The motivating case is Discord DMs, which discord-mcpl forwards via + * push/event with the channel closed (`channelIsOpen:false`). Idempotent. */ ensureChannelRegistered( serverId: string, @@ -2119,15 +2129,43 @@ export class ChannelRegistry { * Publish into a channel on behalf of a named non-resident agent (the * subconscious's speak_in_channel). Same delivery path as the * channel_publish tool — the publish executor, with the same receipt — - * attributed to that agent; its name also rides the speech-routed trace. + * attributed to that agent. Traced once the outcome is in, as plain + * speech is: speech-routed for a confirmed delivery, with the server, + * channel and thread it resolved to; speech-route-failed otherwise, with + * its outcome (unknown: it may have been posted; failed: it wasn't). */ async publishForAgent( channelId: string, text: string, agentName: string, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { - this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text }); - return this.handleToolPublish({ channelId, content: text }, { kind: 'agent', agentName }); + const result = await this.handleToolPublish({ channelId, content: text }, { kind: 'agent', agentName }); + const at = (result.data ?? {}) as { status?: string; serverId?: string; channelId?: string; threadId?: string | null; messageId?: string }; + if (at.status === 'delivered') { + this.emitTraceFn({ + type: 'mcpl:speech-routed', + conversationId: agentName, + serverId: at.serverId, + channelId: at.channelId ?? channelId, + threadId: at.threadId ?? null, + delivered: true, + textLen: text.length, + text, + ...(at.messageId ? { messageId: at.messageId } : {}), + }); + } else { + this.emitTraceFn({ + type: 'mcpl:speech-route-failed', + conversationId: agentName, + ...(at.serverId ? { serverId: at.serverId } : {}), + channelId: at.channelId ?? channelId, + threadId: at.threadId ?? null, + reason: result.error ?? 'refused', + textLen: text.length, + outcome: at.status === 'unknown' ? 'unknown' : 'failed', + }); + } + return result; } /** @@ -3023,10 +3061,10 @@ export class ChannelRegistry { /** * MCPL Spec 14.3 outgoing streaming: forward prose the agent has published - * to the server owning the channel. Emitted only when that server declared - * `channels.streaming` in its initialize capabilities — servers that never - * opted in receive nothing. The framework streams only text a confirmed - * publish placed at the channel's root (never speculatively: §14.3), so + * to the server owning the channel. Emitted only to a server whose + * effective grant includes `channels.streaming` (§5.4; streamingServerFor), + * and only for text a confirmed publish placed at the root of a channel + * that declares where its posts land (never speculatively: §14.3), so * every chunk names the root (`threadId: null`). `destination` is the * publish outcome's own server and channel, never re-resolved from a bare * channel id. Fire-and-forget and never throws: streaming is an observer @@ -3224,7 +3262,7 @@ export class ChannelRegistry { // A send that would be refused is refused before anything opens: a // closed channel opens only for words that can go out. - const server = this.serverRegistry.getServer(entry.serverId); + let server = this.serverRegistry.getServer(entry.serverId); if (!server) { return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() }; } @@ -3282,6 +3320,19 @@ export class ChannelRegistry { if (withdrawn) { return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(withdrawn, ChannelRegistry.entryPlace(entry), place), at: at() }; } + // So can a grant reduced (or a server gone) while the open waited. + const now = this.serverRegistry.getServer(entry.serverId); + if (!now || !CapabilityGrant.of(now).has('channels.publish')) { + return { + status: 'failed', + destination, + reason: now + ? `channels.publish was withdrawn from "${entry.serverId}"'s effective grant while the channel was being opened (§14.1)` + : `server "${entry.serverId}" went away while the channel was being opened`, + at: at(), + }; + } + server = now; } let result: unknown; @@ -3436,6 +3487,7 @@ export class ChannelRegistry { conversationId, ...(serverId ? { serverId } : {}), channelId: channelId ?? '', + threadId: threadId ?? null, reason, textLen: text.length, outcome: status, @@ -3491,6 +3543,7 @@ export class ChannelRegistry { conversationId, serverId: outcome.destination!.serverId, channelId, + threadId: outcome.destination!.threadId ?? null, delivered: true, textLen: text.length, text, @@ -3553,9 +3606,8 @@ export class ChannelRegistry { if (supplied(input.serverId) && (typeof input.serverId !== 'string' || !input.serverId)) { return refuse('serverId must name a server (leave it out to resolve the channel id alone).'); } - if (supplied(input.threadId) && (typeof input.threadId !== 'string' || !input.threadId)) { - return refuse('threadId must be a thread id (or left out for the channel root).'); - } + const thread = readThreadId(input.threadId); + if ('error' in thread) return refuse(thread.error); if (supplied(input.serverId) && !supplied(input.channelId)) { return refuse('serverId needs the channelId it belongs to.'); } @@ -3567,7 +3619,7 @@ export class ChannelRegistry { target = { channelId: input.channelId as string, ...(supplied(input.serverId) ? { serverId: input.serverId as string } : {}), - threadId: supplied(input.threadId) ? (input.threadId as string) : null, + threadId: thread.threadId, }; } else { const route = origin?.kind === 'agent' ? this.speechRouteResolver?.(origin.agentName) : undefined; diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 8e771105a..d355e8d27 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -188,14 +188,19 @@ export class PushHandler { * occurrence for delivery: a refused one, a retry or a retraction is * never gated (so never a route candidate). */ private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent, decideTrigger?: () => boolean) => Promise, - /** An ordinary (uncoalesced) push was just admitted, before it is queued - * or acknowledged: return its source envelope, frozen now - * (mcpl/inbound-source.ts). Coalesced work is stamped by its own path. */ - private readonly acceptInbound?: (event: McplPushEvent) => InboundSource | undefined, + /** An ordinary (uncoalesced) push was just admitted: return its source + * envelope, frozen now (mcpl/inbound-source.ts), before the trigger + * filter reads it. Building it accepts nothing (inboundAccepted). + * Coalesced work is stamped by its own path. */ + private readonly inboundSource?: (event: McplPushEvent) => InboundSource | undefined, /** A coalesced push's source envelope, built — not accepted — before it * is gated: its coalescer freezes and carries this same envelope, and * observes the acceptance itself if it admits the occurrence. */ private readonly coalescedSource?: (serverId: string, params: PushEventParams, event: McplPushEvent) => InboundSource | undefined, + /** An ordinary push is accepted: once its trigger filter has returned, + * right before it is queued, as a coalesced one is on admission. A + * filter that throws leaves nothing accepted. */ + private readonly inboundAccepted?: (source: InboundSource) => void, ) { this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -312,12 +317,12 @@ export class PushHandler { // The source envelope is the host's, built at admission, before the item // is gated, queued or acknowledged: nothing that changes while it waits // can rewrite where it came from, and the gate reads the same envelope - // the direct path does. An ordinary push's acceptance is observed now; a - // coalesced push's envelope is only built here, and its coalescer - // freezes it and observes the acceptance if it admits the occurrence. + // the direct path does. Building it accepts nothing: an ordinary push is + // accepted once its trigger filter returns (below), a coalesced one by + // its coalescer if it admits the occurrence. const inboundSource = coalesced ? this.coalescedSource?.(serverId, params, pushEvent) - : this.acceptInbound?.(pushEvent); + : this.inboundSource?.(pushEvent); if (inboundSource) pushEvent.inboundSource = inboundSource; // 6. The shouldTriggerInference callback: decided now for an ordinary @@ -363,6 +368,7 @@ export class PushHandler { } return; } + if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(pushEvent); // 7. Emit trace diff --git a/src/speech-routes.ts b/src/speech-routes.ts index a373d3f01..24aec0f7c 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -138,6 +138,19 @@ export function conversationKey(c: ConversationRef): string { : JSON.stringify(['channel', c.serverId ?? null, c.channelId, c.threadId ?? null]); } +/** + * An agent-supplied thread id, read one way by every tool that takes one + * (channel_publish, channel_open, drafts resend): absent or null is the + * channel root; a string is trimmed of surrounding whitespace; a blank one, + * or anything else, is refused, so a blank thread never becomes a place. + */ +export function readThreadId(value: unknown): { threadId: string | null } | { error: string } { + if (value === undefined || value === null) return { threadId: null }; + if (typeof value !== 'string') return { error: 'threadId must be a thread id (a string), or null for the channel root.' }; + const threadId = value.trim(); + return threadId ? { threadId } : { error: 'threadId must be a thread id, not blank: leave it out, or null, for the channel root.' }; +} + /** The conversation a route speaks into. */ export function routeConversation(route: SpeechRoute): ConversationRef { return route.kind === 'surface' diff --git a/src/types/trace.ts b/src/types/trace.ts index cea7ba6fc..45f65591d 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -385,6 +385,8 @@ export type TraceEvent = conversationId: string; serverId: string; channelId: string; + /** The thread it was posted in; null for the channel root. */ + threadId: string | null; delivered: boolean; textLen: number; /** The routed prose segment, verbatim. */ @@ -398,6 +400,8 @@ export type TraceEvent = conversationId: string; /** Empty string when the failure was "no locus at all". */ channelId: string; + /** The thread it was bound for; null for the channel root (or no locus). */ + threadId: string | null; reason: string; textLen: number; /** `failed`: nothing was posted; `unknown`: the request was dispatched @@ -590,6 +594,8 @@ export type TraceEvent = status: 'delivered' | 'failed' | 'unknown'; serverId: string; channelId: string; + /** The thread it was sent to; null for the channel root. */ + threadId: string | null; messageId?: string; }) | (TraceEventBase & { diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 19512ed07..5d68ece6c 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -170,7 +170,8 @@ test('a coalesced message is gated with the host envelope its coalescer will fre () => {}, { shouldTriggerInference: (_content, metadata) => { seen.push(metadata); return false; }, - acceptInbound: () => { accepted++; return undefined; }, + inboundSource: () => undefined, + inboundAccepted: () => { accepted++; }, coalescedSource: () => envelope as never, // The coalescer admits the occurrence, and only then decides its trigger. handleCoalescedIncoming: async (_serverId, message, event, decideTrigger) => { @@ -197,6 +198,38 @@ test('a coalesced message is gated with the host envelope its coalescer will fre assert.equal(handed[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate read'); }); +test('an ordinary channels/incoming message is accepted only once its trigger filter returns: a filter that throws accepts nothing', async () => { + const observed: unknown[] = []; + const queued: unknown[] = []; + const registryWith = (filter: () => boolean) => { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + (event) => queued.push(event), + () => {}, + { + shouldTriggerInference: filter, + inboundSource: () => ({ kind: 'channel', lane: 'channels/incoming', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', acceptedAt: 1 }) as never, + inboundAccepted: (source) => observed.push(source), + }, + ); + seedRegistered(registry, 'slack', 'slack:C1'); + return registry; + }; + const message = { + messages: [{ + channelId: 'slack:C1', messageId: 'm-1', author: { id: 'u1', name: 'Ada' }, + timestamp: '2026-10-07T00:00:00.000Z', content: [{ type: 'text' as const, text: 'hi' }], + }], + }; + await assert.rejects(registryWith(() => { throw new Error('filter broke'); }).handleIncoming('slack', message as never), /filter broke/); + assert.deepEqual(observed, [], 'nothing accepted'); + assert.deepEqual(queued, [], 'nothing queued'); + await registryWith(() => false).handleIncoming('slack', message as never); + assert.equal(observed.length, 1, 'accepted once the filter returned, as it is queued'); + assert.equal(queued.length, 1); +}); + test('publish rechecks its destination after opening it: a declaration withdrawn meanwhile refuses the send', async () => { // A closed channel declares where posts land; while the open waits on the // connector, a channels/changed withdraws that (or removes the channel, or @@ -212,11 +245,13 @@ test('publish rechecks its destination after opening it: a declaration withdrawn return { delivered: true, threadId: params.threadId }; }, }; + const failures: Array<{ draftId?: string }> = []; const registry = new ChannelRegistry( { getServer: () => server } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, () => {}, + { onRouteFailure: (info) => { failures.push(info); } }, ); const channels = (registry as unknown as { channels: Map; open: boolean }>; @@ -249,6 +284,27 @@ test('publish rechecks its destination after opening it: a declaration withdrawn assert.equal(moved.status, 'failed'); assert.equal(moved.destination?.serverId, 'discord'); + // The grant loses channels.publish while the open waits. + seed(); + const fullGrant = server.grant; + duringOpen = () => { server.grant = new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.publish')), []); }; + const ungranted = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(ungranted.status, 'failed'); + assert.match(ungranted.reason ?? '', /channels\.publish was withdrawn from "discord"'s effective grant while the channel was being opened/); + server.grant = fullGrant; + + // As plain speech: the refusal is reported, and the words kept as the + // caller's draft, which the failure names. + seed(); + duringOpen = () => { channels.get('discord:dm-alice')!.descriptor = { id: 'dm-alice', type: 'discord', label: 'dm-alice' }; }; + const kept: string[] = []; + const speech = await registry.deliverSpeech('sol', 'reply', { serverId: 'discord', channelId: 'dm-alice', threadId: null }, { + holdFailed: (reason) => { kept.push(reason); return 'd-kept'; }, + }); + assert.equal(speech.status, 'failed'); + assert.equal(kept.length, 1, 'the words were kept'); + assert.equal(failures.at(-1)?.draftId, 'd-kept', 'and the failure names their draft'); + assert.deepEqual(published, [], 'nothing was sent in any case'); // Unchanged across the open: it sends. @@ -405,6 +461,32 @@ test('typing shows on the route\'s own server when another server registers the } }); +test('speech traces name the thread, and publishForAgent traces its outcome once it is in', async () => { + const { registry, traces } = makeRegistry({ delivered: true }); + seedRegistered(registry, 'discord', 'forum'); + const channels = (registry as unknown as { channels: Map }> }).channels; + channels.get('discord:forum')!.descriptor.capabilities = { publish: { target: 'exact' } }; + await registry.deliverSpeech('scout', 'in the thread', { serverId: 'discord', channelId: 'forum', threadId: 't-1' }); + const routed = traces.filter((t) => t.type === 'mcpl:speech-routed'); + assert.deepEqual(routed.map((t) => [t.serverId, t.channelId, t.threadId]), [['discord', 'forum', 't-1']]); + + // A refused thread publish names the thread it was bound for. + seedRegistered(registry, 'discord', 'flat'); + await registry.deliverSpeech('scout', 'nowhere', { serverId: 'discord', channelId: 'flat', threadId: 't-2' }); + const failed = traces.filter((t) => t.type === 'mcpl:speech-route-failed'); + assert.deepEqual(failed.map((t) => [t.channelId, t.threadId, t.outcome]), [['flat', 't-2', 'failed']]); + + // publishForAgent: routed only once delivered, with where it went; a + // refused one is a failure, never routed. + traces.length = 0; + await registry.publishForAgent('forum', 'from the subconscious', 'subconscious'); + await registry.publishForAgent('nowhere-registered', 'refused', 'subconscious'); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:speech-routed').map((t) => [t.conversationId, t.serverId, t.channelId, t.threadId]), + [['subconscious', 'discord', 'forum', null]]); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:speech-route-failed').map((t) => [t.channelId, t.outcome]), + [['nowhere-registered', 'failed']]); +}); + test('routeSpeech surfaces a failure when there is no locus at all', async () => { const { registry, failures } = makeRegistry({ delivered: true }); const res = await registry.routeSpeech('cairn', 'into the void', registry.resolveLocus('cairn')); diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index cab965e75..054a1df64 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -25,6 +25,7 @@ import { McplRequestError } from '../src/mcpl/server-connection.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; import { AgentFramework } from '../src/index.js'; +import { McplServerConnection } from '../src/mcpl/server-connection.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; type Publish = (params: { channelId: string; threadId?: string | null }) => Promise; @@ -141,6 +142,32 @@ describe('ChannelRegistry.publish outcomes', () => { assert.deepEqual(published, []); }); + it('a root publish whose receipt says nothing of where it posted is unknown, keeping its message id', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: true, messageId: 'posted-1' }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.equal(outcome.messageId, 'posted-1'); + assert.match(outcome.reason!, /did not say where it posted/); + assert.doesNotMatch(outcome.reason!, /posted (at|in) the channel root/, 'not a claim that it landed at the root'); + }); + + it('a streamed publish is a Notification only when it names no place; a thread, or the root by null, is a Request', () => { + const sent: Array<['request' | 'notification', Record]> = []; + const connection = Object.create(McplServerConnection.prototype) as McplServerConnection; + Object.assign(connection, { + requestTimeoutMs: 0, + publishDeadlineMs: 60_000, + sendRequest: (_method: string, params: Record) => { sent.push(['request', params]); return Promise.resolve({}); }, + sendNotification: (_method: string, params: Record) => { sent.push(['notification', params]); }, + }); + const base = { conversationId: 'c', channelId: 'ch', content: [{ type: 'text' as const, text: 'x' }], stream: true }; + void connection.sendChannelsPublish({ ...base, threadId: 't-1' }); + void connection.sendChannelsPublish({ ...base, threadId: null }); + void connection.sendChannelsPublish(base); + assert.deepEqual(sent.map(([kind, params]) => [kind, params.threadId]), [['request', 't-1'], ['request', null], ['notification', undefined]]); + }); + it('treats delivered:false with no posted message as failed', async () => { const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false }) } }); seed('discord', 'c', '#c'); diff --git a/test/push-gate-metadata.test.ts b/test/push-gate-metadata.test.ts index 15d4dc3c9..12eb874a4 100644 --- a/test/push-gate-metadata.test.ts +++ b/test/push-gate-metadata.test.ts @@ -113,3 +113,31 @@ test('with no host envelope, an origin key can\'t pose as one', async () => { assert.ok(INBOUND_SOURCE_KEY in seen[0]!, 'the key is the host\'s, present even when it has no envelope'); assert.equal(seen[0]![INBOUND_SOURCE_KEY], undefined); }); + +test('an ordinary push is accepted only once its trigger filter returns: a filter that throws accepts nothing', async () => { + const envelope: InboundSource = { + kind: 'channel', lane: 'push/event', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', eventId: 'ev-3', acceptedAt: 1, + }; + const observed: InboundSource[] = []; + const queued: unknown[] = []; + const handlerWith = (filter: () => boolean) => new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + (event) => queued.push(event), + () => {}, + filter, + undefined, + () => envelope, + undefined, + (source) => observed.push(source), + ); + const push = (eventId: string) => ({ + featureSet: 'chat', eventId, timestamp: '2026-10-07T00:00:00.000Z', + origin: { channelId: 'C1' }, payload: { content: [{ type: 'text', text: 'hi' }] }, + }); + await assert.rejects(handlerWith(() => { throw new Error('filter broke'); }).handlePushEvent('slack', push('ev-3') as never), /filter broke/); + assert.deepEqual(observed, [], 'nothing accepted'); + assert.deepEqual(queued, [], 'nothing queued'); + await handlerWith(() => false).handlePushEvent('slack', push('ev-4') as never); + assert.deepEqual(observed, [envelope], 'accepted once the filter returned, as it is queued'); + assert.equal(queued.length, 1); +}); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts index 6ad67a8fa..85b677c61 100644 --- a/test/targeted-publish.test.ts +++ b/test/targeted-publish.test.ts @@ -164,6 +164,31 @@ describe('targeted publish (MCPL RFC-011)', () => { assert.match(results, /doesn't declare where a post lands \(MCPL RFC-011\)/); }); + it('every tool reads a thread id one way: a blank one is refused with nothing sent or set, and a padded one is trimmed', async () => { + await turn([say('kept for later')], LEGACY); + const [held] = drafts(); + await turn([ + call('channel_publish', { channelId: ROOM, threadId: ' ', content: 'blank' }, 'p1'), + call('channel_publish', { channelId: ROOM, threadId: ' t-10 ', content: 'padded' }, 'p2'), + call('drafts', { action: 'resend', draftIds: [held!.id], destination: ROOM, threadId: ' ' }, 'r1'), + createMockResponse([]), + ], GENERAL); + const sends = toolResults(); + assert.equal(sends.match(/threadId must be a thread id, not blank/g)?.length, 2, 'publish and resend each refused the blank one'); + // A turn with no explicit send, so its plain speech follows the route. + await turn([ + call('channel_open', { channelId: ROOM, threadId: '\t' }, 'o1'), + call('channel_open', { channelId: ROOM, threadId: ' t-11 ' }, 'o2'), + say('in t-11'), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [ + [ROOM, 't-10', 'padded'], + [ROOM, 't-11', 'in t-11'], + ], 'nothing went out for a blank thread; padded ones were trimmed'); + assert.match(toolResults(), /threadId must be a thread id, not blank: leave it out, or null, for the channel root\. Nothing was opened\./); + assert.equal(drafts()[0]!.attempts.length, 0, 'the refused resend recorded no attempt'); + }); + it('channel_publish without a channel follows the route, thread included', async () => { await turn([call('channel_publish', { content: 'explicitly here' }), createMockResponse([])], ROOM, { threadId: 't-4' }); assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[ROOM, 't-4', 'explicitly here']]); From a84c6e501150cd3ee50da2c7cac57a3f630283ea Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 16:20:34 -0400 Subject: [PATCH 51/65] fix(stop): stop() settles the speech chains, and the tests wait for what they assert From a review of this PR's own commits. - stop() waits for every agent's speech chain twice, bounded by SPEECH_DRAIN_MS (10 s) each time. The first wait comes after the streams settle and before the connections close, so speech already written still publishes. The second comes after the connections close and before the store closes: a publish still waiting has its answer then, and speech queued behind it fails with nothing sent and keeps its words as a draft (holdFailed). Each outcome is recorded while the store is open. A chain still busy at a bound is logged and left. Tests: - stop-with-work-in-flight: stop() doesn't resolve while a publish on a speech chain is out, and the publish lands before the connections close; a publish that never answers holds stop() only for the bound at each point, and is logged. - the fixed waits that stood in for events now wait for those events, bounded: - stop-with-work-in-flight: the late result's drop log, the responders' answers, and both logged handler failures; - present-while-acting: the publish results that record engagement, the channel_open refusals and the valid open, the fork's four results, the completion after a trailing reply, and the aborted stream's completion. - the three race tests hold the first publish on a gate they control, released when the racing change lands (the channel_open's result, or round 2's), and assert the words were still pending then. - the ambient-chatter test asserts the chatter was injected, carried its envelope (a candidate the suspension check weighs), and left the route unheld. - a route set with channel_open isn't held by an addressed arrival from another conversation: no draft, no hold notice, and speech lands in the opened channel. --- src/framework.ts | 30 ++++++ test/present-while-acting.test.ts | 143 ++++++++++++++++++++++++-- test/stop-with-work-in-flight.test.ts | 96 ++++++++++++++--- 3 files changed, 246 insertions(+), 23 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index c496598f6..4fadc4da3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2152,6 +2152,10 @@ export class AgentFramework { await this.loopPromise; } + // Speech the streams already wrote is still on its agents' speech + // chains: let it publish while the connections are open, bounded. + await this.settleSpeechChains('before the connections close'); + // Stop typing indicators this.channelRegistry?.stopAll(); @@ -2166,6 +2170,12 @@ export class AgentFramework { } await Promise.all(shutdownPromises); + // A publish still waiting has its answer now that the connections are + // closed, and speech queued behind it can no longer go out: let the + // chains record what became of their words while the store is open (a + // publish that provably sent nothing keeps its words as a draft). + await this.settleSpeechChains('before the store closes'); + // Streams may queue storage repairs while being cancelled above. Retry // after their teardown, while the store is still open, before final sync. for (const agent of this.agents.values()) { @@ -10167,6 +10177,26 @@ export class AgentFramework { return this.speechChains?.get(agentName) ?? Promise.resolve(); } + /** How long stop() waits, at each of its two points, for the speech + * chains to settle. A chain still busy then is left, and logged. */ + private static SPEECH_DRAIN_MS = 10_000; + + /** stop(): wait for every agent's speech chain, bounded. */ + private async settleSpeechChains(when: string): Promise { + const chains = [...(this.speechChains?.entries() ?? [])]; + if (chains.length === 0) return; + let timer: ReturnType | undefined; + const settled = new Set(); + const all = Promise.all(chains.map(([agent, chain]) => chain.then(() => { settled.add(agent); }))); + const bound = new Promise<'timeout'>((resolve) => { timer = setTimeout(() => resolve('timeout'), AgentFramework.SPEECH_DRAIN_MS); }); + const outcome = await Promise.race([all.then(() => 'settled' as const), bound]); + clearTimeout(timer); + if (outcome === 'timeout') { + const busy = chains.map(([agent]) => agent).filter((agent) => !settled.has(agent)); + console.error(`[stop] speech of ${busy.join(', ')} still settling after ${AgentFramework.SPEECH_DRAIN_MS} ms, ${when}; going on without it`); + } + } + /** * Record an explicit send by the host itself — a resend or channel_publish * — as engagement (turnEngagement), from its outcome: delivered, or diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 063403abc..1ab7ccc96 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -31,7 +31,7 @@ import type { ToolCall, ToolResult, } from '../src/index.js'; -import { AgentFramework } from '../src/index.js'; +import { AgentFramework, readInboundSource } from '../src/index.js'; import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; import type { ContentBlock, StreamEvent } from '@animalabs/membrane'; @@ -39,6 +39,16 @@ import type { ContentBlock, StreamEvent } from '@animalabs/membrane'; // Test module: tools that optionally emit a mid-turn external message // --------------------------------------------------------------------------- +/** Until `cond` holds, bounded: wait for the event a check stands for, + * never a fixed pause a loaded machine can outrun. */ +async function until(cond: () => boolean, what: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + class RobotModule implements Module { readonly name = 'robot'; framework: AgentFramework | null = null; @@ -624,6 +634,18 @@ describe('present while acting', () => { assert.deepEqual(routed, [{ text: 'Continuing my report.', locus: 'chan-A' }], 'un-engaged ambient changed nothing'); assert.deepEqual(heldDrafts(framework), []); + // It did reach the suspension check: injected into the turn, as a + // conversational item with its envelope (a route candidate), and the + // check left the route unheld. + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('unrelated lounge chatter')), 'the chatter was injected into the turn'); + const chatter = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('unrelated lounge chatter'))!; + assert.equal(readInboundSource(chatter.metadata)?.kind, 'channel', 'with its envelope: a candidate the check weighs'); + const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); + assert.equal(turn?.route?.channelId, 'chan-A'); + assert.equal(turn?.hold, undefined, 'the check found nothing to suspend'); await framework.stop(); }); @@ -1177,6 +1199,42 @@ describe('speech follows the moment its words were written', () => { name === 'channel_open' ? { success: true, data: { channelId: input.channelId, opened: true } } : { success: true }; } + /** + * The registry method's first call waits until the result of tool call + * `callId` (a channel_open, a hold) is in, so the words it publishes are + * certainly still pending when that change lands: the race always + * happens. `pendingWhenItLanded()` says it did. + */ + function holdFirstUntil(framework: AgentFramework, method: 'routeSpeech' | 'deliverSpeech', callId: string): { pendingWhenItLanded: () => boolean | undefined } { + const registry = registryOf(framework); + const real = registry[method] as (...args: unknown[]) => Promise; + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + let pending = false; + let first = true; + let pendingWhenItLanded: boolean | undefined; + registry[method] = async (...args: unknown[]) => { + if (first) { + first = false; + pending = true; + await gate; + pending = false; + } + return real(...args); + }; + const f = framework as unknown as { pushEvent(e: unknown): void }; + const push = f.pushEvent.bind(framework); + f.pushEvent = (e: unknown) => { + push(e); + const ev = e as { type?: string; callId?: string }; + if (ev.type === 'tool-result' && ev.callId === callId) { + pendingWhenItLanded = pending; + release(); + } + }; + return { pendingWhenItLanded: () => pendingWhenItLanded }; + } + /** The registry method's first call waits `ms` first, so later speech queues behind it. */ function slowFirst(framework: AgentFramework, method: 'routeSpeech' | 'deliverSpeech', ms = 60): void { const registry = registryOf(framework); @@ -1247,11 +1305,12 @@ describe('speech follows the moment its words were written', () => { const framework = await createFramework(); const routed = stubChannelRegistry(framework, { home: false }); opensChannels(framework); - slowFirst(framework, 'routeSpeech'); + const race = holdFirstUntil(framework, 'routeSpeech', 'c2'); triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); + assert.equal(race.pendingWhenItLanded(), true, 'the first words were still waiting when the open landed'); assert.deepEqual(routed, [ { text: 'First words.', locus: 'chan-A' }, { text: 'Second words, still for chan-A.', locus: 'chan-A' }, @@ -1277,11 +1336,12 @@ describe('speech follows the moment its words were written', () => { const framework = await createFramework('hybrid'); const routed = stubChannelRegistry(framework, { home: false }); opensChannels(framework); - slowFirst(framework, 'deliverSpeech'); + const race = holdFirstUntil(framework, 'deliverSpeech', 'c2'); triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); + assert.equal(race.pendingWhenItLanded(), true, 'the pinned words were still waiting when the open landed'); assert.deepEqual(routed, [ { text: 'Pinned words.', locus: 'chan-alpha' }, { text: 'More for alpha.', locus: 'chan-alpha' }, @@ -1324,6 +1384,44 @@ describe('speech follows the moment its words were written', () => { await framework.stop(); }); + it('a route chosen with channel_open is never held by a mid-turn arrival, addressed or not', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still for the observatory.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + // An addressed arrival from another conversation, during the move. + addressedArrivalElsewhere(); + const notices: string[] = []; + framework.onTrace((event) => { + const e = event as { type: string; source?: string }; + if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); + }); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('hey, over here?')), 'the addressed arrival was injected'); + assert.deepEqual(routed, [{ text: 'Still for the observatory.', locus: 'discord:guild:observatory' }]); + assert.deepEqual(heldDrafts(framework), [], 'nothing held'); + assert.equal(notices.length, 1, 'only the turn-start announcement: no hold notice'); + const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); + assert.equal(turn?.route?.origin, 'open'); + assert.equal(turn?.hold, undefined); + + await framework.stop(); + }); + it('hybrid: a channel_open that sets the speech route ends a `>>>skip_reply` suppression', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>>skip_reply An aside to myself.' }, @@ -1526,7 +1624,14 @@ describe('speech follows the moment its words were written', () => { it('channel_publish engages the conversation its outcome names when delivered or unknown, and nothing when it fails', async () => { const framework = await createFramework(); stubChannelRegistry(framework, { home: false }); - const dispatch = (framework as unknown as { dispatchChannelToolCall(agent: string, call: unknown): void }); + const dispatch = (framework as unknown as { dispatchChannelToolCall(agent: string, call: unknown): void; pushEvent(e: unknown): void }); + const answered = new Set(); + const push = dispatch.pushEvent.bind(framework); + dispatch.pushEvent = (e: unknown) => { + const ev = e as { type?: string; callId?: string }; + if (ev.type === 'tool-result') answered.add(ev.callId!); + else push(e); + }; const outcomes: Array<[string, string, string | undefined]> = [ ['delivered', 't1', 'delivered'], ['unknown', 't2', 'unknown'], @@ -1538,7 +1643,7 @@ describe('speech follows the moment its words were written', () => { data: { delivered: status === 'delivered', status, serverId: 'srv', channelId: 'chan-T', threadId }, }); dispatch.dispatchChannelToolCall('assistant', { id: `p-${status}`, name: 'channel_publish', input: { channelId: 'chan-T', threadId, content: 'hi' } }); - await new Promise((r) => setTimeout(r, 5)); + await until(() => answered.has(`p-${status}`), `the ${status} publish's result`); assert.equal(engage(framework).engagementOf('assistant', { kind: 'channel', serverId: 'srv', channelId: 'chan-T', threadId }), expected, status); } await framework.stop(); @@ -1571,7 +1676,7 @@ describe('speech follows the moment its words were written', () => { for (const [i, [input, why]] of invalid.entries()) { f.dispatchChannelToolCall('assistant', { id: `o${i}`, name: 'channel_open', input }); } - await new Promise((r) => setTimeout(r, 5)); + await until(() => results.length === invalid.length, 'every refusal'); assert.equal(opened, 0, 'nothing was opened'); assert.equal(results.length, invalid.length); for (const [i, [, why]] of invalid.entries()) { @@ -1582,7 +1687,7 @@ describe('speech follows the moment its words were written', () => { } // null means "not in use", as for channel_publish's selectors. f.dispatchChannelToolCall('assistant', { id: 'ok', name: 'channel_open', input: { channelId: 'chan-B', threadId: null, serverId: null, setSpeechTarget: null } }); - await new Promise((r) => setTimeout(r, 5)); + await until(() => results.some((r) => r.callId === 'ok'), 'the valid open'); assert.equal(opened, 1); await framework.stop(); }); @@ -1608,7 +1713,7 @@ describe('speech follows the moment its words were written', () => { f.dispatchChannelToolCall('assistant', { id: 'number', name: 'channel_close', input: { channelId: 5 } }); f.dispatchChannelToolCall('assistant', { id: 'null', name: 'channel_publish', input: { channelId: null, content: 'hi' } }); f.dispatchChannelToolCall('assistant', { id: 'absent', name: 'channel_publish', input: { content: 'hi' } }); - await new Promise((r) => setTimeout(r, 5)); + await until(() => results.length === 4, 'all four results'); assert.match(results.find((r) => r.callId === 'empty')!.result.error ?? '', /channelId must name a channel .*Nothing was sent\./); assert.match(results.find((r) => r.callId === 'number')!.result.error ?? '', /channelId must name a channel .*Nothing was closed\./); assert.deepEqual(handled.map((h) => [h.name, h.input.channelId]), [ @@ -1827,17 +1932,33 @@ describe('speech follows the moment its words were written', () => { return outcome ? { ...outcome, serverId: 'stub' } : outcome; }; const deliver = registry.deliverSpeech as (...args: unknown[]) => Promise; + // The first envelope's delivery fails only once round 2 (and its newer + // prefix) has been written: the race always happens. let calls = 0; + let releaseFirst!: () => void; + const firstGate = new Promise((r) => { releaseFirst = r; }); registry.deliverSpeech = async (...args: unknown[]) => { if (calls++ === 0) { - await new Promise((r) => setTimeout(r, 100)); + await firstGate; return { status: 'failed', reason: 'refused', at: Date.now() }; } return deliver(...args); }; + let pendingWhenRound2Landed: boolean | undefined; + const fw = framework as unknown as { pushEvent(e: unknown): void }; + const push = fw.pushEvent.bind(framework); + fw.pushEvent = (e: unknown) => { + push(e); + const ev = e as { type?: string; callId?: string }; + if (ev.type === 'tool-result' && ev.callId === 'c2') { + pendingWhenRound2Landed = calls === 1; + releaseFirst(); + } + }; streams = recordStreams(framework); triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); + assert.equal(pendingWhenRound2Landed, true, "the older envelope's delivery was still out when the newer prefix was written"); assert.deepEqual(routed, [{ text: 'Second.', locus: 'chan-beta' }, { text: 'More for beta.', locus: 'chan-beta' }]); assert.equal(streams.streamedTo('chan-beta'), 'Second.\n\nMore for beta.'); assert.deepEqual(streams.completes.filter((c) => c.channelId === 'chan-beta'), @@ -1929,7 +2050,7 @@ describe('speech follows the moment its words were written', () => { triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - await new Promise((r) => setTimeout(r, 80)); + await until(() => events.some(([kind]) => kind === 'complete'), 'the completion, after the trailing reply is published'); assert.deepEqual(events.map(([kind, , text]) => [kind, text]), [ ['chunk', 'Narrating.'], @@ -1999,7 +2120,7 @@ describe('speech follows the moment its words were written', () => { assert.ok(streams.chunks.length > 0, 'the narration was published and streamed'); assert.ok(framework.abortInference('assistant', 'test abort'), 'the turn was still running'); await idle; - await new Promise((r) => setTimeout(r, 50)); + await until(() => streams.completes.length > 0, 'the completion the stream sends'); assert.equal(streams.streamedTo('chan-A'), 'Narrating.'); assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Narrating.', threadId: null }]); diff --git a/test/stop-with-work-in-flight.test.ts b/test/stop-with-work-in-flight.test.ts index b346cada2..7efd55eb9 100644 --- a/test/stop-with-work-in-flight.test.ts +++ b/test/stop-with-work-in-flight.test.ts @@ -36,6 +36,15 @@ import type { import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +/** Until `cond` holds, bounded: wait for the event a check stands for. */ +async function until(cond: () => boolean, what: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + /** A tool that settles only when the test says so, so the test owns the work it started. */ class SlowModule implements Module { readonly name = 'slow'; @@ -61,11 +70,10 @@ class SlowModule implements Module { }); } - /** Settle the running call; resolves once its completion callbacks have run. */ - async finish(outcome: 'success' | 'failure'): Promise { + /** Settle the running call. What its completion does is the test's to wait for. */ + finish(outcome: 'success' | 'failure'): void { if (outcome === 'success') this.settle!.resolve({ success: true, data: { done: true } }); else this.settle!.reject(new Error('the work failed')); - await new Promise((r) => setTimeout(r, 20)); } async onProcess(event: ProcessEvent, _state: ProcessState): Promise { @@ -138,7 +146,8 @@ describe('stop() with work still in flight', () => { it('a tool that succeeds after stop() has its result dropped, not thrown or misreported as a failure', async () => { const { framework, module, failed } = await frameworkWithRunningTool(); await framework.stop(); - await module.finish('success'); + module.finish('success'); + await until(() => dropped().length > 0, 'the late result is dropped'); assert.deepEqual(rejections, []); assert.deepEqual(failed, [], 'the tool succeeded; nothing reports it as a failure'); @@ -148,7 +157,8 @@ describe('stop() with work still in flight', () => { it('a tool that fails after stop() has its error result dropped, not thrown', async () => { const { framework, module, failed } = await frameworkWithRunningTool(); await framework.stop(); - await module.finish('failure'); + module.finish('failure'); + await until(() => dropped().length > 0, 'the late error result is dropped'); assert.deepEqual(rejections, []); assert.deepEqual(failed, [], 'the registry answers a module throw with an error result'); @@ -158,7 +168,8 @@ describe('stop() with work still in flight', () => { it('a dispatch that rejects after stop() is reported as failed, and its failure result dropped, not thrown', async () => { const { framework, module, failed } = await frameworkWithRunningTool({ dispatch: 'rejects' }); await framework.stop(); - await module.finish('success'); + module.finish('success'); + await until(() => dropped().length > 0, 'the failure result is dropped'); assert.deepEqual(rejections, []); assert.equal(failed.length, 1, 'the host got no result: a dispatch failure'); @@ -172,10 +183,69 @@ describe('stop() with work still in flight', () => { () => framework.pushEvent({ type: 'external-message', source: 'test', content: 'too late', metadata: {} } as unknown as ProcessEvent), /Queue is closed/, ); - await module.finish('success'); + module.finish('success'); + await until(() => dropped().length > 0, 'the late result is dropped'); assert.deepEqual(rejections, []); }); + it('stop() lets speech already on a speech chain publish before the connections close, and is bounded', async () => { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const internals = framework as unknown as { + channelRegistry: unknown; + chainSpeech(agent: string, what: string, work: () => Promise): void; + }; + const statics = AgentFramework as unknown as { SPEECH_DRAIN_MS: number }; + const drain = statics.SPEECH_DRAIN_MS; + statics.SPEECH_DRAIN_MS = 300; + const events: string[] = []; + let release: (() => void) | undefined; + internals.channelRegistry = new Proxy({ + routeSpeech: async (_agent: string, text: string) => { + await new Promise((r) => { release = r; }); + events.push(`published ${text}`); + return { delivered: true, serverId: 'srv', channelId: 'c' }; + }, + stopAll: () => { events.push('connections closing'); }, + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + try { + const registry = internals.channelRegistry as { routeSpeech(a: string, t: string, l: string): Promise }; + internals.chainSpeech('assistant', 'last words', async () => { await registry.routeSpeech('assistant', 'last words', 'c'); }); + await until(() => release !== undefined, 'the publish is in flight'); + let stopped = false; + const stopping = framework.stop().then(() => { stopped = true; }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(stopped, false, 'stop() waits for the speech in flight'); + release!(); + await stopping; + assert.deepEqual(events, ['published last words', 'connections closing'], 'published before the connections closed'); + + // A publish that never answers holds stop() only for the bound. + const second = await AgentFramework.create({ + storePath: join(tempDir, 'store2'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const s = second as unknown as typeof internals; + s.channelRegistry = new Proxy({ routeSpeech: () => new Promise(() => {}) } as Record, + { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + s.chainSpeech('assistant', 'stuck words', async () => { + await (s.channelRegistry as { routeSpeech(): Promise }).routeSpeech(); + }); + const started = Date.now(); + await second.stop(); + assert.ok(Date.now() - started >= 2 * 300, 'it waited the bound at each point'); + assert.ok(logged.some((line) => line.includes('[stop] speech of assistant still settling after 300 ms')), logged.join('\n')); + } finally { + statics.SPEECH_DRAIN_MS = drain; + } + }); + describe('a server sending while the host stops', () => { async function wiredFramework() { const framework = await AgentFramework.create({ @@ -198,7 +268,6 @@ describe('stop() with work still in flight', () => { }; } - const settle = (): Promise => new Promise((r) => setTimeout(r, 10)); it('a push or incoming message after stop() is answered with an error, never accepted', async () => { const { framework, connection } = await wiredFramework(); @@ -210,7 +279,7 @@ describe('stop() with work still in flight', () => { connection.emit('push-event', { featureSet: 'chat', eventId: 'e1', payload: { content: [{ type: 'text', text: 'late' }] } }, push); const incoming = responder(); connection.emit('channels-incoming', { messages: [{ channelId: 'c', messageId: 'm', content: [] }] }, incoming); - await settle(); + await until(() => push.answers.length > 0 && incoming.answers.length > 0, 'both are answered'); assert.equal(handled, 0, 'never handed to the push handler, which would record it as accepted'); assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); @@ -231,7 +300,7 @@ describe('stop() with work still in flight', () => { connection.emit('push-event', { featureSet: 'chat', eventId: 'e2', payload: { content: [{ type: 'text', text: 'waiting' }] } }, push); await framework.stop(); lift(); - await settle(); + await until(() => push.answers.length > 0, 'the push is answered'); assert.equal(handled, 0); assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); @@ -247,7 +316,10 @@ describe('stop() with work still in flight', () => { const push = responder(); connection.emit('push-event', { featureSet: 'chat', eventId: 'e3', payload: { content: [{ type: 'text', text: 'x' }] } }, push); connection.emit('push-event', { featureSet: 'chat', eventId: 'e4', payload: { content: [{ type: 'text', text: 'y' }] } }); - await settle(); + await until( + () => push.answers.length > 0 && logged.filter((line) => line.includes('the handler broke')).length >= 2, + 'the request is answered, and both failures are logged', + ); assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the handler broke' } }]); assert.deepEqual(rejections, []); @@ -268,7 +340,7 @@ describe('stop() with work still in flight', () => { connection.emit('push-event', { featureSet: 'chat', eventId: 'e5', payload: { content: [{ type: 'text', text: 'x' }] } }, push); const incoming = responder(); connection.emit('channels-incoming', { messages: [{ channelId: 'c', messageId: 'm', content: [] }] }, incoming); - await settle(); + await until(() => push.answers.length > 0 && incoming.answers.length > 0, 'both are answered'); assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the handler failed' } }]); assert.deepEqual(incoming.answers, [{ error: { code: -32603, message: 'the handler failed' } }]); From 11236a5f8e8ca05fc7b00646fa831f9cf116d078 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 16:24:04 -0400 Subject: [PATCH 52/65] docs(changelog): #256's fragments say what the review round changed The fragments for the review round's behavior changes. - speech-routes.changed: pins keep their server; typing on the route's own server; the route's server in inference:started and the failure log; threads in the batch notice, defaultOutgoing and the speech traces; one reading of a thread id; failed speech kept as a draft named by its one notice; the sleep announcement as unaddressed speech; every suspending arrival named in a hold; a silent wake's channel_open setting no route; only the host marking machinery; candidates kept through the fork rule, module events, the inference buffer's evictions and coalesced admission; acceptance after the trigger filter; publishForAgent's traces; the grant rechecked after an open; labels shown as given. - stop-with-work-in-flight.fixed: stop() settles the speech chains, bounded, before the connections close and before the store closes. - speech-routes.breaking: resolveProseTarget returns the server; startTyping/stopTyping take { serverId, channelId }; and handleCoalescedIncoming gets decideTrigger, which a host's own handler calls on admission. (acceptInbound is #247's and not on main, so its replacement isn't a breaking change to name.) Every fragment passes the release script's grammar. --- changelog.d/speech-routes.breaking.md | 10 +++- changelog.d/speech-routes.changed.md | 47 +++++++++++++++++++ changelog.d/stop-with-work-in-flight.fixed.md | 6 +++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index 7083679ab..98a74b5e5 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -13,9 +13,15 @@ registry, and pass `channelId` explicitly when publishing outside a turn. `routeSpeech`/`deliverSpeech` also accept `{ serverId, channelId }` to publish to an exact server. + - **Also changed:** `resolveProseTarget` returns the `serverId` its + target resolved to beside the channel id. `startTyping` and + `stopTyping` take `{ serverId, channelId }` (a bare id still works, + resolving only an id one server registers). `handleCoalescedIncoming` + receives a fourth argument, `decideTrigger`: the registry no longer + decides a coalesced message's trigger before handing it over, so a + handler that admits the occurrence calls it. - **Unchanged:** fork homes, explicit sends naming a channel, and - `resolveProseTarget`/`resolveDestination`. No sibling package version - changes. + `resolveDestination`. No sibling package version changes. - **Operators whose residents speak on scheduled wakes:** a turn woken only by events that name no conversation now has no speech route, unless the resident is a conversation fork, whose home is its route. That covers diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index a3acd11e9..8345c07f3 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -90,3 +90,50 @@ apart. Two servers' channels with the same id and label no longer read alike. A refusal for a channel id that several servers register lists those servers, so the sender has a `serverId` to give. +- **More of routing keeps the server and the thread.** A `>>` or `>>>` pin + keeps the server its target resolved to, so the same id registered by a + second server later changes nothing. Typing shows on the route's own + server's channel, and an id two servers share, named alone, shows no + indicator rather than one on a guessed server. `inference:started` and + the failure log name the route's server beside its channel. The gate's + batched-wake notice counts a thread apart from its channel's root, and + the beforeInference context's `defaultOutgoing` names the route's thread + (an extension of §14.4's shape). The `mcpl:speech-routed`, + `mcpl:speech-route-failed` and `prose:draft-resent` traces carry + `threadId` (null for the root). +- **A thread id is read one way** by `channel_publish`, `channel_open` and + `drafts` resend: surrounding whitespace is trimmed, and a blank or + non-string id is refused with nothing sent, opened or set. +- **Unaddressed speech that provably wasn't posted is kept.** A publish + that fails with nothing posted keeps its words as a bounce draft, on the + route, a sticky `>>>` pin or the sleep announcement, and the one + `[discord-send-failed]` notice names the draft and how to resend it. An + unconfirmed publish keeps none, so as not to invite a duplicate. +- **The sleep announcement is unaddressed speech.** It follows a hybrid + `>>>` pin, else the turn's route, after the words written before it. A + held or routeless turn keeps it as a draft, and a surface shows it. +- **A mid-turn hold names every arrival that suspends the route** in that + round, each with its own cause. A local surface's message reads as + addressing the resident, tagged or not. +- **In a silent wake, `channel_open` opens the channel and sets no route.** + Its result says the wake's plain speech stays private and that a send + tool posts there. +- **Only the host marks machinery.** `system: true` in a push's origin or + an incoming message's metadata is dropped at ingestion and never makes + the item non-conversational, so it is still a route candidate. +- **A route candidate isn't lost or invented on the way to a wake.** An + unroutable candidate in a fork's channel follows the fork rule, so it + holds no other agent's turn. A module event that names a channel is that + channel's conversation, unroutable, never a local surface's. An event the + gate's inference buffer evicts keeps its route candidate, so an + addressed conversation still competes. A coalesced item is gated (and so + becomes a candidate) only once its coalescer admits it for delivery, + never for a retry, a retraction or a refused occurrence. +- **An ordinary push or incoming message is accepted** (the acceptance + observer is told) only once its trigger filter returns, right before it + is queued; a filter that throws leaves nothing accepted. +- `publishForAgent` traces `mcpl:speech-routed` only for a confirmed + delivery, once the outcome is in, and a failed or unconfirmed one as + `mcpl:speech-route-failed`. `publish()` rechecks the server and its + `channels.publish` grant after opening a closed channel, as well as + before. A label shows as its connector gave it: no `#` is added. diff --git a/changelog.d/stop-with-work-in-flight.fixed.md b/changelog.d/stop-with-work-in-flight.fixed.md index 9029b04f0..f67776883 100644 --- a/changelog.d/stop-with-work-in-flight.fixed.md +++ b/changelog.d/stop-with-work-in-flight.fixed.md @@ -17,3 +17,9 @@ already have been admitted. The error is never thrown out of the connection's listener. A notification is logged. - Direct input to `pushEvent` after `stop()` still throws to its caller. +- **`stop()` lets speech already written publish.** It waits for every + agent's speech chain before the MCPL connections close, so words a turn + wrote still go out, and again before the store closes, so what became of + each is recorded (a publish that sent nothing keeps its words as a + draft). Each wait is bounded at 10 s, and a chain still busy then is + logged and left. From fd8cfa4fb35948502db98522a9d77d7bbdc4d941 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 16:39:43 -0400 Subject: [PATCH 53/65] fix(routing): a coalesced item carries its trigger inputs, and the review's smaller points A review of the round found these: - A coalesced item's trigger is still decided only when its coalescer first delivers or wakes for an admitted occurrence, but from inputs the event carries (triggerInputs: the filter's text and gate metadata, envelope included), not a closure in a WeakMap. Both are plain data, so a persisted occurrence keeps whichever it has: the inputs before the decision, the decision after. As built, the decision was already made inside accept, before the receipt and snapshot are written, so nothing persisted lacked it; this form doesn't depend on that ordering. The framework keeps the trigger filter both lanes use (coalescedTriggerFilter) and decides with it. PushHandler's and ChannelRegistry's coalesced handlers lose the decideTrigger argument. - The gate's dispose() clears the evicted route events with the buffer. - holdUndelivered takes only the round; the draft it keeps is always named at turn end, and by the failure notice. - The failure notice's resend hint names the destination the words were bound for (its channel id, and its thread), not a "#channel" placeholder an agent might copy. - channel_open's serverId, setSpeechTarget and threadId, channel_close's serverId, and channel_publish's channelId, serverId and threadId are nullable in their schemas, as drafts' fields are, and their descriptions say null means what omitting does. A caller whose provider makes every field required can then ask for the root with null; the handlers already read null that way, and a blank thread stays refused. Tests: the coalesced gate tests in push-gate-metadata and channel-registry-routing see nothing gated before admission, and the event's inputs carrying the host envelope as plain data. The breaking note says the event carries triggerInputs. --- changelog.d/speech-routes.breaking.md | 8 +-- src/framework.ts | 50 +++++++++--------- src/gate/event-gate.ts | 1 + src/mcpl/channel-registry.ts | 73 +++++++++++++++++---------- src/mcpl/push-handler.ts | 32 ++++++++---- test/channel-registry-routing.test.ts | 19 +++---- test/prose-drafts.test.ts | 2 +- test/push-gate-metadata.test.ts | 23 ++++----- 8 files changed, 113 insertions(+), 95 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index 98a74b5e5..13366f965 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -16,10 +16,10 @@ - **Also changed:** `resolveProseTarget` returns the `serverId` its target resolved to beside the channel id. `startTyping` and `stopTyping` take `{ serverId, channelId }` (a bare id still works, - resolving only an id one server registers). `handleCoalescedIncoming` - receives a fourth argument, `decideTrigger`: the registry no longer - decides a coalesced message's trigger before handing it over, so a - handler that admits the occurrence calls it. + resolving only an id one server registers). The registry no longer + decides a coalesced message's trigger before `handleCoalescedIncoming`: + the event carries the filter's inputs as `triggerInputs`, and a host's + own handler decides from them once it admits the occurrence. - **Unchanged:** fork homes, explicit sends naming a channel, and `resolveDestination`. No sibling package version changes. - **Operators whose residents speak on scheduled wakes:** a turn woken only diff --git a/src/framework.ts b/src/framework.ts index 4fadc4da3..fd2830417 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8294,32 +8294,32 @@ export class AgentFramework { : this.pushSource(event, { deferred }); } - /** A coalesced item's trigger decision, keyed by the delivery event its - * occurrence carries, until the coalescer first acts on it - * (settleCoalescedTrigger). Not on the event itself: the coalescer - * persists occurrences, and a function can't be. */ - private readonly coalescedTriggers = new WeakMap boolean>(); + /** The trigger filter both MCPL lanes use (a server's + * shouldTriggerInference, else the event gate's), for coalesced items, + * whose trigger is decided at their first delivery or wake. */ + private coalescedTriggerFilter?: (content: string, metadata: Record) => boolean; /** * Make a coalesced item's trigger decision (the gate's, which records the * item in its batch as a route candidate), once: when the coalescer first - * delivers or wakes for an occurrence it admitted. A refused occurrence, a - * retry it deduplicates, or a retraction is never delivered or woken for, - * so it never reaches the gate. The decision is written onto the event, - * so a persisted occurrence keeps it. + * delivers or wakes for an occurrence it admitted, from the filter inputs + * the event carries (triggerInputs). A refused occurrence, a retry it + * deduplicates, or a retraction is never delivered or woken for, so it + * never reaches the gate. The decision replaces the inputs on the event, + * which is plain data either way, so a persisted occurrence keeps + * whichever it has. */ - private settleCoalescedTrigger(event: { triggerInference?: boolean }): void { - const decide = this.coalescedTriggers.get(event); - if (!decide) return; - this.coalescedTriggers.delete(event); - event.triggerInference = decide(); + private settleCoalescedTrigger(event: { triggerInference?: boolean; triggerInputs?: { content: string; metadata: Record } }): void { + const inputs = event.triggerInputs; + if (!inputs) return; + delete event.triggerInputs; + if (this.coalescedTriggerFilter) event.triggerInference = this.coalescedTriggerFilter(inputs.content, inputs.metadata); } private handleCoalescedIncoming( serverId: string, message: ChannelIncomingMessage, event: CoalescedChannelEvent, - decideTrigger?: () => boolean, ): Promise { return this.admitCoalesced(async () => { if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000); @@ -8335,7 +8335,6 @@ export class AgentFramework { // (mcpl/inbound-source.ts). const inboundSource = event.inboundSource ?? this.coalescedIncomingSource(serverId, message, event); const delivery = { ...event, eventId: message.eventId, inboundSource }; - if (decideTrigger) this.coalescedTriggers.set(delivery, decideTrigger); const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -8355,7 +8354,7 @@ export class AgentFramework { }); } - private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent, decideTrigger?: () => boolean): Promise { + private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise { return this.admitCoalesced(async () => { if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000); validateCoalesceMember(params.coalesce, 'push'); @@ -8374,7 +8373,6 @@ export class AgentFramework { // the channel lane). const inboundSource = event.inboundSource ?? this.coalescedPushSource(serverId, params, event); const delivery = { ...event, inboundSource }; - if (decideTrigger) this.coalescedTriggers.set(delivery, decideTrigger); return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -10411,7 +10409,7 @@ export class AgentFramework { * weren't posted are kept as a bounce draft (as a failed `>>>` delivery's * are), named by the one failure notice. An unknown outcome keeps none, * so as not to invite a duplicate. */ - private holdUndelivered(agent: Agent, text: string, hold: { round?: number; notice: 'now' | 'later' }): (reason: string) => string | undefined { + private holdUndelivered(agent: Agent, text: string, hold: { round?: number }): (reason: string) => string | undefined { return (reason) => this.holdProseDrafts(agent, [text], 'bounced', { round: hold.round, note: `delivery failed: ${reason}`, notice: 'later' })[0]?.id; } @@ -15279,6 +15277,7 @@ export class AgentFramework { // Per-server callback takes precedence; fall back to EventGate; fall back to no filter. const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined); + this.coalescedTriggerFilter = triggerFilter; // Push events handler (Step 6) this.initializePushCoalescer(); @@ -15287,7 +15286,7 @@ export class AgentFramework { (event) => this.pushEvent(event as unknown as ProcessEvent), (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), triggerFilter, - (serverId, params, event, decideTrigger) => this.handleCoalescedPush(serverId, params, event, decideTrigger), + (serverId, params, event) => this.handleCoalescedPush(serverId, params, event), // An ordinary push: freeze its envelope at admission; it is accepted // (the last argument) once its trigger filter returns, before it is // queued. @@ -15325,8 +15324,8 @@ export class AgentFramework { (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), { store: this.store, - handleCoalescedIncoming: (serverId, message, event, decideTrigger) => - this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent, decideTrigger), + handleCoalescedIncoming: (serverId, message, event) => + this.handleCoalescedIncoming(serverId, message, event as CoalescedChannelEvent), // An ordinary channels/incoming message: freeze its envelope at // admission; accepted once its trigger filter returns, before it is // queued. @@ -15392,8 +15391,9 @@ export class AgentFramework { ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the ${threadId ? 'thread' : 'channel'} before sending it again. It is saved in your archive.` : draftId ? `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). ` + - `Nothing was posted; it is held as draft ${draftId}, and drafts(action: "resend", draftIds: ["${draftId}"], ` + - 'destination: "#channel") sends it.' + `Nothing was posted; it is held as draft ${draftId}. drafts(action: "resend", draftIds: ["${draftId}"]` + + (channelId ? `, destination: "${channelId}"${threadId ? `, threadId: "${threadId}"` : ''}` : '') + + ') sends it there once that can work, or name another destination.' : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, }], { @@ -17647,7 +17647,7 @@ export class AgentFramework { this.chainSpeech(agentName, '[sleep] announcement', async () => { const pin = agent.proseRouting === 'hybrid' ? this.proseTargetPins.get(agentName) : undefined; if (pin) { - const outcome = await this.channelRegistry!.routeSpeech(agentName, text, pin, { holdFailed: this.holdUndelivered(agent, text, { notice: 'later' }) }); + const outcome = await this.channelRegistry!.routeSpeech(agentName, text, pin, { holdFailed: this.holdUndelivered(agent, text, {}) }); this.recordProseDelivery(agentName, outcome); if (outcome?.delivered) this.streamPublished(agentName, moment, outcome, text); return; diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index a2055506b..3c63f1c59 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -2127,5 +2127,6 @@ export class EventGate { this.rateLimitDenied.clear(); this.passiveSampleFires.clear(); this.inferenceBuffer = []; + this.evictedRouteEvents.clear(); } } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 7aad68540..73c5fd373 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -43,6 +43,7 @@ import { CapabilityGrant } from './capability-grant.js'; import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; import { McplRequestError } from './server-connection.js'; import { describeConversation, readThreadId } from '../speech-routes.js'; +import type { TriggerInputs } from './push-handler.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -244,6 +245,10 @@ interface McplChannelIncomingEvent { acceptedAt?: number; /** Source envelope frozen at admission (mcpl/inbound-source.ts). */ inboundSource?: InboundSource; + /** A coalesced message's trigger-filter inputs, until the framework + * decides its trigger when its coalescer first delivers an admitted + * occurrence. Plain data, so a persisted occurrence keeps them. */ + triggerInputs?: TriggerInputs; } // ============================================================================ @@ -318,6 +323,9 @@ function convertBlock(block: McplContentBlock): ContentBlock { // Channel Tool Definitions // ============================================================================ +// Nullable fields (a caller whose provider presents every property as +// required can still say "not in use") are cast, as the drafts tool's are: +// ToolDefinition's schema type admits one type name per property. const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ { name: 'channel_list', @@ -334,7 +342,10 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'object' as const, properties: { channelId: { type: 'string', description: 'ID of the channel to open' }, - serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' }, + serverId: { + type: ['string', 'null'], + description: 'Owning MCPL server; required only when channelId is ambiguous. null, like omitting it, names none.', + }, backscroll: { type: 'number', description: 'Number of earlier messages to return while opening (0-500; default 0).', @@ -344,22 +355,22 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.', }, setSpeechTarget: { - type: 'boolean', + type: ['boolean', 'null'], description: 'Whether your unaddressed plain speech should go to this channel for the rest of this turn ' + - '(default true). false opens it for reading only; the result says where your speech goes.', + '(default true, as for null). false opens it for reading only; the result says where your speech goes.', }, threadId: { - type: 'string', + type: ['string', 'null'], description: 'With setSpeechTarget, a thread of this channel to speak into instead of its root (the thread ' + 'id from a [source: … · thread ] line). Only where the channel\'s connector can post into ' + - 'a named thread; without it, your speech goes to the channel root.', + 'a named thread. null, like omitting it, means the channel root.', }, }, required: ['channelId'], }, - }, + } as unknown as ToolDefinition, { name: 'channel_decline', description: @@ -387,11 +398,14 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'object' as const, properties: { channelId: { type: 'string', description: 'ID of the channel to close' }, - serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' }, + serverId: { + type: ['string', 'null'], + description: 'Owning MCPL server; required only when channelId is ambiguous. null, like omitting it, names none.', + }, }, required: ['channelId'], }, - }, + } as unknown as ToolDefinition, { name: 'channel_publish', description: @@ -403,20 +417,26 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to your current speech route)' }, - serverId: { type: 'string', description: 'Owning MCPL server; needed only when channelId is registered by more than one.' }, + channelId: { + type: ['string', 'null'], + description: 'ID of the channel to publish to; null, like omitting it, means your current speech route', + }, + serverId: { + type: ['string', 'null'], + description: 'Owning MCPL server; needed only when channelId is registered by more than one. null, like omitting it, names none.', + }, threadId: { - type: 'string', + type: ['string', 'null'], description: - 'A thread of channelId to post in (the id from a [source: … · thread ] line); omitted, the post ' + - 'goes to the channel root. Only where the channel\'s connector can post into a named thread.', + 'A thread of channelId to post in (the id from a [source: … · thread ] line); null, like omitting ' + + 'it, means the channel root. Only where the channel\'s connector can post into a named thread.', }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, required: [], }, - }, + } as unknown as ToolDefinition, { name: 'think', description: @@ -593,10 +613,6 @@ interface ChannelRegistryOptions { serverId: string, message: ChannelIncomingMessage, event: McplChannelIncomingEvent, - /** The trigger decision, made only once the coalescer admits the - * occurrence for delivery: a refused one, a retry or a retraction is - * never gated (so never a route candidate). */ - decideTrigger?: () => boolean, ) => Promise; /** Chronicle store used for durable desired channel lifecycle state. */ store?: JsStore; @@ -1124,18 +1140,18 @@ export class ChannelRegistry { : this.inboundSource?.(event); if (inboundSource) event.inboundSource = inboundSource; - // Whether to trigger inference: decided now for an ordinary message, - // and for a coalesced one only once its coalescer admits it. - let decideTrigger: (() => boolean) | undefined; + // Whether to trigger inference: decided now for an ordinary message; + // a coalesced one carries the filter's inputs (triggerInputs), decided + // once its coalescer admits it, so a refused occurrence, a retry or a + // retraction is never gated. if (this.shouldTriggerInference) { - const shouldTrigger = this.shouldTriggerInference; const textContent = message.content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map((b) => b.text) .join('\n'); - decideTrigger = () => shouldTrigger( - textContent, - { + const inputs: TriggerInputs = { + content: textContent, + metadata: { // The adapter's own metadata first; the protocol's fields after // it, always present (even undefined), so an adapter metadata key // can never stand in for them; and the frozen envelope last — @@ -1152,8 +1168,9 @@ export class ChannelRegistry { ...(message.tags ? { tags: message.tags } : {}), [INBOUND_SOURCE_KEY]: inboundSource, }, - ); - if (!coalesced) event.triggerInference = decideTrigger(); + }; + if (coalesced) event.triggerInputs = inputs; + else event.triggerInference = this.shouldTriggerInference(inputs.content, inputs.metadata); } if (coalesced) { @@ -1162,7 +1179,7 @@ export class ChannelRegistry { // the coalescer, which replaces, appends or withdraws. Malformed // `coalesce` is a per-message failure; siblings are unaffected. try { - const result = await this.handleCoalescedIncoming!(serverId, message, event, decideTrigger); + const result = await this.handleCoalescedIncoming!(serverId, message, event); if (result.accepted) markAccepted(); results.push(result); } catch (error) { diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index d355e8d27..36a885158 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -54,6 +54,16 @@ export interface McplPushEvent { /** Framework-owned source envelope, frozen at a coalesced occurrence's * acceptance so its later delivery cannot restamp it (inbound-source.ts). */ inboundSource?: InboundSource; + /** A coalesced push's trigger-filter inputs, until the framework decides + * its trigger when its coalescer first delivers or wakes for an admitted + * occurrence. Plain data, so a persisted occurrence keeps them. */ + triggerInputs?: TriggerInputs; +} + +/** What a trigger filter (shouldTriggerInference) is called with. */ +export interface TriggerInputs { + content: string; + metadata: Record; } // ============================================================================ @@ -183,11 +193,12 @@ export class PushHandler { pushEventFn: (event: McplPushEvent) => void, emitTraceFn: (event: { type: string; [key: string]: unknown }) => void, shouldTriggerInference?: (content: string, metadata: Record) => boolean, - /** RFC-006: hand a coalesced push to its coalescer. `decideTrigger` - * is the trigger decision, made only once the coalescer admits the - * occurrence for delivery: a refused one, a retry or a retraction is - * never gated (so never a route candidate). */ - private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent, decideTrigger?: () => boolean) => Promise, + /** RFC-006: hand a coalesced push to its coalescer. Its trigger isn't + * decided here: the event carries the filter's inputs + * (triggerInputs), and the framework decides only once the coalescer + * admits the occurrence for delivery, so a refused one, a retry or a + * retraction is never gated (never a route candidate). */ + private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent) => Promise, /** An ordinary (uncoalesced) push was just admitted: return its source * envelope, frozen now (mcpl/inbound-source.ts), before the trigger * filter reads it. Building it accepts nothing (inboundAccepted). @@ -326,10 +337,9 @@ export class PushHandler { if (inboundSource) pushEvent.inboundSource = inboundSource; // 6. The shouldTriggerInference callback: decided now for an ordinary - // push, and for a coalesced one only once its coalescer admits it. - let decideTrigger: (() => boolean) | undefined; + // push; a coalesced one carries the inputs, decided once its coalescer + // admits it. if (this.shouldTriggerInference) { - const shouldTrigger = this.shouldTriggerInference; const textContent = content .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') .map((b) => b.text) @@ -348,8 +358,8 @@ export class PushHandler { ...(params.tags ? { tags: params.tags } : {}), [INBOUND_SOURCE_KEY]: inboundSource, }; - decideTrigger = () => shouldTrigger(textContent, metadata); - if (!coalesced) pushEvent.triggerInference = decideTrigger(); + if (coalesced) pushEvent.triggerInputs = { content: textContent, metadata }; + else pushEvent.triggerInference = this.shouldTriggerInference(textContent, metadata); } if (coalesced) { @@ -357,7 +367,7 @@ export class PushHandler { // unread one, appends, or withdraws; it delivers through the same event // the ordinary path would have queued. try { - const result = await this.handleCoalesced!(serverId, params, pushEvent, decideTrigger); + const result = await this.handleCoalesced!(serverId, params, pushEvent); this.emitTraceFn({ type: 'mcpl:push_event', serverId, eventId: params.eventId, featureSet: params.featureSet, coalesce: result.coalesce }); responder?.respond(result); } catch (error) { diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 5d68ece6c..b31cbff88 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -154,15 +154,14 @@ test('the gate reads a message\'s thread from the protocol field, never from ada assert.equal(seen[1]!.threadId, '1700.0001'); }); -test('a coalesced message is gated with the host envelope its coalescer will freeze; adapter metadata can\'t pose as one', async () => { +test('a coalesced message carries its gate inputs, with the host envelope its coalescer will freeze; adapter metadata can\'t pose as one', async () => { const seen: Array> = []; const envelope = { kind: 'channel', lane: 'channels/incoming', coalesced: true, serverId: 'slack', binding: 'b1', channelId: 'slack:C1', messageId: 'm-c', acceptedAt: 1, }; let accepted = 0; - let gatedBeforeAdmission = -1; - const handed: Array<{ inboundSource?: unknown }> = []; + const handed: Array<{ inboundSource?: unknown; triggerInputs?: { content: string; metadata: Record } }> = []; const registry = new ChannelRegistry( { getServer: () => undefined } as unknown as McplServerRegistry, {} as FeatureSetManager, @@ -173,11 +172,8 @@ test('a coalesced message is gated with the host envelope its coalescer will fre inboundSource: () => undefined, inboundAccepted: () => { accepted++; }, coalescedSource: () => envelope as never, - // The coalescer admits the occurrence, and only then decides its trigger. - handleCoalescedIncoming: async (_serverId, message, event, decideTrigger) => { - gatedBeforeAdmission = seen.length; - handed.push(event as { inboundSource?: unknown }); - decideTrigger?.(); + handleCoalescedIncoming: async (_serverId, message, event) => { + handed.push(event as (typeof handed)[number]); return { messageId: message.messageId, accepted: true }; }, }, @@ -192,10 +188,9 @@ test('a coalesced message is gated with the host envelope its coalescer will fre }], } as never); assert.equal(accepted, 0, 'building the envelope accepts nothing: the coalescer observes its own admissions'); - assert.equal(gatedBeforeAdmission, 0, 'the gate is not consulted before the coalescer admits the occurrence'); - assert.equal(seen.length, 1); - assert.equal(seen[0]!.inboundSource, envelope, 'the gate reads the host envelope'); - assert.equal(handed[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate read'); + assert.equal(seen.length, 0, 'the gate is not consulted before the coalescer admits the occurrence'); + assert.equal(handed[0]!.triggerInputs!.metadata.inboundSource, envelope, 'the gate will read the host envelope'); + assert.equal(handed[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate will read'); }); test('an ordinary channels/incoming message is accepted only once its trigger filter returns: a filter that throws accepts nothing', async () => { diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index d436a750f..6f2e34e7b 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -623,7 +623,7 @@ describe('held prose drafts, end to end', () => { assert.match(kept?.note ?? '', /^delivery failed: /); const markers = () => h.texts().filter((t) => t.startsWith('[discord-send-failed]')); assert.equal(markers().length, 1); - assert.match(markers()[0]!, new RegExp(`Nothing was posted; it is held as draft ${kept!.id}, and drafts\\(action: "resend"`)); + assert.ok(markers()[0]!.includes(`Nothing was posted; it is held as draft ${kept!.id}. drafts(action: "resend", draftIds: ["${kept!.id}"], destination: "${ROOM}") sends it there once that can work, or name another destination.`), markers()[0]); h.command({ op: 'publish-mode', mode: 'no-receipt' }); await h.turn([createMockResponse([text('words that may have gone')])]); diff --git a/test/push-gate-metadata.test.ts b/test/push-gate-metadata.test.ts index 12eb874a4..5fbe62f47 100644 --- a/test/push-gate-metadata.test.ts +++ b/test/push-gate-metadata.test.ts @@ -44,7 +44,7 @@ test('origin keys cannot override the host\'s fields, and the envelope rides las assert.deepEqual(m[INBOUND_SOURCE_KEY], envelope, 'the frozen envelope, not anything the origin carried under its key'); }); -test('a coalesced push is gated with the host envelope its coalescer will freeze, and its acceptance is not observed at the gate', async () => { +test('a coalesced push carries its gate inputs, with the host envelope its coalescer will freeze, and nothing is gated or accepted yet', async () => { const seen: Array> = []; const envelope: InboundSource = { kind: 'channel', lane: 'push/event', coalesced: true, serverId: 'slack', binding: 'b1', channelId: 'slack:C1', @@ -52,20 +52,13 @@ test('a coalesced push is gated with the host envelope its coalescer will freeze }; let accepted = 0; let built = 0; - const coalescedEvents: Array<{ inboundSource?: InboundSource }> = []; - let gatedBeforeAdmission = -1; + const coalescedEvents: Array<{ inboundSource?: InboundSource; triggerInputs?: { content: string; metadata: Record } }> = []; const handler = new PushHandler( { validateInbound: () => {} } as unknown as FeatureSetManager, () => {}, () => {}, (_content, metadata) => { seen.push(metadata); return false; }, - // The coalescer admits the occurrence, and only then decides its trigger. - async (_serverId, _params, event, decideTrigger) => { - gatedBeforeAdmission = seen.length; - coalescedEvents.push(event); - decideTrigger?.(); - return { accepted: true } as never; - }, + async (_serverId, _params, event) => { coalescedEvents.push(event); return { accepted: true } as never; }, () => { accepted++; return undefined; }, (_serverId, params) => { built++; @@ -84,11 +77,13 @@ test('a coalesced push is gated with the host envelope its coalescer will freeze } as never); assert.equal(built, 1); assert.equal(accepted, 0, 'building a coalesced envelope accepts nothing: the coalescer observes its own admissions'); - assert.equal(gatedBeforeAdmission, 0, 'the gate is not consulted before the coalescer admits the occurrence'); - assert.equal(seen.length, 1); - assert.deepEqual(seen[0]![INBOUND_SOURCE_KEY], envelope, 'the gate reads the host envelope, not the origin\'s channel or key'); + assert.equal(seen.length, 0, 'the gate is not consulted before the coalescer admits the occurrence'); assert.equal(coalescedEvents.length, 1); - assert.equal(coalescedEvents[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate read'); + const inputs = coalescedEvents[0]!.triggerInputs!; + assert.deepEqual(inputs.metadata[INBOUND_SOURCE_KEY], envelope, 'the gate will read the host envelope, not the origin\'s channel or key'); + assert.equal(inputs.content, 'hi'); + assert.equal(coalescedEvents[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate will read'); + assert.deepEqual(structuredClone(inputs), inputs, 'the inputs are plain data, so a persisted occurrence keeps them'); }); test('with no host envelope, an origin key can\'t pose as one', async () => { From 6aaeb858640a90f29853b386838ca49116d29060 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 16:50:12 -0400 Subject: [PATCH 54/65] test(routing): explicit-prose-routing and channel-label-history expect targets on their server Two files the targeted runs had missed, both failing on the pin change (resolveProseTarget now returns the server its target resolved to, and a `>>`/`>>>` pin publishes with it): - explicit-prose-routing: its stub registry resolves targets on its own server, as the registry does, and its pin deliveries are expected as { serverId: 'stub', channelId } (pin()). The stub's routeSpeech now reads a target object's channelId for the outcome it returns; before, it returned the object itself as the channel id, which left one test waiting until the file's timeout. - channel-label-history: live resolutions (resolveProseTarget, and resolveProseTargetDurable on a live hit) carry serverId; resolutions from label history after a restart still name no server, since the history records none. Runs (on-vps): both files pass, 36/36. --- test/channel-label-history.test.ts | 8 ++++-- test/explicit-prose-routing.test.ts | 44 ++++++++++++++++------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/test/channel-label-history.test.ts b/test/channel-label-history.test.ts index b5c869593..2382fcecf 100644 --- a/test/channel-label-history.test.ts +++ b/test/channel-label-history.test.ts @@ -163,7 +163,7 @@ test('resolveProseTargetDurable prefers the live path and only falls back to his // Live path resolves uniquely against the live channel — must win over // the (stale) history entry for the ghost channel. const live = registry.resolveProseTarget('shared-label'); - assert.deepEqual(live, { channelId: 'discord:g1:live', label: 'shared-label' }); + assert.deepEqual(live, { serverId: 'discord', channelId: 'discord:g1:live', label: 'shared-label' }); assert.deepEqual(registry.resolveProseTargetDurable('shared-label'), live); }); @@ -285,6 +285,7 @@ test('a label-less descriptor from handleRegister does not crash and does not po // (this is what made it crash all four HistoryModule tools process-wide). await registry.handleRegister('discord', { channels: [descriptor('discord:g1:c2', '#general')] }); assert.deepEqual(registry.resolveProseTargetDurable('#general'), { + serverId: 'discord', channelId: 'discord:g1:c2', label: '#general', }); @@ -300,7 +301,7 @@ test('a channel renamed A -> B -> A is still resolvable by a name from BEFORE th await registry.handleChanged('discord', { updated: [descriptor('discord:g1:c1', '#alpha')] }); // Live-path check: '#alpha' is current again, so it resolves live. - assert.deepEqual(registry.resolveProseTarget('#alpha'), { channelId: 'discord:g1:c1', label: '#alpha' }); + assert.deepEqual(registry.resolveProseTarget('#alpha'), { serverId: 'discord', channelId: 'discord:g1:c1', label: '#alpha' }); // The interesting case: '#beta' is a name from BEFORE the most recent // rename (A -> B -> A), not the current OR immediately-previous label — @@ -377,6 +378,7 @@ test('a DM registered live resolves after a restart via @name, not just the lite // Live check: '@antra' already resolves live, before any restart — // establishes this is real DM-shaped addressing, not a coincidence. assert.deepEqual(first.resolveProseTarget('@antra'), { + serverId: 'discord', channelId: 'discord:dm:antra', label: 'DM: antra', }); @@ -444,6 +446,7 @@ test('a DM whose live username differs from its display label resolves by USERNA // label for DM matching — establishes this is real behavior to mirror, // not an invented requirement. assert.deepEqual(first.resolveProseTarget('@antra_tessera'), { + serverId: 'discord', channelId: 'discord:dm:42', label: 'DM: Tess', }); @@ -480,6 +483,7 @@ test('a DM classified only via metadata.channelType (non-conventional id/label) // Live check: classified as a DM live via metadata alone. assert.deepEqual(first.resolveProseTarget('<@42>'), { + serverId: 'discord', channelId: 'private-room-42', label: 'Tess', }); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 9d964406f..0e8d219d2 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -64,9 +64,13 @@ class ToolboxModule implements Module { } } +/** A `>>`/`>>>` target as the framework publishes it: on the server its + * target resolved to (the stub's). */ +const pin = (channelId: string) => ({ serverId: 'stub', channelId }); + /** Registry stub: two known channels (#alpha guild, laria's DM) + capture. */ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'false' | 'throw'> = []) { - const routed: Array<{ text: string; locus: string | null }> = []; + const routed: Array<{ text: string; locus: unknown }> = []; const known: Record = { '#alpha': { channelId: 'chan-alpha', label: '#alpha' }, '#cafe': { channelId: 'discord:g:cafe', label: '#cafe' }, @@ -77,17 +81,19 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals 'discord:dm:99': { channelId: 'discord:dm:99', label: 'DM: laria' }, }; const explicit: Record = { + // A target resolves on its server, as the registry's does. resolveProseTarget: (spec: string) => - known[spec] ?? { error: `no channel matches "${spec}"` }, - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { + known[spec] ? { serverId: 'stub', ...known[spec] } : { error: `no channel matches "${spec}"` }, + routeSpeech: async (_agent: string, text: string, locus?: string | null | { channelId: string }) => { routed.push({ text, locus: locus ?? null }); const behavior = plan.shift() ?? 'delivered'; if (behavior === 'throw') throw new Error('synthetic publication failure'); - return { delivered: behavior === 'delivered', channelId: locus ?? '' }; + const channelId = locus && typeof locus === 'object' ? locus.channelId : locus ?? ''; + return { delivered: behavior === 'delivered', channelId }; }, // The registry's outcome-returning form of routeSpeech, over the same plan. - deliverSpeech: async (agent: string, text: string, locus?: string | null) => { - const result = await (explicit.routeSpeech as (a: string, t: string, l?: string | null) => Promise<{ delivered: boolean; channelId: string }>)(agent, text, locus); + deliverSpeech: async (agent: string, text: string, locus?: string | null | { channelId: string }) => { + const result = await (explicit.routeSpeech as (a: string, t: string, l?: unknown) => Promise<{ delivered: boolean; channelId: string }>)(agent, text, locus); return result.delivered ? { status: 'delivered', destination: { serverId: 'stub', channelId: result.channelId }, at: Date.now() } : { status: 'failed', destination: { serverId: 'stub', channelId: result.channelId }, reason: 'stub reported delivered:false', at: Date.now() }; @@ -180,7 +186,7 @@ describe('explicit prose routing', () => { assert.equal(bounceNotices.length, 1, 'exactly one bounce notice'); assert.equal(routed.length, 1, 'only the prefixed resend delivered'); - assert.equal(routed[0]!.locus, 'discord:dm:99', 'delivered to the DM the model named'); + assert.deepEqual(routed[0]!.locus, pin('discord:dm:99'), 'delivered to the DM the model named, on its server'); assert.equal(routed[0]!.text, original, 'clipboard substitution is verbatim'); await framework.stop(); @@ -202,8 +208,8 @@ describe('explicit prose routing', () => { await framework.runUntilIdle(); assert.deepEqual(routed, [ - { text: 'first segment', locus: 'chan-alpha' }, - { text: 'second segment, no prefix', locus: 'chan-alpha' }, + { text: 'first segment', locus: pin('chan-alpha') }, + { text: 'second segment, no prefix', locus: pin('chan-alpha') }, ], 'prefix stripped on wire; sticky target carries the second segment'); await framework.stop(); @@ -247,7 +253,7 @@ describe('explicit prose routing', () => { assert.equal(membrane.calls.length, 2, 'a second inference ran without any external event'); assert.deepEqual(routed.map((r) => r.text), ['step one done, continuing', 'step two done']); - assert.deepEqual(routed.map((r) => r.locus), ['chan-alpha', 'chan-alpha']); + assert.deepEqual(routed.map((r) => r.locus), [pin('chan-alpha'), pin('chan-alpha')]); await framework.stop(); }); @@ -298,7 +304,7 @@ describe('explicit prose routing', () => { assert.equal(stream.receivedToolResults.length, 1); const result = JSON.stringify(stream.receivedToolResults[0]); assert.ok(result.includes('destination'), 'help text served as tool result'); - assert.deepEqual(routed, [{ text: 'got it', locus: 'chan-alpha' }]); + assert.deepEqual(routed, [{ text: 'got it', locus: pin('chan-alpha') }]); await framework.stop(); }); @@ -315,8 +321,8 @@ describe('explicit prose routing', () => { await framework.runUntilIdle(); assert.deepEqual(routed, [ - { text: 'part one for the channel', locus: 'chan-alpha' }, - { text: 'part two, just for you\nwith a second line\n>> quoted arrow stays in body', locus: 'discord:dm:99' }, + { text: 'part one for the channel', locus: pin('chan-alpha') }, + { text: 'part two, just for you\nwith a second line\n>> quoted arrow stays in body', locus: pin('discord:dm:99') }, ], 'two envelopes, two destinations; quoted arrow not split'); await framework.stop(); @@ -330,7 +336,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'A message meant for the café.', locus: 'discord:g:cafe' }]); + assert.deepEqual(routed, [{ text: 'A message meant for the café.', locus: pin('discord:g:cafe') }]); const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content) .filter(b => b.type === 'text').map(b => (b as { text: string }).text); @@ -379,8 +385,8 @@ describe('explicit prose routing', () => { trigger(framework); await framework.runUntilIdle(); assert.deepEqual(routed, [ - { text: 'first segment', locus: 'discord:g:cafe' }, - { text: 'second segment, no repeated envelope', locus: 'discord:g:cafe' }, + { text: 'first segment', locus: pin('discord:g:cafe') }, + { text: 'second segment, no repeated envelope', locus: pin('discord:g:cafe') }, ]); await framework.stop(); }); @@ -396,7 +402,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'body follows', locus: 'discord:g:cafe' }]); + assert.deepEqual(routed, [{ text: 'body follows', locus: pin('discord:g:cafe') }]); await framework.stop(); }); @@ -410,7 +416,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'first', locus: 'discord:g:cafe' }, { text: 'recovered', locus: 'world:commons' }]); + assert.deepEqual(routed, [{ text: 'first', locus: pin('discord:g:cafe') }, { text: 'recovered', locus: pin('world:commons') }]); await framework.stop(); }); @@ -426,7 +432,7 @@ describe('explicit prose routing', () => { trigger(framework); await framework.runUntilIdle(); assert.equal(routed.some(r => r.text.includes('plain text must remain suppressed')), false); - assert.deepEqual(routed.filter(r => r.text !== 'fails'), [{ text: 'first', locus: 'discord:g:cafe' }, { text: 'recovered', locus: 'world:commons' }]); + assert.deepEqual(routed.filter(r => r.text !== 'fails'), [{ text: 'first', locus: pin('discord:g:cafe') }, { text: 'recovered', locus: pin('world:commons') }]); const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.some(t => t.includes('[prose-routing]') && t.includes('>>>#channel {{unsent}}'))); From 9dc823952860d7d856b1725704d8ef0b997d6742 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 17:53:22 -0400 Subject: [PATCH 55/65] fix(routing): defaultOutgoing names its place as its publish does: null for the root, nothing once it can't go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCPL RFC-011 revision 5 gives §14.4's defaultOutgoing one meaning: it carries threadId exactly as the publish it describes will, a thread or null for the root on a declared channel, and nothing otherwise, so a server is never told a place no post will reach. - A route here is only ever a place the framework publishes to exactly when it forms, so defaultOutgoing carries the place: the route's thread, or null for a root route, where it used to leave the field out. - A route lasts its turn, but a declaration withdrawn mid-turn refuses the publish (publish() rechecks). buildChannelContext now checks the route's channel, on its server, against its current declaration (routeStillPublishable), and leaves defaultOutgoing out when the place would be refused, as when there is no route. - ChannelContext's type allows null, and its doc, the comment and the changelog fragment say so. Tests (channel-registry-routing): a root route's defaultOutgoing is { channelId, threadId: null }; the thread routes are on a channel that declares `exact`; and once that declaration is withdrawn, defaultOutgoing is absent, which fails with the check removed. The context suites (channel-registry-routing, batched-wake-locus, speech-route-turns, present-while-acting, targeted-publish) pass 118/118. Basil-1007 reviewed it and found the mid-turn withdrawal. --- changelog.d/speech-routes.changed.md | 5 +++-- src/mcpl/channel-registry.ts | 23 +++++++++++++++++++---- src/mcpl/types.ts | 9 +++++---- test/channel-registry-routing.test.ts | 10 +++++++++- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 8345c07f3..d4d1e47eb 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -97,8 +97,9 @@ indicator rather than one on a guessed server. `inference:started` and the failure log name the route's server beside its channel. The gate's batched-wake notice counts a thread apart from its channel's root, and - the beforeInference context's `defaultOutgoing` names the route's thread - (an extension of §14.4's shape). The `mcpl:speech-routed`, + the beforeInference context's `defaultOutgoing` carries the route's place + as its publish does: the thread, or `null` for the root (MCPL RFC-011 + revision 5's amendment of §14.4). The `mcpl:speech-routed`, `mcpl:speech-route-failed` and `prose:draft-resent` traces carry `threadId` (null for the root). - **A thread id is read one way** by `channel_publish`, `channel_open` and diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 73c5fd373..4aa062052 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1518,6 +1518,15 @@ export class ChannelRegistry { // Channel Context for beforeInference // ========================================================================== + /** Whether a route's place is one a publish can still go to now: its + * channel (on its server) still declares a target that allows the + * route's thread or root. */ + private routeStillPublishable(route: { serverId?: string; channelId: string; threadId?: string }): boolean { + const found = this.findExactEntry({ ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId }); + if ('error' in found) return false; + return !publishPlaceRefusal(declaredPublishTarget(found.entry.descriptor), route.threadId ?? null); + } + /** * Build channel context for inclusion in beforeInference params. * @@ -1531,7 +1540,11 @@ export class ChannelRegistry { // Without a channel route there is no outgoing channel to advertise — // never a latest-inbound guess. const route = agentName ? this.speechRouteResolver?.(agentName) : undefined; - const outgoing = route?.kind === 'channel' ? route.channelId : undefined; + // The outgoing channel, while its publish can still go there: a route + // lasts its turn, but a declaration withdrawn mid-turn refuses the + // publish, and the context never names a place no post will reach + // (MCPL RFC-011 §5). + const outgoing = route?.kind === 'channel' && this.routeStillPublishable(route) ? route.channelId : undefined; if (openChannels.length === 0 && !outgoing) { return undefined; @@ -1548,11 +1561,13 @@ export class ChannelRegistry { } if (outgoing) { - // Where the speech lands, its thread included, whether or not the - // route has a reply edge. + // Where the speech lands, whether or not the route has a reply edge, + // with its place exactly as the publish will carry it (MCPL RFC-011 + // §5): the thread, or null for the root. A route is only ever a place + // the framework publishes to exactly, so the place is always known. context.defaultOutgoing = { channelId: outgoing, - ...(route?.kind === 'channel' && route.threadId ? { threadId: route.threadId } : {}), + threadId: route?.kind === 'channel' ? route.threadId ?? null : null, }; } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index db1b23320..90f9f88cb 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -1019,12 +1019,13 @@ export interface ChannelContext { threadId?: string; }; - /** Default channel for outgoing messages, and the thread in it that the - * agent's plain speech posts into, when it has one (an extension of - * §14.4's shape: a server that doesn't read it loses nothing). */ + /** Default channel for outgoing messages, and the place in it the agent's + * plain speech is published to, exactly as that publish carries it (MCPL + * RFC-011 §5, amending §14.4): a thread, or null for the root. A server + * that doesn't read it loses nothing. */ defaultOutgoing?: { channelId: string; - threadId?: string; + threadId?: string | null; }; /** All candidate channels for this inference */ diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index b31cbff88..1aa23ac1e 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -378,10 +378,14 @@ test('buildChannelContext advertises the agent\'s own route and its reply edge, }; const { registry } = makeRegistry({ delivered: true }, () => undefined, (n) => routes[n] ?? { kind: 'none' }); seedRegistered(registry, 'discord', 'chanA', 'chanB'); + // chanB holds threads: a thread route is a place a publish goes to there. + const map = (registry as unknown as { channels: Map }> }).channels; + map.get('discord:chanB')!.descriptor.capabilities = { publish: { target: 'exact' } }; registry.handleIncoming('discord', incoming('chanA', 'hi from A')); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); - assert.equal(registry.buildChannelContext('conversation-chanA-g1')?.defaultOutgoing?.channelId, 'chanA'); + assert.deepEqual(registry.buildChannelContext('conversation-chanA-g1')?.defaultOutgoing, { channelId: 'chanA', threadId: null }, + "a root route names the root, as its publish's null does"); const scout = registry.buildChannelContext('scout'); assert.equal(scout?.defaultOutgoing?.channelId, 'chanB'); assert.deepEqual(scout?.incoming, { channelId: 'chanB', messageId: 'm-7', threadId: 't-1' }, @@ -389,6 +393,10 @@ test('buildChannelContext advertises the agent\'s own route and its reply edge, assert.deepEqual(scout?.defaultOutgoing, { channelId: 'chanB', threadId: 't-1' }); const opener = registry.buildChannelContext('opener'); assert.deepEqual(opener?.defaultOutgoing, { channelId: 'chanB', threadId: 't-2' }, 'the thread is named without a reply edge too'); + // A declaration withdrawn while the route stands: the publish would be + // refused, so the context names no place. + delete map.get('discord:chanB')!.descriptor.capabilities; + assert.equal(registry.buildChannelContext('scout')?.defaultOutgoing, undefined, 'no place a post would not reach'); assert.equal(opener?.incoming, undefined); for (const agent of ['held', 'tui', 'nobody']) { const ctx = registry.buildChannelContext(agent); From 2690edbcb45e2bca206a98a3388ded10ec19acf8 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 23:32:27 -0400 Subject: [PATCH 56/65] feat(routing): a wake that names no conversation keeps the fallback route nissa decided that speech routes keep today's fallback: a turn whose wake names no conversation (a heartbeat, a timer, a self-wake, a script or subagent wake, a nudge) speaks where the last message came in, as before. Removing that fallback is a separate behaviour change, out of this PR. - inferTurnRoute takes an optional fallback candidate, used only when the wake has no candidates at all: the route is that conversation with origin 'fallback' (its server, thread and the message as reply edge). It is checked like every route: an undeclared channel, a thread on a root channel, or an unresolved channel gives no route (not an "unroutable conversation in front of you": nothing in the wake named it), and the speech is held as drafts. A fork home and a wake's own conversations still win, and several conversations still hold. - The registry remembers the last message a channel delivered again (channels/incoming, once accepted; coalesced items when admitted), with its server, channel, message and thread, and forgets it when its channel is removed or its server goes. getDefaultPublishChannel() returns its channel id, as before speech routes; getLastInbound() gives the whole place. - The framework's fallbackCandidate takes it, except into a channel a conversation fork owns when this agent isn't that fork: the fork rule every other route keeps. - A fallback route's notice says why: "Nothing in this wake named a conversation, so your plain speech lands where the last message came in: ." - A fallback route is inferred, so a mid-turn arrival that addresses the resident from another conversation holds it, as it holds a 'trigger' route (suspendsRoute). - Changelog: speech-routes.changed.md says the fallback stays for wakes that name no conversation, checked like any route; the breaking entry for operators whose residents speak on scheduled wakes is gone, and the registry note keeps getDefaultPublishChannel(). channel_publish without a channelId goes to the current route, the fallback included. The changed fragment also credits agent-framework#190's author and the resident qa-engineer for diagnosing a console reply published to the channel that last spoke, which surface routes fix. - Comments in derivePushEventChannel, the gate's provenance and channel_publish's destination, and the no-destination draft note, say the same. Tests: inferTurnRoute takes the fallback with its thread and message, gives no route for an undeclared, thread-on-root or unresolved fallback, and lets a wake's own conversation, a fork home and a hold win; suspendsRoute holds a fallback route on an addressed arrival from elsewhere. Framework: a heartbeat turn takes the fallback and publishes there with its thread and the notice; the fallback skips a fork-owned channel and an undeclared one; a stale route never carries over (renamed from "has no speech route"). Registry: the last delivered message is remembered, the newest wins, and it is forgotten when its channel or server goes. Each piece's removal fails its test (8 mutants). --- changelog.d/speech-routes.breaking.md | 26 +++------- changelog.d/speech-routes.changed.md | 33 ++++++++----- src/framework.ts | 35 ++++++++++++-- src/gate/event-gate.ts | 8 +-- src/mcpl/channel-registry.ts | 39 ++++++++++++--- src/speech-routes.ts | 45 ++++++++++++++--- test/channel-registry-routing.test.ts | 34 +++++++++++++ test/speech-routes.test.ts | 40 +++++++++++++++ test/trunk-channel-routing.test.ts | 70 +++++++++++++++++++++++++-- 9 files changed, 274 insertions(+), 56 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index 13366f965..92aa699c1 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -1,8 +1,11 @@ - **Hosts and module authors using `ChannelRegistry` directly:** the registry - no longer tracks the most recent inbound channel. - - **Removed:** `getDefaultPublishChannel()`, and the - `activeChannelResolver` option. The new `speechRouteResolver(agentName)` - option replaces that option, and the framework supplies it. + no longer decides where speech goes. It still remembers the last message a + channel delivered, which the framework uses only for a turn whose wake + names no conversation (`getDefaultPublishChannel()` returns its channel + id, and `getLastInbound()` its server, channel, message and thread). + - **Removed:** the `activeChannelResolver` option. The new + `speechRouteResolver(agentName)` option replaces it, and the framework + supplies it. - **Changed:** `resolveLocus(agent)` returns only a conversation fork's home channel, or null. `getChannelServerId(id)` names a server only when exactly one registered the id, and returns null for an id several @@ -22,18 +25,3 @@ own handler decides from them once it admits the occurrence. - **Unchanged:** fork homes, explicit sends naming a channel, and `resolveDestination`. No sibling package version changes. -- **Operators whose residents speak on scheduled wakes:** a turn woken only - by events that name no conversation now has no speech route, unless the - resident is a conversation fork, whose home is its route. That covers - heartbeat-mcpl's message-mode heartbeats and its reminders, whose `origin` - names no conversation, as well as timers and self-wakes. Their plain speech - used to go to the most recent inbound channel. It is now held as - `no-destination` drafts, which the turn's `[delivered]` receipt names. - - **Migration:** a routine that should post names its place on every - wake. Before speaking, the resident can `channel_open` the channel, - which sets the speech route for that turn only, or it can send - explicitly with `channel_publish` and a `channelId`, or with the - connector's own send tool. The heartbeat's or reminder's own message can - say where. Held words can be resent with `drafts`. - - **Unaffected:** heartbeat-mcpl's silent ticks, whose prose was already - private. diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index d4d1e47eb..7d8f62d9a 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -1,6 +1,7 @@ - **Speech routes: unaddressed plain speech goes where the turn was - deliberately or actually addressed, never to the last channel that saw - traffic.** A turn's route is decided once at a true new turn: + deliberately or actually addressed, and to the last channel that saw + traffic only when nothing in its wake named a conversation.** A turn's + route is decided once at a true new turn: - a conversation fork's home channel always wins; - otherwise it is inferred from the whole wake. The addressed messages count if there are any, else all of the conversational ones. A local surface @@ -10,12 +11,22 @@ Several competing conversations start the turn **held**: unaddressed speech is kept as `ambiguous` drafts, and a `[routing]` notice names each - conversation's address. A turn with no conversation, such as a heartbeat, - a timer or a self-wake, has no route, and its speech is held as - `no-destination` drafts. Previously such speech went to the process-global - most-recent-inbound channel. That is also the fallback the batched-wake - routing fix kept for ambient-only batches, and this change replaces it. A surface - route shows speech on that surface and publishes it to no channel. + conversation's address. A wake that names no conversation at all, such as + a heartbeat, a timer or a self-wake, keeps the fallback: the conversation + of the last message any channel delivered, as before, now with its server, + thread and message named. It is checked like every route: a channel that + declares no publish target, a thread on a `root` channel, or a channel a + conversation fork owns gives no route, and the speech is held as + `no-destination` drafts. A `[routing]` notice says the speech goes where + the last message came in because nothing in the wake named a + conversation, and a mid-turn arrival that addresses the resident from + elsewhere holds a fallback route as it holds an inferred one. An + ambient-only batch is no longer such a wake: it routes to its own + conversation, or holds when it names several. A surface route shows speech + on that surface and publishes it to no channel, so a console turn's reply + stays on the console: anima-research/agent-framework#190, by + @rufalupa666-netizen with the resident qa-engineer, found and diagnosed + such a reply published to the channel that last spoke. - Context-budget restarts, guard retries and framework retries keep the turn's route, and everything else the turn has chosen or done: its explicit-send engagement, its `>>` or `>>>` choice, and the deliveries and @@ -45,9 +56,9 @@ new `setSpeechTarget: false` opens the channel for reading only, and the result says where speech goes then. - **`channel_publish` is an explicit send through the publish executor.** - Without `channelId` it goes to the caller's current route. With no route - (held, a local surface, or none) it is refused with the reason, and it no - longer uses the most recent inbound channel. A new optional `serverId` + Without `channelId` it goes to the caller's current route, the fallback + included. With no route (held, a local surface, or none) it is refused + with the reason, rather than going to the most recent inbound channel. A new optional `serverId` disambiguates a channel id that several servers register; a conversation fork may name a server only as its home's sole registrant, since the same id on another server is another conversation. Its receipt diff --git a/src/framework.ts b/src/framework.ts index fd2830417..8ea3edb2b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8840,7 +8840,8 @@ export class AgentFramework { * fix works even against a discord-mcpl build that predates `mcplChannelId`. * Returns undefined for push events with no channel provenance (heartbeats, * reminders, timers): they name no conversation, so they are no route - * candidate, and a turn they alone wake infers no speech route (shelf-355). + * candidate, and a turn they alone wake takes the fallback route, the last + * delivered message's conversation (shelf-355). */ private derivePushEventChannel( origin: Record | undefined, @@ -9346,7 +9347,7 @@ export class AgentFramework { private static readonly DRAFT_REASON_TEXT: Record = { 'explicit-send': 'an explicit send in the same round holds plain speech back', - 'no-destination': 'it had no destination — nothing this turn named a conversation to answer, or a failed routing envelope left plain speech waiting for a new valid target', + 'no-destination': 'it had no destination — nothing this turn named a conversation it could speak into, or a failed routing envelope left plain speech waiting for a new valid target', ambiguous: 'more than one conversation was waiting for you, so unaddressed speech was held rather than guessed', bounced: 'its routing prefix bounced', }; @@ -10080,6 +10081,19 @@ export class AgentFramework { }; } + /** + * The fallback for a wake that names no conversation: the conversation of + * the last message any channel delivered (its server, thread and message), + * as plain speech went before speech routes. Not when a conversation fork + * owns that channel and this agent isn't the fork: the fork rule that keeps + * every other route out of a fork's channel. + */ + private fallbackCandidate(agentName: string): RouteCandidate | null { + const last = this.channelRegistry?.getLastInbound?.(); + if (!last || !this.mayTakeRoute(agentName, last.channelId)) return null; + return this.candidateForChannel(last.channelId, false, Date.now(), last.serverId, last.messageId, last.threadId); + } + /** * A conversation fork's home as its standing route (it always wins). Homes * are bare channel ids: the server is the id's sole registrant, and a @@ -10466,6 +10480,10 @@ export class AgentFramework { text = `[routing] Your plain speech now goes to ${describeConversation(routeConversation(turn.route))}: ` + 'it is shown there and published to no channel. Channels need an explicit send tool.'; + } else if (turn.route.origin === 'fallback') { + text = + `[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message ` + + `came in: ${describeConversation(routeConversation(turn.route))}. Other channels need an explicit send tool.`; } else { text = `[routing] Your plain speech now lands in ${describeConversation(routeConversation(turn.route))}. ` + @@ -11335,11 +11353,18 @@ export class AgentFramework { } else if (attempt === 0) { // A true new turn: the route comes from a fork's home, else from the // wake's candidates — addressed ones first, one conversation only, - // else held (src/speech-routes.ts). Never from whichever channel - // last saw traffic. A silent control-plane wake has none. + // else held (src/speech-routes.ts). A wake that names no + // conversation at all (a heartbeat, a timer, a self-wake) takes the + // fallback, the last delivered message's conversation, as before + // speech routes. A silent control-plane wake has none. const turn: TurnRoute = trigger?.suppressProse ? { route: null } - : inferTurnRoute(trigger?.routeCandidates ?? [], this.homeRoute(agent.name), (c) => this.publishTargetOf(c)); + : inferTurnRoute( + trigger?.routeCandidates ?? [], + this.homeRoute(agent.name), + (c) => this.publishTargetOf(c), + this.fallbackCandidate(agent.name), + ); this.turnRoutes.set(agent.name, turn); this.midTurnInputSignals.delete(agent.name); if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 3c63f1c59..8bdb4b4ff 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -147,8 +147,9 @@ export interface WakeProvenance { * addressed the agent (mention / reply / DM) and the host resolved its * registered channel: the framework marks the wake addressed there. * Ambient-only batches set nothing. It is not the speech route, which the - * framework infers from `routeCandidates` (shelf-355); no wake falls back - * to the most recent inbound channel any more. */ + * framework infers from `routeCandidates` (shelf-355); only a wake whose + * batch names no conversation at all falls back to the last delivered + * message's conversation. */ routeChannelId?: string; /** Timestamp (ms) of the chosen event, so a consumer coalescing several * requests can order by event recency, not by flush order. */ @@ -1718,7 +1719,8 @@ export class EventGate { // event addressed the agent and its registered channel is known, // `routeChannelId` marks the wake addressed there. The turn's speech // route comes from `routeCandidates`, every conversation in the batch - // (shelf-355), never from whichever channel last saw any traffic. + // (shelf-355); the last delivered message's conversation is the route + // only when the batch names none. const provenance = wakeProvenance([...evicted, ...events]); for (const agentName of this.getAgentNamesFn()) { this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 4aa062052..15cde7b91 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -724,6 +724,10 @@ export class ChannelRegistry { /** Registered channels, keyed by `{serverId}:{channelId}`. */ private channels = new Map(); + /** The last message any channel delivered (`channels/incoming`, once + * accepted): a turn whose wake names no conversation speaks there, as + * before speech routes. Cleared when its channel or server goes. */ + private lastInbound: { serverId: string; channelId: string; messageId: string; threadId?: string } | null = null; /** * Most-recently-known label per channelId, replayed from @@ -928,6 +932,7 @@ export class ChannelRegistry { const key = `${serverId}:${channelId}`; const existed = this.channels.delete(key); this.stopTyping({ serverId, channelId }); + if (this.lastInbound?.serverId === serverId && this.lastInbound.channelId === channelId) this.lastInbound = null; addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' }); } } @@ -1098,11 +1103,18 @@ export class ChannelRegistry { continue; } - // An accepted message never retargets speech: a route comes from a - // turn's own wake or a deliberate choice, never from whichever channel - // last saw traffic (shelf-355). A coalesced item is "accepted" only - // once the coalescer admits it, so for those this runs after the hook. + // An accepted message retargets only the fallback: the route of a turn + // whose wake names no conversation. Every other route comes from a + // turn's own wake or a deliberate choice. A coalesced item is + // "accepted" only once the coalescer admits it, so for those this runs + // after the hook. const markAccepted = () => { + this.lastInbound = { + serverId, + channelId: message.channelId, + messageId: message.messageId, + ...(typeof message.threadId === 'string' && message.threadId ? { threadId: message.threadId } : {}), + }; // A server sending channels/incoming is authoritative evidence that // the transport is actually open. This repairs transient status only; // durable desired state still changes exclusively through lifecycle @@ -1390,6 +1402,17 @@ export class ChannelRegistry { // Accessors // ========================================================================== + /** The channel id of the last message any channel delivered, or null. */ + getDefaultPublishChannel(): string | null { + return this.lastInbound?.channelId ?? null; + } + + /** The last message any channel delivered: its server, channel, message and + * thread, for the route of a turn whose wake names no conversation. */ + getLastInbound(): { serverId: string; channelId: string; messageId: string; threadId?: string } | null { + return this.lastInbound ? { ...this.lastInbound } : null; + } + /** * Get the descriptor for a channel by its channelId (first match across * servers). Used by the conversation router for DM classification. @@ -1594,6 +1617,7 @@ export class ChannelRegistry { this.channels.delete(key); this.stopTyping({ serverId: entry.serverId, channelId: entry.descriptor.id }); } + if (this.lastInbound?.serverId === serverId) this.lastInbound = null; // Desired state and migration markers deliberately survive disconnects. } @@ -1610,6 +1634,7 @@ export class ChannelRegistry { // Clear channels map this.channels.clear(); + this.lastInbound = null; } // ========================================================================== @@ -3623,9 +3648,9 @@ export class ChannelRegistry { } // The destination: the one named — its root, or the thread named with - // it — else the caller's current speech route, thread included; never - // the most recent inbound channel (shelf-355), and never a thread - // borrowed from an earlier route for a channel named explicitly. + // it — else the caller's current speech route, thread included (for a + // turn whose wake named no conversation, its fallback route); never a + // thread borrowed from an earlier route for a channel named explicitly. // Supplied selectors are checked before any default applies: an empty // or non-string one is refused, never read as omission (which would // widen it to "your route" or "whichever server has the id"). null means diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 24aec0f7c..67bc2ae92 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -65,7 +65,10 @@ export type RouteOrigin = /** A conversation fork's home channel. */ | 'home' /** The resident's own channel_open (setSpeechTarget). */ - | 'open'; + | 'open' + /** A wake that named no conversation: the conversation of the last + * message any channel delivered, as before speech routes. */ + | 'fallback'; export type SpeechRoute = | { @@ -192,15 +195,21 @@ export function describeConversation(c: ConversationRef): string { * considered if there are any, else every (conversational) candidate; they * infer a route only when they name one conversation, from the newest * candidate in it (its message is the reply edge). Several conversations - * start the turn held, naming each; none leaves the turn without a route, - * and so does one that can't take plain speech — a channel the framework - * can't publish into exactly (`targetOf`, RFC-011), or one that couldn't be - * resolved — which the turn records as `unroutable`. + * start the turn held, naming each. One that can't take plain speech — a + * channel the framework can't publish into exactly (`targetOf`, RFC-011), + * or one that couldn't be resolved — leaves the turn without a route, which + * it records as `unroutable`. + * + * A wake with no candidates at all names no conversation (a heartbeat, a + * timer, a self-wake): its route is `fallback`, the conversation of the last + * message any channel delivered, when the caller has one and plain speech + * can go there exactly; otherwise it has none. */ export function inferTurnRoute( candidates: readonly RouteCandidate[], home: SpeechRoute | null | undefined, targetOf: PublishTargetOf, + fallback?: RouteCandidate | null, ): TurnRoute { if (home) { const conversation = routeConversation(home); @@ -211,7 +220,7 @@ export function inferTurnRoute( return { route: replyTo && home.kind === 'channel' ? { ...home, replyTo } : home }; } const pool = considered(candidates); - if (pool.length === 0) return { route: null }; + if (pool.length === 0) return { route: fallbackRoute(fallback, targetOf) }; const newestByConversation = new Map(); for (const candidate of pool) { const key = conversationKey(candidate.conversation); @@ -248,6 +257,25 @@ export function inferTurnRoute( }; } +/** The fallback conversation as a route, or null when there is none or plain + * speech can't go there exactly. Nothing in the wake named it, so it is no + * conversation in front of the resident: an unroutable fallback is simply + * no route, not an `unroutable` one. */ +function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: PublishTargetOf): SpeechRoute | null { + if (!fallback || fallback.unroutable) return null; + const c = fallback.conversation; + if (c.kind !== 'channel' || routeRefusal(c, targetOf)) return null; + return { + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + ...(fallback.messageId ? { replyTo: fallback.messageId } : {}), + ...(c.label ? { label: c.label } : {}), + origin: 'fallback', + }; +} + /** The candidates a route is inferred from: the addressed ones if any, else all. */ function considered(candidates: readonly RouteCandidate[]): readonly RouteCandidate[] { const addressed = candidates.filter((c) => c.addressed); @@ -280,7 +308,8 @@ export function isConversational(tags: readonly string[] | undefined, metadata?: /** * Whether a mid-turn arrival suspends the turn's route: only an inferred - * route (origin 'trigger') not already held, and only for an arrival from a + * route (origin 'trigger', or the 'fallback' a wake that named no + * conversation took) not already held, and only for an arrival from a * DIFFERENT conversation that addressed the resident, or that continues a * conversation the resident explicitly sent into this turn. Ambient chatter * never does; deliberate routes never are. It sees no tags: the caller passes @@ -292,7 +321,7 @@ export function suspendsRoute( arrival: { conversation: ConversationRef; addressed: boolean }, engaged: (c: ConversationRef) => boolean, ): boolean { - if (turn.hold || !turn.route || turn.route.origin !== 'trigger') return false; + if (turn.hold || !turn.route || (turn.route.origin !== 'trigger' && turn.route.origin !== 'fallback')) return false; if (conversationKey(arrival.conversation) === conversationKey(routeConversation(turn.route))) return false; return arrival.addressed || engaged(arrival.conversation); } diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 1aa23ac1e..33a16ee48 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -128,6 +128,40 @@ test('handleIncoming REJECTS an unknown channel instead of minting it (§14.5)', assert.equal(registry.resolveLocus('cairn'), null, 'a rejected message must not establish a locus'); }); +test('the registry remembers the last delivered message for the fallback, and forgets it when its channel or server goes', async () => { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:C2'); + assert.equal(registry.getLastInbound(), null); + assert.equal(registry.getDefaultPublishChannel(), null); + const deliver = (channelId: string, messageId: string, threadId?: string) => registry.handleIncoming('slack', { + messages: [{ + channelId, messageId, author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], ...(threadId ? { threadId } : {}), + }], + }); + await deliver('slack:C1', 'm-1', '1700.0001'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C1', messageId: 'm-1', threadId: '1700.0001' }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); + await deliver('slack:C2', 'm-2'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', messageId: 'm-2' }, 'the newest wins, a root message naming no thread'); + + // A removal of another channel leaves it; its own channel's removal forgets it. + await registry.handleChanged('slack', { removed: ['slack:C1'] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C2'); + await registry.handleChanged('slack', { removed: ['slack:C2'] }); + assert.equal(registry.getLastInbound(), null); + + seedRegistered(registry, 'slack', 'slack:C1'); + await deliver('slack:C1', 'm-3'); + registry.removeServer('slack'); + assert.equal(registry.getLastInbound(), null, 'its server going forgets it'); +}); + test('the gate reads a message\'s thread from the protocol field, never from adapter metadata', async () => { const seen: Array> = []; const registry = new ChannelRegistry( diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 7f8b2ddfd..91e21eea1 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -60,6 +60,39 @@ describe('inferTurnRoute', () => { assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere), { route: null }); }); + it('no candidates takes the fallback: the last delivered message\'s conversation, its thread and message included', () => { + const last = candidate(channel('last', { threadId: 't-9', label: '#last' }), false, 7, 'm-last'); + assert.deepEqual(inferTurnRoute([], undefined, exactEverywhere, last), { + route: { kind: 'channel', serverId: 'discord', channelId: 'last', threadId: 't-9', replyTo: 'm-last', label: '#last', origin: 'fallback' }, + }); + }); + + it('a fallback plain speech can\'t go to exactly is no route, not an unroutable one', () => { + // Nothing in the wake named it, so no conversation is in front of the + // resident: the turn simply has no destination. + const last = candidate(channel('last'), false, 7, 'm-last'); + assert.deepEqual(inferTurnRoute([], undefined, declaredNowhere, last), { route: null }); + const threaded = candidate(channel('last', { threadId: 't-9' }), false, 7); + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere, threaded), { route: null }, 'a thread on a root channel'); + const unresolved: RouteCandidate = { ...candidate(channel('last'), false, 7), unroutable: true }; + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere, unresolved), { route: null }); + }); + + it('the fallback only answers a wake with no candidates: a wake\'s own conversation and a fork home both win', () => { + const last = candidate(channel('last'), false, 7, 'm-last'); + const own = inferTurnRoute([candidate(channel('own'), false, 5, 'm-own')], undefined, rootEverywhere, last); + assert.equal((own.route as { channelId: string; origin: string }).channelId, 'own'); + assert.equal((own.route as { origin: string }).origin, 'trigger'); + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + assert.deepEqual(inferTurnRoute([], home, rootEverywhere, last), { route: home }); + // Two conversations still hold: the fallback never breaks a tie. + const held = inferTurnRoute([ + candidate(channel('a'), true, 1), candidate(channel('b'), true, 2), + ], undefined, rootEverywhere, last); + assert.equal(held.route, null); + assert.ok(held.hold); + }); + it('an addressed candidate outranks newer ambient chatter elsewhere', () => { const turn = inferTurnRoute([ candidate(channel('dm'), true, 1, 'm-dm'), @@ -197,6 +230,13 @@ describe('suspendsRoute', () => { assert.equal(suspendsRoute(routeA, { conversation: channel('dm'), addressed: true }, notEngaged), true); }); + it('a fallback route is inferred too: an addressed arrival from elsewhere suspends it, its own conversation doesn\'t', () => { + const fallback: TurnRoute = { route: { kind: 'channel', serverId: 'discord', channelId: 'A', origin: 'fallback' } }; + assert.equal(suspendsRoute(fallback, { conversation: channel('dm'), addressed: true }, notEngaged), true); + assert.equal(suspendsRoute(fallback, { conversation: channel('A'), addressed: true }, notEngaged), false); + assert.equal(suspendsRoute(fallback, { conversation: channel('dm'), addressed: false }, notEngaged), false); + }); + it('the same conversation never does, addressed or not', () => { assert.equal(suspendsRoute(routeA, { conversation: channel('A'), addressed: true }, notEngaged), false); }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 83894cd20..324980a0c 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -503,14 +503,15 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it('a no-trigger (heartbeat) turn has no speech route, whatever the previous turn had', async () => { + it('a no-trigger (heartbeat) turn never carries a stale route over: with no delivered message it has none', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); const i = internals(framework); // A stale route left by a prior turn must not carry over: a turn with no - // route candidates (a heartbeat/timer) has no destination, so its plain - // speech is held as a draft rather than sent anywhere. + // route candidates (a heartbeat/timer) takes only the fallback, the last + // delivered message's conversation, and with none delivered it has no + // destination, so its plain speech is held as a draft. i.turnRoutes.set('scout', { route: { kind: 'channel', channelId: 'discord:guild:stale', origin: 'trigger' } }); const scout = framework.getAgent('scout')!; await (framework as unknown as { @@ -523,4 +524,67 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'a no-trigger turn has no route'); await framework.stop(); }); + + /** A registry stand-in whose last delivered message is `last`. */ + function withLastInbound(target: 'exact' | 'root' | undefined, last: { serverId: string; channelId: string; messageId: string; threadId?: string }) { + const declaring = declaringRegistry(target); + (declaring.registry as Record).getLastInbound = () => ({ ...last }); + return declaring; + } + + async function heartbeat(framework: AgentFramework): Promise { + const scout = framework.getAgent('scout')!; + await (framework as unknown as { + startAgentStream(agent: unknown, trigger?: unknown): Promise; + }).startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await framework.runUntilIdle(); + } + + it('a no-trigger (heartbeat) turn takes the fallback, the last delivered message\'s conversation, and speaks there with its thread', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('exact', { + serverId: 'slack', channelId: 'slack:C1', messageId: 'm-last', threadId: '1700.01', + }); + internals(framework).channelRegistry = registry; + await heartbeat(framework); + const turn = internals(framework).turnRoutes.get('scout') as { route: Record | null }; + assert.deepEqual(turn.route, { + kind: 'channel', serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01', replyTo: 'm-last', origin: 'fallback', + }); + assert.equal(published.length, 1); + assert.equal(published[0]!.text, 'tick'); + assert.deepEqual( + { ...(published[0]!.to as Record) }, + { serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01' }, + 'published into the conversation the last message came from, its thread named', + ); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + assert.ok(texts.some((t) => t.startsWith('[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message came in')), + 'the resident is told why its speech goes there'); + await framework.stop(); + }); + + it('the fallback never goes into a channel a conversation fork owns, nor into one that declares no publish target', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + const framework = await makeFramework(); + const owned = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:forked', messageId: 'm-1' }); + internals(framework).channelRegistry = owned.registry; + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes + .set('fork-1', 'discord:guild:forked'); + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a fork\'s channel is the fork\'s'); + assert.deepEqual(owned.published, []); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.delete('fork-1'); + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tock' }])); + const undeclared = withLastInbound(undefined, { serverId: 'discord', channelId: 'discord:guild:plain', messageId: 'm-2' }); + internals(framework).channelRegistry = undeclared.registry; + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, + 'an undeclared channel is no fallback: no route, not an unroutable conversation in front of the resident'); + assert.deepEqual(undeclared.published, []); + await framework.stop(); + }); }); From f1dde7fec70511e42d8cecec00fcd11308e981e4 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 00:04:13 -0400 Subject: [PATCH 57/65] fix(routing): only a wake that names no conversation takes the fallback The fallback round (2690edb) applied the fallback to any turn whose route candidates came out empty. The haiku review sw-af256c (wf-389) found that an empty pool also comes from wakes that DID name a conversation: a reaction or a system marker (never a candidate), or a channel another agent's conversation fork owns (dropped by the fork rule). Those took the last delivered message's channel where the base gave no route (I1, a blocker, and I2). nissa's decision keeps the fallback for wakes that name no conversation at all, so: - InferenceRequest.namesConversation: set by every path whose event names a conversation, candidate or not: the gate's WakeProvenance (any batch event with a channel, an author, a route channel or a local surface), every channels/incoming wake, pushes whose envelope or origin names a channel, coalesced batch wakes, and module events from a local surface or naming a channel in metadata. Parked and batched requests OR it together. At a true new turn the fallback is consulted only when the trigger doesn't name one. - A local-surface event (external-message/api:message with no channel) that requests inference now always gives its surface candidate, with or without addMessages, so a console wake's speech stays on the console instead of falling back to a channel (I9; Clement-2260 reached the same contract, room-448 #74292). An api:inference-request nudge names nothing and still falls back. - The registry remembers only where a delivered MESSAGE came in (I4, I5, I7, I13): set once the ordinary message is queued (after its trigger filter) or the coalescer admits it, never for a reaction (isConversational on the item's tags), a withdrawal (coalesce.retract) or a channel removed meanwhile. markAccepted no longer dereferences a channel the coalescer's await may have seen removed (it threw a TypeError there, as on main). The record drops messageId: nothing reads it now. - A fallback route has no reply edge (I11): the last delivered message didn't trigger the turn, so beforeInference names defaultOutgoing and no `incoming`, per MCPL's contract for incoming. - A fallback held mid-turn says what it was (I20): "Your unaddressed plain speech was going to X, where the last message came in, because nothing in this wake named a conversation." The 'ambiguous' draft reason now reads "more than one place could have taken it". - Text: speech-routes.ts's header, trunk-channel-routing's header (Clement-2260's two), the speechRouteResolver and buildChannelContext comments, InferenceRequest.channelId's doc, and the gate's provenance docs no longer deny or misdescribe the fallback (I15). Changelog: the fallback paragraph says which wakes take it, what it answers and how it's checked; an unresolved addressed conversation competes only with other addressed ones (I10); resolveLocus is named by its real parameter (I18); the registry note says what it remembers. Tests: a reaction, a fork-filtered channel-incoming and a pushed reaction take no fallback with one remembered; a console event requesting inference without a message answers on the console; a threadless root fallback speaks at its root (I17); the gate marks a reaction-only batch as naming a conversation and a plain batch as not; the registry counts neither a reaction, a refused or unqueued message, a refused coalesced occurrence nor a withdrawal, counts an admitted one, forgets on stopAll (I3, I6), and survives a channel removed during admission; a fallback's hold notice; and, through the real registry and an RFC-011 fixture, a delivery followed by a heartbeat that speaks into the same thread (I8). Eleven mutants (af256-fb2-red) each fail a test, and the removal test fails against the old unguarded markAccepted. --- changelog.d/speech-routes.breaking.md | 14 ++-- changelog.d/speech-routes.changed.md | 54 +++++++++----- src/framework.ts | 96 +++++++++++++++++------- src/gate/event-gate.ts | 23 +++++- src/mcpl/channel-registry.ts | 67 +++++++++++------ src/speech-routes.ts | 13 +++- src/types/agent.ts | 13 +++- test/channel-registry-routing.test.ts | 88 +++++++++++++++++++++- test/event-gate.test.ts | 18 +++++ test/present-while-acting.test.ts | 39 ++++++++++ test/speech-routes.test.ts | 8 +- test/targeted-publish.test.ts | 19 +++++ test/trunk-channel-routing.test.ts | 102 ++++++++++++++++++++++++-- 13 files changed, 460 insertions(+), 94 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index 92aa699c1..24cc7f3bf 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -1,13 +1,15 @@ - **Hosts and module authors using `ChannelRegistry` directly:** the registry - no longer decides where speech goes. It still remembers the last message a - channel delivered, which the framework uses only for a turn whose wake - names no conversation (`getDefaultPublishChannel()` returns its channel - id, and `getLastInbound()` its server, channel, message and thread). + no longer decides where speech goes. It still remembers where the last + message a channel delivered came in, which the framework uses only for a + turn whose wake names no conversation. `getDefaultPublishChannel()` + returns its channel id, and `getLastInbound()` its server, channel and + thread. A reaction or a withdrawal doesn't count as a message. - **Removed:** the `activeChannelResolver` option. The new `speechRouteResolver(agentName)` option replaces it, and the framework supplies it. - - **Changed:** `resolveLocus(agent)` returns only a conversation fork's - home channel, or null. `getChannelServerId(id)` names a server only when + - **Changed:** `resolveLocus(conversationId)`, which the framework calls + with an agent's name, returns only a conversation fork's home channel, + or null. `getChannelServerId(id)` names a server only when exactly one registered the id, and returns null for an id several servers share, where it used to return the first registrant. `buildChannelContext(agent)` advertises the agent's own route and reply edge, and nothing for a held, surface-only or diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 7d8f62d9a..ddd18c317 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -11,22 +11,39 @@ Several competing conversations start the turn **held**: unaddressed speech is kept as `ambiguous` drafts, and a `[routing]` notice names each - conversation's address. A wake that names no conversation at all, such as - a heartbeat, a timer or a self-wake, keeps the fallback: the conversation - of the last message any channel delivered, as before, now with its server, - thread and message named. It is checked like every route: a channel that - declares no publish target, a thread on a `root` channel, or a channel a - conversation fork owns gives no route, and the speech is held as - `no-destination` drafts. A `[routing]` notice says the speech goes where - the last message came in because nothing in the wake named a - conversation, and a mid-turn arrival that addresses the resident from - elsewhere holds a fallback route as it holds an inferred one. An - ambient-only batch is no longer such a wake: it routes to its own - conversation, or holds when it names several. A surface route shows speech - on that surface and publishes it to no channel, so a console turn's reply - stays on the console: anima-research/agent-framework#190, by - @rufalupa666-netizen with the resident qa-engineer, found and diagnosed - such a reply published to the channel that last spoke. + conversation's address. + + **A wake that names no conversation at all keeps the fallback,** as + before: a heartbeat, a timer, a self-wake, a script or subagent wake, or an + `api:inference-request` nudge. + - **Where it goes:** where the last message any channel delivered came in, + now with its server and thread named. A reaction or a withdrawal is no + message, so it never becomes that place. + - **What it answers:** no message. It has no reply edge, so the + `beforeInference` context names where the speech lands + (`defaultOutgoing`) but no `incoming` message. + - **How it's checked:** like every route. A channel that declares no + publish target, a thread on a `root` channel, or a channel a + conversation fork owns gives no route, and the speech is held as + `no-destination` drafts. + - **What the resident is told:** a `[routing]` notice says the speech goes + where the last message came in because nothing in the wake named a + conversation. A mid-turn arrival that addresses the resident from + elsewhere holds a fallback route as it holds an inferred one, and the + notice says what the route was. + - **Which wakes name a conversation:** any event in the wake that names a + channel, a thread or a local surface does, whether or not it's a route + candidate. A reaction, a system marker or a channel another agent's + fork owns names one but answers nothing, so such a wake gets no route, + never the fallback. An ambient-only batch names its own conversations: + it routes to one, or holds when it names several. + + A surface route shows speech on that surface and publishes it to no + channel, so a console turn's reply stays on the console. That's so + whether its module added the input as a message or only requested + inference. anima-research/agent-framework#190, by @rufalupa666-netizen + with the resident qa-engineer, found and diagnosed such a reply published + to the channel that last spoke. - Context-budget restarts, guard retries and framework retries keep the turn's route, and everything else the turn has chosen or done: its explicit-send engagement, its `>>` or `>>>` choice, and the deliveries and @@ -72,8 +89,9 @@ its home conversation, or else its newest message there. - A batched gate wake reports every conversation in its batch to the framework (`WakeProvenance.routeCandidates`). An addressed event whose - registered channel the host can't resolve still competes, so the turn is - held, but it is never itself a route. A coalesced item is gated with the + registered channel the host can't resolve still competes with the other + addressed conversations: beside one, the turn is held; alone, the turn + has no route, since it is never itself a route. A coalesced item is gated with the host's source envelope too, the one its coalescer then freezes, and an `inboundSource` key in a connector's origin or metadata is never read as one. diff --git a/src/framework.ts b/src/framework.ts index 8ea3edb2b..216811425 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1840,8 +1840,10 @@ export class AgentFramework { ...(provenance?.routeChannelId && framework.mayTakeRoute(agentName, provenance.routeChannelId) ? { channelId: provenance.routeChannelId, addressed: true } : {}), - // None left, as for an agent the fork rule leaves out, is none. + // None left, as for an agent the fork rule leaves out, is none: + // the batch still named its conversations, so no fallback either. ...(routeCandidates.length > 0 ? { routeCandidates } : {}), + ...(provenance?.namesConversation ? { namesConversation: true as const } : {}), }); }, getAgentNames: () => [...framework.agents.keys()].filter( @@ -7102,14 +7104,23 @@ export class AgentFramework { }; const causes = competing.map(causeOf); const cause = causes.length === 1 ? causes[0]! : `${causes.slice(0, -1).join('; ')}; and ${causes.at(-1)}`; + // A fallback route answered no conversation: it was only where + // the last message came in, so it isn't one the speech could + // be answering. + const fallback = turn.route.origin === 'fallback'; const noticeContent: ContentBlock[] = [{ type: 'text', - text: - `[routing] ${cause}, so your unaddressed plain speech could now answer ` + - `${competing.length === 1 ? 'either conversation' : 'any of these conversations'}. ` + - 'For the rest of this turn it is held as drafts ' + - `instead of going to ${describeConversation(current)}. Answer ${competing.length === 1 ? 'either one' : 'any of them'} ` + - 'with an explicit send (or a channel_open); held words can be resent from your drafts.', + text: fallback + ? `[routing] ${cause}. Your unaddressed plain speech was going to ${describeConversation(current)}, ` + + 'where the last message came in, because nothing in this wake named a conversation. ' + + 'For the rest of this turn it is held as drafts instead. ' + + `Answer ${competing.length === 1 ? 'it' : 'any of them'} with an explicit send (or a channel_open); ` + + 'held words can be resent from your drafts.' + : `[routing] ${cause}, so your unaddressed plain speech could now answer ` + + `${competing.length === 1 ? 'either conversation' : 'any of these conversations'}. ` + + 'For the rest of this turn it is held as drafts ' + + `instead of going to ${describeConversation(current)}. Answer ${competing.length === 1 ? 'either one' : 'any of them'} ` + + 'with an explicit send (or a channel_open); held words can be resent from your drafts.', }]; const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata; try { @@ -7326,11 +7337,12 @@ export class AgentFramework { // Queue inference requests if (response.requestInference) { const source = 'source' in event ? (event as { source: string }).source : 'unknown'; + // The channel a module event names in its metadata, if any. + const named = extractGateFields(event).metadata.channelId; // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry) if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') { const fields = extractGateFields(event); - const named = fields.metadata.channelId; const decision = this.eventGate.evaluate({ ...fields, eventType: event.type, @@ -7352,10 +7364,14 @@ export class AgentFramework { : response.requestInference; // Console/API input is a conversation with a local surface, which - // counts as addressed: the turn's speech route can be that surface. - const surfaceCandidate = surface && response.addMessages?.length - ? this.candidateFromSource(surface, true) - : undefined; + // counts as addressed: the turn's speech route can be that surface, + // however its module put the input in context (with a message or + // without one), so a console reply never leaves the console. A module + // event that names a channel names that conversation too, though it + // is no candidate here: neither takes the fallback. An event naming + // neither (an `api:inference-request` nudge, a timer) names none. + const surfaceCandidate = surface ? this.candidateFromSource(surface, true) : undefined; + const namesConversation = !!surface || (typeof named === 'string' && named.length > 0); for (const agentName of targetAgents) { const agent = this.agents.get(agentName); if (agent && agent.canBeTriggeredBy(source)) { @@ -7365,6 +7381,7 @@ export class AgentFramework { source, timestamp: Date.now(), ...(surfaceCandidate ? { routeCandidates: [surfaceCandidate] } : {}), + ...(namesConversation ? { namesConversation: true as const } : {}), }); } } @@ -7471,6 +7488,8 @@ export class AgentFramework { this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, + // Its channel is named even when it is no candidate (a reaction). + namesConversation: true, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed: addressedHere, ...(candidate ? { routeCandidates: [candidate] } : {}), @@ -7556,6 +7575,9 @@ export class AgentFramework { source: event.serverId, timestamp: Date.now(), ...(takesChannel ? { channelId: event.channelId } : {}), + // Its channel is named even when it is no candidate here (a + // reaction, or a channel a fork owns): no fallback for this wake. + namesConversation: true, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed, // This message's conversation, as a speech-route candidate: the @@ -7666,6 +7688,7 @@ export class AgentFramework { source: event.serverId, timestamp: Date.now(), channelId: event.channelId, + namesConversation: true, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed: addressedHere, ...(candidate ? { routeCandidates: [candidate] } : {}), @@ -8541,9 +8564,11 @@ export class AgentFramework { 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); const addressedHere = isAddressedMessage(occ.tags, origin); - const candidate = isConversational(occ.tags) - ? this.candidateFromSource(occ.event.event.inboundSource, addressedHere) - : undefined; + // The conversation the batch names, whether or not it is a candidate (a + // reaction names its channel but answers nothing). + const named = this.candidateFromSource(occ.event.event.inboundSource, addressedHere); + const candidate = isConversational(occ.tags) ? named : undefined; + const namesConversation = !!named || !!channelId; for (const agentName of await this.coalescedAudience(occ)) { const takes = candidate && this.mayTakeCandidate(agentName, candidate) ? candidate : undefined; this.pendingRequests.push({ @@ -8552,6 +8577,7 @@ export class AgentFramework { source: occ.serverId, timestamp: Date.now(), channelId: channelId && this.mayTakeRoute(agentName, channelId) ? channelId : undefined, + ...(namesConversation ? { namesConversation: true as const } : {}), counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, addressed: addressedHere, ...(takes ? { routeCandidates: [takes] } : {}), @@ -8772,10 +8798,12 @@ export class AgentFramework { const pushAddressed = isAddressedMessage(event.tags, event.origin); // A push that names a conversation (a DM, an addressed-while-closed // message) is a route candidate like its channels/incoming - // counterpart; an unscoped push names none. - const pushCandidate = silentHeartbeat || !isConversational(event.tags) - ? undefined - : this.candidateFromSource(source, pushAddressed); + // counterpart; an unscoped push (a heartbeat, a reminder) names none, + // and only such a wake takes the fallback route. A reaction names its + // conversation without being a candidate. + const pushNamed = this.candidateFromSource(source, pushAddressed); + const pushNamesConversation = !!pushNamed || !!triggerChannel?.channelId; + const pushCandidate = silentHeartbeat || !isConversational(event.tags) ? undefined : pushNamed; for (const agentName of targetAgents) { // The fork rule, as for a gate batch: no agent takes a channel a fork // owns, and a fork takes only its home. @@ -8791,6 +8819,7 @@ export class AgentFramework { channelId: typingChannel, addressed: pushAddressed, ...(candidate ? { routeCandidates: [candidate] } : {}), + ...(pushNamesConversation ? { namesConversation: true as const } : {}), ...(silentHeartbeat ? { suppressProse: true, ephemeralSystemPrompt: @@ -8987,10 +9016,16 @@ export class AgentFramework { const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`; const prev = newestByKey.get(key); // The kept request carries both requests' route candidates: a - // parked wake's conversation still competes for the turn's route. + // parked wake's conversation still competes for the turn's route, + // and one that named a conversation keeps the turn off the fallback. const candidates = [...(prev?.routeCandidates ?? []), ...(request.routeCandidates ?? [])]; + const named = !!prev?.namesConversation || !!request.namesConversation; const kept = !prev || request.timestamp >= prev.timestamp ? request : prev; - newestByKey.set(key, candidates.length > 0 ? { ...kept, routeCandidates: candidates } : kept); + newestByKey.set(key, { + ...kept, + ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), + ...(named ? { namesConversation: true as const } : {}), + }); } this.pendingRequests.push(...newestByKey.values()); if (passThrough.length === 0) continue; @@ -9157,6 +9192,9 @@ export class AgentFramework { // Every conversation this batch carries: a true new turn infers its // speech route from all of them (shelf-355), never from one pick. routeCandidates: budgetRestart ? undefined : requests.flatMap((r) => r.routeCandidates ?? []), + // Whether any of them named a conversation, candidate or not: only a + // batch none of whose requests did takes the fallback route. + namesConversation: !budgetRestart && requests.some((r) => r.namesConversation) ? true : undefined, }); } } @@ -9348,7 +9386,7 @@ export class AgentFramework { private static readonly DRAFT_REASON_TEXT: Record = { 'explicit-send': 'an explicit send in the same round holds plain speech back', 'no-destination': 'it had no destination — nothing this turn named a conversation it could speak into, or a failed routing envelope left plain speech waiting for a new valid target', - ambiguous: 'more than one conversation was waiting for you, so unaddressed speech was held rather than guessed', + ambiguous: 'more than one place could have taken it, so unaddressed speech was held rather than guessed', bounced: 'its routing prefix bounced', }; @@ -10082,8 +10120,8 @@ export class AgentFramework { } /** - * The fallback for a wake that names no conversation: the conversation of - * the last message any channel delivered (its server, thread and message), + * The fallback for a wake that names no conversation: where the last + * message any channel delivered came in (its server, channel and thread), * as plain speech went before speech routes. Not when a conversation fork * owns that channel and this agent isn't the fork: the fork rule that keeps * every other route out of a fork's channel. @@ -10091,7 +10129,7 @@ export class AgentFramework { private fallbackCandidate(agentName: string): RouteCandidate | null { const last = this.channelRegistry?.getLastInbound?.(); if (!last || !this.mayTakeRoute(agentName, last.channelId)) return null; - return this.candidateForChannel(last.channelId, false, Date.now(), last.serverId, last.messageId, last.threadId); + return this.candidateForChannel(last.channelId, false, Date.now(), last.serverId, undefined, last.threadId); } /** @@ -11363,7 +11401,10 @@ export class AgentFramework { trigger?.routeCandidates ?? [], this.homeRoute(agent.name), (c) => this.publishTargetOf(c), - this.fallbackCandidate(agent.name), + // Only a wake that named no conversation, candidate or not, + // takes the fallback: a reaction, a system marker or a channel + // a fork owns names one, and gets no route as before. + trigger?.namesConversation ? null : this.fallbackCandidate(agent.name), ); this.turnRoutes.set(agent.name, turn); this.midTurnInputSignals.delete(agent.name); @@ -15377,7 +15418,8 @@ export class AgentFramework { // The agent's own speech route for the live turn (shelf-355): the // beforeInference channel context and a channel_publish without a // channel use it, so what the agent is told and where its words land - // never diverge. No latest-inbound fallback exists. + // never diverge. The latest-inbound fallback is itself a route, taken + // only by a turn whose wake named no conversation, so it agrees too. speechRouteResolver: (agentName) => this.speechRouteView(agentName), // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 8bdb4b4ff..9fc120511 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -148,8 +148,8 @@ export interface WakeProvenance { * registered channel: the framework marks the wake addressed there. * Ambient-only batches set nothing. It is not the speech route, which the * framework infers from `routeCandidates` (shelf-355); only a wake whose - * batch names no conversation at all falls back to the last delivered - * message's conversation. */ + * batch names no conversation at all (`namesConversation` unset) falls + * back to the last delivered message's conversation. */ routeChannelId?: string; /** Timestamp (ms) of the chosen event, so a consumer coalescing several * requests can order by event recency, not by flush order. */ @@ -163,6 +163,14 @@ export interface WakeProvenance { * trusting a single pick. */ routeCandidates?: GateRouteCandidate[]; + /** + * Some event in the batch names a conversation — a channel, an author, or + * a local surface — whether or not it is a route candidate: a reaction or a + * system marker names its channel but answers nothing. Unset only when + * nothing in the batch named one (a heartbeat, a timer): the one wake the + * framework's fallback route serves. + */ + namesConversation?: true; } interface DebounceState { @@ -256,7 +264,11 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e; } const routeCandidates: GateRouteCandidate[] = []; + let namesConversation = false; for (const e of events) { + if (e.routeChannelId || e.channelId || e.authorId || e.eventType === 'external-message' || e.eventType === 'api:message') { + namesConversation = true; + } // A reaction or system marker answers nothing: never a route candidate // (the framework's mid-turn rule, applied at turn start too). if (!e.conversational) continue; @@ -292,7 +304,11 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin } } const pick = newestAddressed ?? newest; - if (!pick) return routeCandidates.length > 0 ? { routeCandidates } : undefined; + if (!pick) { + return routeCandidates.length > 0 || namesConversation + ? { ...(routeCandidates.length > 0 ? { routeCandidates } : {}), ...(namesConversation ? { namesConversation: true as const } : {}) } + : undefined; + } // push-event channel ids are the adapter's raw ids (a Discord snowflake): // not a composite channel id, so not reported as one const channelId = pick.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined; @@ -304,6 +320,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}), at: pick.timestamp, ...(routeCandidates.length > 0 ? { routeCandidates } : {}), + ...(namesConversation ? { namesConversation: true as const } : {}), }; } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 15cde7b91..4755f12a4 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -42,7 +42,7 @@ import { isVisiblyEmptyContent } from './visible-content.js'; import { CapabilityGrant } from './capability-grant.js'; import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; import { McplRequestError } from './server-connection.js'; -import { describeConversation, readThreadId } from '../speech-routes.js'; +import { describeConversation, isConversational, readThreadId } from '../speech-routes.js'; import type { TriggerInputs } from './push-handler.js'; // ============================================================================ @@ -674,8 +674,10 @@ interface ChannelRegistryOptions { * goes, if anywhere. The same route feeds the beforeInference channel * context (defaultOutgoing, and its reply edge as `incoming`) and is the * destination of a channel_publish that names no channel — so what the - * agent is told and where it lands never diverge. There is no - * latest-inbound fallback: without a route there is nothing to default to. + * agent is told and where it lands never diverge. The registry's last + * delivered message reaches speech only through the framework, as the + * fallback route of a turn whose wake named no conversation: without a + * route there is nothing to default to. */ speechRouteResolver?: (agentName: string) => SpeechRouteView; } @@ -725,9 +727,11 @@ export class ChannelRegistry { /** Registered channels, keyed by `{serverId}:{channelId}`. */ private channels = new Map(); /** The last message any channel delivered (`channels/incoming`, once - * accepted): a turn whose wake names no conversation speaks there, as - * before speech routes. Cleared when its channel or server goes. */ - private lastInbound: { serverId: string; channelId: string; messageId: string; threadId?: string } | null = null; + * queued, or admitted by its coalescer): a turn whose wake names no + * conversation speaks there, as before speech routes. Only a message + * counts: never a reaction or a withdrawal. Cleared when its channel or + * server goes. */ + private lastInbound: { serverId: string; channelId: string; threadId?: string } | null = null; /** * Most-recently-known label per channelId, replayed from @@ -1103,23 +1107,32 @@ export class ChannelRegistry { continue; } - // An accepted message retargets only the fallback: the route of a turn - // whose wake names no conversation. Every other route comes from a - // turn's own wake or a deliberate choice. A coalesced item is - // "accepted" only once the coalescer admits it, so for those this runs - // after the hook. + // A coalesced item is "accepted" only once the coalescer admits it, so + // for those this runs after the hook, when its channel may be gone. const markAccepted = () => { + // A server sending channels/incoming is authoritative evidence that + // the transport is actually open. This repairs transient status only; + // durable desired state still changes exclusively through lifecycle + // operations. + const entry = this.channels.get(incomingKey); + if (entry) entry.open = true; + }; + // A delivered message retargets only the fallback: the route of a turn + // whose wake names no conversation. Every other route comes from a + // turn's own wake or a deliberate choice. It counts once it is queued + // (or admitted), on a channel still registered, and only as a message + // a conversation could continue: never a reaction or a withdrawal. + const rememberDelivered = () => { + if (!this.channels.has(incomingKey)) return; + if ((message.coalesce as { retract?: unknown } | null | undefined)?.retract === true) return; + // An MCPL item's tags alone: `metadata.system` is host-written, and + // an adapter's metadata can't mark its item as machinery. + if (!isConversational(message.tags)) return; this.lastInbound = { serverId, channelId: message.channelId, - messageId: message.messageId, ...(typeof message.threadId === 'string' && message.threadId ? { threadId: message.threadId } : {}), }; - // A server sending channels/incoming is authoritative evidence that - // the transport is actually open. This repairs transient status only; - // durable desired state still changes exclusively through lifecycle - // operations. - this.channels.get(incomingKey)!.open = true; }; if (!coalesced) markAccepted(); @@ -1192,7 +1205,10 @@ export class ChannelRegistry { // `coalesce` is a per-message failure; siblings are unaffected. try { const result = await this.handleCoalescedIncoming!(serverId, message, event); - if (result.accepted) markAccepted(); + if (result.accepted) { + markAccepted(); + rememberDelivered(); + } results.push(result); } catch (error) { const err = error as Error & { code?: number }; @@ -1211,6 +1227,7 @@ export class ChannelRegistry { // CustomEvent `${string}:${string}` type pattern but lacks an index signature. if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(event as unknown as ProcessEvent); + rememberDelivered(); // Collect per-message result results.push({ @@ -1407,9 +1424,10 @@ export class ChannelRegistry { return this.lastInbound?.channelId ?? null; } - /** The last message any channel delivered: its server, channel, message and - * thread, for the route of a turn whose wake names no conversation. */ - getLastInbound(): { serverId: string; channelId: string; messageId: string; threadId?: string } | null { + /** Where the last message any channel delivered came in: its server, + * channel and thread, for the route of a turn whose wake names no + * conversation. */ + getLastInbound(): { serverId: string; channelId: string; threadId?: string } | null { return this.lastInbound ? { ...this.lastInbound } : null; } @@ -1559,9 +1577,10 @@ export class ChannelRegistry { const openChannels = this.getOpenChannels(); // The agent's own speech route, the one its plain speech will actually - // use: the outgoing channel, and the message it answers as `incoming`. - // Without a channel route there is no outgoing channel to advertise — - // never a latest-inbound guess. + // use: the outgoing channel, and the message it answers as `incoming` + // (a fallback route answers none). Without a channel route there is no + // outgoing channel to advertise — never a latest-inbound guess beyond + // the fallback route itself. const route = agentName ? this.speechRouteResolver?.(agentName) : undefined; // The outgoing channel, while its publish can still go there: a route // lasts its turn, but a declaration withdrawn mid-turn refuses the diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 67bc2ae92..edaf63120 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -2,13 +2,16 @@ * Speech routes: where a resident's unaddressed plain speech goes (shelf-355). * * A route is decided deliberately or inferred from what triggered the turn, - * never from whichever channel last saw traffic: + * and comes from whichever channel last saw traffic only when nothing did: * - deliberate: a conversation fork's home channel, or a channel the resident * opened with `channel_open` (setSpeechTarget, the default); a hybrid * `>>>target` prefix is deliberate too and lives in its own pin; * - inferred, at a true new turn, from the wake's candidates: the addressed * ones if any, else the conversational ones. Only candidates that name ONE - * conversation infer a route; several conversations start the turn held. + * conversation infer a route; several conversations start the turn held; + * - fallback, at a true new turn whose wake has no candidates at all (a + * heartbeat, a timer, a self-wake): the conversation of the last message + * any channel delivered, as before speech routes, checked like any route. * * A held turn (competing conversations at turn start, or a competing * addressed or engaged arrival mid-turn) keeps unaddressed speech as drafts @@ -67,7 +70,8 @@ export type RouteOrigin = /** The resident's own channel_open (setSpeechTarget). */ | 'open' /** A wake that named no conversation: the conversation of the last - * message any channel delivered, as before speech routes. */ + * message any channel delivered (its thread, but no reply edge), as + * before speech routes. */ | 'fallback'; export type SpeechRoute = @@ -265,12 +269,13 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu if (!fallback || fallback.unroutable) return null; const c = fallback.conversation; if (c.kind !== 'channel' || routeRefusal(c, targetOf)) return null; + // No reply edge: the turn answers no message, and the last delivered one + // didn't trigger it (MCPL's `incoming` names only a triggering message). return { kind: 'channel', ...(c.serverId ? { serverId: c.serverId } : {}), channelId: c.channelId, ...(c.threadId ? { threadId: c.threadId } : {}), - ...(fallback.messageId ? { replyTo: fallback.messageId } : {}), ...(c.label ? { label: c.label } : {}), origin: 'fallback', }; diff --git a/src/types/agent.ts b/src/types/agent.ts index d9ba89faf..a7c1712d1 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -325,8 +325,7 @@ export interface InferenceRequest { * turn's plain speech goes: that is the turn's speech route, inferred from * `routeCandidates` (src/speech-routes.ts). Undefined for non-channel wakes * (heartbeat, timers, module events), which name no conversation; a turn - * they alone wake has no inferred route, and nothing falls back to the most - * recent inbound channel. + * they alone wake takes the fallback route (`namesConversation`). */ channelId?: string; /** @@ -367,6 +366,16 @@ export interface InferenceRequest { * batch: the addressed ones if any, else all; one conversation only. */ routeCandidates?: import('../speech-routes.js').RouteCandidate[]; + /** + * The wake names a conversation — a channel, a thread or a local surface — + * whether or not any of it became a route candidate: a reaction, a system + * marker, or a channel a conversation fork owns names one but answers + * nothing here. A turn none of whose requests names one (a heartbeat, a + * timer, a self-wake, a script or subagent wake, a nudge) takes the + * fallback route, the last delivered message's conversation; every other + * turn's route comes from its candidates alone. + */ + namesConversation?: true; /** Suppress every automatic plain-prose delivery for this logical turn. * Explicit tool calls remain available. Used by authenticated silent wakes. */ suppressProse?: boolean; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 33a16ee48..f028b1381 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -145,10 +145,10 @@ test('the registry remembers the last delivered message for the fallback, and fo }], }); await deliver('slack:C1', 'm-1', '1700.0001'); - assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C1', messageId: 'm-1', threadId: '1700.0001' }); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C1', threadId: '1700.0001' }); assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); await deliver('slack:C2', 'm-2'); - assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', messageId: 'm-2' }, 'the newest wins, a root message naming no thread'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2' }, 'the newest wins, a root message naming no thread'); // A removal of another channel leaves it; its own channel's removal forgets it. await registry.handleChanged('slack', { removed: ['slack:C1'] }); @@ -162,6 +162,90 @@ test('the registry remembers the last delivered message for the fallback, and fo assert.equal(registry.getLastInbound(), null, 'its server going forgets it'); }); +test('only a delivered message becomes the fallback\'s place: not a reaction, a refused or unqueued message, or a withdrawal', async () => { + let throwFilter = false; + let admit = true; + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { + shouldTriggerInference: () => { if (throwFilter) throw new Error('filter broke'); return true; }, + handleCoalescedIncoming: async (_serverId, message) => ({ messageId: message.messageId, accepted: admit }), + }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:C2', 'slack:C3'); + const msg = (channelId: string, messageId: string, extra: Record = {}) => ({ + channelId, messageId, author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], ...extra, + }); + await registry.handleIncoming('slack', { messages: [msg('slack:C1', 'm-1')] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); + + // A reaction answers nothing: it isn't a message a conversation continues. + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'r-1', { tags: ['chat:reaction'] })] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a reaction never becomes the place'); + + // An unregistered channel's message is refused before anything counts. + await registry.handleIncoming('slack', { messages: [msg('slack:NOPE', 'm-x')] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a refused message never becomes the place'); + + // A message whose trigger filter throws is never queued, so never counts. + throwFilter = true; + await assert.rejects(registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-2')] }), /filter broke/); + throwFilter = false; + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'an unqueued message never becomes the place'); + + // A coalesced message counts once its coalescer admits it, not before. + admit = false; + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-3', { eventId: 'e-3', coalesce: { key: 'k' } })] } as never); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a refused coalesced occurrence never counts'); + admit = true; + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-4', { eventId: 'e-4', coalesce: { key: 'k' }, threadId: 't-4' })] } as never); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'an admitted one counts'); + + // A withdrawal the coalescer admits is no message: the place stays. + await registry.handleIncoming('slack', { + messages: [msg('slack:C3', 'm-5', { eventId: 'e-5', coalesce: { key: 'k2', retract: true }, content: [] })], + } as never); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a withdrawal never counts'); + + // Stopping forgets it. + registry.stopAll(); + assert.equal(registry.getLastInbound(), null); + assert.equal(registry.getDefaultPublishChannel(), null); +}); + +test('a coalesced message whose channel goes while its coalescer decides counts for nothing, and breaks nothing', async () => { + let registry: ChannelRegistry; + registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { + handleCoalescedIncoming: async (serverId, message) => { + // The channel is withdrawn while the occurrence is being admitted. + await registry.handleChanged(serverId, { removed: [message.channelId] }); + return { messageId: message.messageId, accepted: true }; + }, + }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:GONE'); + await registry.handleIncoming('slack', { messages: [{ + channelId: 'slack:C1', messageId: 'm-1', author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], + }] }); + let answered: { results: Array<{ accepted: boolean; reason?: string }> } | undefined; + await registry.handleIncoming('slack', { messages: [{ + channelId: 'slack:GONE', messageId: 'm-2', author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'bye' }], eventId: 'e-2', coalesce: { key: 'k' }, + }] } as never, { respond: (r: unknown) => { answered = r as typeof answered; } } as never); + assert.deepEqual(answered?.results.map((r) => [r.accepted, r.reason]), [[true, undefined]], 'the admission itself stands'); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a channel removed meanwhile never becomes the place'); +}); + test('the gate reads a message\'s thread from the protocol field, never from adapter metadata', async () => { const seen: Array> = []; const registry = new ChannelRegistry( diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 431ef966f..dc73fa298 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -792,6 +792,22 @@ describe('debounce', () => { assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:general']); }); + it('a reaction-only batch names its conversation though it has no candidate, so it never takes the fallback route', async () => { + const path = writeConfig('debounce-route-reaction-only.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: '👍', + tags: ['chat:reaction', 'chat:addressed'], metadata: { authorId: '1', messageId: 'r-1' } })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 1); + assert.strictEqual(inferenceRequests[0].routeCandidates, undefined, 'a reaction is no candidate'); + assert.strictEqual((inferenceRequests[0] as { namesConversation?: true }).namesConversation, true, 'but it names its conversation'); + }); + it('a batched wake with no channel-bearing event carries no provenance', async () => { const path = writeConfig('debounce-noprov.json', { policies: [ @@ -805,6 +821,8 @@ describe('debounce', () => { assert.strictEqual(inferenceRequests.length, 1); assert.strictEqual(inferenceRequests[0].channelId, undefined); assert.strictEqual(inferenceRequests[0].counterparty, undefined); + assert.strictEqual((inferenceRequests[0] as { namesConversation?: true }).namesConversation, undefined, + 'a batch that names no conversation is the one wake the fallback route serves'); }); it('quotes content for non-MCPL events (no context entry of their own)', async () => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 1ab7ccc96..d6bd60b09 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -474,6 +474,45 @@ describe('present while acting', () => { await framework.stop(); }); + it('a fallback route is held by an addressed mid-turn arrival, and the notice says it was only where the last message came in', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Checking the logs.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Answering the person who addressed me.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + // Where the last message came in: the fallback of a wake that names none. + (framework as unknown as { channelRegistry: Record }).channelRegistry.getLastInbound = + () => ({ serverId: 'srv', channelId: 'chan-A' }); + module.toolDelayMs = 25; + module.interjection = 'hey, quick question over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + + // A heartbeat-like wake: it names no conversation. + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'timer', source: 'timer', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Checking the logs.', locus: 'chan-A' }], 'the fallback carried the first round'); + const [held] = heldDrafts(framework); + assert.equal(held?.text, 'Answering the person who addressed me.'); + assert.equal(held?.reason, 'ambiguous'); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok( + wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn. Your unaddressed plain speech was going to (srv / chan-A), where the last message came in, because nothing in this wake named a conversation.')), + 'the hold notice says what the fallback was, not that it could be answering it', + ); + await framework.stop(); + }); + it('a mid-turn hold names every conversation that suspends the route in that round', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Station four, proceeding.' }, diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 91e21eea1..6dca66cd0 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -60,10 +60,14 @@ describe('inferTurnRoute', () => { assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere), { route: null }); }); - it('no candidates takes the fallback: the last delivered message\'s conversation, its thread and message included', () => { + it('no candidates takes the fallback: where the last delivered message came in, its thread included, answering no message', () => { const last = candidate(channel('last', { threadId: 't-9', label: '#last' }), false, 7, 'm-last'); assert.deepEqual(inferTurnRoute([], undefined, exactEverywhere, last), { - route: { kind: 'channel', serverId: 'discord', channelId: 'last', threadId: 't-9', replyTo: 'm-last', label: '#last', origin: 'fallback' }, + route: { kind: 'channel', serverId: 'discord', channelId: 'last', threadId: 't-9', label: '#last', origin: 'fallback' }, + }, 'no reply edge: the last message did not trigger this turn'); + // A root channel's fallback is its root. + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere, candidate(channel('plain'), false, 7)), { + route: { kind: 'channel', serverId: 'discord', channelId: 'plain', origin: 'fallback' }, }); }); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts index 85b677c61..563b114ba 100644 --- a/test/targeted-publish.test.ts +++ b/test/targeted-publish.test.ts @@ -123,6 +123,25 @@ describe('targeted publish (MCPL RFC-011)', () => { assert.ok(texts().some((t) => t.startsWith('[delivered]') && t.includes('thread t-1')), texts().filter((t) => t.startsWith('[delivered]')).join(' | ')); }); + it('a wake that names no conversation speaks where the last message came in, its thread named, through the real registry', async () => { + // A delivered message in a thread: its own turn answers it there. + await turn([say('answering the thread')], ROOM, { threadId: 't-7' }); + // Then a heartbeat-like wake that names no conversation: the fallback. + membrane.pushResponse(say('a scheduled note')); + const before = membrane.calls.length; + await (framework as unknown as { startAgentStream(a: unknown, t?: unknown): Promise }) + .startAgentStream(framework.getAgent('scout')!, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await until(() => membrane.calls.length > before, 'heartbeat turn start'); + await framework.runUntilIdle(); + await until(() => publishes().length >= 2, 'the fallback publish'); + assert.deepEqual(publishes().map((p) => [p.event, p.channelId, p.threadId, p.text]), [ + ['publish', ROOM, 't-7', 'answering the thread'], + ['publish', ROOM, 't-7', 'a scheduled note'], + ], 'the fallback named the thread the last message came in'); + assert.ok(texts().some((t) => t.startsWith('[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message came in')) === false, + 'the place is unchanged since the last announced route, so no new notice'); + }); + it('plain speech answering the channel itself names the root explicitly: threadId null on the wire', async () => { await turn([say('at the top level')], ROOM); assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[ROOM, null]]); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 324980a0c..36dbad956 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -9,7 +9,9 @@ * origin.mcplChannelId. * - a channel-incoming turn, and a DM push-event turn, take their * triggering conversation as the turn's route; a reaction is no candidate. - * - the route belongs to the current turn, and a no-trigger turn has none. + * - the route belongs to the current turn; a no-trigger turn takes only the + * fallback, the last delivered message's conversation, and none without + * one. * - threads are routes only where the channel's connector posts into named * threads (MCPL RFC-011 exact), and a channel that declares no publish * target is no route at all. @@ -526,7 +528,7 @@ describe('Trunk channel routing (item-3 redux)', () => { }); /** A registry stand-in whose last delivered message is `last`. */ - function withLastInbound(target: 'exact' | 'root' | undefined, last: { serverId: string; channelId: string; messageId: string; threadId?: string }) { + function withLastInbound(target: 'exact' | 'root' | undefined, last: { serverId: string; channelId: string; threadId?: string }) { const declaring = declaringRegistry(target); (declaring.registry as Record).getLastInbound = () => ({ ...last }); return declaring; @@ -544,14 +546,18 @@ describe('Trunk channel routing (item-3 redux)', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('exact', { - serverId: 'slack', channelId: 'slack:C1', messageId: 'm-last', threadId: '1700.01', + serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01', }); internals(framework).channelRegistry = registry; await heartbeat(framework); const turn = internals(framework).turnRoutes.get('scout') as { route: Record | null }; assert.deepEqual(turn.route, { - kind: 'channel', serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01', replyTo: 'm-last', origin: 'fallback', + kind: 'channel', serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01', origin: 'fallback', }); + // It answers no message, so the beforeInference context names where the + // speech lands and no `incoming` (MCPL's triggering message). + const view = (framework as unknown as { speechRouteView(a: string): Record }).speechRouteView('scout'); + assert.deepEqual(view, { kind: 'channel', serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01' }); assert.equal(published.length, 1); assert.equal(published[0]!.text, 'tick'); assert.deepEqual( @@ -566,10 +572,94 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('a wake that names a conversation never takes the fallback: a reaction, or a channel another agent\'s fork owns', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + // A reaction names its channel but answers nothing: no route, as before + // the fallback, rather than the last delivered message's channel. + framework.pushEvent({ + ...(channelIncoming('discord:guild:chanA', '👍') as unknown as Record), + tags: ['chat:reaction', 'chat:addressed'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a reaction-woken turn takes no fallback'); + + // A message in a channel another agent's fork owns: the trunk takes no + // candidate there, and no fallback either. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not mine' }])); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes + .set('fork-1', 'discord:guild:forked'); + framework.pushEvent(channelIncoming('discord:guild:forked', 'hello fork')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a fork-filtered wake takes no fallback'); + + // A pushed reaction in a DM names that DM: no fallback either. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'saw it' }])); + framework.pushEvent({ + ...(dmPushEvent('42', '👍') as unknown as Record), + tags: ['chat:reaction'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 3); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a pushed reaction takes no fallback'); + assert.deepEqual(published, [], 'nothing went to the last delivered message\'s channel'); + await framework.stop(); + }); + + it('a console message requesting inference without adding a message still answers on the console, never the fallback', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'on the console' }])); + // A module that requests inference for console input without adding it + // as a message (the input reaches context some other way). + const nudger = { + name: 'nudger', + async start() {}, + async stop() {}, + getTools: () => [], + async handleToolCall() { return { success: false, error: 'none', isError: true }; }, + async onProcess(event: ProcessEvent) { + return event.type === 'external-message' ? { requestInference: true } : {}; + }, + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'nudge.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [nudger as never], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'status?', metadata: {} } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + const turn = internals(framework).turnRoutes.get('scout') as { route: { kind: string; surface?: string } | null }; + assert.equal(turn.route?.kind, 'surface', 'the console is the route'); + assert.equal(turn.route?.surface, 'tui'); + assert.deepEqual(published, [], 'nothing was published to the last channel'); + await framework.stop(); + }); + + it('a threadless root-channel fallback speaks at its root when no fork owns it', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:general' }); + internals(framework).channelRegistry = registry; + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { + route: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:general', origin: 'fallback' }, + }); + assert.equal(published.length, 1); + assert.equal((published[0]!.to as { channelId: string }).channelId, 'discord:guild:general'); + await framework.stop(); + }); + it('the fallback never goes into a channel a conversation fork owns, nor into one that declares no publish target', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); - const owned = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:forked', messageId: 'm-1' }); + const owned = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:forked' }); internals(framework).channelRegistry = owned.registry; (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes .set('fork-1', 'discord:guild:forked'); @@ -579,7 +669,7 @@ describe('Trunk channel routing (item-3 redux)', () => { (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.delete('fork-1'); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tock' }])); - const undeclared = withLastInbound(undefined, { serverId: 'discord', channelId: 'discord:guild:plain', messageId: 'm-2' }); + const undeclared = withLastInbound(undefined, { serverId: 'discord', channelId: 'discord:guild:plain' }); internals(framework).channelRegistry = undeclared.registry; await heartbeat(framework); assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, From 3f09c91dedf0c2ba0a4280eefc2a3cdf117391f4 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 00:46:03 -0400 Subject: [PATCH 58/65] fix(routing): every lane says whether a wake names a conversation; retries keep their route; a cached coalesced retry is no delivery From the haiku review of f1dde7f (sw-af256d, wf-395) and Clement-2260's probe (room-448 #74615): - A cached coalesced retry moved the fallback back (I12, Clement's J3592): the registry read the coalesced result's `accepted`, which is true for a producer's retry too. The registry's own path no longer counts coalesced items at all. The framework's coalescer hook `accepted`, which runs once per fresh admission and never for a retry, calls the registry's new public noteDelivered for a channel-lane, non-withdrawal occurrence. Ordinary messages call it once queued. noteDelivered also refuses a deletion notice (chat:deleted) (I14). - Framework wakes that continue the previous turn's work kept nothing and re-inferred from an empty wake, so they took the fallback (I1): a refusal, unstick or inference-failure rewind retry, tool_results_ready, a prose continuation or bounce, a provider-cooldown retry. Naming nothing new, they now keep the previous turn's route record (route, hold or none), as an in-stream retry keeps it (CONTINUES_PREVIOUS_TURN). - An ephemeral subagent never takes the fallback. Found reading the wake sites: with the fallback, a diver's between-tool prose (locus routing inherited from its caller) would again reach the last channel, the 2026-08-26 leak connectome-host's subagent module records and #256's routes had closed. - namesConversation coverage (I3, I5, I13, I18, I23, I29): a push and a coalesced push batch count an author or a thread; the gate counts a thread; a module event counts a thread or an author in its metadata; a conversation's closure names its binding's channel; a tune-out wake passes its channel through the coordinator's hook (new optional channelId parameter) and names it. - Text (I2, I15, I21, I24, I28): speech-routes.ts's header and inferTurnRoute's doc say the caller passes a fallback only for a wake that named no conversation; InferenceRequest.channelId's doc; getDefaultPublishChannel's doc; breaking.md's typing note (startTyping resolves a bare id only when one server registers it; stopTyping stops that id on every server). The changelog's fallback section says which wakes take it, what counts as the place, that continuations keep their route, and that ephemerals never fall back. Tests: Clement's sequence through the coalescing fixture (coalesced A, ordinary B, retried A: the place stays B and a heartbeat publishes to B), plus a withdrawal that leaves it; the registry's noteDelivered (counts, refuses deletions, reaction removals and unregistered channels; the registry's coalesced path counts nothing); continuation wakes keep the previous route; an ephemeral never falls back; a heartbeat batched with a naming wake takes none; a module event naming a channel; an author-only push; the gate's author-only and console batches; the end-to-end test asserts its first notice before asserting no new one (I22). Ten mutants (af256-fb3-red) each fail a test. --- changelog.d/speech-routes.breaking.md | 5 +- changelog.d/speech-routes.changed.md | 31 ++++++---- src/framework.ts | 79 ++++++++++++++++++++---- src/gate/event-gate.ts | 12 ++-- src/mcpl/channel-registry.ts | 63 +++++++++++--------- src/speech-routes.ts | 22 ++++--- src/tune-out/coordinator.ts | 10 ++-- src/types/agent.ts | 6 +- test/channel-registry-routing.test.ts | 28 ++++++--- test/event-gate.test.ts | 17 ++++++ test/mcpl-coalescing.test.ts | 44 ++++++++++++++ test/targeted-publish.test.ts | 2 + test/trunk-channel-routing.test.ts | 86 +++++++++++++++++++++++++++ 13 files changed, 323 insertions(+), 82 deletions(-) diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md index 24cc7f3bf..adc6eb1d3 100644 --- a/changelog.d/speech-routes.breaking.md +++ b/changelog.d/speech-routes.breaking.md @@ -20,8 +20,9 @@ publish to an exact server. - **Also changed:** `resolveProseTarget` returns the `serverId` its target resolved to beside the channel id. `startTyping` and - `stopTyping` take `{ serverId, channelId }` (a bare id still works, - resolving only an id one server registers). The registry no longer + `stopTyping` take `{ serverId, channelId }`. A bare id still works: + `startTyping` resolves it only when one server registers it, and + `stopTyping` stops that id's typing on every server that has it. The registry no longer decides a coalesced message's trigger before `handleCoalescedIncoming`: the event carries the filter's inputs as `triggerInputs`, and a host's own handler decides from them once it admits the occurrence. diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index ddd18c317..5a0c877ee 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -13,12 +13,15 @@ is kept as `ambiguous` drafts, and a `[routing]` notice names each conversation's address. - **A wake that names no conversation at all keeps the fallback,** as - before: a heartbeat, a timer, a self-wake, a script or subagent wake, or an - `api:inference-request` nudge. + **A resident's wake that names no conversation at all keeps the + fallback,** as before: a heartbeat, a timer, a self-wake, a background + script's wake, an admin nudge, or an `api:inference-request` nudge. - **Where it goes:** where the last message any channel delivered came in, - now with its server and thread named. A reaction or a withdrawal is no - message, so it never becomes that place. + now with its server and thread named. An ordinary message counts once + it's queued, and a coalesced one once its coalescer admits it afresh, + never for a producer's retry the coalescer acknowledges again. A + reaction, a deletion or a withdrawal is no message, so it never becomes + that place. - **What it answers:** no message. It has no reply edge, so the `beforeInference` context names where the speech lands (`defaultOutgoing`) but no `incoming` message. @@ -32,11 +35,19 @@ elsewhere holds a fallback route as it holds an inferred one, and the notice says what the route was. - **Which wakes name a conversation:** any event in the wake that names a - channel, a thread or a local surface does, whether or not it's a route - candidate. A reaction, a system marker or a channel another agent's - fork owns names one but answers nothing, so such a wake gets no route, - never the fallback. An ambient-only batch names its own conversations: - it routes to one, or holds when it names several. + channel, a thread, an author or a local surface does, whether or not + it's a route candidate, on every lane. So does a conversation's closure + and a tune-out wake about a channel. A reaction, a system marker or a + channel another agent's fork owns names one but answers nothing, so such + a wake gets no route, never the fallback. An ambient-only batch names + its own conversations: it routes to one, or holds when it names several. + - **Wakes that continue the previous turn's work keep its route:** a + retry after a refusal, an unstick or an inference failure rewinds, the + results of a non-streaming tool round, an explicit-prose continuation or + bounce, and a provider-cooldown retry, when they name nothing new. + - **An ephemeral subagent never takes the fallback.** Its prose isn't a + channel's, and before speech routes a subagent's prose between its tool + calls could reach the resident's live channel this way. A surface route shows speech on that surface and publishes it to no channel, so a console turn's reply stays on the console. That's so diff --git a/src/framework.ts b/src/framework.ts index 216811425..34cc766d5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -546,6 +546,24 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string): return { ...(metadata ?? {}), deferredWriteId: id } as MessageMetadata; } +/** + * Framework wakes that continue the resident's previous turn's work: a retry + * after a rewind (refusal, unstick, inference failure), the results of a + * non-streaming tool round, an explicit-prose continuation or bounce, and a + * provider-cooldown retry with nothing else held. Naming nothing new, such a + * wake keeps the previous turn's speech route (startAgentStream). + */ +const CONTINUES_PREVIOUS_TURN: ReadonlySet = new Set([ + 'tool_results_ready', + 'refusal-rewind-retry', + 'unstick', + 'unstick-retry', + 'inference-failure-rewind-retry', + 'prose-continuation', + 'prose-bounce', + 'provider-acceleration-retry', +]); + function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; @@ -1992,8 +2010,12 @@ export class AgentFramework { addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata as MessageMetadata, forAgent ? { forAgent } : undefined), - requestInference: (agentName, reason, source) => { - framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() }); + requestInference: (agentName, reason, source, channelId) => { + framework.pendingRequests.push({ + agentName, reason, source, timestamp: Date.now(), + // A tune-out wake is about the channel it names. + ...(channelId ? { namesConversation: true as const } : {}), + }); }, subconsciousName: () => framework.subconsciousAgentName, primaryName: () => framework.primaryAgentName, @@ -7337,8 +7359,9 @@ export class AgentFramework { // Queue inference requests if (response.requestInference) { const source = 'source' in event ? (event as { source: string }).source : 'unknown'; - // The channel a module event names in its metadata, if any. - const named = extractGateFields(event).metadata.channelId; + // What a module event names in its metadata, if anything. + const namedMetadata = extractGateFields(event).metadata; + const named = namedMetadata.channelId; // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry) if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') { @@ -7371,7 +7394,9 @@ export class AgentFramework { // is no candidate here: neither takes the fallback. An event naming // neither (an `api:inference-request` nudge, a timer) names none. const surfaceCandidate = surface ? this.candidateFromSource(surface, true) : undefined; - const namesConversation = !!surface || (typeof named === 'string' && named.length > 0); + const nonEmpty = (v: unknown): boolean => typeof v === 'string' && v.length > 0; + const namesConversation = !!surface || nonEmpty(named) || nonEmpty(namedMetadata.threadId) + || nonEmpty(namedMetadata.authorId); for (const agentName of targetAgents) { const agent = this.agents.get(agentName); if (agent && agent.canBeTriggeredBy(source)) { @@ -7859,6 +7884,8 @@ export class AgentFramework { reason: 'conversation:closure', source: 'framework', timestamp: now, + // It closes the conversation in its binding's channel. + namesConversation: true, }); // Disposed when the closure stream ends (driveStream finally), with // the reaper below as fallback. @@ -8178,6 +8205,13 @@ export class AgentFramework { accepted: (occ) => { const source = occ.event.event.inboundSource; if (source) this.noteInboundAccepted(source); + // A channel message admitted afresh is where the last message came + // in: the fallback route's place. This hook never runs for a + // producer's retry, which the coalescer acknowledges again without + // delivering anything; a withdrawal delivers nothing either. + if (occ.event.lane === 'channel' && occ.scope.kind === 'channel' && !occ.retract) { + this.channelRegistry?.noteDelivered(occ.serverId, occ.scope.id, occ.identity?.threadId, occ.tags); + } }, wasPublished: (subject, eventId) => { // Boot-time only: the occurrence's durable delivery identity @@ -8568,7 +8602,9 @@ export class AgentFramework { // reaction names its channel but answers nothing). const named = this.candidateFromSource(occ.event.event.inboundSource, addressedHere); const candidate = isConversational(occ.tags) ? named : undefined; - const namesConversation = !!named || !!channelId; + // A channel, a thread or an author names a conversation, as for a gate + // batch (wakeProvenance). + const namesConversation = !!named || !!channelId || !!authorId || !!occ.identity?.threadId; for (const agentName of await this.coalescedAudience(occ)) { const takes = candidate && this.mayTakeCandidate(agentName, candidate) ? candidate : undefined; this.pendingRequests.push({ @@ -8802,7 +8838,14 @@ export class AgentFramework { // and only such a wake takes the fallback route. A reaction names its // conversation without being a candidate. const pushNamed = this.candidateFromSource(source, pushAddressed); - const pushNamesConversation = !!pushNamed || !!triggerChannel?.channelId; + // A channel, a thread or an author names a conversation, as for a gate + // batch (wakeProvenance): a reaction that names only its author too. + const originNames = (key: string): boolean => { + const value = event.origin?.[key]; + return typeof value === 'string' && value.length > 0; + }; + const pushNamesConversation = !!pushNamed || !!triggerChannel?.channelId + || originNames('authorId') || originNames('threadId'); const pushCandidate = silentHeartbeat || !isConversational(event.tags) ? undefined : pushNamed; for (const agentName of targetAgents) { // The fork rule, as for a gate batch: no agent takes a channel a fork @@ -11395,16 +11438,28 @@ export class AgentFramework { // conversation at all (a heartbeat, a timer, a self-wake) takes the // fallback, the last delivered message's conversation, as before // speech routes. A silent control-plane wake has none. + // A framework wake that continues the resident's previous work (a + // retry after a rewind, results of a non-streaming tool round, a + // prose continuation or bounce) and names nothing new keeps that + // turn's route, as an in-stream retry does: re-inferring it from an + // empty wake would send the retried words to the fallback instead. + const continues = !!trigger && CONTINUES_PREVIOUS_TURN.has(trigger.reason) + && !trigger.namesConversation && !trigger.routeCandidates?.length; + const previous = continues ? this.turnRoutes.get(agent.name) : undefined; const turn: TurnRoute = trigger?.suppressProse ? { route: null } - : inferTurnRoute( + : previous ?? inferTurnRoute( trigger?.routeCandidates ?? [], this.homeRoute(agent.name), (c) => this.publishTargetOf(c), - // Only a wake that named no conversation, candidate or not, - // takes the fallback: a reaction, a system marker or a channel - // a fork owns names one, and gets no route as before. - trigger?.namesConversation ? null : this.fallbackCandidate(agent.name), + // Only a resident's wake that named no conversation, candidate + // or not, takes the fallback: a reaction, a system marker or a + // channel a fork owns names one, and gets no route as before. An + // ephemeral subagent never takes it: its prose is its caller's + // business, never a channel's. + trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) + ? null + : this.fallbackCandidate(agent.name), ); this.turnRoutes.set(agent.name, turn); this.midTurnInputSignals.delete(agent.name); diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 9fc120511..68f13c91c 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -164,11 +164,11 @@ export interface WakeProvenance { */ routeCandidates?: GateRouteCandidate[]; /** - * Some event in the batch names a conversation — a channel, an author, or - * a local surface — whether or not it is a route candidate: a reaction or a - * system marker names its channel but answers nothing. Unset only when - * nothing in the batch named one (a heartbeat, a timer): the one wake the - * framework's fallback route serves. + * Some event in the batch names a conversation — a channel, a thread, an + * author, or a local surface — whether or not it is a route candidate: a + * reaction or a system marker names its channel but answers nothing. Unset + * only when nothing in the batch named one (a heartbeat, a timer): the one + * wake the framework's fallback route serves. */ namesConversation?: true; } @@ -266,7 +266,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin const routeCandidates: GateRouteCandidate[] = []; let namesConversation = false; for (const e of events) { - if (e.routeChannelId || e.channelId || e.authorId || e.eventType === 'external-message' || e.eventType === 'api:message') { + if (e.routeChannelId || e.channelId || e.threadId || e.authorId || e.eventType === 'external-message' || e.eventType === 'api:message') { namesConversation = true; } // A reaction or system marker answers nothing: never a route candidate diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 4755f12a4..e63b6fefc 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -726,11 +726,9 @@ export class ChannelRegistry { /** Registered channels, keyed by `{serverId}:{channelId}`. */ private channels = new Map(); - /** The last message any channel delivered (`channels/incoming`, once - * queued, or admitted by its coalescer): a turn whose wake names no - * conversation speaks there, as before speech routes. Only a message - * counts: never a reaction or a withdrawal. Cleared when its channel or - * server goes. */ + /** Where the last message any channel delivered came in (noteDelivered): + * a turn whose wake names no conversation speaks there, as before speech + * routes. Cleared when its channel or server goes. */ private lastInbound: { serverId: string; channelId: string; threadId?: string } | null = null; /** @@ -1117,23 +1115,10 @@ export class ChannelRegistry { const entry = this.channels.get(incomingKey); if (entry) entry.open = true; }; - // A delivered message retargets only the fallback: the route of a turn - // whose wake names no conversation. Every other route comes from a - // turn's own wake or a deliberate choice. It counts once it is queued - // (or admitted), on a channel still registered, and only as a message - // a conversation could continue: never a reaction or a withdrawal. - const rememberDelivered = () => { - if (!this.channels.has(incomingKey)) return; - if ((message.coalesce as { retract?: unknown } | null | undefined)?.retract === true) return; - // An MCPL item's tags alone: `metadata.system` is host-written, and - // an adapter's metadata can't mark its item as machinery. - if (!isConversational(message.tags)) return; - this.lastInbound = { - serverId, - channelId: message.channelId, - ...(typeof message.threadId === 'string' && message.threadId ? { threadId: message.threadId } : {}), - }; - }; + // An ordinary message, once queued, is where the last message came in + // (noteDelivered). A coalesced one counts only when its coalescer + // admits it afresh, which the host observes itself: its result here + // reads `accepted` for a producer's retry too. if (!coalesced) markAccepted(); // Build the incoming event; whether it triggers inference is decided @@ -1205,10 +1190,7 @@ export class ChannelRegistry { // `coalesce` is a per-message failure; siblings are unaffected. try { const result = await this.handleCoalescedIncoming!(serverId, message, event); - if (result.accepted) { - markAccepted(); - rememberDelivered(); - } + if (result.accepted) markAccepted(); results.push(result); } catch (error) { const err = error as Error & { code?: number }; @@ -1227,7 +1209,7 @@ export class ChannelRegistry { // CustomEvent `${string}:${string}` type pattern but lacks an index signature. if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(event as unknown as ProcessEvent); - rememberDelivered(); + this.noteDelivered(serverId, message.channelId, message.threadId, message.tags); // Collect per-message result results.push({ @@ -1419,7 +1401,10 @@ export class ChannelRegistry { // Accessors // ========================================================================== - /** The channel id of the last message any channel delivered, or null. */ + /** The channel id of where the last message any channel delivered came in + * (noteDelivered: a conversational message, never a reaction or a + * deletion), or null. Kept from before speech routes for hosts that read + * it; the framework uses it only as a turn's fallback route. */ getDefaultPublishChannel(): string | null { return this.lastInbound?.channelId ?? null; } @@ -1431,6 +1416,28 @@ export class ChannelRegistry { return this.lastInbound ? { ...this.lastInbound } : null; } + /** + * A message was delivered: it is now where the last message came in, the + * fallback route of a turn whose wake names no conversation. Called for an + * ordinary `channels/incoming` message once it is queued, and by the host + * for a coalesced one when its coalescer admits it afresh (never for a + * producer's retry, which the coalescer acknowledges again without + * delivering anything). Only a message a conversation could continue + * counts, on a channel still registered: never a reaction or a deletion + * (`chat:reaction`, `chat:reaction-remove`, `chat:deleted`). The caller + * leaves out withdrawals, which deliver nothing. An MCPL item's tags + * alone decide: `metadata.system` is host-written. + */ + noteDelivered(serverId: string, channelId: string, threadId?: string, tags?: readonly string[]): void { + if (!this.channels.has(`${serverId}:${channelId}`)) return; + if (!isConversational(tags) || tags?.includes('chat:deleted')) return; + this.lastInbound = { + serverId, + channelId, + ...(typeof threadId === 'string' && threadId ? { threadId } : {}), + }; + } + /** * Get the descriptor for a channel by its channelId (first match across * servers). Used by the conversation router for DM classification. diff --git a/src/speech-routes.ts b/src/speech-routes.ts index edaf63120..d79b9983a 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -2,16 +2,20 @@ * Speech routes: where a resident's unaddressed plain speech goes (shelf-355). * * A route is decided deliberately or inferred from what triggered the turn, - * and comes from whichever channel last saw traffic only when nothing did: + * and comes from where the last message came in only when the wake named no + * conversation at all: * - deliberate: a conversation fork's home channel, or a channel the resident * opened with `channel_open` (setSpeechTarget, the default); a hybrid * `>>>target` prefix is deliberate too and lives in its own pin; * - inferred, at a true new turn, from the wake's candidates: the addressed * ones if any, else the conversational ones. Only candidates that name ONE * conversation infer a route; several conversations start the turn held; - * - fallback, at a true new turn whose wake has no candidates at all (a - * heartbeat, a timer, a self-wake): the conversation of the last message - * any channel delivered, as before speech routes, checked like any route. + * - fallback, at a true new turn whose wake named no conversation at all (a + * heartbeat, a timer, a self-wake): where the last message any channel + * delivered came in, as before speech routes, checked like any route. The + * framework decides which wakes those are (InferenceRequest + * .namesConversation): a reaction or a channel a fork owns names one + * without being a candidate, and gets no fallback. * * A held turn (competing conversations at turn start, or a competing * addressed or engaged arrival mid-turn) keeps unaddressed speech as drafts @@ -204,10 +208,12 @@ export function describeConversation(c: ConversationRef): string { * or one that couldn't be resolved — leaves the turn without a route, which * it records as `unroutable`. * - * A wake with no candidates at all names no conversation (a heartbeat, a - * timer, a self-wake): its route is `fallback`, the conversation of the last - * message any channel delivered, when the caller has one and plain speech - * can go there exactly; otherwise it has none. + * `fallback` is for a wake that named no conversation at all (a heartbeat, + * a timer, a self-wake), and only the caller can tell that from a wake whose + * named conversations all fell out as candidates (a reaction, a channel a + * fork owns): it passes one only for the former. With no candidates, the + * route is then where the last message any channel delivered came in, when + * plain speech can go there exactly; otherwise there is none. */ export function inferTurnRoute( candidates: readonly RouteCandidate[], diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index dbfee7013..df911612a 100644 --- a/src/tune-out/coordinator.ts +++ b/src/tune-out/coordinator.ts @@ -42,7 +42,9 @@ export interface TuneOutFrameworkHooks { forAgent?: string, ) => string; /** Queue an inference request for an agent. */ - requestInference: (agentName: string, reason: string, source: string) => void; + /** Wake an agent. `channelId`, when the wake is about a channel, says it + * names that conversation, so the turn never takes the fallback route. */ + requestInference: (agentName: string, reason: string, source: string, channelId?: string) => void; /** The subconscious's registry/participant name. */ subconsciousName: () => string | null; /** The primary resident's registry name. */ @@ -245,7 +247,7 @@ export class TuneOutCoordinator { } const primary = this.hooks.primaryName(); if (primary) { - this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out'); + this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out', channelId); } // The subconscious gets one cancel turn of its own (unless it initiated // the cancel — its note already carries its words): the issue's cancel @@ -261,7 +263,7 @@ export class TuneOutCoordinator { { system: true, kind: 'tune-out-cancel-report', channelId }, subName, ); - this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out'); + this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out', channelId); } this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount }); return { ok: true }; @@ -414,7 +416,7 @@ export class TuneOutCoordinator { { system: true, kind: `tune-out-${trigger}`, channelId }, subName, ); - this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out'); + this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out', channelId); } // ========================================================================== diff --git a/src/types/agent.ts b/src/types/agent.ts index a7c1712d1..0619a493a 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -323,9 +323,9 @@ export interface InferenceRequest { * The MCPL channel whose message triggered this inference, if any (composite * id, e.g. `discord:guild:channel` / `discord:dm:id`). It is not where the * turn's plain speech goes: that is the turn's speech route, inferred from - * `routeCandidates` (src/speech-routes.ts). Undefined for non-channel wakes - * (heartbeat, timers, module events), which name no conversation; a turn - * they alone wake takes the fallback route (`namesConversation`). + * `routeCandidates` (src/speech-routes.ts). Undefined for wakes from no + * channel (a heartbeat, a timer, most module events). Whether a wake names + * a conversation at all is `namesConversation`. */ channelId?: string; /** diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index f028b1381..df3d20d27 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -197,19 +197,26 @@ test('only a delivered message becomes the fallback\'s place: not a reaction, a throwFilter = false; assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'an unqueued message never becomes the place'); - // A coalesced message counts once its coalescer admits it, not before. + // A coalesced message is the host's to count: its coalescer's acceptance + // reads `accepted` for a producer's retry too, so the registry's own path + // never counts it, refused or admitted. admit = false; await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-3', { eventId: 'e-3', coalesce: { key: 'k' } })] } as never); assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a refused coalesced occurrence never counts'); admit = true; await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-4', { eventId: 'e-4', coalesce: { key: 'k' }, threadId: 't-4' })] } as never); - assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'an admitted one counts'); - - // A withdrawal the coalescer admits is no message: the place stays. - await registry.handleIncoming('slack', { - messages: [msg('slack:C3', 'm-5', { eventId: 'e-5', coalesce: { key: 'k2', retract: true }, content: [] })], - } as never); - assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a withdrawal never counts'); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'an accepted coalesced result is the host\'s to count'); + // The host counts a fresh admission through noteDelivered. + registry.noteDelivered('slack', 'slack:C2', 't-4', []); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a noted delivery counts'); + registry.noteDelivered('slack', 'slack:C3', undefined, ['chat:deleted']); + registry.noteDelivered('slack', 'slack:C3', undefined, ['chat:reaction-remove']); + registry.noteDelivered('slack', 'slack:GONE', undefined, []); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, + 'a deletion, a reaction\'s removal or an unregistered channel never counts'); + // An ordinary deletion notice never counts either. + await registry.handleIncoming('slack', { messages: [msg('slack:C3', 'd-1', { tags: ['chat:deleted'] })] }); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a deletion never counts'); // Stopping forgets it. registry.stopAll(); @@ -217,7 +224,7 @@ test('only a delivered message becomes the fallback\'s place: not a reaction, a assert.equal(registry.getDefaultPublishChannel(), null); }); -test('a coalesced message whose channel goes while its coalescer decides counts for nothing, and breaks nothing', async () => { +test('a coalesced message whose channel goes while its coalescer decides breaks nothing', async () => { let registry: ChannelRegistry; registry = new ChannelRegistry( { getServer: () => undefined } as unknown as McplServerRegistry, @@ -243,6 +250,9 @@ test('a coalesced message whose channel goes while its coalescer decides counts content: [{ type: 'text' as const, text: 'bye' }], eventId: 'e-2', coalesce: { key: 'k' }, }] } as never, { respond: (r: unknown) => { answered = r as typeof answered; } } as never); assert.deepEqual(answered?.results.map((r) => [r.accepted, r.reason]), [[true, undefined]], 'the admission itself stands'); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); + // A host noting it afterwards finds the channel gone: it never becomes the place. + registry.noteDelivered('slack', 'slack:GONE', undefined, []); assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a channel removed meanwhile never becomes the place'); }); diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index dc73fa298..70bd66e94 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -792,6 +792,23 @@ describe('debounce', () => { assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:general']); }); + it('a batch naming only an author, or a local surface, names a conversation', async () => { + const path = writeConfig('debounce-route-author-surface.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:push-event', 'external-message'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'from someone', metadata: { authorId: '7' } })); + await new Promise(r => setTimeout(r, 150)); + gate.evaluate(event({ eventType: 'external-message', serverId: 'tui', content: 'status?' })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 2); + assert.strictEqual((inferenceRequests[0] as { namesConversation?: true }).namesConversation, true, 'an author names a conversation'); + assert.strictEqual((inferenceRequests[1] as { namesConversation?: true }).namesConversation, true, 'a local surface names one'); + }); + it('a reaction-only batch names its conversation though it has no candidate, so it never takes the fallback route', async () => { const path = writeConfig('debounce-route-reaction-only.json', { policies: [ diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 337e424b5..8b996a349 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1053,3 +1053,47 @@ for (const reader of ['other', 'agent'] as const) test(`R18 (${reader} read it): assert.equal(del.result.coalesce.outcome, 'noted'); assert(f.context().includes('read_by_one') && f.context().includes('deletion_notice') && !f.context().includes('later_edit')); }); + +test('a producer retry of a coalesced message is no new delivery: the fallback stays where the last message came in', async (t) => { + // Clement-2260's sequence (#256 review, room-448 #74615): coalesced A, then + // ordinary B, then A retried with the same event id. + const f = await fixture(); t.after(f.close); + f.alwaysRespond(); + await f.register('A'); + await f.register('B'); + const registry = (f.framework as unknown as { + channelRegistry: { getLastInbound(): { channelId: string } | null }; + }).channelRegistry; + const original = { messages: [f.channel('A-1', 'first channel', { initial: true }, 'A', 'message-A')] }; + const accepted = await f.send('channels/incoming', original); + assert.equal(accepted.result.results[0].accepted, true); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'A', 'a fresh admission counts'); + + const b = f.channel('B-1', 'latest real message', {}, 'B', 'message-B') as Record; + delete b.coalesce; + delete b.eventId; + await f.send('channels/incoming', { messages: [b] }); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B'); + const calls = f.membrane.calls.length; + + const retry = await f.send('channels/incoming', original); + assert.deepEqual(retry.result, accepted.result, 'the producer retry gets its cached acknowledgement'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'the retry starts no turn'); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a retry is no delivery: the place stays B'); + + // A withdrawal admitted afresh delivers nothing either. + await f.register('C'); + await f.send('channels/incoming', { messages: [{ ...f.channel('C-1', '', { retract: true }, 'C', 'message-C'), content: [] }] }); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a withdrawal is no delivery: the place stays B'); + + const publishes = f.published.length; + await (f.framework as unknown as { startAgentStream(a: unknown, tr?: unknown): Promise }) + .startAgentStream(f.framework.getAgent('agent')!, { agentName: 'agent', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await f.framework.runUntilIdle(); + await eventually(() => f.published.length > publishes, 'the scheduled note is published'); + assert.equal(f.published.at(-1)!.channelId, 'B', 'the scheduled note lands where the last message came in'); +}); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts index 563b114ba..5e384086a 100644 --- a/test/targeted-publish.test.ts +++ b/test/targeted-publish.test.ts @@ -138,6 +138,8 @@ describe('targeted publish (MCPL RFC-011)', () => { ['publish', ROOM, 't-7', 'answering the thread'], ['publish', ROOM, 't-7', 'a scheduled note'], ], 'the fallback named the thread the last message came in'); + assert.ok(texts().some((t) => t.startsWith('[routing] Your plain speech now lands in #room (Guild One) (discord / discord:g1:room, thread t-7)')), + 'the first turn announced its route'); assert.ok(texts().some((t) => t.startsWith('[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message came in')) === false, 'the place is unchanged since the last announced route, so no new notice'); }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 36dbad956..4045e2040 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -606,6 +606,18 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal(membrane.calls.length, 3); assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a pushed reaction takes no fallback'); + + // A push that names only its author names a conversation too. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'who was that' }])); + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'discord', featureSet: 'discord.messaging', + eventId: `e-${Math.random().toString(36).slice(2)}`, content: [{ type: 'text', text: 'hm' }], + origin: { source: 'discord', authorId: 'U9' }, timestamp: new Date().toISOString(), + inferenceId: `inf-${Math.random().toString(36).slice(2)}`, triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 4); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'an author-only push takes no fallback'); assert.deepEqual(published, [], 'nothing went to the last delivered message\'s channel'); await framework.stop(); }); @@ -642,6 +654,80 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('a framework wake that continues the previous turn keeps its route, never the fallback', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + // A rewind retry of that work names nothing new: it keeps chanA, though + // the last message came in elsewhere. + for (const reason of ['refusal-rewind-retry', 'inference-failure-rewind-retry', 'tool_results_ready']) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: `again after ${reason}` }])); + internals(framework).pendingRequests.push({ agentName: 'scout', reason, source: 'framework', timestamp: Date.now() }); + await framework.runUntilIdle(); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA', `${reason} keeps the route`); + } + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), + ['discord:guild:chanA', 'discord:guild:chanA', 'discord:guild:chanA', 'discord:guild:chanA']); + await framework.stop(); + }); + + it('an ephemeral subagent never takes the fallback, and several requests batched into one turn name a conversation if any does', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'diver notes' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + // As if scout were an ephemeral subagent: its prose is its caller's. + const ephemerals = (framework as unknown as { usedEphemeralAgentNames: Set }).usedEphemeralAgentNames; + ephemerals.add('scout'); + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'no fallback for an ephemeral'); + ephemerals.delete('scout'); + + // A heartbeat batched with a wake that names a conversation (as a + // reaction's does) is one turn that names one: no fallback. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'batched' }])); + internals(framework).pendingRequests.push( + { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }, + { agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true } as never, + ); + await framework.runUntilIdle(); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'the batch named a conversation'); + assert.deepEqual(published, []); + await framework.stop(); + }); + + it('a module event that names a channel in its metadata never takes the fallback', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'about that channel' }])); + const relay = { + name: 'relay', + async start() {}, + async stop() {}, + getTools: () => [], + async handleToolCall() { return { success: false, error: 'none', isError: true }; }, + async onProcess(event: ProcessEvent) { + return event.type === 'relay:note' ? { requestInference: true } : {}; + }, + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'relay.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [relay as never], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + framework.pushEvent({ type: 'relay:note', source: 'relay', metadata: { channelId: 'discord:guild:other' } } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); + assert.deepEqual(published, []); + await framework.stop(); + }); + it('a threadless root-channel fallback speaks at its root when no fork owns it', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); From 611492608c5d0bf980599bc437da475a87bbb2fc Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 02:15:27 -0400 Subject: [PATCH 59/65] fix(routing): a wake that continues a turn names that turn's conversations From the haiku review of 3f09c91 (sw-af256e, wf-410; all 31 verdicts recorded with review_verdicts). Continuation wakes (I1, I3, I5, I10, I13, I16, I21, I23, I27, I31). 3f09c91 kept the previous turn's route record for a set of wake reasons (CONTINUES_PREVIOUS_TURN), read at stream start from requests[0]. So the outcome depended on queue order beside a heartbeat, could read a record a turn in between had written, and skipped the fork rule. Now each producer queues its wake through continuationRequest, which reads the turn's record when the wake is queued and names the conversations that turn answered as addressed route candidates (speech-routes' continuationCandidates): a route's conversation with its reply edge, a hold's conversations, or an unroutable one, to be checked again. The general rules then decide in any batch: beside a heartbeat the turn keeps them, beside an ambient arrival from elsewhere they win, beside an addressed one the turn holds. They pass the fork rule as every producer's candidates do; a claimed channel still names its conversation, so the turn gets no route. A turn that answered none gives a wake that names none, which takes the fallback. CONTINUES_PREVIOUS_TURN and the special branch in beginAgentTurn are gone. Producers: the refusal and unstick rewind retries, the inference-failure rewind retry, unstick, tool_results_ready, a prose continuation and a bounce, and a bare provider-cooldown retry (held requests are re-queued as they were, carrying their own conversations). The text overclaimed (I1): a queued continuation is a true new turn for everything but where its words go (a rewind sheds the turn whose choices those were). The changelog and comments say so; only budget restarts, guard retries and a stream's own retries keep turn state. Smaller fixes: - A withdrawal reaching the ordinary path (a registry with no coalescing hook) is left out of noteDelivered again, as before speech routes (I2). The comments say a withdrawal is never the place, even when a coalescer shows its notice (I29). - A coalesced push batch counts its origin's thread, as the uncoalesced push does (I19). - The tune-out hook's push is queueTuneOutWake, so a test can drive it (I7). - getDefaultPublishChannel's doc: the framework reads getLastInbound (I15). Tests: a refusal's rewind retry end to end; continuations batched in both orders beside a heartbeat and beside ambient and addressed arrivals; a fork's claim; a turn that answered none; continuationCandidates unit cases; the inference-failure retry names its turn's conversation (I10). Thread-only naming at the gate, on a push, a module event and a deferred push batch, and author-only on a module event (I6, I19). The tune-out coordinator passes its channel at each wake and the framework hook names it (I7). The withdrawal is admitted before its verdict (I8). The gate test waits for each wake instead of fixed sleeps (I9). The coalesced place keeps its thread (I12). A channel-scoped push and a coalesced reaction and deletion leave the place (I14, I26). The batched turn's model call is counted (I17). The deletion notice's delivery is asserted (I20). The ephemeral test spawns a real ephemeral (I25). A registry with no hook leaves a withdrawal out (I2). Red checks: 21 mutants, each removing one fix or clause, each fails its named tests (af256-red, mutants.tmp.py). Not changed: I4 and I11 are not real (a budget restart and a guard retry set continuingTurn, so the route block never runs for them); I23 is not real (ephemeral and fork names are never reused); I5, I22 and I24 are intended; I18 and I30 are not worth it. --- changelog.d/speech-routes.changed.md | 24 ++-- src/framework.ts | 155 ++++++++++---------- src/mcpl/channel-registry.ts | 21 ++- src/speech-routes.ts | 41 +++++- test/channel-registry-routing.test.ts | 32 ++++- test/event-gate.test.ts | 25 ++-- test/inference-exhaustion-rewind.test.ts | 7 + test/mcpl-coalescing.test.ts | 57 +++++++- test/speech-routes.test.ts | 50 +++++++ test/trunk-channel-routing.test.ts | 176 ++++++++++++++++++++--- test/tune-out-gate-composition.test.ts | 34 +++++ 11 files changed, 494 insertions(+), 128 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 5a0c877ee..5e46a7453 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -41,10 +41,17 @@ channel another agent's fork owns names one but answers nothing, so such a wake gets no route, never the fallback. An ambient-only batch names its own conversations: it routes to one, or holds when it names several. - - **Wakes that continue the previous turn's work keep its route:** a - retry after a refusal, an unstick or an inference failure rewinds, the - results of a non-streaming tool round, an explicit-prose continuation or - bounce, and a provider-cooldown retry, when they name nothing new. + - **A framework wake that continues a turn names that turn's + conversations:** a retry after a refusal, an unstick or an inference + failure rewinds; an unstick; the results of a non-streaming tool round; + an explicit-prose continuation or bounce; and a provider-cooldown retry + that holds no wake of its own. The wake carries those conversations, read + when it is queued, as addressed ones, so its words go where the turn's + would have. Beside a heartbeat they keep the turn, beside an ambient + arrival from elsewhere they win, and beside an addressed one from + elsewhere the turn is held. A turn that answered none gives a wake that + names none. Everything else starts afresh, as at any new turn: a rewind + sheds the turn whose choices those were. - **An ephemeral subagent never takes the fallback.** Its prose isn't a channel's, and before speech routes a subagent's prose between its tool calls could reach the resident's live channel this way. @@ -55,10 +62,11 @@ inference. anima-research/agent-framework#190, by @rufalupa666-netizen with the resident qa-engineer, found and diagnosed such a reply published to the channel that last spoke. -- Context-budget restarts, guard retries and framework retries keep the - turn's route, and everything else the turn has chosen or done: its - explicit-send engagement, its `>>` or `>>>` choice, and the deliveries and - drafts its receipt reports. Only the next true new turn decides again. +- Context-budget restarts, guard retries and a stream's own retries (its + later attempts) keep the turn's route, and everything else the turn has + chosen or done: its explicit-send engagement, its `>>` or `>>>` choice, and + the deliveries and drafts its receipt reports. Only the next true new turn + decides again, and a queued wake that continues a turn starts one. - **Speech goes where the route stood when its words were written.** A `channel_open`, or a hold, that lands while earlier words still wait to be published governs only words written after it; the same goes for a diff --git a/src/framework.ts b/src/framework.ts index 34cc766d5..1c88033a6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -86,6 +86,7 @@ import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, publishPlaceRefusal, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { + continuationCandidates, conversationKey, describeConversation, inferTurnRoute, @@ -546,24 +547,6 @@ function withDeferredWriteId(metadata: MessageMetadata | undefined, id: string): return { ...(metadata ?? {}), deferredWriteId: id } as MessageMetadata; } -/** - * Framework wakes that continue the resident's previous turn's work: a retry - * after a rewind (refusal, unstick, inference failure), the results of a - * non-streaming tool round, an explicit-prose continuation or bounce, and a - * provider-cooldown retry with nothing else held. Naming nothing new, such a - * wake keeps the previous turn's speech route (startAgentStream). - */ -const CONTINUES_PREVIOUS_TURN: ReadonlySet = new Set([ - 'tool_results_ready', - 'refusal-rewind-retry', - 'unstick', - 'unstick-retry', - 'inference-failure-rewind-retry', - 'prose-continuation', - 'prose-bounce', - 'provider-acceleration-retry', -]); - function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; @@ -2010,13 +1993,8 @@ export class AgentFramework { addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata as MessageMetadata, forAgent ? { forAgent } : undefined), - requestInference: (agentName, reason, source, channelId) => { - framework.pendingRequests.push({ - agentName, reason, source, timestamp: Date.now(), - // A tune-out wake is about the channel it names. - ...(channelId ? { namesConversation: true as const } : {}), - }); - }, + requestInference: (agentName, reason, source, channelId) => + framework.queueTuneOutWake(agentName, reason, source, channelId), subconsciousName: () => framework.subconsciousAgentName, primaryName: () => framework.primaryAgentName, getStoredMessages: () => { @@ -2381,8 +2359,8 @@ export class AgentFramework { } const recovery = this.providerAccelerationRecoveries.get(agentName); if (recovery) { recovery.releasedAt = Date.now(); recovery.heldRequests = cooldown.heldRequests.length; } - const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName, - reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() } satisfies InferenceRequest]; + const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests + : [this.continuationRequest(agentName, 'provider-acceleration-retry')]; this.pendingRequests.push(...requests); console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` + `fresh compile queued with ${requests.length} retained request(s)`); @@ -5472,12 +5450,7 @@ export class AgentFramework { channelId: params.channelId ?? '', }); this.refusalRewinds.set(agentName, 0); - this.pendingRequests.push({ - agentName, - reason: 'unstick', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agentName, 'unstick')); console.error( `[unstick] agent=${agentName} started cap=${cap} ` + `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`, @@ -7233,12 +7206,7 @@ export class AgentFramework { }); } else { // Non-streaming fallback: schedule re-inference - this.pendingRequests.push({ - agentName: agent.name, - reason: 'tool_results_ready', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agent.name, 'tool_results_ready')); } } } @@ -8208,7 +8176,9 @@ export class AgentFramework { // A channel message admitted afresh is where the last message came // in: the fallback route's place. This hook never runs for a // producer's retry, which the coalescer acknowledges again without - // delivering anything; a withdrawal delivers nothing either. + // delivering anything. A withdrawal is never the place, even when + // the coalescer shows the resident its notice, as before speech + // routes (noteDelivered). if (occ.event.lane === 'channel' && occ.scope.kind === 'channel' && !occ.retract) { this.channelRegistry?.noteDelivered(occ.serverId, occ.scope.id, occ.identity?.threadId, occ.tags); } @@ -8603,8 +8573,10 @@ export class AgentFramework { const named = this.candidateFromSource(occ.event.event.inboundSource, addressedHere); const candidate = isConversational(occ.tags) ? named : undefined; // A channel, a thread or an author names a conversation, as for a gate - // batch (wakeProvenance). - const namesConversation = !!named || !!channelId || !!authorId || !!occ.identity?.threadId; + // batch (wakeProvenance) and an uncoalesced push: the batch's merged + // thread, or for a scope that merges none, its origin's. + const originThread = typeof origin?.threadId === 'string' && origin.threadId.length > 0; + const namesConversation = !!named || !!channelId || !!authorId || !!occ.identity?.threadId || originThread; for (const agentName of await this.coalescedAudience(occ)) { const takes = candidate && this.mayTakeCandidate(agentName, candidate) ? candidate : undefined; this.pendingRequests.push({ @@ -10175,6 +10147,48 @@ export class AgentFramework { return this.candidateForChannel(last.channelId, false, Date.now(), last.serverId, undefined, last.threadId); } + /** + * A tune-out coordinator's wake: the subconscious's cadence and wake + * turns, and a cancel's wakes of the resident and the subconscious. One + * about a channel names that conversation, so its turn never takes the + * fallback: a resident's speech just after a cancel is held rather than + * sent where the last message came in (the subconscious is explicit-prose, + * so this changes nothing for it). + */ + private queueTuneOutWake(agentName: string, reason: string, source: string, channelId?: string): void { + this.pendingRequests.push({ + agentName, reason, source, timestamp: Date.now(), + ...(channelId ? { namesConversation: true as const } : {}), + }); + } + + /** + * A framework wake that continues `agentName`'s turn: an unstick, a retry + * after a rewind (a refusal, an unstick or an inference failure), the + * results of a non-streaming tool round, an explicit-prose continuation or + * bounce, or a bare provider-cooldown retry. When it is queued, its turn's + * route record gives the conversations that turn answered + * (continuationCandidates), and the wake names them. So the true new turn + * it starts, alone or batched with other wakes, infers its route from them + * by the rules every wake follows, rather than taking the fallback. Like + * every producer's candidates, they pass the fork rule here: one a fork has + * claimed still names its conversation, so the turn gets no route. A turn + * that answered none gives a wake that names none. + */ + private continuationRequest(agentName: string, reason: string): InferenceRequest { + const timestamp = Date.now(); + const named = continuationCandidates(this.turnRoutes.get(agentName), timestamp); + const candidates = named.filter((c) => this.mayTakeCandidate(agentName, c)); + return { + agentName, + reason, + source: 'framework', + timestamp, + ...(named.length > 0 ? { namesConversation: true as const } : {}), + ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), + }; + } + /** * A conversation fork's home as its standing route (it always wins). Homes * are bare channel ids: the server is the id's sole registrant, and a @@ -11118,12 +11132,7 @@ export class AgentFramework { if (!capped) { // Wake so the resend can happen immediately — an unsent DM reply must // not sit invisible until the next unrelated event. - this.pendingRequests.push({ - agentName: name, - reason: 'prose-bounce', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(name, 'prose-bounce')); } } @@ -11410,7 +11419,12 @@ export class AgentFramework { // A true new turn: forget the previous turn's explicit-send // engagements, prose deliveries, drafts and deliberate prose choices // (a `>>` or `>>>` target, a hybrid suppression) — all strictly - // turn-scoped. A framework retry (attempt > 0), like a restart, + // turn-scoped. A queued framework wake that continues a turn (a + // rewind retry, an unstick, a prose continuation or bounce) starts a + // true new turn too: a rewind sheds the turn whose choices these + // were, and only where the words go carries over, through the + // conversations the wake names (continuationRequest). + // A framework retry (attempt > 0), like a restart, // continues the same logical turn: its sends were made, its words // delivered or held, its choices chosen, and none of that rolls back // with a failed stream, so it keeps them, as it keeps the route. @@ -11437,18 +11451,14 @@ export class AgentFramework { // else held (src/speech-routes.ts). A wake that names no // conversation at all (a heartbeat, a timer, a self-wake) takes the // fallback, the last delivered message's conversation, as before - // speech routes. A silent control-plane wake has none. - // A framework wake that continues the resident's previous work (a - // retry after a rewind, results of a non-streaming tool round, a - // prose continuation or bounce) and names nothing new keeps that - // turn's route, as an in-stream retry does: re-inferring it from an - // empty wake would send the retried words to the fallback instead. - const continues = !!trigger && CONTINUES_PREVIOUS_TURN.has(trigger.reason) - && !trigger.namesConversation && !trigger.routeCandidates?.length; - const previous = continues ? this.turnRoutes.get(agent.name) : undefined; + // speech routes. A silent control-plane wake has none. A framework + // wake that continues a turn (a rewind retry, an unstick, a prose + // continuation or bounce) carries the conversations that turn + // answered as its candidates (continuationRequest), so it is + // inferred here like any other. const turn: TurnRoute = trigger?.suppressProse ? { route: null } - : previous ?? inferTurnRoute( + : inferTurnRoute( trigger?.routeCandidates ?? [], this.homeRoute(agent.name), (c) => this.publishTargetOf(c), @@ -11456,7 +11466,9 @@ export class AgentFramework { // or not, takes the fallback: a reaction, a system marker or a // channel a fork owns names one, and gets no route as before. An // ephemeral subagent never takes it: its prose is its caller's - // business, never a channel's. + // business, never a channel's. (A conversation fork's name is in + // the same set, but a fork's home always wins before any + // fallback is read.) trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) ? null : this.fallbackCandidate(agent.name), @@ -12438,12 +12450,9 @@ export class AgentFramework { const count = this.updateRewindMarker(agent, category); onStep(count); if (forced) forced.removed.push(rec); - this.pendingRequests.push({ - agentName: agent.name, - reason: forced ? 'unstick-retry' : 'refusal-rewind-retry', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push( + this.continuationRequest(agent.name, forced ? 'unstick-retry' : 'refusal-rewind-retry'), + ); handledByRewind = true; }; @@ -12699,12 +12708,7 @@ export class AgentFramework { // robotics-style loops where an end-of-turn pause is harmful). if (this.proseContinuations.delete(agent.name)) { console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`); - this.pendingRequests.push({ - agentName: agent.name, - reason: 'prose-continuation', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agent.name, 'prose-continuation')); } break; @@ -15356,12 +15360,7 @@ export class AgentFramework { ); // Retry immediately on the repaired history (bounded by the cap above). - this.pendingRequests.push({ - agentName: agent.name, - reason: 'inference-failure-rewind-retry', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agent.name, 'inference-failure-rewind-retry')); } // ========================================================================== diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index e63b6fefc..4fe180177 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1209,7 +1209,11 @@ export class ChannelRegistry { // CustomEvent `${string}:${string}` type pattern but lacks an index signature. if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(event as unknown as ProcessEvent); - this.noteDelivered(serverId, message.channelId, message.threadId, message.tags); + // A withdrawal is never the place, as before speech routes: on a + // registry with no coalescing hook it arrives here as a plain item. + if ((message.coalesce as { retract?: unknown } | null | undefined)?.retract !== true) { + this.noteDelivered(serverId, message.channelId, message.threadId, message.tags); + } // Collect per-message result results.push({ @@ -1402,9 +1406,10 @@ export class ChannelRegistry { // ========================================================================== /** The channel id of where the last message any channel delivered came in - * (noteDelivered: a conversational message, never a reaction or a - * deletion), or null. Kept from before speech routes for hosts that read - * it; the framework uses it only as a turn's fallback route. */ + * (noteDelivered: a conversational message, never a reaction, a deletion + * or a withdrawal), or null. Kept from before speech routes for hosts + * that read it. The framework doesn't: a turn's fallback route reads + * getLastInbound, which names the server and thread too. */ getDefaultPublishChannel(): string | null { return this.lastInbound?.channelId ?? null; } @@ -1424,9 +1429,11 @@ export class ChannelRegistry { * producer's retry, which the coalescer acknowledges again without * delivering anything). Only a message a conversation could continue * counts, on a channel still registered: never a reaction or a deletion - * (`chat:reaction`, `chat:reaction-remove`, `chat:deleted`). The caller - * leaves out withdrawals, which deliver nothing. An MCPL item's tags - * alone decide: `metadata.system` is host-written. + * (`chat:reaction`, `chat:reaction-remove`, `chat:deleted`). Each caller + * leaves out withdrawals (`coalesce.retract`), which are never the place, + * even when a coalescer shows the resident a withdrawal notice: the + * place is where the last message came in. An MCPL item's tags alone + * decide: `metadata.system` is host-written. */ noteDelivered(serverId: string, channelId: string, threadId?: string, tags?: readonly string[]): void { if (!this.channels.has(`${serverId}:${channelId}`)) return; diff --git a/src/speech-routes.ts b/src/speech-routes.ts index d79b9983a..8b1602926 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -15,7 +15,10 @@ * delivered came in, as before speech routes, checked like any route. The * framework decides which wakes those are (InferenceRequest * .namesConversation): a reaction or a channel a fork owns names one - * without being a candidate, and gets no fallback. + * without being a candidate, and gets no fallback. A framework wake that + * continues a turn (a rewind retry, an unstick, a prose bounce) names the + * conversations that turn answered (continuationCandidates), so its words + * go where that turn's would have, by the rules every wake follows. * * A held turn (competing conversations at turn start, or a competing * addressed or engaged arrival mid-turn) keeps unaddressed speech as drafts @@ -287,6 +290,42 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu }; } +/** + * The conversations a framework wake names when it continues a turn (a + * rewind retry, an unstick, a prose continuation or bounce), as candidates: + * the ones that turn answered, so the continuation's words go where the + * turn's would have. A held turn names each conversation it held between; a + * turn with a route names its route's conversation, reply edge kept; a turn + * whose one conversation couldn't take plain speech names that one, to be + * checked again. Each counts as addressed, since the resident was answering + * it: an ambient arrival batched beside it doesn't take the turn, and an + * addressed one from elsewhere holds it. A turn that answered none (a silent + * wake, an explicit-prose turn, a no-name wake with no fallback place) gives + * none, so its continuation names none. + */ +export function continuationCandidates(turn: TurnRoute | undefined, at: number): RouteCandidate[] { + if (!turn) return []; + if (turn.hold) return turn.hold.conversations.map((conversation) => ({ conversation, addressed: true, at })); + if (turn.route) { + const { route } = turn; + return [{ + conversation: routeConversation(route), + addressed: true, + at, + ...(route.kind === 'channel' && route.replyTo ? { messageId: route.replyTo } : {}), + }]; + } + if (turn.unroutable) { + return [{ + conversation: turn.unroutable.conversation, + addressed: true, + at, + ...(turn.unroutable.reason === 'unresolved' ? { unroutable: true as const } : {}), + }]; + } + return []; +} + /** The candidates a route is inferred from: the addressed ones if any, else all. */ function considered(candidates: readonly RouteCandidate[]): readonly RouteCandidate[] { const addressed = candidates.filter((c) => c.addressed); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index df3d20d27..078b232e8 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -214,8 +214,11 @@ test('only a delivered message becomes the fallback\'s place: not a reaction, a registry.noteDelivered('slack', 'slack:GONE', undefined, []); assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a deletion, a reaction\'s removal or an unregistered channel never counts'); - // An ordinary deletion notice never counts either. - await registry.handleIncoming('slack', { messages: [msg('slack:C3', 'd-1', { tags: ['chat:deleted'] })] }); + // An ordinary deletion notice is delivered, and never counts either. + let deletion: { results: Array<{ accepted: boolean }> } | undefined; + await registry.handleIncoming('slack', { messages: [msg('slack:C3', 'd-1', { tags: ['chat:deleted'] })] }, + { respond: (r: unknown) => { deletion = r as typeof deletion; } } as never); + assert.equal(deletion?.results[0]?.accepted, true, 'the deletion notice was delivered'); assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a deletion never counts'); // Stopping forgets it. @@ -224,6 +227,31 @@ test('only a delivered message becomes the fallback\'s place: not a reaction, a assert.equal(registry.getDefaultPublishChannel(), null); }); +test('on a registry with no coalescing hook, a withdrawal arrives as a plain item and is still never the place', async () => { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { shouldTriggerInference: () => true }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:C2'); + const msg = (channelId: string, messageId: string, extra: Record = {}) => ({ + channelId, messageId, author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], ...extra, + }); + await registry.handleIncoming('slack', { messages: [msg('slack:C1', 'm-1')] }); + let withdrawal: { results: Array<{ accepted: boolean }> } | undefined; + await registry.handleIncoming('slack', + { messages: [msg('slack:C2', 'm-2', { eventId: 'e-2', coalesce: { key: 'k', retract: true } })] } as never, + { respond: (r: unknown) => { withdrawal = r as typeof withdrawal; } } as never); + assert.equal(withdrawal?.results[0]?.accepted, true, 'delivered as a plain item'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C1' }, 'a withdrawal is never the place'); + // A plain message there still is. + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-3')] }); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2' }); +}); + test('a coalesced message whose channel goes while its coalescer decides breaks nothing', async () => { let registry: ChannelRegistry; registry = new ChannelRegistry( diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 70bd66e94..119127701 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -792,7 +792,7 @@ describe('debounce', () => { assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:general']); }); - it('a batch naming only an author, or a local surface, names a conversation', async () => { + it('a batch naming only an author, a thread or a local surface names a conversation; one naming none does not', async () => { const path = writeConfig('debounce-route-author-surface.json', { policies: [ { name: 'chat', match: { scope: ['mcpl:push-event', 'external-message'] }, behavior: { debounce: 100 } }, @@ -800,13 +800,22 @@ describe('debounce', () => { default: 'skip', }); const { gate, inferenceRequests } = makeGate(path); - gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'from someone', metadata: { authorId: '7' } })); - await new Promise(r => setTimeout(r, 150)); - gate.evaluate(event({ eventType: 'external-message', serverId: 'tui', content: 'status?' })); - await new Promise(r => setTimeout(r, 150)); - assert.strictEqual(inferenceRequests.length, 2); - assert.strictEqual((inferenceRequests[0] as { namesConversation?: true }).namesConversation, true, 'an author names a conversation'); - assert.strictEqual((inferenceRequests[1] as { namesConversation?: true }).namesConversation, true, 'a local surface names one'); + // Each event is its own batch: the next is evaluated only once the + // previous batch's wake has arrived. + const batches = [ + event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'from someone', metadata: { authorId: '7' } }), + event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'in a thread', metadata: { threadId: 't-3' } }), + event({ eventType: 'external-message', serverId: 'tui', content: 'status?' }), + event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'a nudge' }), + ]; + for (const [index, e] of batches.entries()) { + gate.evaluate(e); + await waitForRequests(inferenceRequests, index + 1); + } + assert.strictEqual(inferenceRequests.length, 4); + const names = inferenceRequests.map((r) => (r as { namesConversation?: true }).namesConversation); + assert.deepStrictEqual(names, [true, true, true, undefined], + 'an author, a thread and a local surface each name a conversation; a bare push names none'); }); it('a reaction-only batch names its conversation though it has no candidate, so it never takes the fallback route', async () => { diff --git a/test/inference-exhaustion-rewind.test.ts b/test/inference-exhaustion-rewind.test.ts index 4462ab2d7..af8dd105c 100644 --- a/test/inference-exhaustion-rewind.test.ts +++ b/test/inference-exhaustion-rewind.test.ts @@ -48,6 +48,9 @@ function makeHarness(opts?: { maxRewinds?: number; messages?: any[] }) { fw.rewindEpisode = new Map(); fw.lastInferenceAt = new Map(); // upstream last-inference tracking; noteInferenceExhausted writes it fw.pendingRequests = []; + // The turn the retry continues answered a channel (its speech route). + fw.turnRoutes = new Map([['cairn', { route: { kind: 'channel', serverId: 'discord', channelId: 'discord:g:c1', replyTo: 'm-1', origin: 'trigger' } }]]); + fw.conversationAgentHomes = new Map(); fw.traceListeners = []; fw.inferenceFailureEscalationThreshold = 3; fw.agents = new Map([['cairn', { @@ -89,6 +92,10 @@ test('at the threshold, invalid_request failures trigger an automatic rewind + r // A retry was queued so the rewound history is verified immediately. assert.equal(fw.pendingRequests.length, 1); assert.equal(fw.pendingRequests[0].reason, 'inference-failure-rewind-retry'); + // It names the conversation the failed turn answered, so its words go there. + assert.equal(fw.pendingRequests[0].namesConversation, true); + assert.deepEqual(fw.pendingRequests[0].routeCandidates.map((c: any) => [c.conversation.channelId, c.addressed, c.messageId]), + [['discord:g:c1', true, 'm-1']]); assert.ok(errs.some((e) => e.includes('[inference-rewind]') && e.includes('auto-quarantined'))); }); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 8b996a349..0145677c3 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1062,13 +1062,14 @@ test('a producer retry of a coalesced message is no new delivery: the fallback s await f.register('A'); await f.register('B'); const registry = (f.framework as unknown as { - channelRegistry: { getLastInbound(): { channelId: string } | null }; + channelRegistry: { getLastInbound(): { channelId: string; threadId?: string } | null }; }).channelRegistry; - const original = { messages: [f.channel('A-1', 'first channel', { initial: true }, 'A', 'message-A')] }; + const original = { messages: [{ ...f.channel('A-1', 'first channel', { initial: true }, 'A', 'message-A'), threadId: 't-9' }] }; const accepted = await f.send('channels/incoming', original); assert.equal(accepted.result.results[0].accepted, true); await f.framework.runUntilIdle(); - assert.equal(registry.getLastInbound()?.channelId, 'A', 'a fresh admission counts'); + assert.deepEqual([registry.getLastInbound()?.channelId, registry.getLastInbound()?.threadId], ['A', 't-9'], + 'a fresh admission counts, with its thread'); const b = f.channel('B-1', 'latest real message', {}, 'B', 'message-B') as Record; delete b.coalesce; @@ -1084,11 +1085,28 @@ test('a producer retry of a coalesced message is no new delivery: the fallback s assert.equal(f.membrane.calls.length, calls, 'the retry starts no turn'); assert.equal(registry.getLastInbound()?.channelId, 'B', 'a retry is no delivery: the place stays B'); - // A withdrawal admitted afresh delivers nothing either. + // A withdrawal admitted afresh is never the place either. await f.register('C'); - await f.send('channels/incoming', { messages: [{ ...f.channel('C-1', '', { retract: true }, 'C', 'message-C'), content: [] }] }); + const withdrawal = await f.send('channels/incoming', { messages: [{ ...f.channel('C-1', '', { retract: true }, 'C', 'message-C'), content: [] }] }); + assert.equal(withdrawal.result.results[0].accepted, true, 'the withdrawal was admitted'); await f.framework.runUntilIdle(); - assert.equal(registry.getLastInbound()?.channelId, 'B', 'a withdrawal is no delivery: the place stays B'); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a withdrawal is never the place: the place stays B'); + + // Nor is a coalesced reaction or deletion, nor a push admitted in a + // channel's scope: only a channel message is where the last message came in. + await f.register('D'); + for (const [eventId, tags] of [['D-1', ['chat:reaction']], ['D-2', ['chat:deleted']]] as const) { + const item = await f.send('channels/incoming', { + messages: [{ ...f.channel(eventId, 'not a message', { initial: true }, 'D', `message-${eventId}`), tags: [...tags] }], + }); + assert.equal(item.result.results[0].accepted, true, `${tags[0]} was admitted`); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B', `a coalesced ${tags[0]} is never the place`); + } + const push = await f.send('push/event', f.params('P-1', 'a push about D', { channelId: 'D', key: 'message:p-1', initial: true })); + assert.equal(push.result.coalesce.outcome, 'first', 'the channel-scoped push was admitted afresh'); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a push is never the place'); const publishes = f.published.length; await (f.framework as unknown as { startAgentStream(a: unknown, tr?: unknown): Promise }) @@ -1097,3 +1115,30 @@ test('a producer retry of a coalesced message is no new delivery: the fallback s await eventually(() => f.published.length > publishes, 'the scheduled note is published'); assert.equal(f.published.at(-1)!.channelId, 'B', 'the scheduled note lands where the last message came in'); }); + +test('a deferred push batch whose origin names only a thread names a conversation, so its turn takes no fallback', async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => true } }); t.after(f.close); + f.alwaysRespond(); + await f.register('A'); + const a = f.channel('A-1', 'a real message', {}, 'A', 'message-A') as Record; + delete a.coalesce; + delete a.eventId; + await f.send('channels/incoming', { messages: [a] }); + await f.framework.runUntilIdle(); + const turnRoutes = (f.framework as unknown as { turnRoutes: Map }).turnRoutes; + assert.equal(turnRoutes.get('agent')?.route?.channelId, 'A'); + const calls = f.membrane.calls.length; + const publishes = f.published.length; + // A deferred batch wakes through the coalescer's batch wake, which names + // its conversation from the batch (not the ordinary push path). + await f.send('push/event', { ...f.params('T-1', 'in a thread', { deferred: true, initial: true }), origin: { threadId: 't-1' } }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'the push woke a turn'); + assert.deepEqual(turnRoutes.get('agent'), { route: null }, 'its thread names a conversation: no fallback'); + assert.equal(f.published.length, publishes, 'nothing went where the last message came in'); + // A deferred batch naming nothing takes the fallback. + await f.send('push/event', f.params('T-2', 'a nudge', { key: 'nudge', deferred: true, initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 2); + assert.deepEqual([turnRoutes.get('agent')?.route?.channelId, turnRoutes.get('agent')?.route?.origin], ['A', 'fallback']); +}); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 6dca66cd0..889a6fa7e 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -5,6 +5,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { + continuationCandidates, conversationKey, describeConversation, inferTurnRoute, @@ -321,3 +322,52 @@ describe('conversation identity and wording', () => { assert.equal(on('beta'), '#general (beta / general)'); }); }); + +describe('continuationCandidates', () => { + const route = (channelId: string, extra: Partial> = {}): SpeechRoute => + ({ kind: 'channel', serverId: 'discord', channelId, origin: 'trigger', ...extra }); + + it('a turn with a route names its conversation, addressed, with its reply edge', () => { + const turn: TurnRoute = { route: route('general', { threadId: 't-1', replyTo: 'm-7', label: '#general', origin: 'fallback' }) }; + assert.deepEqual(continuationCandidates(turn, 9), [{ + conversation: { kind: 'channel', serverId: 'discord', channelId: 'general', threadId: 't-1', label: '#general' }, + addressed: true, + at: 9, + messageId: 'm-7', + }]); + assert.deepEqual(continuationCandidates({ route: { kind: 'surface', surface: 'tui', origin: 'trigger' } }, 9), + [{ conversation: { kind: 'surface', surface: 'tui' }, addressed: true, at: 9 }]); + }); + + it('a held turn names every conversation it held between, so its continuation is held again', () => { + const held: TurnRoute = { route: route('a'), hold: { conversations: [channel('a'), channel('b')], since: 'mid-turn' } }; + const candidates = continuationCandidates(held, 5); + assert.deepEqual(candidates.map((c) => conversationKey(c.conversation)), [conversationKey(channel('a')), conversationKey(channel('b'))]); + assert.ok(candidates.every((c) => c.addressed && c.at === 5 && !c.messageId)); + assert.deepEqual(inferTurnRoute(candidates, null, rootEverywhere).hold?.conversations.length, 2); + }); + + it('a turn whose one conversation could not take plain speech names it, to be checked again', () => { + const unresolved = continuationCandidates({ route: null, unroutable: { conversation: channel('dup'), reason: 'unresolved' } }, 3); + assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3, unroutable: true }]); + const untargetable = continuationCandidates({ route: null, unroutable: { conversation: channel('quiet'), reason: 'untargetable' } }, 3); + assert.deepEqual(untargetable, [{ conversation: channel('quiet'), addressed: true, at: 3 }]); + // Checked again: still refused while the channel declares nothing, a route once it does. + assert.equal(inferTurnRoute(untargetable, null, declaredNowhere).unroutable?.reason, 'untargetable'); + assert.equal(inferTurnRoute(untargetable, null, rootEverywhere).route?.kind, 'channel'); + }); + + it('a turn that answered no conversation, or left no record, gives none', () => { + assert.deepEqual(continuationCandidates({ route: null }, 1), []); + assert.deepEqual(continuationCandidates(undefined, 1), []); + }); + + it('beside an ambient arrival from elsewhere the continued conversation wins; beside an addressed one the turn holds', () => { + const continued = continuationCandidates({ route: route('a', { replyTo: 'm-1' }) }, 10); + const ambient = inferTurnRoute([...continued, candidate(channel('b'), false, 20, 'm-2')], null, rootEverywhere); + assert.deepEqual(ambient.route, { kind: 'channel', serverId: 'discord', channelId: 'a', replyTo: 'm-1', origin: 'trigger' }); + const addressed = inferTurnRoute([...continued, candidate(channel('b'), true, 20, 'm-2')], null, rootEverywhere); + assert.equal(addressed.route, null); + assert.equal(addressed.hold?.conversations.length, 2); + }); +}); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 4045e2040..fdf3e6290 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -28,7 +28,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { ProcessEvent } from '../src/index.js'; -import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; /** Reach the private per-turn speech routes + the pure channel-deriver. */ function internals(framework: AgentFramework) { @@ -40,6 +40,11 @@ function internals(framework: AgentFramework) { ): { channelId: string; label?: string } | undefined; channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; + continuationRequest(agentName: string, reason: string): { + agentName: string; reason: string; source: string; timestamp: number; + namesConversation?: true; routeCandidates?: Array<{ conversation: { channelId?: string }; addressed: boolean }>; + }; + conversationAgentHomes: Map; }; } @@ -618,6 +623,18 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal(membrane.calls.length, 4); assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'an author-only push takes no fallback'); + + // So does one that names only a thread. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'in that thread' }])); + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'discord', featureSet: 'discord.messaging', + eventId: `e-${Math.random().toString(36).slice(2)}`, content: [{ type: 'text', text: 'hm' }], + origin: { source: 'discord', threadId: 'T-9' }, timestamp: new Date().toISOString(), + inferenceId: `inf-${Math.random().toString(36).slice(2)}`, triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 5); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a thread-only push takes no fallback'); assert.deepEqual(published, [], 'nothing went to the last delivered message\'s channel'); await framework.stop(); }); @@ -654,53 +671,161 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it('a framework wake that continues the previous turn keeps its route, never the fallback', async () => { + it('a refusal\'s rewind retry names the refused turn\'s conversation, so it speaks there, never where the last message came in', async () => { + // One response per stream: the refused turn, then its retry. + const streams = [createMockResponse([], 'refusal'), createMockResponse([{ type: 'text', text: 'after the rewind' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'rewind.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', refusalHandling: { autoRewind: true } }], + modules: [], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, 'the refused turn, then its retry'); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanA', 'after the rewind']]); + await framework.stop(); + }); + + it('a continuation names its turn\'s conversation in any batch: it keeps it beside a heartbeat in either order and beside an ambient arrival, and holds beside an addressed one', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); - // A rewind retry of that work names nothing new: it keeps chanA, though - // the last message came in elsewhere. - for (const reason of ['refusal-rewind-retry', 'inference-failure-rewind-retry', 'tool_results_ready']) { - membrane.pushResponse(createMockResponse([{ type: 'text', text: `again after ${reason}` }])); - internals(framework).pendingRequests.push({ agentName: 'scout', reason, source: 'framework', timestamp: Date.now() }); + const i = internals(framework); + const chanA = i.continuationRequest('scout', 'refusal-rewind-retry'); + assert.equal(chanA.namesConversation, true); + assert.deepEqual(chanA.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); + const heartbeatRequest = () => ({ agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + const arrivalFromB = (addressed: boolean) => ({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed, at: Date.now() }], + }) as never; + const batches: Array<[string, () => unknown[]]> = [ + ['first', () => [i.continuationRequest('scout', 'inference-failure-rewind-retry'), heartbeatRequest()]], + ['behind a heartbeat', () => [heartbeatRequest(), i.continuationRequest('scout', 'prose-bounce')]], + ['beside an ambient arrival', () => [arrivalFromB(false), i.continuationRequest('scout', 'unstick')]], + ]; + for (const [label, batch] of batches) { + const calls = membrane.calls.length; + membrane.pushResponse(createMockResponse([{ type: 'text', text: label }])); + i.pendingRequests.push(...(batch() as never[])); await framework.runUntilIdle(); - assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA', `${reason} keeps the route`); + assert.equal(membrane.calls.length, calls + 1, `${label}: one turn`); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA', `${label}: it keeps chanA`); } assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:chanA', 'discord:guild:chanA', 'discord:guild:chanA', 'discord:guild:chanA']); + // An addressed arrival from chanB competes with it: the turn is held. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'which one' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry'), arrivalFromB(true)); + await framework.runUntilIdle(); + const held = i.turnRoutes.get('scout') as { route: null; hold?: { conversations: Array<{ channelId: string }> } }; + assert.equal(held.route, null); + assert.deepEqual(held.hold?.conversations.map((c) => c.channelId).sort(), ['discord:guild:chanA', 'discord:guild:chanB']); + assert.equal(published.length, 4, 'nothing published from the held turn'); + // Its continuation names both, and is held again. + const both = i.continuationRequest('scout', 'prose-bounce'); + assert.deepEqual(both.routeCandidates?.map((c) => c.conversation.channelId).sort(), ['discord:guild:chanA', 'discord:guild:chanB']); + await framework.stop(); + }); + + it('a continuation whose conversation a fork has claimed names it but gets no route; one whose turn answered none names none', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const i = internals(framework); + // A fork claims chanA after the turn: the continuation still names chanA, + // so it takes no fallback, but it may not take chanA. + i.conversationAgentHomes.set('fork-1', 'discord:guild:chanA'); + const claimed = i.continuationRequest('scout', 'refusal-rewind-retry'); + assert.equal(claimed.namesConversation, true); + assert.equal(claimed.routeCandidates, undefined); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); + i.pendingRequests.push(claimed); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + assert.equal(published.length, 1, 'only the first answer was published'); + // A turn that answered no conversation (a silent wake's record) gives a + // continuation that names none, so it takes the fallback like any such wake. + i.conversationAgentHomes.delete('fork-1'); + i.turnRoutes.set('scout', { route: null }); + const none = i.continuationRequest('scout', 'unstick'); + assert.equal(none.namesConversation, undefined); + assert.equal(none.routeCandidates, undefined); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'where the last message came in' }])); + i.pendingRequests.push(none); + await framework.runUntilIdle(); + const fallback = i.turnRoutes.get('scout')?.route; + assert.deepEqual([fallback?.channelId, fallback?.origin], ['discord:guild:elsewhere', 'fallback']); await framework.stop(); }); it('an ephemeral subagent never takes the fallback, and several requests batched into one turn name a conversation if any does', async () => { - membrane.pushResponse(createMockResponse([{ type: 'text', text: 'diver notes' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); internals(framework).channelRegistry = registry; - // As if scout were an ephemeral subagent: its prose is its caller's. - const ephemerals = (framework as unknown as { usedEphemeralAgentNames: Set }).usedEphemeralAgentNames; - ephemerals.add('scout'); - await heartbeat(framework); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'no fallback for an ephemeral'); - ephemerals.delete('scout'); + // A real ephemeral subagent: its prose is its caller's. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'diver notes' }])); + const diver = await framework.createEphemeralAgent({ name: 'diver', model: 'test-model', systemPrompt: 'Dive.', allowedTools: 'all' }); + diver.contextManager.addMessage('user', [{ type: 'text', text: 'Look around.' }]); + const run = framework.runEphemeralToCompletion(diver.agent, diver.contextManager); + await framework.runUntilIdle(); + assert.equal((await run).speech, 'diver notes'); + assert.deepEqual(internals(framework).turnRoutes.get('diver'), { route: null }, 'no fallback for an ephemeral'); // A heartbeat batched with a wake that names a conversation (as a // reaction's does) is one turn that names one: no fallback. + const calls = membrane.calls.length; membrane.pushResponse(createMockResponse([{ type: 'text', text: 'batched' }])); internals(framework).pendingRequests.push( { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }, { agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true } as never, ); await framework.runUntilIdle(); + assert.equal(membrane.calls.length, calls + 1, 'the batch ran, as one turn'); assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'the batch named a conversation'); assert.deepEqual(published, []); await framework.stop(); }); - it('a module event that names a channel in its metadata never takes the fallback', async () => { + it('a tune-out wake about a channel names it, so the primary\'s turn after a cancel takes no fallback', async () => { + const framework = await makeFramework(); + // The coordinator's requestInference hook (tune-out/coordinator.ts). + const wake = (framework as unknown as { + queueTuneOutWake(agent: string, reason: string, source: string, channelId?: string): void; + }).queueTuneOutWake.bind(framework); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back from the noisy channel' }])); + wake('scout', 'tune-out cancelled (done)', 'tune-out', 'discord:guild:noisy'); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'it names the noisy channel: no fallback'); + assert.deepEqual(published, []); + // A wake through the same hook that names no channel takes the fallback. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + wake('scout', 'a wake about nothing', 'tune-out'); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:last'); + await framework.stop(); + }); + + it('a module event that names a channel, a thread or an author in its metadata never takes the fallback', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'about that channel' }])); const relay = { name: 'relay', @@ -724,7 +849,22 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1); assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); - assert.deepEqual(published, []); + // A thread alone, or an author alone, names a conversation too; a note + // naming none takes the fallback. + const cases: Array<[Record, string | null]> = [ + [{ threadId: 'T-1' }, null], + [{ authorId: 'U-5' }, null], + [{}, 'discord:guild:last'], + ]; + for (const [metadata, expected] of cases) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: `about ${JSON.stringify(metadata)}` }])); + framework.pushEvent({ type: 'relay:note', source: 'relay', metadata } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId ?? null, expected, JSON.stringify(metadata)); + } + assert.equal(membrane.calls.length, 4); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:last'], + 'only the note naming nothing spoke, where the last message came in'); await framework.stop(); }); diff --git a/test/tune-out-gate-composition.test.ts b/test/tune-out-gate-composition.test.ts index 24a00d03f..716322f0e 100644 --- a/test/tune-out-gate-composition.test.ts +++ b/test/tune-out-gate-composition.test.ts @@ -238,3 +238,37 @@ describe('gate composition on subconscious wakes', () => { assert.equal(acks.length, 0); }); }); + +describe('tune-out wakes name their channel', () => { + it('the cancel wakes and a subconscious wake each pass the channel, so none takes the fallback route', () => { + const requested: Array<[string, string, string | undefined]> = []; + const registry = { + cancelTuneOut: () => ({ params: PARAMS, wakeCount: 0 }), + } as unknown as ChannelRegistry; + const hooks = { + addMessage: () => '', + requestInference: (agentName: string, _reason: string, source: string, channelId?: string) => { + requested.push([agentName, source, channelId]); + }, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + const coordinator = new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); + assert.deepEqual(coordinator.cancel('disc', '#a', 'resident', 'done'), { ok: true }); + (coordinator as unknown as { invokeSubconscious(s: string, c: string, t: 'cadence' | 'wake', n: number): void }) + .invokeSubconscious('disc', '#b', 'cadence', 2); + coordinator.stop(); + assert.deepEqual(requested, [ + ['scout', 'tune-out', '#a'], + ['Subconscious', 'tune-out', '#a'], + ['Subconscious', 'tune-out', '#b'], + ]); + }); +}); From 698c31a746a3e48d52b186dd89807677cc97ccc3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 03:01:17 -0400 Subject: [PATCH 60/65] fix(routing): the fork rule judges a route at turn start; a bounce wakes when its turn ends; a placeless turn's continuation takes no fallback From Basil-1007's and Audrey-2282's reviews of 6114926 (room-448 #75410, #75447, #75458). The fork rule at turn start (Audrey). Each producer checked the fork rule when it queued a wake (the gate, channels/incoming, a push, a coalesced batch, continuationRequest), and beginAgentTurn used the candidates as they were. A wake can wait in the queue while a fork claims a channel, so the trunk could still take it: Audrey's probe moved the claim after continuationRequest returned and got a trigger route into the fork's channel. Now beginAgentTurn filters every wake's candidates with mayTakeCandidate, as ownership stands when the turn starts, and the producers no longer filter candidates (they still keep a fork's channel out of each agent's typing channel, which is shown at once). One place, for every producer: a claim made, or given up, while a wake waits counts. A candidate the rule leaves out still named its conversation, so the wake still takes no fallback, and an unroutable one in a fork's channel holds no other agent's turn. A bounce wakes when its turn ends (Basil). bounceProse runs mid-turn, and queued its continuation then, reading the route record at the bounce. If the turn then moved its route (a channel_open, which the bounce notice suggests, or a mid-turn hold), the follow-up named the place the resident had just left. Now a bounce in a live turn (activeTurnTokens) only marks proseBounceWakes, and driveStream's finally queues the wake once the logical turn has ended (frameReachedTerminal and ownsPhysicalStream, beside settleTurnDrafts), so it reads the record as the turn left it. A bounce outside a turn (a delivery that fails after it) queues at once, as before. A true new turn clears a mark no turn end consumed (a superseded turn): the bounce notice is in that turn's history already. A placeless turn's continuation takes no fallback (Basil's silent-tick point, decided here at the routing level). continuationRequest named a conversation only when the turn's record gave candidates, so a turn whose words had no place ({ route: null }: a silent tick, a reaction or a fork-owned channel, a no-name wake with no fallback place) gave a continuation that took the fallback, speaking where the last message came in. Now any route record names a conversation; only an agent with none (no locus-mode turn yet, or an explicit-prose turn) names nothing. Copying a silent tick's prompt and suppression onto its retry, as a budget restart does, is left out: that is turn semantics beyond where words go, and main doesn't do it either. Not changed: a fallback turn's continuation gets an inferred route to the same place (Basil's second point); the outcome is right, and a mid-turn hold's notice then uses the inferred wording. Tests (trunk-channel-routing): the fork rule judges a continuation at turn start, both ways (claimed after queueing: no route; given up before the turn: chanA), and an unroutable candidate in a fork's channel holds nothing; a placeless turn's continuation gets no place, and an agent with no record takes the fallback; a bounce during a turn wakes at its end and speaks where the turn's route moved (a test module bounces and moves the route mid-turn); a mark a superseded turn left starts no extra wake. batched-wake-locus: the gate, push, coalesced and channels/incoming producers carry their candidates (typing still follows the fork rule). Changelog: the fork rule's timing, the bounce's wake, and a placeless turn's continuation. --- changelog.d/speech-routes.changed.md | 14 ++- src/framework.ts | 82 +++++++++++------ src/speech-routes.ts | 8 +- test/batched-wake-locus.test.ts | 26 +++--- test/trunk-channel-routing.test.ts | 131 ++++++++++++++++++++++++--- 5 files changed, 198 insertions(+), 63 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 5e46a7453..f8fea4eb7 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -49,9 +49,13 @@ when it is queued, as addressed ones, so its words go where the turn's would have. Beside a heartbeat they keep the turn, beside an ambient arrival from elsewhere they win, and beside an addressed one from - elsewhere the turn is held. A turn that answered none gives a wake that - names none. Everything else starts afresh, as at any new turn: a rewind - sheds the turn whose choices those were. + elsewhere the turn is held. A bounce during a turn queues its wake when + that turn ends, so it names where the turn's words went by then, after a + `channel_open` or a hold. A turn whose words had no place (a silent + tick, or a wake whose conversation it couldn't take) gives no + conversation to carry, and its continuation's words get no place either, + never the fallback. Everything else starts afresh, as at any new turn: a + rewind sheds the turn whose choices those were. - **An ephemeral subagent never takes the fallback.** Its prose isn't a channel's, and before speech routes a subagent's prose between its tool calls could reach the resident's live channel this way. @@ -125,7 +129,9 @@ coalesced push and a `channels/incoming` broadcast now give no other agent a speech route or a typing indicator in a channel a conversation fork owns, and a fork takes neither in a channel other than its home. Batched gate - wakes already worked this way. + wakes already worked this way. A route is judged when the turn starts, as + ownership stands then, so a fork's claim, or its giving a channel up, + while a wake waits in the queue counts. - The `[discord-send-failed]` marker for a reply on a thread route names the thread with its channel, and an unconfirmed one says to check the thread. - **Routing names each conversation with its server.** Routing notices, diff --git a/src/framework.ts b/src/framework.ts index 1c88033a6..48532830a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1203,6 +1203,11 @@ export class AgentFramework { /** Consecutive bounce-wakes per agent; capped so an agent that keeps * emitting unprefixed prose can't wake-loop (notices still append). */ private proseBounceStreaks: Map = new Map(); + /** Agents whose words bounced while their turn was live: the bounce's wake + * is queued when that turn ends (driveStream), so it names the + * conversations the turn answered by then, after a channel_open or a hold + * later in the turn. It couldn't start before the turn ends anyway. */ + private proseBounceWakes: Set = new Set(); private pendingAssistantBlocks: Map = new Map(); /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed * `${agentName}:${streamId}`: an endTurn tool result or a context-budget @@ -1797,13 +1802,10 @@ export class AgentFramework { emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }), addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined), requestInference: (agentName, reason, source, provenance) => { - // The batch's conversations as speech-route candidates, under the - // same fork rule: a fork takes only its home's, and no other agent - // takes a channel a fork owns. That includes an unroutable - // candidate: it never routes, but it competes, so a fork's - // channel would otherwise hold another agent's turn. + // The batch's conversations as speech-route candidates. The fork + // rule is applied to them at turn start (beginAgentTurn), as for + // every wake. const routeCandidates = (provenance?.routeCandidates ?? []) - .filter((c) => c.kind === 'surface' || framework.mayTakeRoute(agentName, c.channelId)) .map((c): RouteCandidate => c.kind === 'surface' ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } : c.unroutable @@ -1841,8 +1843,6 @@ export class AgentFramework { ...(provenance?.routeChannelId && framework.mayTakeRoute(agentName, provenance.routeChannelId) ? { channelId: provenance.routeChannelId, addressed: true } : {}), - // None left, as for an agent the fork rule leaves out, is none: - // the batch still named its conversations, so no fallback either. ...(routeCandidates.length > 0 ? { routeCandidates } : {}), ...(provenance?.namesConversation ? { namesConversation: true as const } : {}), }); @@ -7560,7 +7560,7 @@ export class AgentFramework { for (const agentName of targetAgents) { if (!this.agents.has(agentName)) continue; // The broadcast reaches forks too: the fork rule keeps each agent's - // typing and route candidate to a channel it may take. + // typing to a channel it may take (and its route, at turn start). const takesChannel = this.mayTakeRoute(agentName, event.channelId); this.pendingRequests.push({ agentName, @@ -7575,7 +7575,7 @@ export class AgentFramework { addressed, // This message's conversation, as a speech-route candidate: the // turn's route is inferred from every candidate of its batch. - ...(candidate && this.mayTakeCandidate(agentName, candidate) ? { routeCandidates: [candidate] } : {}), + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -8578,7 +8578,6 @@ export class AgentFramework { const originThread = typeof origin?.threadId === 'string' && origin.threadId.length > 0; const namesConversation = !!named || !!channelId || !!authorId || !!occ.identity?.threadId || originThread; for (const agentName of await this.coalescedAudience(occ)) { - const takes = candidate && this.mayTakeCandidate(agentName, candidate) ? candidate : undefined; this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', @@ -8588,7 +8587,7 @@ export class AgentFramework { ...(namesConversation ? { namesConversation: true as const } : {}), counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, addressed: addressedHere, - ...(takes ? { routeCandidates: [takes] } : {}), + ...(candidate ? { routeCandidates: [candidate] } : {}), coalescingSubject: subject, coalescingBatch: true, }); @@ -8820,12 +8819,12 @@ export class AgentFramework { || originNames('authorId') || originNames('threadId'); const pushCandidate = silentHeartbeat || !isConversational(event.tags) ? undefined : pushNamed; for (const agentName of targetAgents) { - // The fork rule, as for a gate batch: no agent takes a channel a fork - // owns, and a fork takes only its home. + // The fork rule for typing, as for a gate batch: no agent types in a + // channel a fork owns, and a fork only in its home. (Its route is + // judged at turn start.) const typingChannel = triggerChannel?.channelId && this.mayTakeRoute(agentName, triggerChannel.channelId) ? triggerChannel.channelId : undefined; - const candidate = pushCandidate && this.mayTakeCandidate(agentName, pushCandidate) ? pushCandidate : undefined; this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', @@ -8833,7 +8832,7 @@ export class AgentFramework { timestamp: Date.now(), channelId: typingChannel, addressed: pushAddressed, - ...(candidate ? { routeCandidates: [candidate] } : {}), + ...(pushCandidate ? { routeCandidates: [pushCandidate] } : {}), ...(pushNamesConversation ? { namesConversation: true as const } : {}), ...(silentHeartbeat ? { suppressProse: true, @@ -8850,10 +8849,11 @@ export class AgentFramework { } /** - * Whether a wake's channel may become `agentName`'s speech route or typing - * channel: for a gate batch, a push event or a coalesced push alike. A - * fork speaks only in its home channel, and a channel bound to a fork is - * that fork's conversation, so no other agent takes it. + * Whether a channel may become `agentName`'s speech route or typing + * channel: read for a wake's typing channel when it is queued, and for its + * route candidates and the fallback when its turn starts. A fork speaks + * only in its home channel, and a channel bound to a fork is that fork's + * conversation, so no other agent takes it. */ private mayTakeRoute(agentName: string, routeChannelId: string): boolean { const home = this.conversationAgentHomes.get(agentName); @@ -10170,21 +10170,24 @@ export class AgentFramework { * route record gives the conversations that turn answered * (continuationCandidates), and the wake names them. So the true new turn * it starts, alone or batched with other wakes, infers its route from them - * by the rules every wake follows, rather than taking the fallback. Like - * every producer's candidates, they pass the fork rule here: one a fork has - * claimed still names its conversation, so the turn gets no route. A turn - * that answered none gives a wake that names none. + * by the rules every wake follows (the fork rule included, at turn start), + * rather than taking the fallback. A turn whose words had no place (a + * silent tick, a wake whose conversation it couldn't take, a no-name wake + * with no fallback place) gives no candidates, but its continuation still + * names a conversation: the fallback mustn't give the continuation's words + * a place the turn's never had. Only an agent with no route record (no + * turn yet, or an explicit-prose turn) gives a wake that names nothing. */ private continuationRequest(agentName: string, reason: string): InferenceRequest { const timestamp = Date.now(); - const named = continuationCandidates(this.turnRoutes.get(agentName), timestamp); - const candidates = named.filter((c) => this.mayTakeCandidate(agentName, c)); + const turn = this.turnRoutes.get(agentName); + const candidates = continuationCandidates(turn, timestamp); return { agentName, reason, source: 'framework', timestamp, - ...(named.length > 0 ? { namesConversation: true as const } : {}), + ...(turn ? { namesConversation: true as const } : {}), ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), }; } @@ -11131,8 +11134,10 @@ export class AgentFramework { ); if (!capped) { // Wake so the resend can happen immediately — an unsent DM reply must - // not sit invisible until the next unrelated event. - this.pendingRequests.push(this.continuationRequest(name, 'prose-bounce')); + // not sit invisible until the next unrelated event. A bounce in a live + // turn wakes when that turn ends (proseBounceWakes). + if (this.activeTurnTokens.has(name)) this.proseBounceWakes.add(name); + else this.pendingRequests.push(this.continuationRequest(name, 'prose-bounce')); } } @@ -11436,6 +11441,9 @@ export class AgentFramework { this.turnDraftFailures.delete(agent.name); this.proseHybridSuppressed.delete(agent.name); this.proseTargetPins.delete(agent.name); + // A bounce whose turn ended without waking (superseded) is in this + // turn's history already. + this.proseBounceWakes.delete(agent.name); (this.logicalTurns ??= new Map()).set(agent.name, this.logicalTurn(agent.name) + 1); } if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { @@ -11456,10 +11464,19 @@ export class AgentFramework { // continuation or bounce) carries the conversations that turn // answered as its candidates (continuationRequest), so it is // inferred here like any other. + // + // The fork rule applies here, where the route is decided, to every + // wake's candidates: a fork takes only its home's, and no other agent + // takes a channel a fork owns, as ownership stands now. A wake can + // wait in the queue while a fork claims or gives up a channel. A + // candidate it leaves out still named its conversation, so it still + // keeps the fallback away. That includes an unroutable candidate, + // which never routes but competes: a fork's channel never holds + // another agent's turn. const turn: TurnRoute = trigger?.suppressProse ? { route: null } : inferTurnRoute( - trigger?.routeCandidates ?? [], + (trigger?.routeCandidates ?? []).filter((c) => this.mayTakeCandidate(agent.name, c)), this.homeRoute(agent.name), (c) => this.publishTargetOf(c), // Only a resident's wake that named no conversation, candidate @@ -13182,6 +13199,11 @@ export class AgentFramework { } catch (err) { console.error('[drafts] turn-end settlement failed:', err); } + // A bounce during the turn wakes now, naming the conversations the + // turn answered at its end. + if (this.proseBounceWakes.delete(agent.name)) { + this.pendingRequests.push(this.continuationRequest(agent.name, 'prose-bounce')); + } } } } diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 8b1602926..0a1e99f5a 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -299,9 +299,11 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu * whose one conversation couldn't take plain speech names that one, to be * checked again. Each counts as addressed, since the resident was answering * it: an ambient arrival batched beside it doesn't take the turn, and an - * addressed one from elsewhere holds it. A turn that answered none (a silent - * wake, an explicit-prose turn, a no-name wake with no fallback place) gives - * none, so its continuation names none. + * addressed one from elsewhere holds it. A turn whose words had no place (a + * silent wake, a wake whose conversation it couldn't take, a no-name wake + * with no fallback place) gives none; the framework's wake still names a + * conversation then, so the fallback gives its words no place either + * (continuationRequest). */ export function continuationCandidates(turn: TurnRoute | undefined, at: number): RouteCandidate[] { if (!turn) return []; diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index 9f918563d..161d4fdf0 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -214,7 +214,7 @@ describe('batched wake routing with conversation forks', () => { rmSync(tempDir, { recursive: true, force: true }); }); - it('a batch candidate the host could not resolve follows the fork rule too: no other agent is held by a fork-owned channel', async () => { + it("a batch candidate the host could not resolve reaches every agent's wake, for the fork rule to judge when each turn starts", async () => { const internals = framework as unknown as { conversationAgentHomes: Map; pendingRequests: Array<{ agentName: string; routeCandidates?: Array<{ conversation: { channelId?: string }; unroutable?: true }> }>; @@ -229,11 +229,12 @@ describe('batched wake routing with conversation forks', () => { }); await waitFor(() => internals.pendingRequests.some((r) => r.agentName === 'trunk'), 'gate wake broadcast'); const byAgent = new Map(internals.pendingRequests.map((r) => [r.agentName, r])); - assert.deepEqual(byAgent.get('fork-room')?.routeCandidates?.map((c) => [c.conversation.channelId, c.unroutable]), [[ROOM, true]], - 'the fork homed there competes for it'); - for (const other of ['trunk', 'fork-other']) { - assert.equal(byAgent.get(other)?.routeCandidates, undefined, `${other}'s turn is not held by the fork's channel`); - assert.ok(byAgent.has(other), `${other} is still woken`); + // Only the fork homed there may take it, as ownership stands when its + // turn starts (trunk-channel-routing pins that a fork's channel holds no + // other agent's turn). + for (const agent of ['fork-room', 'trunk', 'fork-other']) { + assert.deepEqual(byAgent.get(agent)?.routeCandidates?.map((c) => [c.conversation.channelId, c.unroutable]), [[ROOM, true]], + `${agent}'s wake names it`); } }); @@ -267,7 +268,7 @@ describe('batched wake routing with conversation forks', () => { origin: { mcplChannelId: channelId, messageId: `${eventId}-m`, authorId: '5' }, }); - it('a push event follows the fork rule: no agent takes a fork-owned channel as its route or typing', async () => { + it('a push event follows the fork rule for typing, and names its conversation for the turn to judge', async () => { const internals = framework as unknown as { conversationAgentHomes: Map; pendingRequests: Wake[]; @@ -278,7 +279,7 @@ describe('batched wake routing with conversation forks', () => { const trunk = internals.pendingRequests.find((r) => r.agentName === 'trunk'); assert.ok(trunk, 'the trunk is still woken'); assert.equal(trunk!.channelId, undefined, 'no typing in a fork-owned channel'); - assert.equal(trunk!.routeCandidates, undefined, 'and no route into it'); + assert.deepEqual(trunk!.routeCandidates?.map((c) => c.conversation.channelId), [ROOM], 'its route is judged at turn start'); // Control: the same push into a channel no fork owns. internals.pendingRequests.length = 0; internals.handleMcplPushEvent(pushInto(GENERAL, 'e2')); @@ -287,7 +288,7 @@ describe('batched wake routing with conversation forks', () => { assert.deepEqual(free?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL]); }); - it('a coalesced push batch follows the fork rule for its typing channel and route candidate', async () => { + it('a coalesced push batch follows the fork rule for its typing channel, and names its conversation for the turn to judge', async () => { const internals = framework as unknown as { conversationAgentHomes: Map; pendingRequests: Wake[]; @@ -310,7 +311,7 @@ describe('batched wake routing with conversation forks', () => { const trunk = internals.pendingRequests.find((r) => r.agentName === 'trunk'); assert.ok(trunk, 'the trunk is still woken'); assert.equal(trunk!.channelId, undefined, 'no typing in a fork-owned channel'); - assert.equal(trunk!.routeCandidates, undefined, 'and no route into it'); + assert.deepEqual(trunk!.routeCandidates?.map((c) => c.conversation.channelId), [ROOM], 'its route is judged at turn start'); internals.pendingRequests.length = 0; await internals.wakeForCoalescedBatch(batch(GENERAL, 'c2')); const free = internals.pendingRequests.find((r) => r.agentName === 'trunk'); @@ -318,7 +319,7 @@ describe('batched wake routing with conversation forks', () => { assert.deepEqual(free?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'as a route candidate too'); }); - it('a channel-incoming broadcast follows the fork rule for every agent it wakes', async () => { + it('a channel-incoming broadcast follows the fork rule for every agent it wakes: typing here, the route at turn start', async () => { const internals = framework as unknown as { conversationAgentHomes: Map; pendingRequests: Wake[]; @@ -336,7 +337,8 @@ describe('batched wake routing with conversation forks', () => { assert.deepEqual(byAgent.get('fork-room')?.routeCandidates?.map((c) => c.conversation.channelId), [ROOM]); for (const other of ['fork-other', 'trunk']) { assert.equal(byAgent.get(other)?.channelId, undefined, `${other} gets no typing there`); - assert.equal(byAgent.get(other)?.routeCandidates, undefined, `${other} gets no route there`); + assert.deepEqual(byAgent.get(other)?.routeCandidates?.map((c) => c.conversation.channelId), [ROOM], + `${other}'s wake names it; its turn may not take it (trunk-channel-routing)`); } }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index fdf3e6290..246e3749a 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -740,7 +740,7 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it('a continuation whose conversation a fork has claimed names it but gets no route; one whose turn answered none names none', async () => { + it('the fork rule judges a continuation when its turn starts: a claim made after it was queued keeps it out, one given up lets it in', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); @@ -748,32 +748,135 @@ describe('Trunk channel routing (item-3 redux)', () => { framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); const i = internals(framework); - // A fork claims chanA after the turn: the continuation still names chanA, - // so it takes no fallback, but it may not take chanA. + const chanARoute = i.turnRoutes.get('scout')!; + // Queued while chanA is free; a fork claims it before the turn starts. + const queued = i.continuationRequest('scout', 'refusal-rewind-retry'); + assert.equal(queued.namesConversation, true); + assert.deepEqual(queued.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); + i.pendingRequests.push(queued); i.conversationAgentHomes.set('fork-1', 'discord:guild:chanA'); - const claimed = i.continuationRequest('scout', 'refusal-rewind-retry'); - assert.equal(claimed.namesConversation, true); - assert.equal(claimed.routeCandidates, undefined); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); - i.pendingRequests.push(claimed); await framework.runUntilIdle(); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'no route into the fork, and no fallback'); assert.equal(published.length, 1, 'only the first answer was published'); - // A turn that answered no conversation (a silent wake's record) gives a - // continuation that names none, so it takes the fallback like any such wake. + // Queued while the fork owns chanA; the fork gives it up before the turn starts. + i.turnRoutes.set('scout', chanARoute); + const released = i.continuationRequest('scout', 'unstick'); + assert.deepEqual(released.routeCandidates?.map((c) => c.conversation.channelId), ['discord:guild:chanA'], 'named whoever owns it'); + i.pendingRequests.push(released); i.conversationAgentHomes.delete('fork-1'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back in chanA' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:chanA', 'discord:guild:chanA']); + // An unroutable candidate in a fork's channel never routes, but it would + // compete: the rule keeps it from holding another agent's turn. + i.conversationAgentHomes.set('fork-1', 'discord:guild:chanA'); + const now = Date.now(); + i.pendingRequests.push({ + agentName: 'scout', reason: 'mcpl:push-event', source: 'discord', timestamp: now, namesConversation: true, + routeCandidates: [ + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanA' }, addressed: true, at: now, unroutable: true }, + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed: true, at: now - 1 }, + ], + } as never); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'to chanB' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB', "the fork's channel holds nothing"); + await framework.stop(); + }); + + it("a continuation of a turn whose words had no place gets none, never the fallback; only an agent with no route record names nothing", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + // A silent tick's record, or a wake whose conversation the turn couldn't take. i.turnRoutes.set('scout', { route: null }); - const none = i.continuationRequest('scout', 'unstick'); - assert.equal(none.namesConversation, undefined); - assert.equal(none.routeCandidates, undefined); + const placeless = i.continuationRequest('scout', 'refusal-rewind-retry'); + assert.equal(placeless.namesConversation, true); + assert.equal(placeless.routeCandidates, undefined); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still nowhere' }])); + i.pendingRequests.push(placeless); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + assert.deepEqual(published, [], 'nothing went where the last message came in'); + // No record at all (no locus-mode turn yet): the wake names nothing, and + // takes the fallback like any such wake. + i.turnRoutes.delete('scout'); + const fresh = i.continuationRequest('scout', 'unstick'); + assert.equal(fresh.namesConversation, undefined); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'where the last message came in' }])); - i.pendingRequests.push(none); + i.pendingRequests.push(fresh); await framework.runUntilIdle(); const fallback = i.turnRoutes.get('scout')?.route; assert.deepEqual([fallback?.channelId, fallback?.origin], ['discord:guild:elsewhere', 'fallback']); await framework.stop(); }); + it("a bounce during a turn wakes when the turn ends, naming where the turn's words went by then", async () => { + // Turn 1 answers chanA; mid-turn, a tool bounces words and moves the + // route to chanB, as a channel_open would. Turn 2 is the bounce's wake. + const mover = { + name: 'mover', + framework: undefined as AgentFramework | undefined, + queuedMidTurn: undefined as boolean | undefined, + async start() {}, + async stop() {}, + getTools: () => [{ name: 'move', description: 'Move along', inputSchema: { type: 'object', properties: {} } }], + async handleToolCall() { + const fw = this.framework as unknown as { + bounceProse(agent: unknown, text: string, reason: string): void; + turnRoutes: Map; + pendingRequests: Array<{ reason: string }>; + }; + fw.bounceProse(this.framework!.getAgent('scout'), 'lost words', 'a test bounce'); + this.queuedMidTurn = fw.pendingRequests.some((r) => r.reason === 'prose-bounce'); + fw.turnRoutes.set('scout', { route: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB', origin: 'open' } }); + return { success: true, data: { ok: true } }; + }, + async onProcess() { return {}; }, + }; + const streams = [ + [createMockResponse([{ type: 'tool_use', id: 't1', name: 'mover--move', input: {} }], 'tool_use'), createMockResponse([{ type: 'text', text: 'moved' }])], + [createMockResponse([{ type: 'text', text: 'resending' }])], + ]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.shift() ?? []); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'bounce.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [mover as never], + }); + mover.framework = framework; + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(mover.queuedMidTurn, false, 'no wake queued while the turn was live'); + assert.equal(membrane.calls.length, 2, 'the turn, then the bounce\'s wake'); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanB', 'moved'], ['discord:guild:chanB', 'resending']]); + await framework.stop(); + }); + + it("a bounce whose turn ended without waking is in the next turn's history, so that turn starts no extra wake", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answered' }])); + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + // As a superseded turn leaves it: a bounce flag no turn end consumed. + (framework as unknown as { proseBounceWakes: Set }).proseBounceWakes.add('scout'); + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1, 'one turn, and no bounce wake after it'); + await framework.stop(); + }); + it('an ephemeral subagent never takes the fallback, and several requests batched into one turn name a conversation if any does', async () => { const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); From 5ba5d8c9d8c6996fd6d40a27b68977f34e94d575 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 03:18:52 -0400 Subject: [PATCH 61/65] fix(routing): a fallback turn's continuation takes the fallback afresh, a silent tick's retry stays silent, and an unresolved conversation is checked again From the haiku review of 6114926 (sw-af256f, wf-428; all 17 verdicts recorded with review_verdicts). Its blockers, I1 (a silent turn's continuation took the fallback) and I2 (so did any { route: null } turn's), and I13 (the fork rule only at queue time) were fixed by 698c31a; this round adds what the sheet found beyond them. A fallback turn's continuation (I3, and Basil-1007's notice-wording point, room-448 #75410). continuationCandidates named a fallback route's place as an addressed candidate, so the continuation was inferred as a 'trigger' route there: it beat an ambient arrival, held beside an addressed one, and a mid-turn hold used the inferred wording. A fallback turn's wake named no conversation, so now its continuation names none either: the route gives no candidates, and continuationNames (speech-routes) is false for a record whose route is a fallback and that isn't held. The new turn takes the fallback afresh, as the turn did, and an addressed arrival batched beside it takes the turn. Every other route record still counts as named, so a turn whose words had no place gives its continuation no place either. A silent tick's continuation is a silent tick (I1's remainder). continuationRequest copies the running turn's suppressProse, ephemeral prompt and silentHeartbeat stamp (activeTurnTriggers), as a context-budget restart does, so a rewind retry of a refused silent tick runs as the tick: told it's private, its rows stamped, its prose not routed. A continuation queued when no turn runs (an unstick, which needs an idle agent) carries nothing; a silent tick's record still gives it no place. An unresolved conversation is checked again (I14). A continuation candidate kept the old record's unroutable flag, so an 'unresolved' place stayed unresolved; now it carries none, and routeRefusal checks it at the turn's start like any candidate. The tune-out hook is queueTuneOutWake.bind(framework) (I6), so there is no forwarding left to drop the channel. Text: the coalesced batch's comment says the merged thread or the origin's, either (I9); getDefaultPublishChannel says withdrawals are left out by noteDelivered's callers (I11); continuationRequest's doc says a budget restart and a guard retry continue the same logical turn and aren't built there (I12); the changelog's continuation bullet names its producers plainly, says a channel_open sets a route for its own turn only (I7), and says what a fallback turn's and a silent tick's continuations do (I17). Tests: speech-routes: a fallback turn's continuation names none (held, it names what it held between); an unresolved conversation routes once it resolves; continuationNames for a placeless record and for none. trunk-channel-routing: the placeless continuation follows a real reaction-woken turn (I2); a fallback turn's continuation takes the fallback afresh and yields to an addressed arrival (I3); a refused silent tick's retry carries the tick's prompt and stamp, gets no place and publishes nothing (I1); an unstick names its agent's last conversation, through handleHostCommand (I5); the superseded-bounce test gives each stream its own response, so an extra wake fails it at once instead of timing out. inference-exhaustion-rewind's harness gets activeTurnTriggers, which continuationRequest now reads, in place of conversationAgentHomes, which it no longer does. Not changed: I4 (intended: the continuation re-answers its turn's message, as the mid-turn rule keeps a same-conversation arrival from moving the reply edge), I7 (intended), I8 (not real: J3737's mutant removing !occ.retract failed mcpl-coalescing), I10 (not worth it), I15 and I16 (intended). Not driven to their route (I5): tool_results_ready, whose only source has no caller in src; the bare provider-acceleration-retry; and prose-continuation, an explicit or hybrid-mode wake. --- changelog.d/speech-routes.changed.md | 32 ++++---- src/framework.ts | 32 +++++--- src/mcpl/channel-registry.ts | 9 ++- src/speech-routes.ts | 37 +++++---- test/inference-exhaustion-rewind.test.ts | 3 +- test/speech-routes.test.ts | 23 +++++- test/trunk-channel-routing.test.ts | 99 +++++++++++++++++++++++- 7 files changed, 184 insertions(+), 51 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index f8fea4eb7..4a9d84927 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -42,20 +42,24 @@ a wake gets no route, never the fallback. An ambient-only batch names its own conversations: it routes to one, or holds when it names several. - **A framework wake that continues a turn names that turn's - conversations:** a retry after a refusal, an unstick or an inference - failure rewinds; an unstick; the results of a non-streaming tool round; - an explicit-prose continuation or bounce; and a provider-cooldown retry - that holds no wake of its own. The wake carries those conversations, read - when it is queued, as addressed ones, so its words go where the turn's - would have. Beside a heartbeat they keep the turn, beside an ambient - arrival from elsewhere they win, and beside an addressed one from - elsewhere the turn is held. A bounce during a turn queues its wake when - that turn ends, so it names where the turn's words went by then, after a - `channel_open` or a hold. A turn whose words had no place (a silent - tick, or a wake whose conversation it couldn't take) gives no - conversation to carry, and its continuation's words get no place either, - never the fallback. Everything else starts afresh, as at any new turn: a - rewind sheds the turn whose choices those were. + conversations:** the retry after a rewind (for a refusal, an unstick's + forced rewind, or an inference failure); an unstick itself; the results + of a non-streaming tool round; an explicit-prose continuation or bounce; + and a provider-cooldown retry that holds no wake of its own. The wake + carries those conversations, read when it is queued, as addressed ones, + so its words go where the turn's would have. Beside a heartbeat they keep + the turn, beside an ambient arrival from elsewhere they win, and beside + an addressed one from elsewhere the turn is held. The route is inferred + from them like any wake's: a `channel_open` sets a route for its own + turn only. A bounce during a turn queues its wake when that turn ends, so + it names where the turn's words went by then, after a `channel_open` or + a hold. A fallback turn named no conversation, so its continuation names + none and takes the fallback afresh. A turn whose words had no place (a + silent tick, or a wake whose conversation it couldn't take) gives its + continuation no place either, never the fallback, and a silent tick's + continuation queued during it is a silent tick too. The new turn is + otherwise fresh, as any is: a rewind sheds the turn whose choices those + were. - **An ephemeral subagent never takes the fallback.** Its prose isn't a channel's, and before speech routes a subagent's prose between its tool calls could reach the resident's live channel this way. diff --git a/src/framework.ts b/src/framework.ts index 48532830a..f65d5e392 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -87,6 +87,7 @@ import { ChannelRegistry, publishPlaceRefusal, type ChannelToolOrigin, type Publ import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { continuationCandidates, + continuationNames, conversationKey, describeConversation, inferTurnRoute, @@ -1993,8 +1994,7 @@ export class AgentFramework { addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata as MessageMetadata, forAgent ? { forAgent } : undefined), - requestInference: (agentName, reason, source, channelId) => - framework.queueTuneOutWake(agentName, reason, source, channelId), + requestInference: framework.queueTuneOutWake.bind(framework), subconsciousName: () => framework.subconsciousAgentName, primaryName: () => framework.primaryAgentName, getStoredMessages: () => { @@ -8574,7 +8574,7 @@ export class AgentFramework { const candidate = isConversational(occ.tags) ? named : undefined; // A channel, a thread or an author names a conversation, as for a gate // batch (wakeProvenance) and an uncoalesced push: the batch's merged - // thread, or for a scope that merges none, its origin's. + // thread or its origin's, either one. const originThread = typeof origin?.threadId === 'string' && origin.threadId.length > 0; const namesConversation = !!named || !!channelId || !!authorId || !!occ.identity?.threadId || originThread; for (const agentName of await this.coalescedAudience(occ)) { @@ -10170,25 +10170,35 @@ export class AgentFramework { * route record gives the conversations that turn answered * (continuationCandidates), and the wake names them. So the true new turn * it starts, alone or batched with other wakes, infers its route from them - * by the rules every wake follows (the fork rule included, at turn start), - * rather than taking the fallback. A turn whose words had no place (a - * silent tick, a wake whose conversation it couldn't take, a no-name wake - * with no fallback place) gives no candidates, but its continuation still - * names a conversation: the fallback mustn't give the continuation's words - * a place the turn's never had. Only an agent with no route record (no - * turn yet, or an explicit-prose turn) gives a wake that names nothing. + * by the rules every wake follows (the fork rule included, at turn start). + * A fallback turn's continuation names nothing and takes the fallback + * afresh, as that turn did; a turn whose words had no place gives a + * continuation that names a conversation but no candidate, so the fallback + * gives it no place either (continuationNames). + * + * A continuation of a silent tick queued while that turn runs (a rewind + * retry, its tool round's results) is a silent tick too: it carries the + * turn's suppressProse, its prompt and its stamp, as a budget restart does. + * A budget restart and a guard retry aren't built here: they continue the + * same logical turn, which keeps its route record (continuingTurn). */ private continuationRequest(agentName: string, reason: string): InferenceRequest { const timestamp = Date.now(); const turn = this.turnRoutes.get(agentName); const candidates = continuationCandidates(turn, timestamp); + const active = this.activeTurnTriggers.get(agentName); return { agentName, reason, source: 'framework', timestamp, - ...(turn ? { namesConversation: true as const } : {}), + ...(continuationNames(turn) ? { namesConversation: true as const } : {}), ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), + ...(active?.suppressProse ? { + suppressProse: true, + ...(active.ephemeralSystemPrompt ? { ephemeralSystemPrompt: active.ephemeralSystemPrompt } : {}), + ...(active.silentHeartbeat ? { silentHeartbeat: active.silentHeartbeat } : {}), + } : {}), }; } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 4fe180177..80efe5bb8 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1406,10 +1406,11 @@ export class ChannelRegistry { // ========================================================================== /** The channel id of where the last message any channel delivered came in - * (noteDelivered: a conversational message, never a reaction, a deletion - * or a withdrawal), or null. Kept from before speech routes for hosts - * that read it. The framework doesn't: a turn's fallback route reads - * getLastInbound, which names the server and thread too. */ + * (noteDelivered: a conversational message, never a reaction or a + * deletion, nor a withdrawal, which its callers leave out), or null. Kept + * from before speech routes for hosts that read it. The framework + * doesn't: a turn's fallback route reads getLastInbound, which names the + * server and thread too. */ getDefaultPublishChannel(): string | null { return this.lastInbound?.channelId ?? null; } diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 0a1e99f5a..24167c9ba 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -297,19 +297,20 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu * turn's would have. A held turn names each conversation it held between; a * turn with a route names its route's conversation, reply edge kept; a turn * whose one conversation couldn't take plain speech names that one, to be - * checked again. Each counts as addressed, since the resident was answering - * it: an ambient arrival batched beside it doesn't take the turn, and an - * addressed one from elsewhere holds it. A turn whose words had no place (a - * silent wake, a wake whose conversation it couldn't take, a no-name wake - * with no fallback place) gives none; the framework's wake still names a - * conversation then, so the fallback gives its words no place either - * (continuationRequest). + * checked again as any candidate is. Each counts as addressed, since the + * resident was answering it: an ambient arrival batched beside it doesn't + * take the turn, and an addressed one from elsewhere holds it. + * + * A turn that answered no conversation gives none: a fallback turn, or a + * turn whose words had no place. Whether its continuation names a + * conversation anyway is continuationNames. */ export function continuationCandidates(turn: TurnRoute | undefined, at: number): RouteCandidate[] { if (!turn) return []; if (turn.hold) return turn.hold.conversations.map((conversation) => ({ conversation, addressed: true, at })); if (turn.route) { const { route } = turn; + if (route.origin === 'fallback') return []; return [{ conversation: routeConversation(route), addressed: true, @@ -317,17 +318,23 @@ export function continuationCandidates(turn: TurnRoute | undefined, at: number): ...(route.kind === 'channel' && route.replyTo ? { messageId: route.replyTo } : {}), }]; } - if (turn.unroutable) { - return [{ - conversation: turn.unroutable.conversation, - addressed: true, - at, - ...(turn.unroutable.reason === 'unresolved' ? { unroutable: true as const } : {}), - }]; - } + if (turn.unroutable) return [{ conversation: turn.unroutable.conversation, addressed: true, at }]; return []; } +/** + * Whether a framework wake that continues a turn names a conversation. A + * fallback turn's wake named none, so its continuation names none and takes + * the fallback afresh. Every other route record counts as named: a turn + * whose words had no place (a silent wake, a wake whose conversation it + * couldn't take, or a no-name wake that found no fallback place) gives its + * continuation no place either. No record (no turn yet, or an + * explicit-prose turn) names none. + */ +export function continuationNames(turn: TurnRoute | undefined): boolean { + return !!turn && !(turn.route?.origin === 'fallback' && !turn.hold); +} + /** The candidates a route is inferred from: the addressed ones if any, else all. */ function considered(candidates: readonly RouteCandidate[]): readonly RouteCandidate[] { const addressed = candidates.filter((c) => c.addressed); diff --git a/test/inference-exhaustion-rewind.test.ts b/test/inference-exhaustion-rewind.test.ts index af8dd105c..4a0d869ec 100644 --- a/test/inference-exhaustion-rewind.test.ts +++ b/test/inference-exhaustion-rewind.test.ts @@ -50,7 +50,8 @@ function makeHarness(opts?: { maxRewinds?: number; messages?: any[] }) { fw.pendingRequests = []; // The turn the retry continues answered a channel (its speech route). fw.turnRoutes = new Map([['cairn', { route: { kind: 'channel', serverId: 'discord', channelId: 'discord:g:c1', replyTo: 'm-1', origin: 'trigger' } }]]); - fw.conversationAgentHomes = new Map(); + // No running turn's trigger: the turn the retry continues wasn't silent. + fw.activeTurnTriggers = new Map(); fw.traceListeners = []; fw.inferenceFailureEscalationThreshold = 3; fw.agents = new Map([['cairn', { diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 889a6fa7e..dc9d0b472 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -6,6 +6,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { continuationCandidates, + continuationNames, conversationKey, describeConversation, inferTurnRoute, @@ -328,17 +329,28 @@ describe('continuationCandidates', () => { ({ kind: 'channel', serverId: 'discord', channelId, origin: 'trigger', ...extra }); it('a turn with a route names its conversation, addressed, with its reply edge', () => { - const turn: TurnRoute = { route: route('general', { threadId: 't-1', replyTo: 'm-7', label: '#general', origin: 'fallback' }) }; + const turn: TurnRoute = { route: route('general', { threadId: 't-1', replyTo: 'm-7', label: '#general', origin: 'open' }) }; assert.deepEqual(continuationCandidates(turn, 9), [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'general', threadId: 't-1', label: '#general' }, addressed: true, at: 9, messageId: 'm-7', }]); + assert.equal(continuationNames(turn), true); assert.deepEqual(continuationCandidates({ route: { kind: 'surface', surface: 'tui', origin: 'trigger' } }, 9), [{ conversation: { kind: 'surface', surface: 'tui' }, addressed: true, at: 9 }]); }); + it('a fallback turn answered no conversation: its continuation names none, and takes the fallback afresh', () => { + const fallback: TurnRoute = { route: route('last', { origin: 'fallback' }) }; + assert.deepEqual(continuationCandidates(fallback, 9), []); + assert.equal(continuationNames(fallback), false); + // Held mid-turn, it names what it held between, as any held turn does. + const held: TurnRoute = { ...fallback, hold: { conversations: [channel('last'), channel('b')], since: 'mid-turn' } }; + assert.equal(continuationCandidates(held, 9).length, 2); + assert.equal(continuationNames(held), true); + }); + it('a held turn names every conversation it held between, so its continuation is held again', () => { const held: TurnRoute = { route: route('a'), hold: { conversations: [channel('a'), channel('b')], since: 'mid-turn' } }; const candidates = continuationCandidates(held, 5); @@ -349,17 +361,22 @@ describe('continuationCandidates', () => { it('a turn whose one conversation could not take plain speech names it, to be checked again', () => { const unresolved = continuationCandidates({ route: null, unroutable: { conversation: channel('dup'), reason: 'unresolved' } }, 3); - assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3, unroutable: true }]); + assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3 }]); const untargetable = continuationCandidates({ route: null, unroutable: { conversation: channel('quiet'), reason: 'untargetable' } }, 3); assert.deepEqual(untargetable, [{ conversation: channel('quiet'), addressed: true, at: 3 }]); // Checked again: still refused while the channel declares nothing, a route once it does. assert.equal(inferTurnRoute(untargetable, null, declaredNowhere).unroutable?.reason, 'untargetable'); assert.equal(inferTurnRoute(untargetable, null, rootEverywhere).route?.kind, 'channel'); + // An unresolved one too: unresolved while it still is, a route once it resolves. + assert.equal(inferTurnRoute(unresolved, null, () => 'unresolved').unroutable?.reason, 'unresolved'); + assert.equal(inferTurnRoute(unresolved, null, rootEverywhere).route?.kind, 'channel'); }); - it('a turn that answered no conversation, or left no record, gives none', () => { + it("a turn whose words had no place gives no candidate, but its continuation names a conversation; no record names none", () => { assert.deepEqual(continuationCandidates({ route: null }, 1), []); + assert.equal(continuationNames({ route: null }), true); assert.deepEqual(continuationCandidates(undefined, 1), []); + assert.equal(continuationNames(undefined), false); }); it('beside an ambient arrival from elsewhere the continued conversation wins; beside an addressed one the turn holds', () => { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 246e3749a..1d7d26781 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -787,18 +787,25 @@ describe('Trunk channel routing (item-3 redux)', () => { }); it("a continuation of a turn whose words had no place gets none, never the fallback; only an agent with no route record names nothing", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; const i = internals(framework); - // A silent tick's record, or a wake whose conversation the turn couldn't take. - i.turnRoutes.set('scout', { route: null }); + // A reaction names its channel but answers nothing: the turn has no place. + framework.pushEvent({ + ...(channelIncoming('discord:guild:chanA', '👍') as unknown as Record), + tags: ['chat:reaction', 'chat:addressed'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); const placeless = i.continuationRequest('scout', 'refusal-rewind-retry'); assert.equal(placeless.namesConversation, true); assert.equal(placeless.routeCandidates, undefined); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still nowhere' }])); i.pendingRequests.push(placeless); await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); assert.deepEqual(published, [], 'nothing went where the last message came in'); // No record at all (no locus-mode turn yet): the wake names nothing, and @@ -814,6 +821,87 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('a fallback turn named no conversation: its continuation names none, takes the fallback afresh, and yields to an addressed arrival', async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await heartbeat(framework); + assert.equal(i.turnRoutes.get('scout')?.route?.origin, 'fallback'); + const continued = i.continuationRequest('scout', 'refusal-rewind-retry'); + assert.equal(continued.namesConversation, undefined); + assert.equal(continued.routeCandidates, undefined); + // Alone, it takes the fallback again, as a fallback (its notices included). + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick again' }])); + i.pendingRequests.push(continued); + await framework.runUntilIdle(); + assert.deepEqual([i.turnRoutes.get('scout')?.route?.channelId, i.turnRoutes.get('scout')?.route?.origin], + ['discord:guild:elsewhere', 'fallback']); + // Beside an addressed arrival from chanB, the arrival takes the turn: the + // fallback's place was never a conversation in front of the resident. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'to chanB' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'unstick'), { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed: true, at: Date.now() }], + } as never); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB'); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), + ['discord:guild:elsewhere', 'discord:guild:elsewhere', 'discord:guild:chanB']); + await framework.stop(); + }); + + it("a silent tick's rewind retry is a silent tick too: no place for its words, and the tick's own prompt", async () => { + const streams = [createMockResponse([], 'refusal'), createMockResponse([{ type: 'text', text: 'thinking privately' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'silent.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', refusalHandling: { autoRewind: true } }], + modules: [], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', eventId: 'hb-1', content: [], + timestamp: new Date().toISOString(), origin: { source: 'heartbeat', reason: 'schedule', silent: true }, + inferenceId: 'inf-hb-1', triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, 'the refused tick, then its retry'); + assert.ok(JSON.stringify(membrane.calls[1]).includes('[silent heartbeat]'), "the retry carries the tick's prompt"); + const retryRows = framework.getAgent('scout')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('thinking privately')); + assert.equal(retryRows.length > 0 && retryRows.every((m) => (m.metadata as { silentHeartbeat?: { eventId?: string } } | undefined)?.silentHeartbeat?.eventId === 'hb-1'), true, + "the retry's rows carry the tick's stamp"); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); + assert.deepEqual(published, [], 'nothing was published'); + await framework.stop(); + }); + + it("an unstick names the conversation its agent's last turn answered", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const host = framework as unknown as { handleHostCommand(serverId: string, params: Record): Promise<{ ok: boolean; error?: string }> }; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'unstuck' }])); + const result = await host.handleHostCommand('discord', { command: 'unstick', agentName: 'scout' }); + assert.equal(result.ok, true, result.error); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanA', 'first answer'], ['discord:guild:chanA', 'unstuck']]); + await framework.stop(); + }); + it("a bounce during a turn wakes when the turn ends, naming where the turn's words went by then", async () => { // Turn 1 answers chanA; mid-turn, a tool bounces words and moves the // route to chanB, as a channel_open would. Turn 2 is the bounce's wake. @@ -865,7 +953,12 @@ describe('Trunk channel routing (item-3 redux)', () => { }); it("a bounce whose turn ended without waking is in the next turn's history, so that turn starts no extra wake", async () => { - membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answered' }])); + // One stream per response, so an extra wake would find its own and count. + const streams = [createMockResponse([{ type: 'text', text: 'answered' }]), createMockResponse([{ type: 'text', text: 'extra' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; const framework = await makeFramework(); const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; From 45cfe4692e092eada1cc2b9d71170b5b0fb57abd Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 04:41:18 -0400 Subject: [PATCH 62/65] fix(routing): a continuation reads its turn's record when its own turn starts, and a bounce wakes at once From a review of 6114926..5ba5d8c, and from Basil-1007's and Audrey-2282's reads of the redesign (room-448). A framework wake that continues a turn read that turn's record when the wake was queued. To read a bounce's route late enough, the bounce's wake was then deferred to the end of the turn that bounced. Several teardown paths never reach that end: a terminal start failure, a withdrawn turn, an abandoned or superseded stream, the puppet and store-surgery holds, and a budget restart whose successor fails. On those paths the wake was lost, and the next true new turn cleared the mark. The silent tick's prompt and stamp came from the running turn's trigger, which some paths had already torn down when they built the continuation, so a silent tick's retry could run as an ordinary turn. Now each producer queues a request flagged continuesTurn and nothing else. processInferenceRequests fills it in from the agent's turn record when its batch starts, before the batch's fields are merged (continuationFor). That record belongs to the turn the request continues: a resident's true new turn starts only there, an agent's requests wait while a turn is alive, and the next turn takes every request the agent has pending. A wake queued during a turn therefore names where that turn's words went by its end, after a channel_open or a hold. A bounce queues its wake at once, as on main, and the proseBounceWakes mark and its three sites are gone. A bounce that lands while a successor turn is alive runs after it and names the successor's conversations, which is where the resident last spoke. The turn record keeps what a continuation needs after the teardown: - whether the turn's wake named a conversation (TurnRoute.named). A continuation names one exactly when the turn it continues did, or when it carries that turn's conversations. A no-name turn that found no fallback place gives a continuation that names none, and that continuation takes the fallback as it stands when it starts. - for a fallback turn that wasn't held, its place. The continuation keeps that place as its own fallback (fallbackPlace) instead of reading the last inbound again, so a message arriving elsewhere meanwhile doesn't move the rest of the reply. It still names no conversation, so anything its batch names outranks it, and the fork rule applies to it at turn start like any fallback. - for a silent wake, its prompt and stamp (TurnRecord.silent). A continuation of a silent tick is a silent tick too, whatever queues it. The fork rule now reaches every route source. A channel_open into a channel a conversation fork owns opens it but sets no route, and its result says why. A mid-turn hold no longer counts an arrival in a fork's channel as a competing conversation. A wake's candidate whose channel had no server when it was queued is resolved again when its turn starts, so a server that registered while the wake waited makes it routable. A gate candidate that names its server is a raw id the host couldn't resolve, and stays unroutable. An unstick is refused while a turn is alive (activeTurnTokens), not only while the agent's status is busy, since the status reads idle at a turn's start and after endTurn's reset. beginAgentTurn's arm that gave a suppressProse trigger { route: null } in the route block could never run, because such a trigger takes the disabled branch. The block after the route block writes the silent record, so the arm is gone. continuationNames is replaced by continuationOf, which returns the candidates, whether the continuation names a conversation, and the kept fallback. Changelog: the continuation bullet says when the record is read, what a held fallback turn's and an unroutable turn's continuations do, and that a channel_open moves the route for the rest of its turn. The fork bullet covers channel_open and the mid-turn hold, and the unresolved bullet covers the server found at turn start. Tests: - speech-routes: continuationOf for a fallback turn (its place kept, outranked by a named candidate, none when held), for a placeless turn whose wake named a conversation, and for one whose wake named none. - trunk-channel-routing: - a fallback turn's continuation keeps its place after the last inbound moves, and a fork that claims that place meanwhile keeps the continuation out; - a no-name turn with no fallback place gives a continuation that takes the fallback once there is one; - a bounce in a turn queues its wake at once and the wake names where the turn's route moved; - a bounce outside a turn wakes too; - a silent tick's start that fails at the failure threshold gives a rewind retry that is a silent tick, with the tick's prompt and stamp and no draft left to resend (Audrey-2282's fault injection, made a test); - the silent refusal retry's record shows a silent tick; - an unstick is refused while a turn is alive; - a candidate's server that registered while its wake waited is found, and a gate's unroutable candidate that names its server stays unroutable; - the existing continuation tests read what a request carries through continuationFor. - speech-route-turns: a channel_open into a fork's channel sets no route. - present-while-acting: an addressed mid-turn arrival in a fork's channel holds nothing. - inference-exhaustion-rewind: the retry is a continuesTurn request that names the failed turn's conversation when its batch starts. - mcpl-coalescing and trunk-channel-routing: a record whose wake named a conversation now carries named: true. --- changelog.d/speech-routes.changed.md | 56 +++-- src/framework.ts | 211 +++++++++++------- src/speech-routes.ts | 45 +++- src/types/agent.ts | 15 ++ test/inference-exhaustion-rewind.test.ts | 11 +- test/mcpl-coalescing.test.ts | 2 +- test/present-while-acting.test.ts | 33 +++ test/speech-route-turns.test.ts | 23 +- test/speech-routes.test.ts | 46 ++-- test/trunk-channel-routing.test.ts | 270 +++++++++++++++++++---- 10 files changed, 539 insertions(+), 173 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 4a9d84927..3fa369f5d 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -42,24 +42,34 @@ a wake gets no route, never the fallback. An ambient-only batch names its own conversations: it routes to one, or holds when it names several. - **A framework wake that continues a turn names that turn's - conversations:** the retry after a rewind (for a refusal, an unstick's - forced rewind, or an inference failure); an unstick itself; the results - of a non-streaming tool round; an explicit-prose continuation or bounce; - and a provider-cooldown retry that holds no wake of its own. The wake - carries those conversations, read when it is queued, as addressed ones, - so its words go where the turn's would have. Beside a heartbeat they keep - the turn, beside an ambient arrival from elsewhere they win, and beside - an addressed one from elsewhere the turn is held. The route is inferred - from them like any wake's: a `channel_open` sets a route for its own - turn only. A bounce during a turn queues its wake when that turn ends, so - it names where the turn's words went by then, after a `channel_open` or - a hold. A fallback turn named no conversation, so its continuation names - none and takes the fallback afresh. A turn whose words had no place (a - silent tick, or a wake whose conversation it couldn't take) gives its - continuation no place either, never the fallback, and a silent tick's - continuation queued during it is a silent tick too. The new turn is - otherwise fresh, as any is: a rewind sheds the turn whose choices those - were. + conversations.** Such wakes are the retry after a rewind (for a + refusal, an unstick's forced rewind, or an inference failure), an + unstick itself, the results of a non-streaming tool round, an + explicit-prose continuation or bounce, and a provider-cooldown retry + that holds no wake of its own. The framework reads the turn's record + when the wake's own turn starts, which is after the continued turn has + ended, and gives the wake that turn's conversations as addressed ones. + So its words go where the turn's had gone by its end, after a + `channel_open` or a hold, and a bounce queues its wake at once. Beside + a heartbeat those conversations keep the turn, beside an ambient + arrival from elsewhere they win, and beside an addressed one from + elsewhere the turn is held. A held turn's continuation names every + conversation it held between. A continuation names a conversation + exactly when the turn it continues did, or when it carries that turn's + conversations. So a turn whose words had no place although its wake + named a conversation (a reaction, a channel a fork owns) gives its + continuation no place either, never the fallback, and a turn whose + wake named none, and that found no fallback place, gives a continuation + that takes the fallback as it stands then. A fallback turn's + continuation keeps that turn's place as its own fallback, so a message + arriving elsewhere meanwhile doesn't move the rest of the reply. An + unroutable conversation's continuation names it, and checks it again + when it starts, so it takes a place once its channel can take one. A + silent tick's continuation is a silent tick too, with the tick's prompt + and stamp, whatever queues it. The new turn is otherwise fresh, as any + is: a rewind sheds the turn whose choices those were. An unstick is + refused while a turn is in progress, including the moments the agent + reads idle at a turn's start and end. - **An ephemeral subagent never takes the fallback.** Its prose isn't a channel's, and before speech routes a subagent's prose between its tool calls could reach the resident's live channel this way. @@ -127,15 +137,19 @@ takes the server only from the id's sole registrant. On an id that several servers share, the turn records the conversation as `unresolved`, and its speech is held as drafts rather than published to whichever server - registered first. `unresolved` also now covers a route whose channel was - withdrawn, which used to read as undeclared (`untargetable`). + registered first. The server is looked for again when the turn starts, so + one that registered while the wake waited is found. `unresolved` also now + covers a route whose channel was withdrawn, which used to read as + undeclared (`untargetable`). - **A fork's channel is its own on every wake path.** A push event, a coalesced push and a `channels/incoming` broadcast now give no other agent a speech route or a typing indicator in a channel a conversation fork owns, and a fork takes neither in a channel other than its home. Batched gate wakes already worked this way. A route is judged when the turn starts, as ownership stands then, so a fork's claim, or its giving a channel up, - while a wake waits in the queue counts. + while a wake waits in the queue counts. A `channel_open` into a fork's + channel opens it but sets no route, and its result says why. An arrival + in a fork's channel never holds another agent's route mid-turn. - The `[discord-send-failed]` marker for a reply on a thread route names the thread with its channel, and an unconfirmed one says to check the thread. - **Routing names each conversation with its server.** Routing notices, diff --git a/src/framework.ts b/src/framework.ts index f65d5e392..220bbf07e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -86,8 +86,7 @@ import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, publishPlaceRefusal, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { - continuationCandidates, - continuationNames, + continuationOf, conversationKey, describeConversation, inferTurnRoute, @@ -276,6 +275,15 @@ function estimateAppendedRoundTokens( return Math.ceil(chars / 4) + images * 1600 + blockCount * 20; } +/** + * A turn's routing state, and what a continuation of the turn needs from it + * after the turn's teardown: for a silent wake (suppressProse), its prompt + * and stamp, so the continuation is a silent tick too (continuationFor). + */ +type TurnRecord = TurnRoute & { + silent?: Pick; +}; + /** * What decides where one piece of speech goes, captured when its words were * produced: the turn's route then. Speech is published later, in order, on @@ -1095,7 +1103,7 @@ export class AgentFramework { * competing addressed/engaged arrival (the ambiguity hold). Incoming * traffic never selects a new destination. Lives here, not in driveStream, * so context-budget restarts and retries keep it. */ - private turnRoutes: Map = new Map(); + private turnRoutes: Map = new Map(); /** Where the agent EXPLICITLY sent during the CURRENT turn, per agent: the * second ambiguity signal. A conversational reply where the agent just * sent competes for its unaddressed words even without a mention @@ -1204,11 +1212,6 @@ export class AgentFramework { /** Consecutive bounce-wakes per agent; capped so an agent that keeps * emitting unprefixed prose can't wake-loop (notices still append). */ private proseBounceStreaks: Map = new Map(); - /** Agents whose words bounced while their turn was live: the bounce's wake - * is queued when that turn ends (driveStream), so it names the - * conversations the turn answered by then, after a channel_open or a hold - * later in the turn. It couldn't start before the turn ends anyway. */ - private proseBounceWakes: Set = new Set(); private pendingAssistantBlocks: Map = new Map(); /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed * `${agentName}:${streamId}`: an endTurn tool result or a context-budget @@ -5441,6 +5444,12 @@ export class AgentFramework { if (agent.state.status !== 'idle') { return { ok: false, error: `Cannot unstick while agent is ${agent.state.status}` }; } + // A turn is alive from its dequeue to its settled teardown, which + // includes moments the state machine reads idle: the start of a turn + // before its inference, and the end of one after endTurn's reset. + if (this.activeTurnTokens.has(agentName)) { + return { ok: false, error: 'Cannot unstick while a turn is in progress' }; + } const cap = Math.max(1, Math.min(10, Math.floor(params.maxRewinds ?? agent.refusalHandling?.maxRewinds ?? 3))); this.forcedRewind.set(agentName, { @@ -7067,7 +7076,10 @@ export class AgentFramework { readInboundSource(m), isAddressedMessage(m?.tags as string[] | undefined, m), ); - if (!candidate || !suspendsRoute(turn, candidate, isEngaged)) continue; + // A channel a conversation fork owns is never another + // agent's place, so it doesn't compete for this one's. + if (!candidate || !this.mayTakeCandidate(agent.name, candidate)) continue; + if (!suspendsRoute(turn, candidate, isEngaged)) continue; const key = conversationKey(candidate.conversation); if (seen.has(key)) continue; seen.add(key); @@ -7077,7 +7089,7 @@ export class AgentFramework { if (turn?.route && competing.length > 0) { const current = routeConversation(turn.route); const arrivals = competing.map((c) => c.conversation); - const held: TurnRoute = { route: turn.route, hold: { conversations: [current, ...arrivals], since: 'mid-turn' } }; + const held: TurnRecord = { ...turn, hold: { conversations: [current, ...arrivals], since: 'mid-turn' } }; this.turnRoutes.set(agent.name, held); this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); // Name each actual cause, as certain as the host is of it: @@ -9124,6 +9136,11 @@ export class AgentFramework { continue; } + // A request that continues the agent's turn reads that turn's record + // now, as the turn left it (continuationFor), before the batch's + // fields are merged below. + requests = requests.map((r) => this.continuationFor(r)); + // Start streaming inference (non-blocking — driveStream runs in background) // The request that grants the turn-alive exception must also define the // downstream turn semantics. An ordinary wake can already be pending @@ -9210,6 +9227,8 @@ export class AgentFramework { // Whether any of them named a conversation, candidate or not: only a // batch none of whose requests did takes the fallback route. namesConversation: !budgetRestart && requests.some((r) => r.namesConversation) ? true : undefined, + // A continued fallback turn's place, kept as this batch's fallback. + fallbackPlace: budgetRestart ? undefined : requests.find((r) => r.fallbackPlace)?.fallbackPlace, }); } } @@ -10134,6 +10153,19 @@ export class AgentFramework { }; } + /** + * A candidate as it stands when its turn starts. One whose channel had no + * server when its wake was queued (candidateForChannel) is resolved again, + * so a server that registered while the wake waited makes it routable. A + * gate's unroutable candidate that names its server is a raw id the host + * couldn't resolve to a registered channel, and stays as it is. + */ + private resolvedCandidate(c: RouteCandidate): RouteCandidate { + if (!c.unroutable || c.conversation.kind !== 'channel' || c.conversation.serverId) return c; + const { channelId, threadId } = c.conversation; + return this.candidateForChannel(channelId, c.addressed, c.at, undefined, c.messageId, threadId); + } + /** * The fallback for a wake that names no conversation: where the last * message any channel delivered came in (its server, channel and thread), @@ -10166,39 +10198,49 @@ export class AgentFramework { * A framework wake that continues `agentName`'s turn: an unstick, a retry * after a rewind (a refusal, an unstick or an inference failure), the * results of a non-streaming tool round, an explicit-prose continuation or - * bounce, or a bare provider-cooldown retry. When it is queued, its turn's - * route record gives the conversations that turn answered - * (continuationCandidates), and the wake names them. So the true new turn - * it starts, alone or batched with other wakes, infers its route from them - * by the rules every wake follows (the fork rule included, at turn start). - * A fallback turn's continuation names nothing and takes the fallback - * afresh, as that turn did; a turn whose words had no place gives a - * continuation that names a conversation but no candidate, so the fallback - * gives it no place either (continuationNames). - * - * A continuation of a silent tick queued while that turn runs (a rewind - * retry, its tool round's results) is a silent tick too: it carries the - * turn's suppressProse, its prompt and its stamp, as a budget restart does. - * A budget restart and a guard retry aren't built here: they continue the - * same logical turn, which keeps its route record (continuingTurn). + * bounce, or a bare provider-cooldown retry. It carries only that it + * continues the turn; continuationFor fills in the rest when its batch + * starts. A budget restart and a guard retry aren't built here: they + * continue the same logical turn, which keeps its route record + * (continuingTurn). */ private continuationRequest(agentName: string, reason: string): InferenceRequest { - const timestamp = Date.now(); - const turn = this.turnRoutes.get(agentName); - const candidates = continuationCandidates(turn, timestamp); - const active = this.activeTurnTriggers.get(agentName); + return { agentName, reason, source: 'framework', timestamp: Date.now(), continuesTurn: true }; + } + + /** + * A request that continues its agent's turn, as its batch starts + * (processInferenceRequests): what that turn's record gives it now + * (continuationOf). The conversations the turn answered become the + * request's candidates, so the true new turn it starts, alone or batched + * with other wakes, infers its route from them by the rules every wake + * follows, the fork rule included. A fallback turn's place becomes its + * fallback. A silent wake's continuation is a silent tick too, with the + * turn's prompt and stamp, as a budget restart is. + * + * The record read is the agent's most recent turn's, as that turn left + * it, which is the turn the request continues: a resident's true new turn + * starts only here, an agent's requests wait while a turn is alive, and + * the next turn takes every request the agent has pending. So a request + * queued during its turn or in its teardown names where that turn's words + * went by its end, after a channel_open or a hold. An unstick waits for an + * idle agent, and a provider cooldown holds every request of the agent. + * Only a bounce can be queued after a successor turn has begun, from the + * previous turn's speech landing while the successor waits for it; its + * wake then runs after the successor and names the successor's + * conversations, where the resident last spoke. + */ + private continuationFor(request: InferenceRequest): InferenceRequest { + if (!request.continuesTurn) return request; + const turn = this.turnRoutes.get(request.agentName); + const { candidates, namesConversation, fallback } = continuationOf(turn, request.timestamp); return { - agentName, - reason, - source: 'framework', - timestamp, - ...(continuationNames(turn) ? { namesConversation: true as const } : {}), + ...request, + continuesTurn: undefined, + ...(namesConversation ? { namesConversation: true as const } : {}), ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), - ...(active?.suppressProse ? { - suppressProse: true, - ...(active.ephemeralSystemPrompt ? { ephemeralSystemPrompt: active.ephemeralSystemPrompt } : {}), - ...(active.silentHeartbeat ? { silentHeartbeat: active.silentHeartbeat } : {}), - } : {}), + ...(fallback ? { fallbackPlace: fallback } : {}), + ...(turn?.silent ? { suppressProse: true, ...turn.silent } : {}), }; } @@ -11145,9 +11187,9 @@ export class AgentFramework { if (!capped) { // Wake so the resend can happen immediately — an unsent DM reply must // not sit invisible until the next unrelated event. A bounce in a live - // turn wakes when that turn ends (proseBounceWakes). - if (this.activeTurnTokens.has(name)) this.proseBounceWakes.add(name); - else this.pendingRequests.push(this.continuationRequest(name, 'prose-bounce')); + // turn wakes once that turn has ended, and names the conversations it + // answered by then (continuationFor). + this.pendingRequests.push(this.continuationRequest(name, 'prose-bounce')); } } @@ -11438,7 +11480,7 @@ export class AgentFramework { // rewind retry, an unstick, a prose continuation or bounce) starts a // true new turn too: a rewind sheds the turn whose choices these // were, and only where the words go carries over, through the - // conversations the wake names (continuationRequest). + // conversations the wake names (continuationFor). // A framework retry (attempt > 0), like a restart, // continues the same logical turn: its sends were made, its words // delivered or held, its choices chosen, and none of that rolls back @@ -11451,9 +11493,6 @@ export class AgentFramework { this.turnDraftFailures.delete(agent.name); this.proseHybridSuppressed.delete(agent.name); this.proseTargetPins.delete(agent.name); - // A bounce whose turn ended without waking (superseded) is in this - // turn's history already. - this.proseBounceWakes.delete(agent.name); (this.logicalTurns ??= new Map()).set(agent.name, this.logicalTurn(agent.name) + 1); } if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { @@ -11469,11 +11508,11 @@ export class AgentFramework { // else held (src/speech-routes.ts). A wake that names no // conversation at all (a heartbeat, a timer, a self-wake) takes the // fallback, the last delivered message's conversation, as before - // speech routes. A silent control-plane wake has none. A framework - // wake that continues a turn (a rewind retry, an unstick, a prose - // continuation or bounce) carries the conversations that turn - // answered as its candidates (continuationRequest), so it is - // inferred here like any other. + // speech routes. A framework wake that continues a turn (a rewind + // retry, an unstick, a prose continuation or bounce) carries the + // conversations that turn answered as its candidates, or a fallback + // turn's place as its fallback (continuationFor), so it is inferred + // here like any other. // // The fork rule applies here, where the route is decided, to every // wake's candidates: a fork takes only its home's, and no other agent @@ -11483,24 +11522,27 @@ export class AgentFramework { // keeps the fallback away. That includes an unroutable candidate, // which never routes but competes: a fork's channel never holds // another agent's turn. - const turn: TurnRoute = trigger?.suppressProse - ? { route: null } - : inferTurnRoute( - (trigger?.routeCandidates ?? []).filter((c) => this.mayTakeCandidate(agent.name, c)), - this.homeRoute(agent.name), - (c) => this.publishTargetOf(c), - // Only a resident's wake that named no conversation, candidate - // or not, takes the fallback: a reaction, a system marker or a - // channel a fork owns names one, and gets no route as before. An - // ephemeral subagent never takes it: its prose is its caller's - // business, never a channel's. (A conversation fork's name is in - // the same set, but a fork's home always wins before any - // fallback is read.) - trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) - ? null - : this.fallbackCandidate(agent.name), - ); - this.turnRoutes.set(agent.name, turn); + const turn = inferTurnRoute( + (trigger?.routeCandidates ?? []) + .map((c) => this.resolvedCandidate(c)) + .filter((c) => this.mayTakeCandidate(agent.name, c)), + this.homeRoute(agent.name), + (c) => this.publishTargetOf(c), + // Only a resident's wake that named no conversation, candidate or + // not, takes the fallback: a reaction, a system marker or a channel + // a fork owns names one, and gets no route as before. An ephemeral + // subagent never takes it: its prose is its caller's business, + // never a channel's. (A conversation fork's name is in the same + // set, but a fork's home always wins before any fallback is read.) + // A continued fallback turn keeps that turn's place, under the same + // fork rule as any fallback. + trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) + ? null + : trigger?.fallbackPlace + ? (this.mayTakeCandidate(agent.name, trigger.fallbackPlace) ? trigger.fallbackPlace : null) + : this.fallbackCandidate(agent.name), + ); + this.turnRoutes.set(agent.name, { ...turn, ...(trigger?.namesConversation ? { named: true as const } : {}) }); this.midTurnInputSignals.delete(agent.name); if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); } @@ -11510,7 +11552,16 @@ export class AgentFramework { // control-plane wake has none: its channel_open sets none (the result // says so), so this resets only what a restart could carry over. if (trigger?.suppressProse) { - this.turnRoutes.set(agent.name, { route: null }); + // The record keeps the wake's silence, so a continuation of this turn + // is a silent tick too, whenever it is queued (continuationFor). + this.turnRoutes.set(agent.name, { + route: null, + ...(trigger.namesConversation ? { named: true as const } : {}), + silent: { + ...(trigger.ephemeralSystemPrompt ? { ephemeralSystemPrompt: trigger.ephemeralSystemPrompt } : {}), + ...(trigger.silentHeartbeat ? { silentHeartbeat: trigger.silentHeartbeat } : {}), + }, + }); this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); } @@ -13209,11 +13260,6 @@ export class AgentFramework { } catch (err) { console.error('[drafts] turn-end settlement failed:', err); } - // A bounce during the turn wakes now, naming the conversations the - // turn answered at its end. - if (this.proseBounceWakes.delete(agent.name)) { - this.pendingRequests.push(this.continuationRequest(agent.name, 'prose-bounce')); - } } } } @@ -17485,13 +17531,24 @@ export class AgentFramework { // a route (MCPL RFC-011); otherwise the open stands, and the // route doesn't move. const refusal = routeRefusal(routeConversation(route), (c) => this.publishTargetOf(c)); - if (refusal) { + if (!this.mayTakeRoute(agentName, opened)) { + // The fork rule, as for every route: a channel a conversation + // fork owns is that fork's conversation, and a fork speaks + // only in its home. + routing = 'Opened, but your plain speech can\'t go there: ' + + (this.conversationAgentHomes.has(agentName) + ? 'a conversation fork speaks only in its home channel. ' + : 'a conversation fork owns that channel. ') + + unchanged(); + console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (fork rule)`); + } else if (refusal) { routing = `Opened, but your plain speech can't go there: ${this.unroutableText({ conversation: routeConversation(route), reason: refusal })} ` + unchanged(); console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (${refusal})`); } else { - const next: TurnRoute = { route }; + const prev = this.turnRoutes.get(agentName); + const next: TurnRecord = { route, ...(prev?.named ? { named: true as const } : {}) }; this.turnRoutes.set(agentName, next); this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); // The open supersedes a hybrid `>>>` choice — a pin, or the diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 24167c9ba..02f7da636 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -122,6 +122,10 @@ export interface TurnRoute { * shouldn't produce); * - `unresolved`: a channel the host couldn't resolve. */ unroutable?: { conversation: ConversationRef; reason: UnroutableReason }; + /** The turn's wake named a conversation (a channel, a thread, an author + * or a local surface), candidate or not. A continuation of the turn then + * names one too (continuationOf). */ + named?: true; } export type UnroutableReason = 'untargetable' | 'thread' | 'unresolved'; @@ -302,8 +306,8 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu * take the turn, and an addressed one from elsewhere holds it. * * A turn that answered no conversation gives none: a fallback turn, or a - * turn whose words had no place. Whether its continuation names a - * conversation anyway is continuationNames. + * turn whose words had no place. What its continuation names then is + * continuationOf's to say. */ export function continuationCandidates(turn: TurnRoute | undefined, at: number): RouteCandidate[] { if (!turn) return []; @@ -323,16 +327,35 @@ export function continuationCandidates(turn: TurnRoute | undefined, at: number): } /** - * Whether a framework wake that continues a turn names a conversation. A - * fallback turn's wake named none, so its continuation names none and takes - * the fallback afresh. Every other route record counts as named: a turn - * whose words had no place (a silent wake, a wake whose conversation it - * couldn't take, or a no-name wake that found no fallback place) gives its - * continuation no place either. No record (no turn yet, or an - * explicit-prose turn) names none. + * What a framework wake that continues a turn carries into its own turn, + * read from that turn's record when the continuation's batch starts: + * - `candidates`: the conversations the turn answered + * (continuationCandidates); + * - `namesConversation`: whether it names a conversation. It does when it + * has candidates, or when the turn's own wake named one. So a turn whose + * words had no place although its wake named a conversation (a reaction, + * a channel a conversation fork owns) gives its continuation no place + * either. A turn whose wake named none gives a continuation that names + * none, and that continuation takes the fallback; + * - `fallback`: for a fallback turn that wasn't held, the place it spoke + * into. Its continuation keeps that place as its own fallback, rather + * than the last delivered message's conversation as it is by then, so a + * message arriving between them doesn't move the rest of the reply. Like + * any fallback, it applies only when nothing the batch carries names a + * conversation. + * No record (no turn yet, or an explicit-prose turn) gives nothing. */ -export function continuationNames(turn: TurnRoute | undefined): boolean { - return !!turn && !(turn.route?.origin === 'fallback' && !turn.hold); +export function continuationOf( + turn: TurnRoute | undefined, + at: number, +): { candidates: RouteCandidate[]; namesConversation: boolean; fallback?: RouteCandidate } { + const candidates = continuationCandidates(turn, at); + const namesConversation = candidates.length > 0 || turn?.named === true; + const route = turn?.route; + const fallback = !namesConversation && route?.origin === 'fallback' + ? { conversation: routeConversation(route), addressed: false, at } + : undefined; + return { candidates, namesConversation, ...(fallback ? { fallback } : {}) }; } /** The candidates a route is inferred from: the addressed ones if any, else all. */ diff --git a/src/types/agent.ts b/src/types/agent.ts index 0619a493a..d7f8f7896 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -376,6 +376,21 @@ export interface InferenceRequest { * turn's route comes from its candidates alone. */ namesConversation?: true; + /** + * A framework wake that continues its agent's turn (a rewind retry, an + * unstick, a tool round's results, a prose continuation or bounce, a + * provider-cooldown retry). It carries nothing else of that turn: when + * its batch starts, the framework reads the agent's turn record then and + * fills in the conversations, whether it names one, the place a fallback + * turn spoke into, and a silent wake's silence (continuationOf). + */ + continuesTurn?: true; + /** + * The place a continued fallback turn spoke into: the turn's fallback, + * kept in place of the last delivered message's conversation. Like any + * fallback, it applies only to a turn whose batch names no conversation. + */ + fallbackPlace?: import('../speech-routes.js').RouteCandidate; /** Suppress every automatic plain-prose delivery for this logical turn. * Explicit tool calls remain available. Used by authenticated silent wakes. */ suppressProse?: boolean; diff --git a/test/inference-exhaustion-rewind.test.ts b/test/inference-exhaustion-rewind.test.ts index 4a0d869ec..5c0e35fc8 100644 --- a/test/inference-exhaustion-rewind.test.ts +++ b/test/inference-exhaustion-rewind.test.ts @@ -50,8 +50,6 @@ function makeHarness(opts?: { maxRewinds?: number; messages?: any[] }) { fw.pendingRequests = []; // The turn the retry continues answered a channel (its speech route). fw.turnRoutes = new Map([['cairn', { route: { kind: 'channel', serverId: 'discord', channelId: 'discord:g:c1', replyTo: 'm-1', origin: 'trigger' } }]]); - // No running turn's trigger: the turn the retry continues wasn't silent. - fw.activeTurnTriggers = new Map(); fw.traceListeners = []; fw.inferenceFailureEscalationThreshold = 3; fw.agents = new Map([['cairn', { @@ -93,9 +91,12 @@ test('at the threshold, invalid_request failures trigger an automatic rewind + r // A retry was queued so the rewound history is verified immediately. assert.equal(fw.pendingRequests.length, 1); assert.equal(fw.pendingRequests[0].reason, 'inference-failure-rewind-retry'); - // It names the conversation the failed turn answered, so its words go there. - assert.equal(fw.pendingRequests[0].namesConversation, true); - assert.deepEqual(fw.pendingRequests[0].routeCandidates.map((c: any) => [c.conversation.channelId, c.addressed, c.messageId]), + // It continues the failed turn: when its batch starts, it names the + // conversation that turn answered, so its words go there. + assert.equal(fw.pendingRequests[0].continuesTurn, true); + const started = fw.continuationFor(fw.pendingRequests[0]); + assert.equal(started.namesConversation, true); + assert.deepEqual(started.routeCandidates.map((c: any) => [c.conversation.channelId, c.addressed, c.messageId]), [['discord:g:c1', true, 'm-1']]); assert.ok(errs.some((e) => e.includes('[inference-rewind]') && e.includes('auto-quarantined'))); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 0145677c3..62d9cfbd4 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1134,7 +1134,7 @@ test('a deferred push batch whose origin names only a thread names a conversatio await f.send('push/event', { ...f.params('T-1', 'in a thread', { deferred: true, initial: true }), origin: { threadId: 't-1' } }); await f.framework.runUntilIdle(); assert.equal(f.membrane.calls.length, calls + 1, 'the push woke a turn'); - assert.deepEqual(turnRoutes.get('agent'), { route: null }, 'its thread names a conversation: no fallback'); + assert.deepEqual(turnRoutes.get('agent'), { route: null, named: true }, 'its thread names a conversation: no fallback'); assert.equal(f.published.length, publishes, 'nothing went where the last message came in'); // A deferred batch naming nothing takes the fallback. await f.send('push/event', f.params('T-2', 'a nudge', { key: 'nudge', deferred: true, initial: true })); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index d6bd60b09..f77eda8b9 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -474,6 +474,39 @@ describe('present while acting', () => { await framework.stop(); }); + it("an addressed mid-turn arrival in a channel a conversation fork owns doesn't hold another agent's route", async () => { + // The fork rule, as at turn start: a fork's channel is that fork's + // conversation, so it never competes for another agent's words. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still answering chan-A.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('fork-antra', 'discord:dm:antra'); + module.toolDelayMs = 25; + module.interjection = 'hey, quick question over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Station four, proceeding.', locus: 'chan-A' }, + { text: 'Still answering chan-A.', locus: 'chan-A' }, + ], 'the route was never held'); + assert.deepEqual(heldDrafts(framework), []); + const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); + assert.equal(turn?.hold, undefined); + await framework.stop(); + }); + it('a fallback route is held by an addressed mid-turn arrival, and the notice says it was only where the last message came in', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Checking the logs.' }, diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index 842dc2ddb..bed91953f 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -23,6 +23,7 @@ function internals(framework: AgentFramework) { turnRoutes: Map; startAgentStream(agent: unknown, trigger?: InferenceRequest, attempt?: number): Promise; channelRegistry: unknown; + conversationAgentHomes: Map; }; } @@ -140,12 +141,32 @@ describe('speech route across a logical turn', () => { await i.startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now(), suppressProse: true }); await framework.runUntilIdle(); assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'and no route set'); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, silent: {} }, 'and no route set'); const result = JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); assert.match(result, /This is a silent wake, so your plain speech stays private this turn; to post there, use a send tool\./); await framework.stop(); }); + it('a channel_open into a channel a conversation fork owns opens it but takes no route, and its result says so', async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + const opens: string[] = []; + i.channelRegistry = channelsThatOpen(opens); + i.conversationAgentHomes.set('fork-1', 'chan-fork'); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'chan-fork' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still here' }])); + await i.startAgentStream(scout, fromChannel('chan-A')); + await framework.runUntilIdle(); + assert.deepEqual(opens, ['chan-fork'], 'the channel was opened'); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'chan-A', 'the route stayed'); + const result = JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); + assert.match(result, /Opened, but your plain speech can't go there: a conversation fork owns that channel\. Your plain speech still goes to/); + await framework.stop(); + }); + it('a local surface wake routes speech to that surface: shown there, published nowhere, receipted as such', async () => { const framework = await makeFramework([new SurfaceModule()]); const i = internals(framework); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index dc9d0b472..ecf585e1e 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -6,7 +6,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { continuationCandidates, - continuationNames, + continuationOf, conversationKey, describeConversation, inferTurnRoute, @@ -324,7 +324,7 @@ describe('conversation identity and wording', () => { }); }); -describe('continuationCandidates', () => { +describe('continuationCandidates and continuationOf', () => { const route = (channelId: string, extra: Partial> = {}): SpeechRoute => ({ kind: 'channel', serverId: 'discord', channelId, origin: 'trigger', ...extra }); @@ -336,19 +336,35 @@ describe('continuationCandidates', () => { at: 9, messageId: 'm-7', }]); - assert.equal(continuationNames(turn), true); + assert.equal(continuationOf(turn, 9).namesConversation, true); assert.deepEqual(continuationCandidates({ route: { kind: 'surface', surface: 'tui', origin: 'trigger' } }, 9), [{ conversation: { kind: 'surface', surface: 'tui' }, addressed: true, at: 9 }]); }); - it('a fallback turn answered no conversation: its continuation names none, and takes the fallback afresh', () => { - const fallback: TurnRoute = { route: route('last', { origin: 'fallback' }) }; + it("a fallback turn answered no conversation: its continuation names none, and keeps the turn's place as its fallback", () => { + const fallback: TurnRoute = { route: route('last', { threadId: 't-2', label: '#last', origin: 'fallback' }) }; assert.deepEqual(continuationCandidates(fallback, 9), []); - assert.equal(continuationNames(fallback), false); - // Held mid-turn, it names what it held between, as any held turn does. + assert.deepEqual(continuationOf(fallback, 9), { + candidates: [], + namesConversation: false, + fallback: { + conversation: { kind: 'channel', serverId: 'discord', channelId: 'last', threadId: 't-2', label: '#last' }, + addressed: false, + at: 9, + }, + }); + // As a fallback, it is taken only when nothing in the batch names a + // conversation: an ambient arrival's conversation is the route instead. + const kept = continuationOf(fallback, 9).fallback!; + assert.equal(inferTurnRoute([], null, () => 'exact', kept).route?.origin, 'fallback'); + const arrival = inferTurnRoute([candidate(channel('b'), false, 20)], null, rootEverywhere, kept).route; + assert.equal(arrival?.kind === 'channel' && arrival.channelId, 'b'); + // Held mid-turn, it names what it held between, as any held turn does, + // and keeps no fallback. const held: TurnRoute = { ...fallback, hold: { conversations: [channel('last'), channel('b')], since: 'mid-turn' } }; assert.equal(continuationCandidates(held, 9).length, 2); - assert.equal(continuationNames(held), true); + assert.equal(continuationOf(held, 9).namesConversation, true); + assert.equal(continuationOf(held, 9).fallback, undefined); }); it('a held turn names every conversation it held between, so its continuation is held again', () => { @@ -372,11 +388,15 @@ describe('continuationCandidates', () => { assert.equal(inferTurnRoute(unresolved, null, rootEverywhere).route?.kind, 'channel'); }); - it("a turn whose words had no place gives no candidate, but its continuation names a conversation; no record names none", () => { - assert.deepEqual(continuationCandidates({ route: null }, 1), []); - assert.equal(continuationNames({ route: null }), true); - assert.deepEqual(continuationCandidates(undefined, 1), []); - assert.equal(continuationNames(undefined), false); + it('a turn whose words had no place gives no candidate; its continuation names a conversation exactly when the turn\'s wake did', () => { + // A reaction, a channel a fork owns, a silent wake: the wake named one, + // so the continuation names one, gets no place, and takes no fallback. + assert.deepEqual(continuationOf({ route: null, named: true }, 1), { candidates: [], namesConversation: true }); + // A wake that named none and found no fallback place: the continuation + // names none either, and takes the fallback as it stands then. + assert.deepEqual(continuationOf({ route: null }, 1), { candidates: [], namesConversation: false }); + // No record (no turn yet, an explicit-prose turn): nothing. + assert.deepEqual(continuationOf(undefined, 1), { candidates: [], namesConversation: false }); }); it('beside an ambient arrival from elsewhere the continued conversation wins; beside an addressed one the turn holds', () => { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 1d7d26781..6a94917dd 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -30,6 +30,16 @@ import { AgentFramework } from '../src/index.js'; import type { ProcessEvent } from '../src/index.js'; import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +/** A framework request that continues a turn, as the tests read it. */ +interface ContinuationView { + agentName: string; reason: string; source: string; timestamp: number; + continuesTurn?: true; + namesConversation?: true; + routeCandidates?: Array<{ conversation: { channelId?: string }; addressed: boolean }>; + fallbackPlace?: { conversation: { channelId?: string } }; + suppressProse?: boolean; +} + /** Reach the private per-turn speech routes + the pure channel-deriver. */ function internals(framework: AgentFramework) { return framework as unknown as { @@ -40,10 +50,9 @@ function internals(framework: AgentFramework) { ): { channelId: string; label?: string } | undefined; channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; - continuationRequest(agentName: string, reason: string): { - agentName: string; reason: string; source: string; timestamp: number; - namesConversation?: true; routeCandidates?: Array<{ conversation: { channelId?: string }; addressed: boolean }>; - }; + continuationRequest(agentName: string, reason: string): ContinuationView; + /** A continuation as its batch would start now: what the turn record gives it. */ + continuationFor(request: ContinuationView): ContinuationView; conversationAgentHomes: Map; }; } @@ -409,7 +418,7 @@ describe('Trunk channel routing (item-3 redux)', () => { } as unknown as ProcessEvent); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }); await framework.stop(); }); @@ -590,7 +599,7 @@ describe('Trunk channel routing (item-3 redux)', () => { } as unknown as ProcessEvent); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a reaction-woken turn takes no fallback'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a reaction-woken turn takes no fallback'); // A message in a channel another agent's fork owns: the trunk takes no // candidate there, and no fallback either. @@ -600,7 +609,7 @@ describe('Trunk channel routing (item-3 redux)', () => { framework.pushEvent(channelIncoming('discord:guild:forked', 'hello fork')); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a fork-filtered wake takes no fallback'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a fork-filtered wake takes no fallback'); // A pushed reaction in a DM names that DM: no fallback either. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'saw it' }])); @@ -610,7 +619,7 @@ describe('Trunk channel routing (item-3 redux)', () => { } as unknown as ProcessEvent); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 3); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a pushed reaction takes no fallback'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a pushed reaction takes no fallback'); // A push that names only its author names a conversation too. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'who was that' }])); @@ -622,7 +631,7 @@ describe('Trunk channel routing (item-3 redux)', () => { } as unknown as ProcessEvent); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 4); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'an author-only push takes no fallback'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'an author-only push takes no fallback'); // So does one that names only a thread. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'in that thread' }])); @@ -634,7 +643,7 @@ describe('Trunk channel routing (item-3 redux)', () => { } as unknown as ProcessEvent); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 5); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a thread-only push takes no fallback'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a thread-only push takes no fallback'); assert.deepEqual(published, [], 'nothing went to the last delivered message\'s channel'); await framework.stop(); }); @@ -703,7 +712,9 @@ describe('Trunk channel routing (item-3 redux)', () => { framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); const i = internals(framework); - const chanA = i.continuationRequest('scout', 'refusal-rewind-retry'); + // What a continuation carries is read when its batch starts. + assert.equal(i.continuationRequest('scout', 'refusal-rewind-retry').routeCandidates, undefined); + const chanA = i.continuationFor(i.continuationRequest('scout', 'refusal-rewind-retry')); assert.equal(chanA.namesConversation, true); assert.deepEqual(chanA.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); const heartbeatRequest = () => ({ agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); @@ -735,7 +746,7 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.deepEqual(held.hold?.conversations.map((c) => c.channelId).sort(), ['discord:guild:chanA', 'discord:guild:chanB']); assert.equal(published.length, 4, 'nothing published from the held turn'); // Its continuation names both, and is held again. - const both = i.continuationRequest('scout', 'prose-bounce'); + const both = i.continuationFor(i.continuationRequest('scout', 'prose-bounce')); assert.deepEqual(both.routeCandidates?.map((c) => c.conversation.channelId).sort(), ['discord:guild:chanA', 'discord:guild:chanB']); await framework.stop(); }); @@ -751,18 +762,19 @@ describe('Trunk channel routing (item-3 redux)', () => { const chanARoute = i.turnRoutes.get('scout')!; // Queued while chanA is free; a fork claims it before the turn starts. const queued = i.continuationRequest('scout', 'refusal-rewind-retry'); - assert.equal(queued.namesConversation, true); - assert.deepEqual(queued.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); + const asQueued = i.continuationFor(queued); + assert.equal(asQueued.namesConversation, true); + assert.deepEqual(asQueued.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); i.pendingRequests.push(queued); i.conversationAgentHomes.set('fork-1', 'discord:guild:chanA'); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); await framework.runUntilIdle(); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'no route into the fork, and no fallback'); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }, 'no route into the fork, and no fallback'); assert.equal(published.length, 1, 'only the first answer was published'); // Queued while the fork owns chanA; the fork gives it up before the turn starts. i.turnRoutes.set('scout', chanARoute); const released = i.continuationRequest('scout', 'unstick'); - assert.deepEqual(released.routeCandidates?.map((c) => c.conversation.channelId), ['discord:guild:chanA'], 'named whoever owns it'); + assert.deepEqual(i.continuationFor(released).routeCandidates?.map((c) => c.conversation.channelId), ['discord:guild:chanA'], 'named whoever owns it'); i.pendingRequests.push(released); i.conversationAgentHomes.delete('fork-1'); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back in chanA' }])); @@ -786,7 +798,7 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a continuation of a turn whose words had no place gets none, never the fallback; only an agent with no route record names nothing", async () => { + it("a continuation of a turn whose words had no place gets none, never the fallback, when the turn's wake named a conversation", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); @@ -798,21 +810,21 @@ describe('Trunk channel routing (item-3 redux)', () => { tags: ['chat:reaction', 'chat:addressed'], } as unknown as ProcessEvent); await framework.runUntilIdle(); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); const placeless = i.continuationRequest('scout', 'refusal-rewind-retry'); - assert.equal(placeless.namesConversation, true); - assert.equal(placeless.routeCandidates, undefined); + assert.equal(i.continuationFor(placeless).namesConversation, true); + assert.equal(i.continuationFor(placeless).routeCandidates, undefined); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still nowhere' }])); i.pendingRequests.push(placeless); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null }); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); assert.deepEqual(published, [], 'nothing went where the last message came in'); // No record at all (no locus-mode turn yet): the wake names nothing, and // takes the fallback like any such wake. i.turnRoutes.delete('scout'); const fresh = i.continuationRequest('scout', 'unstick'); - assert.equal(fresh.namesConversation, undefined); + assert.equal(i.continuationFor(fresh).namesConversation, undefined); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'where the last message came in' }])); i.pendingRequests.push(fresh); await framework.runUntilIdle(); @@ -821,7 +833,7 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it('a fallback turn named no conversation: its continuation names none, takes the fallback afresh, and yields to an addressed arrival', async () => { + it("a fallback turn's continuation keeps that turn's place as its fallback when the last message came in elsewhere since, and yields to an addressed arrival", async () => { const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; @@ -830,9 +842,13 @@ describe('Trunk channel routing (item-3 redux)', () => { await heartbeat(framework); assert.equal(i.turnRoutes.get('scout')?.route?.origin, 'fallback'); const continued = i.continuationRequest('scout', 'refusal-rewind-retry'); - assert.equal(continued.namesConversation, undefined); - assert.equal(continued.routeCandidates, undefined); - // Alone, it takes the fallback again, as a fallback (its notices included). + const view = i.continuationFor(continued); + assert.equal(view.namesConversation, undefined); + assert.equal(view.routeCandidates, undefined); + assert.equal(view.fallbackPlace?.conversation.channelId, 'discord:guild:elsewhere'); + // A message comes in on another channel before the continuation starts: + // the rest of the reply still goes where its first part went. + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:moved' }); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick again' }])); i.pendingRequests.push(continued); await framework.runUntilIdle(); @@ -852,6 +868,47 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it("a fallback turn's kept place follows the fork rule when its continuation starts: a fork that claimed it meanwhile keeps the continuation out", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await heartbeat(framework); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:elsewhere'); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + i.conversationAgentHomes.set('fork-1', 'discord:guild:elsewhere'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route, null, "no route into the fork's channel"); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:elsewhere'], 'only the tick was published'); + i.conversationAgentHomes.delete('fork-1'); + await framework.stop(); + }); + + it("a no-name turn that found no fallback place gives a continuation that names none, so it takes the fallback once there is one", async () => { + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('root'); + (registry as Record).getLastInbound = () => null; + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'nowhere to go' }])); + await heartbeat(framework); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'no place, and its wake named none'); + const continued = i.continuationRequest('scout', 'inference-failure-rewind-retry'); + assert.equal(i.continuationFor(continued).namesConversation, undefined); + // A message comes in; the continuation's turn takes it as its fallback. + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:first' }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'now somewhere' }])); + i.pendingRequests.push(continued); + await framework.runUntilIdle(); + assert.deepEqual([i.turnRoutes.get('scout')?.route?.channelId, i.turnRoutes.get('scout')?.route?.origin], + ['discord:guild:first', 'fallback']); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:first', 'now somewhere']]); + await framework.stop(); + }); + it("a silent tick's rewind retry is a silent tick too: no place for its words, and the tick's own prompt", async () => { const streams = [createMockResponse([], 'refusal'), createMockResponse([{ type: 'text', text: 'thinking privately' }])]; (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { @@ -878,7 +935,10 @@ describe('Trunk channel routing (item-3 redux)', () => { .filter((m) => JSON.stringify(m.content).includes('thinking privately')); assert.equal(retryRows.length > 0 && retryRows.every((m) => (m.metadata as { silentHeartbeat?: { eventId?: string } } | undefined)?.silentHeartbeat?.eventId === 'hb-1'), true, "the retry's rows carry the tick's stamp"); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); + // The retry's own turn was a silent tick: its record keeps the tick. + const record = internals(framework).turnRoutes.get('scout') as { route: unknown; silent?: { silentHeartbeat?: { eventId?: string } } }; + assert.equal(record.route, null); + assert.equal(record.silent?.silentHeartbeat?.eventId, 'hb-1', 'the retry was a silent tick'); assert.deepEqual(published, [], 'nothing was published'); await framework.stop(); }); @@ -902,9 +962,129 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a bounce during a turn wakes when the turn ends, naming where the turn's words went by then", async () => { + it("a silent tick whose start fails at the failure threshold: its rewind retry is a silent tick too, with nothing left to resend", async () => { + // The retry is queued from startAgentStream's terminal branch, after the + // tick's trigger is torn down: its silence comes from the turn record. + const streams = [createMockResponse([{ type: 'text', text: 'thinking privately' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'silent-start.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const scout = framework.getAgent('scout')!; + const cm = scout.getContextManager(); + cm.addMessage('user', [{ type: 'text', text: 'An earlier question' }]); + cm.addMessage('scout', [{ type: 'text', text: 'An earlier answer' }]); + // One failure short of the threshold, so this start's failure rewinds. + (framework as unknown as { consecutiveInferenceFailures: Map }).consecutiveInferenceFailures.set('scout', 2); + const { MembraneError } = await import('@animalabs/membrane'); + const compile = cm.compile.bind(cm); + let failNext = true; + (cm as unknown as { compile: (...args: unknown[]) => Promise }).compile = async (...args: unknown[]) => { + if (failNext) { + failNext = false; + throw new MembraneError({ type: 'invalid_request', message: 'compile 400', retryable: false, httpStatus: 400 } as never); + } + return (compile as (...a: unknown[]) => Promise)(...args); + }; + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', eventId: 'hb-2', content: [], + timestamp: new Date().toISOString(), origin: { source: 'heartbeat', reason: 'schedule', silent: true }, + inferenceId: 'inf-hb-2', triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1, 'the failed start reached no model; its retry did'); + assert.ok(JSON.stringify(membrane.calls[0]).includes('[silent heartbeat]'), "the retry carries the tick's prompt"); + const retryRows = cm.getAllMessages().filter((m) => JSON.stringify(m.content).includes('thinking privately')); + assert.equal(retryRows.length > 0 && retryRows.every((m) => (m.metadata as { silentHeartbeat?: { eventId?: string } } | undefined)?.silentHeartbeat?.eventId === 'hb-2'), true, + "the retry's rows carry the tick's stamp"); + const drafts = (framework as unknown as { proseDrafts: { open(agent: string): unknown[] } }).proseDrafts.open('scout'); + assert.deepEqual(drafts, [], 'its words stayed private: no draft to resend'); + const record = internals(framework).turnRoutes.get('scout') as { silent?: { silentHeartbeat?: { eventId?: string } } }; + assert.equal(record.silent?.silentHeartbeat?.eventId, 'hb-2', 'the retry was a silent tick'); + assert.deepEqual(published, [], 'nothing was published'); + await framework.stop(); + }); + + it('an unstick is refused while a turn is alive, even when the agent reads idle', async () => { + const framework = await makeFramework(); + const fw = framework as unknown as { + activeTurnTokens: Map; + handleHostCommand(serverId: string, params: Record): Promise<{ ok: boolean; error?: string }>; + }; + assert.equal(framework.getAgent('scout')!.state.status, 'idle'); + fw.activeTurnTokens.set('scout', 99); + const refused = await fw.handleHostCommand('discord', { command: 'unstick', agentName: 'scout' }); + assert.equal(refused.ok, false); + assert.match(refused.error ?? '', /while a turn is in progress/); + assert.equal(internals(framework).pendingRequests.some((r) => r.reason === 'unstick'), false, 'no unstick queued'); + fw.activeTurnTokens.delete('scout'); + await framework.stop(); + }); + + it("a gate's unroutable candidate that names its server stays unroutable when its turn starts", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + // Every id has a sole registrant here, so a raw id resolved again would + // look routable. + (registry as Record).getChannelServerId = () => 'discord'; + internals(framework).channelRegistry = registry; + const now = Date.now(); + // As the gate passes an event whose registered channel the host couldn't + // resolve: the raw author id, its server named, unroutable. + internals(framework).pendingRequests.push({ + agentName: 'scout', reason: 'gate', source: 'gate', timestamp: now, namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'user:U5' }, addressed: true, at: now, unroutable: true }], + } as never); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'unresolved'); + assert.deepEqual(published, [], 'nothing went to the raw id'); + await framework.stop(); + }); + + it("a wake's conversation whose server registered while it waited is routable when its turn starts", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'there now' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + let registered = false; + (registry as Record).getChannelServerId = (id: string) => (registered && id === 'discord:guild:late' ? 'discord' : undefined); + internals(framework).channelRegistry = registry; + const fw = framework as unknown as { + candidateForChannel(channelId: string, addressed: boolean, at: number): { unroutable?: true }; + }; + // Queued while no server held the channel: unroutable then. + const now = Date.now(); + const candidate = fw.candidateForChannel('discord:guild:late', true, now); + assert.equal(candidate.unroutable, true); + internals(framework).pendingRequests.push({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: now, namesConversation: true, + routeCandidates: [candidate], + } as never); + // The server registers before the turn starts. + registered = true; + await framework.runUntilIdle(); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:late'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string; serverId?: string }).serverId, (p.to as { channelId: string }).channelId]), + [['discord', 'discord:guild:late']]); + await framework.stop(); + }); + + it("a bounce during a turn queues its wake at once, and the wake names where that turn's words went by its end", async () => { // Turn 1 answers chanA; mid-turn, a tool bounces words and moves the - // route to chanB, as a channel_open would. Turn 2 is the bounce's wake. + // route to chanB, as a channel_open would. Turn 2 is the bounce's wake: + // queued at once, it starts only after turn 1 has ended, and reads the + // record then. A bounce that lands while a successor turn is alive (the + // previous turn's speech failing late) waits for that turn the same way. const mover = { name: 'mover', framework: undefined as AgentFramework | undefined, @@ -944,7 +1124,7 @@ describe('Trunk channel routing (item-3 redux)', () => { internals(framework).channelRegistry = registry; framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(mover.queuedMidTurn, false, 'no wake queued while the turn was live'); + assert.equal(mover.queuedMidTurn, true, 'the wake is queued at once'); assert.equal(membrane.calls.length, 2, 'the turn, then the bounce\'s wake'); assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB'); assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), @@ -952,21 +1132,23 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a bounce whose turn ended without waking is in the next turn's history, so that turn starts no extra wake", async () => { - // One stream per response, so an extra wake would find its own and count. - const streams = [createMockResponse([{ type: 'text', text: 'answered' }]), createMockResponse([{ type: 'text', text: 'extra' }])]; - (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { - membrane.calls.push(request as never); - return new MockYieldingStream(streams.splice(0, 1)); - }; + it("a bounce outside a turn queues its wake at once, which names the conversation the turn answered", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answered' }])); const framework = await makeFramework(); - const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; - // As a superseded turn leaves it: a bounce flag no turn end consumed. - (framework as unknown as { proseBounceWakes: Set }).proseBounceWakes.add('scout'); framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(membrane.calls.length, 1, 'one turn, and no bounce wake after it'); + assert.equal(membrane.calls.length, 1); + // The turn is over; its words fail to land (a delivery after it). + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'resending' }])); + (framework as unknown as { bounceProse(agent: unknown, text: string, reason: string): void }) + .bounceProse(framework.getAgent('scout'), 'lost words', 'a test bounce'); + assert.ok(internals(framework).pendingRequests.some((r) => r.reason === 'prose-bounce'), 'queued at once'); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, "the bounce's wake"); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanA', 'answered'], ['discord:guild:chanA', 'resending']]); await framework.stop(); }); @@ -993,7 +1175,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ); await framework.runUntilIdle(); assert.equal(membrane.calls.length, calls + 1, 'the batch ran, as one turn'); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'the batch named a conversation'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'the batch named a conversation'); assert.deepEqual(published, []); await framework.stop(); }); @@ -1010,7 +1192,7 @@ describe('Trunk channel routing (item-3 redux)', () => { wake('scout', 'tune-out cancelled (done)', 'tune-out', 'discord:guild:noisy'); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'it names the noisy channel: no fallback'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'it names the noisy channel: no fallback'); assert.deepEqual(published, []); // A wake through the same hook that names no channel takes the fallback. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); @@ -1044,7 +1226,7 @@ describe('Trunk channel routing (item-3 redux)', () => { framework.pushEvent({ type: 'relay:note', source: 'relay', metadata: { channelId: 'discord:guild:other' } } as unknown as ProcessEvent); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1); - assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }); // A thread alone, or an author alone, names a conversation too; a note // naming none takes the fallback. const cases: Array<[Record, string | null]> = [ From 501fa63284cc850b480af6441531aa8346f31090 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 05:27:02 -0400 Subject: [PATCH 63/65] fix(routing): a silent tick's record holds through its restarts, an unstick and a bounce's resend are ordinary, and a continuation's conversation yields the reply edge From an automated review of 5ba5d8c..45cfe469 by Claude Haiku agents, with Basil-1007's and Audrey-2282's reads (room-448). Silence. The record keeps a silent wake's silence, because the turn's own continuations can be queued in its teardown, after its trigger is gone. Between their queueing and their batch no other turn of the agent runs, so the record they read is theirs. Two kinds of wake follow a turn without being part of it: an operator's unstick, and a bounce's resend, which can land after a later turn has begun. On main both are always ordinary turns. They now take the record's conversations but never its silence (ORDINARY_CONTINUATIONS), where 45cfe469 made an unstick after a silent tick, or a bounce after a silent successor, a silent tick. The silent record is now written only when the turn begins. A context-budget restart or a guard retry keeps it, `named` included; before, the restart rewrote it from a trigger whose namesConversation the batch merge always clears. A restart batched with an ordinary wake runs as an ordinary turn, by the merge's silentOnly rule, and the record then drops `silent`, so its continuations are ordinary too. Its route stays the tick's null, as any restart keeps its route, so the reply to the arrival is held as drafts with the usual notice. channel_open's silent check now reads the record rather than the running turn's trigger, so a result that lands after a silent turn's teardown still sets no route. Its write is { route }: the `named` it carried was never read, since an opened route is a candidate of its own. The refusal's text for a fork agent's own channel_open is gone; such a call is refused before this point. Conversations. - continuationCandidates keeps an unroutable conversation's flag when its channel had no server, so beginAgentTurn resolves it again when the continuation starts. Before, the continuation took the channel without a server, or stayed unroutable. - A continuation's candidates are stamped at time 0, older than anything batched with them. Anything else in the batch arrived after the turn began, so a newer message in the same conversation gives the reply edge, where the continuation's old reply edge used to win. - A continued fallback whose kept place the fork rule or its channel's publish target refuses now marks its record `named`, so the continuations after it take no place either, rather than the current last inbound. - processInferenceRequests fills continuations before the inference policy sees the batch, so a policy reads their fields. A batch the policy drops was only filled. Text. The changelog's continuation bullet now says that a silent tick's own continuations, such as its retries, stay silent, that a restart batched with an ordinary wake is the one exception, and that an unstick and a bounce's resend are repairs, always ordinary. It also says that a bounce landing during a later turn names that turn's conversations, that a kept place yields to a wake batched with it and protects only against a message that wakes nothing, that naming follows inferred prose routing, that a held turn's continuation names the conversations its hold named, and the silence rules above. The held-fallback notice says the speech was going where the last message had come in when the reply began, which is true of a kept place too. Tests: - speech-routes: a serverless unroutable conversation stays flagged in its continuation. - trunk-channel-routing: - a continuation of a turn whose channel had no server takes the route with its server once it registers; - a continuation's conversation yields the reply edge to a newer message batched with it; - a silent tick's restart keeps its record whole, and a restart that runs ordinary drops `silent` and keeps its route and naming; - an unstick and a bounce's resend after a silent tick are ordinary, while the tick's own retry stays silent; - a kept place refused by the fork rule gives no place to that continuation or the next, with the last inbound moved elsewhere; - a kept place whose channel stops declaring a target gives no place. - speech-route-turns: a channel_open whose result lands after a silent tick has ended sets no route and keeps the silent record. - present-while-acting: the fork-guard hold test also checks that the addressed message was injected. --- changelog.d/speech-routes.changed.md | 41 ++++--- src/framework.ts | 97 +++++++++++------ src/speech-routes.ts | 8 +- test/present-while-acting.test.ts | 6 +- test/speech-route-turns.test.ts | 18 ++++ test/speech-routes.test.ts | 5 + test/trunk-channel-routing.test.ts | 155 ++++++++++++++++++++++++++- 7 files changed, 278 insertions(+), 52 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 3fa369f5d..31f6233ee 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -47,28 +47,41 @@ unstick itself, the results of a non-streaming tool round, an explicit-prose continuation or bounce, and a provider-cooldown retry that holds no wake of its own. The framework reads the turn's record - when the wake's own turn starts, which is after the continued turn has + when the wake's batch starts, which is after the continued turn has ended, and gives the wake that turn's conversations as addressed ones. So its words go where the turn's had gone by its end, after a - `channel_open` or a hold, and a bounce queues its wake at once. Beside - a heartbeat those conversations keep the turn, beside an ambient - arrival from elsewhere they win, and beside an addressed one from - elsewhere the turn is held. A held turn's continuation names every - conversation it held between. A continuation names a conversation - exactly when the turn it continues did, or when it carries that turn's + `channel_open` or a hold. A bounce queues its wake at once; one that + lands while a later turn is alive wakes after that turn and names its + conversations instead, where the resident last spoke. Beside a + heartbeat those conversations keep the turn, beside an ambient arrival + from elsewhere they win, and beside an addressed one from elsewhere the + turn is held. They count as older than anything batched with them, so + a newer message in the same conversation gives the reply edge. A held + turn's continuation names the conversations its hold named. With + inferred prose routing, a continuation names a conversation exactly + when the turn it continues did, or when it carries that turn's conversations. So a turn whose words had no place although its wake named a conversation (a reaction, a channel a fork owns) gives its continuation no place either, never the fallback, and a turn whose wake named none, and that found no fallback place, gives a continuation that takes the fallback as it stands then. A fallback turn's continuation keeps that turn's place as its own fallback, so a message - arriving elsewhere meanwhile doesn't move the rest of the reply. An - unroutable conversation's continuation names it, and checks it again - when it starts, so it takes a place once its channel can take one. A - silent tick's continuation is a silent tick too, with the tick's prompt - and stamp, whatever queues it. The new turn is otherwise fresh, as any - is: a rewind sheds the turn whose choices those were. An unstick is - refused while a turn is in progress, including the moments the agent + that comes in elsewhere without waking the agent doesn't move the rest + of the reply. A wake batched with the continuation names its own + conversation and takes the turn, as any arrival does. A kept place + that the fork rule or its channel's publish target now refuses gives + no place, to the continuation and to those after it. An unroutable + conversation's continuation names it, and resolves it again when it + starts, so it takes a place once its channel can take one. A silent + tick's own continuations, such as its retries, are silent ticks too, + with the tick's prompt and stamp, and a restart or a guard retry keeps + the tick's record. The one exception is a restart batched with an + ordinary wake: it runs as an ordinary turn, as on main, and so do the + continuations after it. An unstick and a bounce's resend are repairs + rather than part of the turn they follow, and are always ordinary + turns, as on main. The new turn is otherwise fresh, as + any is: a rewind sheds the turn whose choices those were. An unstick + is refused while a turn is in progress, including the moments the agent reads idle at a turn's start and end. - **An ephemeral subagent never takes the fallback.** Its prose isn't a channel's, and before speech routes a subagent's prose between its tool diff --git a/src/framework.ts b/src/framework.ts index 220bbf07e..ee3e3e389 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7112,14 +7112,16 @@ export class AgentFramework { const causes = competing.map(causeOf); const cause = causes.length === 1 ? causes[0]! : `${causes.slice(0, -1).join('; ')}; and ${causes.at(-1)}`; // A fallback route answered no conversation: it was only where - // the last message came in, so it isn't one the speech could - // be answering. + // the last message had come in when the reply began (a + // continuation keeps that place), so it isn't one the speech + // could be answering. const fallback = turn.route.origin === 'fallback'; const noticeContent: ContentBlock[] = [{ type: 'text', text: fallback ? `[routing] ${cause}. Your unaddressed plain speech was going to ${describeConversation(current)}, ` + - 'where the last message came in, because nothing in this wake named a conversation. ' + + 'where the last message had come in when this reply began, because nothing in the wake ' + + 'that began it named a conversation. ' + 'For the rest of this turn it is held as drafts instead. ' + `Answer ${competing.length === 1 ? 'it' : 'any of them'} with an explicit send (or a channel_open); ` + 'held words can be resent from your drafts.' @@ -9115,6 +9117,11 @@ export class AgentFramework { continue; } + // A request that continues the agent's turn reads that turn's record + // now, as the turn left it (continuationFor), before the policy sees + // the batch and before its fields are merged below. + requests = requests.map((r) => this.continuationFor(r)); + // Check policy if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) { // Loud drop: a queued request that dies here is otherwise invisible — @@ -9136,11 +9143,6 @@ export class AgentFramework { continue; } - // A request that continues the agent's turn reads that turn's record - // now, as the turn left it (continuationFor), before the batch's - // fields are merged below. - requests = requests.map((r) => this.continuationFor(r)); - // Start streaming inference (non-blocking — driveStream runs in background) // The request that grants the turn-alive exception must also define the // downstream turn semantics. An ordinary wake can already be pending @@ -10215,8 +10217,9 @@ export class AgentFramework { * request's candidates, so the true new turn it starts, alone or batched * with other wakes, infers its route from them by the rules every wake * follows, the fork rule included. A fallback turn's place becomes its - * fallback. A silent wake's continuation is a silent tick too, with the - * turn's prompt and stamp, as a budget restart is. + * fallback. A silent wake's own continuations are silent ticks too, with + * the turn's prompt and stamp, as a budget restart is; an unstick and a + * bounce's resend never are (ORDINARY_CONTINUATIONS). * * The record read is the agent's most recent turn's, as that turn left * it, which is the turn the request continues: a resident's true new turn @@ -10228,22 +10231,35 @@ export class AgentFramework { * Only a bounce can be queued after a successor turn has begun, from the * previous turn's speech landing while the successor waits for it; its * wake then runs after the successor and names the successor's - * conversations, where the resident last spoke. + * conversations, where the resident last spoke, but never takes the + * successor's silence. */ private continuationFor(request: InferenceRequest): InferenceRequest { if (!request.continuesTurn) return request; const turn = this.turnRoutes.get(request.agentName); - const { candidates, namesConversation, fallback } = continuationOf(turn, request.timestamp); + // The turn's conversations count as older than anything batched with + // them: whatever else is in the batch arrived after that turn began, so + // a message in the same conversation gives the reply edge. + const { candidates, namesConversation, fallback } = continuationOf(turn, 0); + const silent = turn?.silent && !AgentFramework.ORDINARY_CONTINUATIONS.has(request.reason); return { ...request, continuesTurn: undefined, ...(namesConversation ? { namesConversation: true as const } : {}), ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), ...(fallback ? { fallbackPlace: fallback } : {}), - ...(turn?.silent ? { suppressProse: true, ...turn.silent } : {}), + ...(silent ? { suppressProse: true, ...turn.silent } : {}), }; } + /** + * Wakes that follow a turn without being part of it: an operator's unstick, + * and a bounce's resend, which can land after a later turn has begun. They + * take the turn's conversations but never its silence, so each is an + * ordinary turn, as on main. + */ + private static readonly ORDINARY_CONTINUATIONS: ReadonlySet = new Set(['unstick', 'prose-bounce']); + /** * A conversation fork's home as its standing route (it always wins). Homes * are bare channel ids: the server is the id's sole registrant, and a @@ -11542,7 +11558,10 @@ export class AgentFramework { ? (this.mayTakeCandidate(agent.name, trigger.fallbackPlace) ? trigger.fallbackPlace : null) : this.fallbackCandidate(agent.name), ); - this.turnRoutes.set(agent.name, { ...turn, ...(trigger?.namesConversation ? { named: true as const } : {}) }); + // A continued fallback whose kept place was refused has no place, + // and its own continuations take none either: it counts as named. + const named = trigger?.namesConversation || (!!trigger?.fallbackPlace && !turn.route); + this.turnRoutes.set(agent.name, { ...turn, ...(named ? { named: true as const } : {}) }); this.midTurnInputSignals.delete(agent.name); if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); } @@ -11552,18 +11571,31 @@ export class AgentFramework { // control-plane wake has none: its channel_open sets none (the result // says so), so this resets only what a restart could carry over. if (trigger?.suppressProse) { - // The record keeps the wake's silence, so a continuation of this turn - // is a silent tick too, whenever it is queued (continuationFor). - this.turnRoutes.set(agent.name, { - route: null, - ...(trigger.namesConversation ? { named: true as const } : {}), - silent: { - ...(trigger.ephemeralSystemPrompt ? { ephemeralSystemPrompt: trigger.ephemeralSystemPrompt } : {}), - ...(trigger.silentHeartbeat ? { silentHeartbeat: trigger.silentHeartbeat } : {}), - }, - }); + // The record keeps the wake's silence, so the turn's own continuations + // are silent ticks too (continuationFor). It is written when the turn + // begins; a restart or a guard retry continues the turn and keeps it. + if (!continuingTurn) { + this.turnRoutes.set(agent.name, { + route: null, + ...(trigger.namesConversation ? { named: true as const } : {}), + silent: { + ...(trigger.ephemeralSystemPrompt ? { ephemeralSystemPrompt: trigger.ephemeralSystemPrompt } : {}), + ...(trigger.silentHeartbeat ? { silentHeartbeat: trigger.silentHeartbeat } : {}), + }, + }); + } this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); + } else if (continuingTurn) { + // A restart batched with an ordinary wake runs as an ordinary turn + // (processInferenceRequests), so its record stops being a silent + // tick's, and so do its continuations. It keeps its route, as any + // restart does. + const record = this.turnRoutes.get(agent.name); + if (record?.silent) { + const { silent: _silent, ...ordinary } = record; + this.turnRoutes.set(agent.name, ordinary); + } } this.touchEphemeralRun(agent.name, true); @@ -17503,9 +17535,10 @@ export class AgentFramework { // speech is its own to decide: the open stands, the route and // prose choices don't move. routing = 'Opened. A newer turn has begun since this call, so its speech route is unchanged.'; - } else if (this.activeTurnTriggers.get(agentName)?.suppressProse === true) { + } else if (this.turnRoutes.get(agentName)?.silent) { // A silent control-plane wake keeps its plain speech private: - // the open stands, and no route is set. + // the open stands, and no route is set. The turn's record says + // so, during the turn and after it ends. routing = 'Opened. This is a silent wake, so your plain speech stays private this turn; ' + 'to post there, use a send tool.'; } else if (openInput?.setSpeechTarget !== false) { @@ -17533,12 +17566,9 @@ export class AgentFramework { const refusal = routeRefusal(routeConversation(route), (c) => this.publishTargetOf(c)); if (!this.mayTakeRoute(agentName, opened)) { // The fork rule, as for every route: a channel a conversation - // fork owns is that fork's conversation, and a fork speaks - // only in its home. - routing = 'Opened, but your plain speech can\'t go there: ' + - (this.conversationAgentHomes.has(agentName) - ? 'a conversation fork speaks only in its home channel. ' - : 'a conversation fork owns that channel. ') + + // fork owns is that fork's conversation. (A fork's own + // channel_open is refused before this.) + routing = 'Opened, but your plain speech can\'t go there: a conversation fork owns that channel. ' + unchanged(); console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (fork rule)`); } else if (refusal) { @@ -17547,8 +17577,7 @@ export class AgentFramework { unchanged(); console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (${refusal})`); } else { - const prev = this.turnRoutes.get(agentName); - const next: TurnRecord = { route, ...(prev?.named ? { named: true as const } : {}) }; + const next: TurnRecord = { route }; this.turnRoutes.set(agentName, next); this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); // The open supersedes a hybrid `>>>` choice — a pin, or the diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 02f7da636..1700e502d 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -322,7 +322,13 @@ export function continuationCandidates(turn: TurnRoute | undefined, at: number): ...(route.kind === 'channel' && route.replyTo ? { messageId: route.replyTo } : {}), }]; } - if (turn.unroutable) return [{ conversation: turn.unroutable.conversation, addressed: true, at }]; + if (turn.unroutable) { + const { conversation } = turn.unroutable; + // A channel the turn couldn't resolve to a server stays flagged, so the + // framework resolves it again when the continuation's turn starts. + const serverless = conversation.kind === 'channel' && !conversation.serverId; + return [{ conversation, addressed: true, at, ...(serverless ? { unroutable: true as const } : {}) }]; + } return []; } diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index f77eda8b9..8635cce05 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -504,6 +504,10 @@ describe('present while acting', () => { assert.deepEqual(heldDrafts(framework), []); const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); assert.equal(turn?.hold, undefined); + // The addressed arrival did reach the turn: it was injected mid-turn. + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('quick question')), 'the addressed message was injected'); await framework.stop(); }); @@ -540,7 +544,7 @@ describe('present while acting', () => { const wired = membrane.lastStream!.receivedToolResultOptions .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); assert.ok( - wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn. Your unaddressed plain speech was going to (srv / chan-A), where the last message came in, because nothing in this wake named a conversation.')), + wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn. Your unaddressed plain speech was going to (srv / chan-A), where the last message had come in when this reply began, because nothing in the wake that began it named a conversation.')), 'the hold notice says what the fallback was, not that it could be answering it', ); await framework.stop(); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index bed91953f..86b2e7d2b 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -147,6 +147,24 @@ describe('speech route across a logical turn', () => { await framework.stop(); }); + it("a channel_open whose result lands after a silent tick has ended still sets no route", async () => { + const framework = await makeFramework(); + const i = internals(framework); + const opens: string[] = []; + i.channelRegistry = channelsThatOpen(opens); + // As a silent tick leaves its record once its turn has ended: no trigger + // is running, and the record says the turn was silent. + const record = { route: null, silent: {} }; + i.turnRoutes.set('scout', record as never); + (framework as unknown as { dispatchChannelToolCall(agentName: string, call: unknown): void }) + .dispatchChannelToolCall('scout', { id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }); + await new Promise((r) => setImmediate(r)); + await framework.runUntilIdle(); + assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); + assert.equal(i.turnRoutes.get('scout'), record, 'and the silent record kept, with no route'); + await framework.stop(); + }); + it('a channel_open into a channel a conversation fork owns opens it but takes no route, and its result says so', async () => { const framework = await makeFramework(); const i = internals(framework); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index ecf585e1e..1980d4bf7 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -380,6 +380,11 @@ describe('continuationCandidates and continuationOf', () => { assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3 }]); const untargetable = continuationCandidates({ route: null, unroutable: { conversation: channel('quiet'), reason: 'untargetable' } }, 3); assert.deepEqual(untargetable, [{ conversation: channel('quiet'), addressed: true, at: 3 }]); + // One whose channel had no server stays flagged, so the framework + // resolves it again when the continuation's turn starts. + const bare: ConversationRef = { kind: 'channel', channelId: 'bare' }; + assert.deepEqual(continuationCandidates({ route: null, unroutable: { conversation: bare, reason: 'unresolved' } }, 3), + [{ conversation: bare, addressed: true, at: 3, unroutable: true }]); // Checked again: still refused while the channel declares nothing, a route once it does. assert.equal(inferTurnRoute(untargetable, null, declaredNowhere).unroutable?.reason, 'untargetable'); assert.equal(inferTurnRoute(untargetable, null, rootEverywhere).route?.kind, 'channel'); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 6a94917dd..77f8fc4dc 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -868,7 +868,7 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a fallback turn's kept place follows the fork rule when its continuation starts: a fork that claimed it meanwhile keeps the continuation out", async () => { + it("a fallback turn's kept place that the fork rule refuses when its continuation starts gives no place, then or after, even with the last message come in elsewhere", async () => { const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; @@ -876,16 +876,41 @@ describe('Trunk channel routing (item-3 redux)', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); await heartbeat(framework); assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:elsewhere'); + // A fork claims the kept place, and a message comes in on another channel. i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); i.conversationAgentHomes.set('fork-1', 'discord:guild:elsewhere'); + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:moved' }); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); await framework.runUntilIdle(); - assert.equal(i.turnRoutes.get('scout')?.route, null, "no route into the fork's channel"); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }, "no route into the fork's channel, and none elsewhere"); + // The next continuation of the same reply has no place either. + i.pendingRequests.push(i.continuationRequest('scout', 'inference-failure-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still not moved' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route, null); assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:elsewhere'], 'only the tick was published'); i.conversationAgentHomes.delete('fork-1'); await framework.stop(); }); + it("a fallback turn's kept place whose channel no longer declares a publish target gives its continuation no place", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await heartbeat(framework); + // The kept place's channel stops declaring; the last inbound moves to one that does. + (registry as Record).publishTarget = (c: { channelId?: string }) => (c.channelId === 'discord:guild:elsewhere' ? undefined : 'root'); + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:moved' }); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'nowhere now' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route, null); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:elsewhere'], 'only the tick was published'); + await framework.stop(); + }); + it("a no-name turn that found no fallback place gives a continuation that names none, so it takes the fallback once there is one", async () => { const framework = await makeFramework(); const { registry, published } = declaringRegistry('root'); @@ -1029,6 +1054,132 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it("a continuation of a turn whose channel had no server resolves it again when it starts, and takes the route with its server", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + let registered = false; + (registry as Record).getChannelServerId = (id: string) => (registered && id === 'discord:guild:late' ? 'discord' : undefined); + internals(framework).channelRegistry = registry; + const i = internals(framework); + const fw = framework as unknown as { candidateForChannel(channelId: string, addressed: boolean, at: number): unknown }; + const now = Date.now(); + i.pendingRequests.push({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: now, namesConversation: true, + routeCandidates: [fw.candidateForChannel('discord:guild:late', true, now)], + } as never); + await framework.runUntilIdle(); + const unresolved = i.turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(unresolved.route, null); + assert.equal(unresolved.unroutable?.reason, 'unresolved'); + // The server registers; the turn's retry takes the channel, with its server. + registered = true; + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'there now' }])); + await framework.runUntilIdle(); + const route = i.turnRoutes.get('scout')?.route as { channelId?: string; serverId?: string } | null; + assert.deepEqual([route?.serverId, route?.channelId], ['discord', 'discord:guild:late']); + assert.deepEqual(published.map((p) => [(p.to as { serverId?: string }).serverId, (p.to as { channelId: string }).channelId, p.text]), + [['discord', 'discord:guild:late', 'there now']]); + await framework.stop(); + }); + + it("a continuation's conversation counts as older than a message batched with it, so that message gives the reply edge", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const first = i.turnRoutes.get('scout')?.route?.replyTo; + assert.ok(first); + // A newer message in chanA is queued first; the turn's retry is queued after it. + const arrival = { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanA' }, addressed: true, at: Date.now(), messageId: 'm-newer' }], + }; + i.pendingRequests.push(arrival as never, i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'second answer' }])); + await framework.runUntilIdle(); + assert.deepEqual([i.turnRoutes.get('scout')?.route?.channelId, i.turnRoutes.get('scout')?.route?.replyTo], ['discord:guild:chanA', 'm-newer']); + await framework.stop(); + }); + + it("a silent tick's record survives a restart, and a restart batched with an ordinary wake runs ordinary and drops it", async () => { + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + const scout = framework.getAgent('scout')!; + const start = (trigger: Record) => (framework as unknown as { + startAgentStream(agent: unknown, trigger?: unknown, attempt?: number): Promise; + }).startAgentStream(scout, { agentName: 'scout', source: 'framework', timestamp: Date.now(), ...trigger }); + const silentHeartbeat = { eventId: 'hb-9', origin: { source: 'heartbeat', silent: true } }; + // A silent tick whose wake named a conversation. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private' }])); + await start({ reason: 'heartbeat', suppressProse: true, namesConversation: true, silentHeartbeat }); + await framework.runUntilIdle(); + const record = i.turnRoutes.get('scout') as { route: unknown; named?: true; silent?: { silentHeartbeat?: { eventId?: string } } }; + assert.deepEqual([record.route, record.named, record.silent?.silentHeartbeat?.eventId], [null, true, 'hb-9']); + // Its restart, alone: silent, and the record is kept whole. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still private' }])); + await start({ reason: 'context_budget_restart', suppressProse: true, silentHeartbeat }); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout'), record, 'the restart kept the record'); + // A restart that an ordinary wake shared a batch with runs ordinary: the + // record keeps its route and its naming, and stops being silent. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ordinary now' }])); + await start({ reason: 'context_budget_restart' }); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); + await framework.stop(); + }); + + it("an unstick and a bounce's resend after a silent tick are ordinary turns; the tick's own retry stays silent", async () => { + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + const silentTick = (eventId: string) => framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', eventId, content: [], + timestamp: new Date().toISOString(), origin: { source: 'heartbeat', reason: 'schedule', silent: true }, + inferenceId: `inf-${eventId}`, triggerInference: true, + } as unknown as ProcessEvent); + const silent = () => (i.turnRoutes.get('scout') as { silent?: unknown } | undefined)?.silent !== undefined; + const lastCallSilent = () => JSON.stringify(membrane.calls.at(-1)).includes('[silent heartbeat]'); + // The tick's own retry is a silent tick. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + silentTick('hb-a'); + await framework.runUntilIdle(); + assert.equal(silent(), true); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'retry' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + await framework.runUntilIdle(); + assert.equal(lastCallSilent(), true, 'the retry carries the tick\'s prompt'); + assert.equal(silent(), true); + // An operator's unstick after it is an ordinary turn. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'unstuck' }])); + const host = framework as unknown as { handleHostCommand(serverId: string, params: Record): Promise<{ ok: boolean; error?: string }> }; + const result = await host.handleHostCommand('discord', { command: 'unstick', agentName: 'scout' }); + assert.equal(result.ok, true, result.error); + await framework.runUntilIdle(); + assert.equal(lastCallSilent(), false, 'no tick prompt'); + assert.equal(silent(), false, 'an ordinary record'); + // So is a bounce's resend after another silent tick. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick 2' }])); + silentTick('hb-b'); + await framework.runUntilIdle(); + assert.equal(silent(), true); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'resending' }])); + (framework as unknown as { bounceProse(agent: unknown, text: string, reason: string): void }) + .bounceProse(framework.getAgent('scout'), 'lost words', 'a late bounce'); + await framework.runUntilIdle(); + assert.equal(lastCallSilent(), false, 'no tick prompt'); + assert.equal(silent(), false, 'an ordinary record'); + await framework.stop(); + }); + it("a gate's unroutable candidate that names its server stays unroutable when its turn starts", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held' }])); const framework = await makeFramework(); From fabdc81c505f64a4251f0a5aea6f5b412becd164 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 05:49:13 -0400 Subject: [PATCH 64/65] fix(routing): a conversation from a candidate marked unroutable stays marked in its turn's record and continuations From Audrey-2282's probe of 501fa632 (room-448). A producer marks a route candidate unroutable when it can never be the turn's route: a raw id the gate couldn't resolve to a registered channel, or a channel with no server. inferTurnRoute recorded such a conversation as `unresolved`, and a hold recorded only conversation references, so the mark was lost. A continuation of the turn then named the conversation unmarked, and the route was decided by checking the registry again. Audrey's probe used a registry that declares every id: a gate's `discord / user:U5` held the first turn, and its continuation published to `user:U5`. 501fa632 kept the mark only for a channel with no server. The record now keeps the mark: `unroutable.neverRoutes` for the one conversation, and `hold.neverRoutes` (conversation keys) for those a turn-start hold names. A mid-turn hold needs none: its arrivals' candidates come from inbound sources, which always name a registered channel and its server. continuationCandidates marks those conversations unroutable again. So only finding a serverless channel's server, when the continuation starts, can make one a route; a raw id named with its server stays unroutable. A conversation refused for its publish target carries no mark, and is checked again as before. The changelog's continuation bullet now says which unroutable conversations are checked again and which stay unroutable. Tests: - speech-routes: inferTurnRoute marks a chosen unroutable candidate, a hold lists its marked conversations, and continuations keep the marks for a raw id with its server and for a serverless channel. - trunk-channel-routing: a gate's raw id held the first turn, and its retry stays unroutable with nothing published; a hold between a raw id and chanB, with chanB then claimed by a fork, gives a continuation that stays unroutable with nothing published. --- changelog.d/speech-routes.changed.md | 10 ++++-- src/framework.ts | 3 ++ src/speech-routes.ts | 50 ++++++++++++++++++++-------- test/speech-routes.test.ts | 23 +++++++++---- test/trunk-channel-routing.test.ts | 44 +++++++++++++++++++++--- 5 files changed, 102 insertions(+), 28 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index 31f6233ee..a1ea28fe5 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -70,9 +70,13 @@ of the reply. A wake batched with the continuation names its own conversation and takes the turn, as any arrival does. A kept place that the fork rule or its channel's publish target now refuses gives - no place, to the continuation and to those after it. An unroutable - conversation's continuation names it, and resolves it again when it - starts, so it takes a place once its channel can take one. A silent + no place, to the continuation and to those after it. A continuation + names an unroutable conversation too. One its channel's publish target + refused is checked again when the continuation starts, so it takes a + place once its channel can take one. One that came from a candidate + marked unroutable stays so, unless the framework finds the server of a + channel that had none: a raw id the host couldn't resolve never becomes + a route. A silent tick's own continuations, such as its retries, are silent ticks too, with the tick's prompt and stamp, and a restart or a guard retry keeps the tick's record. The one exception is a restart batched with an diff --git a/src/framework.ts b/src/framework.ts index ee3e3e389..f6ed54a2a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7089,6 +7089,9 @@ export class AgentFramework { if (turn?.route && competing.length > 0) { const current = routeConversation(turn.route); const arrivals = competing.map((c) => c.conversation); + // A mid-turn arrival's candidate comes from its inbound source, + // which always names a registered channel and its server, so + // none is marked unroutable (TurnRoute.hold.neverRoutes). const held: TurnRecord = { ...turn, hold: { conversations: [current, ...arrivals], since: 'mid-turn' } }; this.turnRoutes.set(agent.name, held); this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); diff --git a/src/speech-routes.ts b/src/speech-routes.ts index 1700e502d..e82b2afa3 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -108,10 +108,13 @@ export interface TurnRoute { * has no destination. A mid-turn hold keeps the route it suspended here, * alongside `hold`, which wins while it is set. */ route: SpeechRoute | null; - /** Unaddressed speech is held as drafts: these conversations compete. */ + /** Unaddressed speech is held as drafts: these conversations compete. + * `neverRoutes` keys (conversationKey) the ones that came from a + * candidate marked unroutable, which a continuation keeps marked. */ hold?: { conversations: ConversationRef[]; since: 'turn-start' | 'mid-turn'; + neverRoutes?: string[]; }; /** No route because the one conversation the turn answers can't take * plain speech, said in the routing notice and the drafts' note: @@ -120,8 +123,12 @@ export interface TurnRoute { * - `thread`: a thread on a channel whose connector can't post into a * named thread (it declares `root`: a contradiction the connector * shouldn't produce); - * - `unresolved`: a channel the host couldn't resolve. */ - unroutable?: { conversation: ConversationRef; reason: UnroutableReason }; + * - `unresolved`: a channel the host couldn't resolve. + * `neverRoutes` says the conversation came from a candidate marked + * unroutable (a raw id the host couldn't resolve to a registered + * channel, or a channel with no server), so a continuation keeps it + * marked: only finding the channel's server can make it a route. */ + unroutable?: { conversation: ConversationRef; reason: UnroutableReason; neverRoutes?: true }; /** The turn's wake named a conversation (a channel, a thread, an author * or a local surface), candidate or not. A continuation of the turn then * names one too (continuationOf). */ @@ -245,10 +252,16 @@ export function inferTurnRoute( if (!seen || candidate.at >= seen.at) newestByConversation.set(key, candidate); } if (newestByConversation.size > 1) { - const conversations = [...newestByConversation.values()] - .sort((a, b) => b.at - a.at) - .map((c) => c.conversation); - return { route: null, hold: { conversations, since: 'turn-start' } }; + const competing = [...newestByConversation.values()].sort((a, b) => b.at - a.at); + const neverRoutes = competing.filter((c) => c.unroutable).map((c) => conversationKey(c.conversation)); + return { + route: null, + hold: { + conversations: competing.map((c) => c.conversation), + since: 'turn-start', + ...(neverRoutes.length > 0 ? { neverRoutes } : {}), + }, + }; } const [chosen] = newestByConversation.values(); const c = chosen!.conversation; @@ -256,7 +269,7 @@ export function inferTurnRoute( // neither can one the framework can't publish into exactly: its connector // would choose the place, which may be another conversation (RFC-011). // Both still compete for the turn (above). - if (chosen!.unroutable) return { route: null, unroutable: { conversation: c, reason: 'unresolved' } }; + if (chosen!.unroutable) return { route: null, unroutable: { conversation: c, reason: 'unresolved', neverRoutes: true } }; const refusal = routeRefusal(c, targetOf); if (refusal) return { route: null, unroutable: { conversation: c, reason: refusal } }; return c.kind === 'surface' @@ -311,7 +324,15 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu */ export function continuationCandidates(turn: TurnRoute | undefined, at: number): RouteCandidate[] { if (!turn) return []; - if (turn.hold) return turn.hold.conversations.map((conversation) => ({ conversation, addressed: true, at })); + if (turn.hold) { + const neverRoutes = new Set(turn.hold.neverRoutes ?? []); + return turn.hold.conversations.map((conversation) => ({ + conversation, + addressed: true, + at, + ...(neverRoutes.has(conversationKey(conversation)) ? { unroutable: true as const } : {}), + })); + } if (turn.route) { const { route } = turn; if (route.origin === 'fallback') return []; @@ -323,11 +344,12 @@ export function continuationCandidates(turn: TurnRoute | undefined, at: number): }]; } if (turn.unroutable) { - const { conversation } = turn.unroutable; - // A channel the turn couldn't resolve to a server stays flagged, so the - // framework resolves it again when the continuation's turn starts. - const serverless = conversation.kind === 'channel' && !conversation.serverId; - return [{ conversation, addressed: true, at, ...(serverless ? { unroutable: true as const } : {}) }]; + // A conversation from a candidate marked unroutable stays marked, so it + // never routes unless the framework finds its channel's server when the + // continuation starts. One refused for its publish target is checked + // again as it is. + const { conversation, neverRoutes } = turn.unroutable; + return [{ conversation, addressed: true, at, ...(neverRoutes ? { unroutable: true as const } : {}) }]; } return []; } diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 1980d4bf7..97af4ec41 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -216,7 +216,7 @@ describe('inferTurnRoute', () => { const competing = inferTurnRoute([candidate(channel('room'), true, 1, 'm1'), raw], undefined, rootEverywhere); assert.equal(competing.route, null); assert.equal(competing.hold?.conversations.length, 2, 'never the older conversation by default'); - assert.deepEqual(inferTurnRoute([raw], undefined, rootEverywhere), { route: null, unroutable: { conversation: channel('1548'), reason: 'unresolved' } }); + assert.deepEqual(inferTurnRoute([raw], undefined, rootEverywhere), { route: null, unroutable: { conversation: channel('1548'), reason: 'unresolved', neverRoutes: true } }); }); it('a local surface is addressed by nature, and routes to the surface', () => { @@ -367,6 +367,15 @@ describe('continuationCandidates and continuationOf', () => { assert.equal(continuationOf(held, 9).fallback, undefined); }); + it('a hold remembers which of its conversations came from a candidate marked unroutable, and its continuation keeps the mark', () => { + const raw: RouteCandidate = { conversation: channel('user:U5'), addressed: true, at: 2, unroutable: true }; + const held = inferTurnRoute([candidate(channel('b'), true, 1), raw], undefined, rootEverywhere); + assert.deepEqual(held.hold?.neverRoutes, [conversationKey(channel('user:U5'))]); + const continued = continuationCandidates(held, 5); + assert.deepEqual(continued.map((c) => [c.conversation.kind === 'channel' && c.conversation.channelId, c.unroutable ?? false]), + [['user:U5', true], ['b', false]]); + }); + it('a held turn names every conversation it held between, so its continuation is held again', () => { const held: TurnRoute = { route: route('a'), hold: { conversations: [channel('a'), channel('b')], since: 'mid-turn' } }; const candidates = continuationCandidates(held, 5); @@ -380,11 +389,13 @@ describe('continuationCandidates and continuationOf', () => { assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3 }]); const untargetable = continuationCandidates({ route: null, unroutable: { conversation: channel('quiet'), reason: 'untargetable' } }, 3); assert.deepEqual(untargetable, [{ conversation: channel('quiet'), addressed: true, at: 3 }]); - // One whose channel had no server stays flagged, so the framework - // resolves it again when the continuation's turn starts. - const bare: ConversationRef = { kind: 'channel', channelId: 'bare' }; - assert.deepEqual(continuationCandidates({ route: null, unroutable: { conversation: bare, reason: 'unresolved' } }, 3), - [{ conversation: bare, addressed: true, at: 3, unroutable: true }]); + // One that came from a candidate marked unroutable stays marked: a raw + // id named with its server never routes, and a channel with no server + // routes only once the framework finds its server. + for (const conversation of [channel('user:U5'), { kind: 'channel', channelId: 'bare' } as ConversationRef]) { + assert.deepEqual(continuationCandidates({ route: null, unroutable: { conversation, reason: 'unresolved', neverRoutes: true } }, 3), + [{ conversation, addressed: true, at: 3, unroutable: true }]); + } // Checked again: still refused while the channel declares nothing, a route once it does. assert.equal(inferTurnRoute(untargetable, null, declaredNowhere).unroutable?.reason, 'untargetable'); assert.equal(inferTurnRoute(untargetable, null, rootEverywhere).route?.kind, 'channel'); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 77f8fc4dc..9d158ead6 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -1180,26 +1180,60 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a gate's unroutable candidate that names its server stays unroutable when its turn starts", async () => { + it("a gate's unroutable candidate that names its server stays unroutable when its turn starts, and in its continuations", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held' }])); const framework = await makeFramework(); const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); - // Every id has a sole registrant here, so a raw id resolved again would - // look routable. + // Every id has a sole registrant and declares a target here, so a raw id + // checked again would look routable. (registry as Record).getChannelServerId = () => 'discord'; internals(framework).channelRegistry = registry; + const i = internals(framework); const now = Date.now(); // As the gate passes an event whose registered channel the host couldn't // resolve: the raw author id, its server named, unroutable. - internals(framework).pendingRequests.push({ + i.pendingRequests.push({ agentName: 'scout', reason: 'gate', source: 'gate', timestamp: now, namesConversation: true, routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'user:U5' }, addressed: true, at: now, unroutable: true }], } as never); await framework.runUntilIdle(); - const turn = internals(framework).turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + const turn = i.turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; assert.equal(turn.route, null); assert.equal(turn.unroutable?.reason, 'unresolved'); + // Its retry names the same conversation, still marked: no route. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still held' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route, null); + assert.deepEqual(published, [], 'nothing went to the raw id'); + await framework.stop(); + }); + + it("a hold's conversation from an unroutable candidate stays marked in its continuation, even when it is the one left", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held between them' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + (registry as Record).getChannelServerId = () => 'discord'; + internals(framework).channelRegistry = registry; + const i = internals(framework); + const now = Date.now(); + i.pendingRequests.push({ + agentName: 'scout', reason: 'gate', source: 'gate', timestamp: now, namesConversation: true, + routeCandidates: [ + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'user:U5' }, addressed: true, at: now, unroutable: true }, + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed: true, at: now - 1 }, + ], + } as never); + await framework.runUntilIdle(); + assert.equal((i.turnRoutes.get('scout') as { hold?: unknown }).hold !== undefined, true, 'held'); + // A fork claims chanB, so the raw id is the only conversation left. + i.conversationAgentHomes.set('fork-1', 'discord:guild:chanB'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not to the raw id' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route, null); assert.deepEqual(published, [], 'nothing went to the raw id'); + i.conversationAgentHomes.delete('fork-1'); await framework.stop(); }); From 0bae78b1ddb5e05fcda42722c0c652c6251d949b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 07:11:48 -0400 Subject: [PATCH 65/65] fix(routing): a silent tick's restart that a batched wake makes ordinary takes that wake's route, and a continuation keeps its reply message's time From the haiku review of 45cfe469..fabdc81c (sw-af256i). A context-budget restart of a silent tick, batched with an ordinary wake, runs as an ordinary turn (processInferenceRequests' silentOnly rule). It kept the tick's route, which a silent tick doesn't have, so the reply to the batched wake was held as drafts with a no-destination notice, and its continuations, reading a record with no route and no naming, took the fallback. The tick had no route to keep, so the restart now decides its route from the batch's wakes, as a new turn does: the route decision is one method (decideTurnRoute), used at a true new turn and here. processInferenceRequests passes a restart batch's route candidates, naming and kept fallback place; a restart reads them only in this case. In explicit or disabled prose routing the record is dropped, as at a new turn. A continuation's candidate for its turn's route was stamped at time 0, so any message batched in the same conversation took the reply edge, including an addressed one older than the message the turn answered (one the gate held back while the agent was busy). TurnRoute now keeps `replyAt`, when the reply edge's message arrived, set where inferTurnRoute sets replyTo, and continuationCandidates stamps the route's candidate with it. A newer message takes the reply edge; an older one doesn't. A route with no reply edge (channel_open's) still counts as older than anything batched with it. Text: - The changelog's continuation bullet: an addressed message in the same conversation takes the reply edge when it is newer than the one the turn answered; a batched wake that names a conversation takes a fallback continuation's turn; a silent tick's continuations turn ordinary in any batch holding an ordinary wake, and a restart in such a batch decides its route from the batch's wakes; a marked channel with no server routes once its server is found, and a raw id named with its server never does. The bullet is rewrapped. - TurnRoute.unroutable's neverRoutes doc says the same of marks, and continuationCandidates' doc separates refused conversations (checked again) from marked ones. - channel_open's result in a silent wake says "This call came from a silent wake, so its plain speech stays private", true after the tick has ended too. - The mid-turn notice for a held fallback route says the place is where the last message had come in when the reply began, and that a continuation of it keeps that place. Tests: - speech-routes: the route's reply time, a newer and an older batched message, a channel_open route's continuation at 0, and a 'thread' refusal checked again. - trunk-channel-routing: a silent tick's record kept through a restart and a guard retry alone; a restart batched with an addressed wake runs ordinary, routes to that wake's conversation with its reply edge, and publishes there; the inference policy sees a continuation already filled; the gate raw-id, hold and fork-refusal tests assert that their continuations ran and wrote their own records. - speech-route-turns: a channel_open whose result lands after a silent tick ended delivers the silent-wake text. --- changelog.d/speech-routes.changed.md | 90 +++++++++--------- src/framework.ts | 132 ++++++++++++++------------- src/speech-routes.ts | 33 +++++-- test/present-while-acting.test.ts | 2 +- test/speech-route-turns.test.ts | 13 ++- test/speech-routes.test.ts | 25 ++++- test/trunk-channel-routing.test.ts | 72 ++++++++++++--- 7 files changed, 233 insertions(+), 134 deletions(-) diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md index a1ea28fe5..2b5c5c040 100644 --- a/changelog.d/speech-routes.changed.md +++ b/changelog.d/speech-routes.changed.md @@ -42,51 +42,51 @@ a wake gets no route, never the fallback. An ambient-only batch names its own conversations: it routes to one, or holds when it names several. - **A framework wake that continues a turn names that turn's - conversations.** Such wakes are the retry after a rewind (for a - refusal, an unstick's forced rewind, or an inference failure), an - unstick itself, the results of a non-streaming tool round, an - explicit-prose continuation or bounce, and a provider-cooldown retry - that holds no wake of its own. The framework reads the turn's record - when the wake's batch starts, which is after the continued turn has - ended, and gives the wake that turn's conversations as addressed ones. - So its words go where the turn's had gone by its end, after a - `channel_open` or a hold. A bounce queues its wake at once; one that - lands while a later turn is alive wakes after that turn and names its - conversations instead, where the resident last spoke. Beside a - heartbeat those conversations keep the turn, beside an ambient arrival - from elsewhere they win, and beside an addressed one from elsewhere the - turn is held. They count as older than anything batched with them, so - a newer message in the same conversation gives the reply edge. A held - turn's continuation names the conversations its hold named. With - inferred prose routing, a continuation names a conversation exactly - when the turn it continues did, or when it carries that turn's - conversations. So a turn whose words had no place although its wake - named a conversation (a reaction, a channel a fork owns) gives its - continuation no place either, never the fallback, and a turn whose - wake named none, and that found no fallback place, gives a continuation - that takes the fallback as it stands then. A fallback turn's - continuation keeps that turn's place as its own fallback, so a message - that comes in elsewhere without waking the agent doesn't move the rest - of the reply. A wake batched with the continuation names its own - conversation and takes the turn, as any arrival does. A kept place - that the fork rule or its channel's publish target now refuses gives - no place, to the continuation and to those after it. A continuation - names an unroutable conversation too. One its channel's publish target - refused is checked again when the continuation starts, so it takes a - place once its channel can take one. One that came from a candidate - marked unroutable stays so, unless the framework finds the server of a - channel that had none: a raw id the host couldn't resolve never becomes - a route. A silent - tick's own continuations, such as its retries, are silent ticks too, - with the tick's prompt and stamp, and a restart or a guard retry keeps - the tick's record. The one exception is a restart batched with an - ordinary wake: it runs as an ordinary turn, as on main, and so do the - continuations after it. An unstick and a bounce's resend are repairs - rather than part of the turn they follow, and are always ordinary - turns, as on main. The new turn is otherwise fresh, as - any is: a rewind sheds the turn whose choices those were. An unstick - is refused while a turn is in progress, including the moments the agent - reads idle at a turn's start and end. + conversations.** Such wakes are the retry after a rewind (for a refusal, + an unstick's forced rewind, or an inference failure), an unstick itself, + the results of a non-streaming tool round, an explicit-prose + continuation or bounce, and a provider-cooldown retry that holds no wake + of its own. The framework reads the turn's record when the wake's batch + starts, which is after the continued turn has ended, and gives the wake + that turn's conversations as addressed ones. So its words go where the + turn's had gone by its end, after a `channel_open` or a hold. A bounce + queues its wake at once; one that lands while a later turn is alive + wakes after that turn and names its conversations instead, where the + resident last spoke. Beside a heartbeat those conversations keep the + turn, beside an ambient arrival from elsewhere they win, and beside an + addressed one from elsewhere the turn is held. An addressed message + batched in the same conversation takes the reply edge if it is newer + than the one the turn answered, and an older one doesn't. A held turn's + continuation names the conversations its hold named. With inferred prose + routing, a continuation names a conversation exactly when the turn it + continues did, or when it carries that turn's conversations. So a turn + whose words had no place although its wake named a conversation (a + reaction, a channel a fork owns) gives its continuation no place either, + never the fallback, and a turn whose wake named none, and that found no + fallback place, gives a continuation that takes the fallback as it + stands then. A fallback turn's continuation keeps that turn's place as + its own fallback, so a message that comes in elsewhere without waking + the agent doesn't move the rest of the reply. A wake batched with that + continuation that names a conversation takes the turn, as any arrival + does. A kept place that the fork rule or its channel's publish target + now refuses gives no place, to the continuation and to those after it. A + continuation names an unroutable conversation too. One its channel's + publish target refused is checked again when the continuation starts, so + it takes a place once its channel can take one. One that came from a + candidate marked unroutable stays so: a channel with no server becomes a + route only once the framework finds its server, and a raw id named with + its server never does. A silent tick's own continuations, such as its + retries, are silent ticks too, with the tick's prompt and stamp, and a + restart or a guard retry keeps the tick's record. The exception is a + batch that also holds an ordinary wake: it runs as an ordinary turn, as + on main, and so do the continuations after it. A restart in such a batch + had no route to keep, so its route is decided from the batch's wakes, as + a new turn's is. An unstick and a bounce's resend are repairs rather + than part of the turn they follow, and are always ordinary turns, as on + main. The new turn is otherwise fresh, as any is: a rewind sheds the + turn whose choices those were. An unstick is refused while a turn is in + progress, including the moments the agent reads idle at a turn's start + and end. - **An ephemeral subagent never takes the fallback.** Its prose isn't a channel's, and before speech routes a subagent's prose between its tool calls could reach the resident's live channel this way. diff --git a/src/framework.ts b/src/framework.ts index f6ed54a2a..2de10a635 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -7123,7 +7123,7 @@ export class AgentFramework { type: 'text', text: fallback ? `[routing] ${cause}. Your unaddressed plain speech was going to ${describeConversation(current)}, ` + - 'where the last message had come in when this reply began, because nothing in the wake ' + + 'where the last message had come in when your reply began (a continuation of it keeps that place), because nothing in the wake ' + 'that began it named a conversation. ' + 'For the rest of this turn it is held as drafts instead. ' + `Answer ${competing.length === 1 ? 'it' : 'any of them'} with an explicit send (or a channel_open); ` + @@ -9227,13 +9227,16 @@ export class AgentFramework { wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId, wakeAt: budgetRestart ? undefined : provReq?.wakeAt, // Every conversation this batch carries: a true new turn infers its - // speech route from all of them (shelf-355), never from one pick. - routeCandidates: budgetRestart ? undefined : requests.flatMap((r) => r.routeCandidates ?? []), + // speech route from all of them (shelf-355), never from one pick. A + // restart keeps its turn's route and reads them only when it + // continues a silent tick that this batch makes ordinary, which had + // no route to keep (beginAgentTurn). + routeCandidates: requests.flatMap((r) => r.routeCandidates ?? []), // Whether any of them named a conversation, candidate or not: only a // batch none of whose requests did takes the fallback route. - namesConversation: !budgetRestart && requests.some((r) => r.namesConversation) ? true : undefined, + namesConversation: requests.some((r) => r.namesConversation) ? true : undefined, // A continued fallback turn's place, kept as this batch's fallback. - fallbackPlace: budgetRestart ? undefined : requests.find((r) => r.fallbackPlace)?.fallbackPlace, + fallbackPlace: requests.find((r) => r.fallbackPlace)?.fallbackPlace, }); } } @@ -11352,6 +11355,59 @@ export class AgentFramework { } } + /** + * Decides a turn's speech route from its wake: at a true new turn, and at + * a restart that continues a silent tick a batched ordinary wake made + * ordinary, which had no route to keep. + */ + private decideTurnRoute(agent: Agent, trigger: InferenceRequest | undefined): void { + // The route comes from a fork's home, else from the + // wake's candidates — addressed ones first, one conversation only, + // else held (src/speech-routes.ts). A wake that names no + // conversation at all (a heartbeat, a timer, a self-wake) takes the + // fallback, the last delivered message's conversation, as before + // speech routes. A framework wake that continues a turn (a rewind + // retry, an unstick, a prose continuation or bounce) carries the + // conversations that turn answered as its candidates, or a fallback + // turn's place as its fallback (continuationFor), so it is inferred + // here like any other. + // + // The fork rule applies here, where the route is decided, to every + // wake's candidates: a fork takes only its home's, and no other agent + // takes a channel a fork owns, as ownership stands now. A wake can + // wait in the queue while a fork claims or gives up a channel. A + // candidate it leaves out still named its conversation, so it still + // keeps the fallback away. That includes an unroutable candidate, + // which never routes but competes: a fork's channel never holds + // another agent's turn. + const turn = inferTurnRoute( + (trigger?.routeCandidates ?? []) + .map((c) => this.resolvedCandidate(c)) + .filter((c) => this.mayTakeCandidate(agent.name, c)), + this.homeRoute(agent.name), + (c) => this.publishTargetOf(c), + // Only a resident's wake that named no conversation, candidate or + // not, takes the fallback: a reaction, a system marker or a channel + // a fork owns names one, and gets no route as before. An ephemeral + // subagent never takes it: its prose is its caller's business, + // never a channel's. (A conversation fork's name is in the same + // set, but a fork's home always wins before any fallback is read.) + // A continued fallback turn keeps that turn's place, under the same + // fork rule as any fallback. + trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) + ? null + : trigger?.fallbackPlace + ? (this.mayTakeCandidate(agent.name, trigger.fallbackPlace) ? trigger.fallbackPlace : null) + : this.fallbackCandidate(agent.name), + ); + // A continued fallback whose kept place was refused has no place, + // and its own continuations take none either: it counts as named. + const named = trigger?.namesConversation || (!!trigger?.fallbackPlace && !turn.route); + this.turnRoutes.set(agent.name, { ...turn, ...(named ? { named: true as const } : {}) }); + this.midTurnInputSignals.delete(agent.name); + if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); + } + /** * Body of startAgentStream, split out so the caller's try/finally owns the * turn token unconditionally. Returns true iff the token was handed off to @@ -11522,51 +11578,7 @@ export class AgentFramework { this.midTurnInputSignals.delete(agent.name); this.turnRoutes.delete(agent.name); } else if (attempt === 0) { - // A true new turn: the route comes from a fork's home, else from the - // wake's candidates — addressed ones first, one conversation only, - // else held (src/speech-routes.ts). A wake that names no - // conversation at all (a heartbeat, a timer, a self-wake) takes the - // fallback, the last delivered message's conversation, as before - // speech routes. A framework wake that continues a turn (a rewind - // retry, an unstick, a prose continuation or bounce) carries the - // conversations that turn answered as its candidates, or a fallback - // turn's place as its fallback (continuationFor), so it is inferred - // here like any other. - // - // The fork rule applies here, where the route is decided, to every - // wake's candidates: a fork takes only its home's, and no other agent - // takes a channel a fork owns, as ownership stands now. A wake can - // wait in the queue while a fork claims or gives up a channel. A - // candidate it leaves out still named its conversation, so it still - // keeps the fallback away. That includes an unroutable candidate, - // which never routes but competes: a fork's channel never holds - // another agent's turn. - const turn = inferTurnRoute( - (trigger?.routeCandidates ?? []) - .map((c) => this.resolvedCandidate(c)) - .filter((c) => this.mayTakeCandidate(agent.name, c)), - this.homeRoute(agent.name), - (c) => this.publishTargetOf(c), - // Only a resident's wake that named no conversation, candidate or - // not, takes the fallback: a reaction, a system marker or a channel - // a fork owns names one, and gets no route as before. An ephemeral - // subagent never takes it: its prose is its caller's business, - // never a channel's. (A conversation fork's name is in the same - // set, but a fork's home always wins before any fallback is read.) - // A continued fallback turn keeps that turn's place, under the same - // fork rule as any fallback. - trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) - ? null - : trigger?.fallbackPlace - ? (this.mayTakeCandidate(agent.name, trigger.fallbackPlace) ? trigger.fallbackPlace : null) - : this.fallbackCandidate(agent.name), - ); - // A continued fallback whose kept place was refused has no place, - // and its own continuations take none either: it counts as named. - const named = trigger?.namesConversation || (!!trigger?.fallbackPlace && !turn.route); - this.turnRoutes.set(agent.name, { ...turn, ...(named ? { named: true as const } : {}) }); - this.midTurnInputSignals.delete(agent.name); - if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); + this.decideTurnRoute(agent, trigger); } } @@ -11589,16 +11601,14 @@ export class AgentFramework { } this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); - } else if (continuingTurn) { - // A restart batched with an ordinary wake runs as an ordinary turn - // (processInferenceRequests), so its record stops being a silent - // tick's, and so do its continuations. It keeps its route, as any - // restart does. - const record = this.turnRoutes.get(agent.name); - if (record?.silent) { - const { silent: _silent, ...ordinary } = record; - this.turnRoutes.set(agent.name, ordinary); - } + } else if (continuingTurn && this.turnRoutes.get(agent.name)?.silent) { + // A silent tick's restart batched with an ordinary wake runs as an + // ordinary turn (processInferenceRequests). The tick had no route to + // keep, so its route is decided now from the batch's wakes, as a true + // new turn's is, and its record stops being a silent tick's. In + // explicit or disabled prose routing there is no inferred route. + if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') this.turnRoutes.delete(agent.name); + else this.decideTurnRoute(agent, trigger); } this.touchEphemeralRun(agent.name, true); @@ -17542,7 +17552,7 @@ export class AgentFramework { // A silent control-plane wake keeps its plain speech private: // the open stands, and no route is set. The turn's record says // so, during the turn and after it ends. - routing = 'Opened. This is a silent wake, so your plain speech stays private this turn; ' + + routing = 'Opened. This call came from a silent wake, so its plain speech stays private; ' + 'to post there, use a send tool.'; } else if (openInput?.setSpeechTarget !== false) { const resolved = this.channelRegistry!.resolveDestination({ diff --git a/src/speech-routes.ts b/src/speech-routes.ts index e82b2afa3..6e68828dc 100644 --- a/src/speech-routes.ts +++ b/src/speech-routes.ts @@ -127,12 +127,18 @@ export interface TurnRoute { * `neverRoutes` says the conversation came from a candidate marked * unroutable (a raw id the host couldn't resolve to a registered * channel, or a channel with no server), so a continuation keeps it - * marked: only finding the channel's server can make it a route. */ + * marked. A channel with no server becomes a route only once the + * framework finds its server; one named with its server never does, + * whatever the registry says. */ unroutable?: { conversation: ConversationRef; reason: UnroutableReason; neverRoutes?: true }; /** The turn's wake named a conversation (a channel, a thread, an author * or a local surface), candidate or not. A continuation of the turn then * names one too (continuationOf). */ named?: true; + /** When the message the route's reply edge names (`replyTo`) arrived. A + * continuation's candidate for the route carries it, so only a newer + * message batched in the same conversation takes the reply edge. */ + replyAt?: number; } export type UnroutableReason = 'untargetable' | 'thread' | 'unresolved'; @@ -240,8 +246,10 @@ export function inferTurnRoute( const refusal = routeRefusal(conversation, targetOf); if (refusal) return { route: null, unroutable: { conversation, reason: refusal } }; const key = conversationKey(conversation); - const replyTo = newestOf(considered(candidates.filter((c) => conversationKey(c.conversation) === key)))?.messageId; - return { route: replyTo && home.kind === 'channel' ? { ...home, replyTo } : home }; + const newest = newestOf(considered(candidates.filter((c) => conversationKey(c.conversation) === key))); + return newest?.messageId && home.kind === 'channel' + ? { route: { ...home, replyTo: newest.messageId }, replyAt: newest.at } + : { route: home }; } const pool = considered(candidates); if (pool.length === 0) return { route: fallbackRoute(fallback, targetOf) }; @@ -284,6 +292,7 @@ export function inferTurnRoute( ...(c.label ? { label: c.label } : {}), origin: 'trigger', }, + ...(chosen!.messageId ? { replyAt: chosen!.at } : {}), }; } @@ -312,11 +321,14 @@ function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: Pu * rewind retry, an unstick, a prose continuation or bounce), as candidates: * the ones that turn answered, so the continuation's words go where the * turn's would have. A held turn names each conversation it held between; a - * turn with a route names its route's conversation, reply edge kept; a turn - * whose one conversation couldn't take plain speech names that one, to be - * checked again as any candidate is. Each counts as addressed, since the - * resident was answering it: an ambient arrival batched beside it doesn't - * take the turn, and an addressed one from elsewhere holds it. + * turn with a route names its route's conversation, reply edge kept, at the + * time its reply message arrived (`replyAt`), so only a newer message in + * that conversation takes the reply edge; a turn whose one conversation + * couldn't take plain speech names that one. A conversation refused for its + * publish target is checked again as any candidate is; one from a candidate + * marked unroutable stays marked (`neverRoutes`). Each counts as addressed, + * since the resident was answering it: an ambient arrival batched beside it + * doesn't take the turn, and an addressed one from elsewhere holds it. * * A turn that answered no conversation gives none: a fallback turn, or a * turn whose words had no place. What its continuation names then is @@ -336,11 +348,12 @@ export function continuationCandidates(turn: TurnRoute | undefined, at: number): if (turn.route) { const { route } = turn; if (route.origin === 'fallback') return []; + const replyTo = route.kind === 'channel' ? route.replyTo : undefined; return [{ conversation: routeConversation(route), addressed: true, - at, - ...(route.kind === 'channel' && route.replyTo ? { messageId: route.replyTo } : {}), + at: replyTo && turn.replyAt !== undefined ? turn.replyAt : at, + ...(replyTo ? { messageId: replyTo } : {}), }]; } if (turn.unroutable) { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 8635cce05..f0e41e51a 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -544,7 +544,7 @@ describe('present while acting', () => { const wired = membrane.lastStream!.receivedToolResultOptions .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); assert.ok( - wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn. Your unaddressed plain speech was going to (srv / chan-A), where the last message had come in when this reply began, because nothing in the wake that began it named a conversation.')), + wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn. Your unaddressed plain speech was going to (srv / chan-A), where the last message had come in when your reply began (a continuation of it keeps that place), because nothing in the wake that began it named a conversation.')), 'the hold notice says what the fallback was, not that it could be answering it', ); await framework.stop(); diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts index 86b2e7d2b..00a1223cc 100644 --- a/test/speech-route-turns.test.ts +++ b/test/speech-route-turns.test.ts @@ -143,7 +143,7 @@ describe('speech route across a logical turn', () => { assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); assert.deepEqual(i.turnRoutes.get('scout'), { route: null, silent: {} }, 'and no route set'); const result = JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); - assert.match(result, /This is a silent wake, so your plain speech stays private this turn; to post there, use a send tool\./); + assert.match(result, /This call came from a silent wake, so its plain speech stays private; to post there, use a send tool\./); await framework.stop(); }); @@ -156,12 +156,23 @@ describe('speech route across a logical turn', () => { // is running, and the record says the turn was silent. const record = { route: null, silent: {} }; i.turnRoutes.set('scout', record as never); + // The open's result, as it is delivered. + const results: Array<{ type?: string; callId?: string; result?: { data?: { routing?: string } } }> = []; + const host = framework as unknown as { pushEvent(event: unknown): unknown }; + const pushEvent = host.pushEvent.bind(framework); + host.pushEvent = (event) => { + if ((event as { type?: string }).type === 'tool-result') results.push(event as never); + return pushEvent(event); + }; (framework as unknown as { dispatchChannelToolCall(agentName: string, call: unknown): void }) .dispatchChannelToolCall('scout', { id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }); await new Promise((r) => setImmediate(r)); await framework.runUntilIdle(); assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); assert.equal(i.turnRoutes.get('scout'), record, 'and the silent record kept, with no route'); + const delivered = results.find((r) => r.callId === 'o1'); + assert.match(delivered?.result?.data?.routing ?? '', /^Opened\. This call came from a silent wake, so its plain speech stays private; to post there, use a send tool\.$/, + 'its result, delivered, says so'); await framework.stop(); }); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts index 97af4ec41..2e041d4d1 100644 --- a/test/speech-routes.test.ts +++ b/test/speech-routes.test.ts @@ -47,13 +47,13 @@ describe('inferTurnRoute', () => { candidate(channel('elsewhere'), true, 4, 'm-else'), candidate(channel('home', { threadId: 't1' }), true, 5, 'm-thread'), ], home, exactEverywhere); - assert.deepEqual(turn, { route: { ...home, replyTo: 'm-new' } }); + assert.deepEqual(turn, { route: { ...home, replyTo: 'm-new' }, replyAt: 3 }); // Addressed candidates in the home conversation come first, as for an inferred route. const addressed = inferTurnRoute([ candidate(channel('home'), true, 1, 'm-asked'), candidate(channel('home'), false, 2, 'm-later'), ], home, rootEverywhere); - assert.deepEqual(addressed, { route: { ...home, replyTo: 'm-asked' } }); + assert.deepEqual(addressed, { route: { ...home, replyTo: 'm-asked' }, replyAt: 1 }); // A candidate without a message gives no reply edge. assert.deepEqual(inferTurnRoute([candidate(channel('home'), true, 1)], home, rootEverywhere), { route: home }); }); @@ -389,6 +389,9 @@ describe('continuationCandidates and continuationOf', () => { assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3 }]); const untargetable = continuationCandidates({ route: null, unroutable: { conversation: channel('quiet'), reason: 'untargetable' } }, 3); assert.deepEqual(untargetable, [{ conversation: channel('quiet'), addressed: true, at: 3 }]); + const thread = channel('forum', { threadId: 't1' }); + assert.deepEqual(continuationCandidates({ route: null, unroutable: { conversation: thread, reason: 'thread' } }, 3), + [{ conversation: thread, addressed: true, at: 3 }]); // One that came from a candidate marked unroutable stays marked: a raw // id named with its server never routes, and a channel with no server // routes only once the framework finds its server. @@ -415,6 +418,24 @@ describe('continuationCandidates and continuationOf', () => { assert.deepEqual(continuationOf(undefined, 1), { candidates: [], namesConversation: false }); }); + it("a continuation's conversation keeps the time of its reply message, so a newer message batched there takes the reply edge and an older one doesn't", () => { + // The turn answered m-1, which arrived at 100. + const turn = inferTurnRoute([candidate(channel('a'), true, 100, 'm-1')], null, rootEverywhere); + assert.equal(turn.replyAt, 100); + const continued = continuationCandidates(turn, 0); + assert.deepEqual(continued.map((c) => [c.at, c.messageId]), [[100, 'm-1']]); + const replyEdge = (batched: RouteCandidate): string | undefined => { + const next = inferTurnRoute([...continued, batched], null, rootEverywhere); + return next.route?.kind === 'channel' ? next.route.replyTo : undefined; + }; + assert.equal(replyEdge(candidate(channel('a'), true, 150, 'm-newer')), 'm-newer'); + assert.equal(replyEdge(candidate(channel('a'), true, 50, 'm-older')), 'm-1', 'an older message held back in the gate'); + // A route opened by channel_open names no message, so its continuation's + // conversation counts as older than anything batched with it. + const opened = continuationCandidates({ route: route('a', { origin: 'open' }) }, 0); + assert.deepEqual(opened.map((c) => [c.at, c.messageId]), [[0, undefined]]); + }); + it('beside an ambient arrival from elsewhere the continued conversation wins; beside an addressed one the turn holds', () => { const continued = continuationCandidates({ route: route('a', { replyTo: 'm-1' }) }, 10); const ambient = inferTurnRoute([...continued, candidate(channel('b'), false, 20, 'm-2')], null, rootEverywhere); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 9d158ead6..ecdc1c059 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -884,10 +884,13 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }, "no route into the fork's channel, and none elsewhere"); // The next continuation of the same reply has no place either. + const before = i.turnRoutes.get('scout'); i.pendingRequests.push(i.continuationRequest('scout', 'inference-failure-rewind-retry')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still not moved' }])); await framework.runUntilIdle(); - assert.equal(i.turnRoutes.get('scout')?.route, null); + assert.equal(membrane.calls.length, 3, 'the second continuation ran'); + assert.notEqual(i.turnRoutes.get('scout'), before, 'and wrote its own record'); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:elsewhere'], 'only the tick was published'); i.conversationAgentHomes.delete('fork-1'); await framework.stop(); @@ -1084,6 +1087,30 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it("the inference policy sees a continuation already filled from its turn's record", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const policy = (framework as unknown as { inferencePolicy: { shouldInfer(agentName: string, requests: unknown[], state: unknown): boolean } }).inferencePolicy; + const seen: Array<{ reason: string; namesConversation?: true; routeCandidates?: Array<{ conversation: { channelId?: string } }> }> = []; + const shouldInfer = policy.shouldInfer.bind(policy); + policy.shouldInfer = (agentName, requests, state) => { + seen.push(...(requests as typeof seen)); + return shouldInfer(agentName, requests, state); + }; + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'second answer' }])); + await framework.runUntilIdle(); + const retry = seen.find((r) => r.reason === 'refusal-rewind-retry'); + assert.deepEqual([retry?.namesConversation, retry?.routeCandidates?.map((c) => c.conversation.channelId)], + [true, ['discord:guild:chanA']]); + await framework.stop(); + }); + it("a continuation's conversation counts as older than a message batched with it, so that message gives the reply edge", async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); const framework = await makeFramework(); @@ -1106,9 +1133,9 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); - it("a silent tick's record survives a restart, and a restart batched with an ordinary wake runs ordinary and drops it", async () => { + it("a silent tick's record survives a restart or a guard retry, and a restart batched with an ordinary wake runs ordinary, routed by that wake", async () => { const framework = await makeFramework(); - const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); internals(framework).channelRegistry = registry; const i = internals(framework); const scout = framework.getAgent('scout')!; @@ -1122,17 +1149,28 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.runUntilIdle(); const record = i.turnRoutes.get('scout') as { route: unknown; named?: true; silent?: { silentHeartbeat?: { eventId?: string } } }; assert.deepEqual([record.route, record.named, record.silent?.silentHeartbeat?.eventId], [null, true, 'hb-9']); - // Its restart, alone: silent, and the record is kept whole. - membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still private' }])); - await start({ reason: 'context_budget_restart', suppressProse: true, silentHeartbeat }); - await framework.runUntilIdle(); - assert.equal(i.turnRoutes.get('scout'), record, 'the restart kept the record'); - // A restart that an ordinary wake shared a batch with runs ordinary: the - // record keeps its route and its naming, and stops being silent. + // Its restart and its guard retry, each alone: silent, and the record is kept whole. + for (const reason of ['context_budget_restart', 'tool_result_guard_retry']) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still private' }])); + await start({ reason, suppressProse: true, silentHeartbeat }); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout'), record, `the ${reason} kept the record`); + } + // A restart that shares its batch with an ordinary wake runs ordinary. + // The tick had no route to keep, so the wake's conversation decides it. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ordinary now' }])); - await start({ reason: 'context_budget_restart' }); + i.pendingRequests.push( + { agentName: 'scout', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(), suppressProse: true, silentHeartbeat } as never, + { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:asked' }, addressed: true, at: Date.now(), messageId: 'm-asked' }], + } as never, + ); await framework.runUntilIdle(); - assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); + const ordinary = i.turnRoutes.get('scout') as { route: { channelId?: string; replyTo?: string } | null; named?: true; silent?: unknown }; + assert.deepEqual([ordinary.route?.channelId, ordinary.route?.replyTo, ordinary.named, ordinary.silent], + ['discord:guild:asked', 'm-asked', true, undefined]); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), [['discord:guild:asked', 'ordinary now']]); await framework.stop(); }); @@ -1201,10 +1239,14 @@ describe('Trunk channel routing (item-3 redux)', () => { assert.equal(turn.route, null); assert.equal(turn.unroutable?.reason, 'unresolved'); // Its retry names the same conversation, still marked: no route. + const first = i.turnRoutes.get('scout'); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still held' }])); i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); await framework.runUntilIdle(); - assert.equal(i.turnRoutes.get('scout')?.route, null); + assert.equal(membrane.calls.length, 2, 'the retry ran'); + const retried = i.turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string; neverRoutes?: true } }; + assert.notEqual(retried, first, 'and wrote its own record'); + assert.deepEqual([retried.route, retried.unroutable?.reason, retried.unroutable?.neverRoutes], [null, 'unresolved', true]); assert.deepEqual(published, [], 'nothing went to the raw id'); await framework.stop(); }); @@ -1231,7 +1273,9 @@ describe('Trunk channel routing (item-3 redux)', () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not to the raw id' }])); i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); await framework.runUntilIdle(); - assert.equal(i.turnRoutes.get('scout')?.route, null); + assert.equal(membrane.calls.length, 2, 'the retry ran'); + const retried = i.turnRoutes.get('scout') as { route: unknown; hold?: unknown; unroutable?: { reason: string; neverRoutes?: true } }; + assert.deepEqual([retried.route, retried.hold, retried.unroutable?.reason, retried.unroutable?.neverRoutes], [null, undefined, 'unresolved', true]); assert.deepEqual(published, [], 'nothing went to the raw id'); i.conversationAgentHomes.delete('fork-1'); await framework.stop();