From 3f7aafe6c304243e1525bc84a7e653b9a38c0ebf Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:00:03 -0400 Subject: [PATCH 01/76] 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/76] =?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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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 30ae48cdc05d7213ed03dc4591cd0b0ef50bb2c7 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 12:37:22 -0400 Subject: [PATCH 24/76] feat(mcpl): every channel item names its conversation in a stored source header (shelf-356, AF side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message read alone could not say where it came from: channel provenance lived only in metadata, adapters rendered location prose only when the channel changed, and backscroll items carried nothing (a resident's report: messages lose channel provenance). Grammar agreed with discord-mcpl in room-203 #41210: [source: / · · thread · reply to ] [source: · unscoped] renderSourceHeader() (mcpl/inbound-source.ts) renders it from a frozen inbound source envelope; the label, thread and reply tails appear only when the item has them; a local surface's input gets none. Stamped exactly once, at ingestion, and stored with the message as its first text block: - channels/incoming (handleMcplChannelIncoming, before fork/trunk/router placement) — creates, coalesced deliveries and RFC-006 corrections all pass there with their own frozen envelope; - push/event — a derived channel (DMs, addressed-while-closed) or `unscoped`, never a guessed channel; a silent heartbeat stores nothing and gets nothing (its empty-content detection runs first). Because it is stored, a later rename, a recompile or a replay never rewrites it, and the second of two consecutive messages names its channel too. Adapter body text and structured origin fields are unchanged. channel_open backscroll: each history item gains a `source` field rendered from the ITEM's channelId (a spliced item wears its true channel); label from the registry for that id, else the adapter's item channelLabel; an item that names no channel gets none. The authority rule (SOURCE_HEADER_RULE: the canonical id is authoritative when a label differs) is stated in channel_list, channel_open and channel_publish descriptions and in a one-time `[source]` notice, appended at a resident's first turn with channel traffic in its recent context and recorded in framework state (sourceHeadersExplained) so it is said once. The rest of 356's AF requirement — channel_publish receipts naming serverId, channelId and channelLabel with honest failed/unknown statuses — landed with the routes commit (8a1d579). Tests (source-headers.test.ts, 10, over real MCPL children: a Discord-shaped server with two guilds sharing a channel name and DMs, a Zulip-shaped server with topics in a stream): grammar; consecutive and interleaved messages; same-named channels kept apart by id; thread and reply tails; DM push, unscoped push, no header on console input; a rename after ingestion keeps stored headers; headers on the wire and the notice said once with the rule; tool descriptions state it; backscroll items per own channel (spliced, unregistered with adapter label); backscroll headers in the channel_open tool result on the wire; a coalesced correction carries the same header and reaches the model with it. XML tool mode renders the same tool result content (membrane's formatting), so it is not separately exercised. Suite (--test-concurrency=4, Jobs job): 1370/1370 + code-execution 36/36. Co-Authored-By: Claude Opus 5.5 --- changelog.d/source-headers.added.md | 19 ++ src/framework.ts | 69 ++++++- src/mcpl/channel-registry.ts | 36 +++- src/mcpl/inbound-source.ts | 33 ++++ test/inbound-source.test.ts | 19 +- test/source-headers.test.ts | 285 ++++++++++++++++++++++++++++ 6 files changed, 445 insertions(+), 16 deletions(-) create mode 100644 changelog.d/source-headers.added.md create mode 100644 test/source-headers.test.ts diff --git a/changelog.d/source-headers.added.md b/changelog.d/source-headers.added.md new file mode 100644 index 000000000..142776e6e --- /dev/null +++ b/changelog.d/source-headers.added.md @@ -0,0 +1,19 @@ +- **Visible source headers on channel traffic.** Every channel-bearing item + from either MCPL lane begins with a header naming the conversation it came + from: `[source: / ·