From 3f7aafe6c304243e1525bc84a7e653b9a38c0ebf Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:00:03 -0400 Subject: [PATCH 01/54] 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 eb2a588b1f28a81ea0f7e7fbe5a35edd0cbe9412 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 7 Oct 2026 05:32:48 -0400 Subject: [PATCH 02/54] 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 03/54] 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 04/54] 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 05/54] 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 06/54] 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 07/54] 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 08/54] 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 09/54] 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 10/54] 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 11/54] 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 12/54] 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 315d12c2ed8ebf31cc6fac9b1ba4a17ba26ec57a Mon Sep 17 00:00:00 2001 From: Rupert-1859 Date: Thu, 8 Oct 2026 14:28:00 -0400 Subject: [PATCH 13/54] fix(drafts): refuse a dismissal while its draft's attempt is out; the receipt keeps a dismissed draft's own uncertain attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slimepriestess's approving review of #247 at 1d6d2bb noted that a `dismiss` issued while that draft's resend is in flight is accepted (the dismiss path doesn't consult draftsInFlight, while resend and {{unsent}} do), and that if the send then comes back unknown, draftState reports `dismissed`, so the receipt's one-word state hides a possibly-posted draft. Two routes record a dismissal: the drafts tool's `dismiss`, and dismissLatestBounce (`>>skip_reply {{unsent}}`, its hybrid form, and the held hybrid path beside a send). A round's calls are dispatched without awaiting each other (`drafts` is fire-and-forget), and a round's prose is routed alongside its tools, so either route can land while a resend's publish, or an {{unsent}} delivery, is out. Neither remedy as worded fits: - consulting draftsInFlight would also refuse a draft only queued in a resend batch, whose dismissal the batch deliberately honours when it reaches it (test: "a draft dismissed while the batch waits is not published"); - ranking an uncertain attempt above `dismissed` inside draftState would make a draft with an unknown attempt impossible to dismiss: it would stay `unconfirmed` and listed, its only way out a confirmDuplicate resend. So: - draftsSending: the drafts in draftsInFlight whose attempt is out right now. It is set around the awaited publish in the resend loop and in deliverWithUnsent, cleared as each publish resolves, and cleared again in the batch's finally as a backstop. The tool's dismiss is refused up front, dismissing nothing, when any draft it names is sending, and the resident is told to wait for that result; dismissLatestBounce leaves the draft and tells the resident privately (takeUnsent's busy-notice pattern). No dismissal is then recorded while that draft's attempt is out, so the resend's own reply ("The draft stays held.", "that needs confirmDuplicate: true") can't contradict a mid-send dismissal, and no "dismissed" reply is given over words that go out. A queued draft can still be dismissed. - The turn-end receipt names a dismissed draft whose own attempt may have been posted (uncertainAttempt; not inherited risk: a dismissed copy was never sent itself, and its source's risk is reported where that attempt is) with its riskText, and counts it toward "nothing confirmed". This also covers the ordinary, knowing case: an unknown resend followed by a deliberate dismiss used to leave "[delivered] nothing — draft d dismissed by you". - The changelog fragment says both. Tests (prose-drafts.test.ts): - the race through real round dispatch, `[resend d, dismiss d]` in one round, delivered and no-receipt; - a dismissal naming a sending and a queued draft dismisses nothing; - the prose route's busy notice; - a tool dismissal while an {{unsent}} delivery of that draft is out (the ownership harness's registry stub now holds deliverSpeech like publish); - the knowing case's receipt, as a third turn of the receipt test. All five fail on 1d6d2bb's source, and the fourth also fails when only deliverWithUnsent's sending mark is removed. The new assertions don't pin destinationText's rendering, which #256 changes. Design worked out with Humphrey-1858, who reviewed it. His probes showed the resend replies contradicting a mid-send dismissal (failed: "The draft stays held."; unknown: "that needs confirmDuplicate: true" for a draft a resend then refuses; delivered: a "dismissed" reply over words that went out), which is why the dismissal is refused rather than only the receipt changing, and his probe U is the {{unsent}} test. --- changelog.d/prose-drafts.added.md | 14 +++- src/framework.ts | 60 ++++++++++++-- test/prose-drafts.test.ts | 130 ++++++++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 16 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 7e0a0a73c..85c6607ad 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -29,8 +29,12 @@ `confirmDuplicate` its resend needs), and the turn-end `[delivered]` receipt names every draft held in the turn by its state at turn end: still held ("held as drafts d-… (not sent …)"), delivered by the resident's resend - (listed where it landed), unconfirmed, or dismissed. A draft no notice - reached (a crash, an aborted turn) is named at the next turn's start. + (listed where it landed), unconfirmed, or dismissed. A dismissed draft + whose own attempt may already have been posted is named with that risk, + and counts like an unconfirmed one: a receipt that names one, with nothing + confirmed delivered, reads "nothing confirmed", not "nothing". A draft no + notice reached (a crash, an aborted turn) is named at the next turn's + start. - A draft is never published except by its resident's explicit resend (or `{{unsent}}`), and only that resident can list or act on it. Its notices go into the resident's own history, which residents sharing one message slot @@ -43,7 +47,11 @@ instead of sending again; a draft with any attempt whose outcome is unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend owns its drafts until it finishes, re-checking each before it is sent. - Unused fields may be passed as null. + Dismissing a draft (with the tool or `>>skip_reply {{unsent}}`) is + refused while that draft's attempt is out, since a dismissal can't call + back words already on their way; the resident is told to wait for that + result. A draft only queued in a resend can still be dismissed, and is + then not sent. Unused fields may be passed as null. - Prose segments also break at XML tool mode's refused attempts and their notices (`tool_attempt`, `tool_notice`): the words before an all-refused call and after its notice are two messages, routed or held separately. diff --git a/src/framework.ts b/src/framework.ts index bd582fa12..8534ce4c6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9205,6 +9205,12 @@ export class AgentFramework { /** Drafts with a delivery attempt in flight, `${agent}\u0000${id}`: a * parallel resend (or `{{unsent}}`) of the same draft is refused. */ private draftsInFlight = new Set(); + /** The drafts in draftsInFlight whose attempt is out right now (journaled, + * its publish awaited). A dismissal of one is refused, and the resident + * told to wait for that result, since a dismissal can't call back words + * already on their way. A draft only queued in a resend batch can still + * be dismissed: the batch honours that when it reaches the draft. */ + private draftsSending = new Set(); /** * The resident's `drafts` tool: list, read, resend and dismiss its own held @@ -9301,6 +9307,14 @@ export class AgentFramework { } if (action === 'dismiss') { + // Refuse up front, before anything is dismissed, rather than half-way. + const sending = drafts.find((d) => this.draftsSending.has(`${agentName}\u0000${d.id}`)); + if (sending) { + return refuse( + `${sending.id} is being sent right now, and dismissing it can't call those words back; ` + + 'wait for that result before dismissing it. Nothing was dismissed.', + ); + } const lines: string[] = []; for (const d of drafts) { const state = draftState(d); @@ -9409,7 +9423,10 @@ export class AgentFramework { stopped = true; continue; } + const sendingKey = `${agentName}\u0000${d.id}`; + this.draftsSending.add(sendingKey); const outcome = await registry.publish(agentName, d.text, { serverId: destination.serverId, channelId: destination.channelId }); + this.draftsSending.delete(sendingKey); let recorded = true; try { this.proseDrafts.recordOutcome(agentName, d.id, attemptId, outcome); @@ -9450,7 +9467,10 @@ export class AgentFramework { : ' The draft stays held.')); } } finally { - for (const claim of claims) this.draftsInFlight.delete(claim); + for (const claim of claims) { + this.draftsInFlight.delete(claim); + this.draftsSending.delete(claim); + } } const allDelivered = !stopped; return allDelivered ? ok(lines.join('\n')) : refuse(lines.join('\n')); @@ -9534,8 +9554,15 @@ export class AgentFramework { for (const d of unconfirmed) { notes.push(`draft ${d.id} is unconfirmed: ${this.riskText(d)} — check that channel before sending it again (resend needs confirmDuplicate: true)`); } + // A dismissal sets a draft aside; it doesn't unsay that the draft's own + // attempt may already have been posted. const dismissed = inState('dismissed'); - if (dismissed.length > 0) notes.push(`draft${dismissed.length === 1 ? '' : 's'} ${idList(dismissed)} dismissed by you`); + const dismissedAtRisk = dismissed.filter((d) => uncertainAttempt(d)); + const dismissedQuietly = dismissed.filter((d) => !uncertainAttempt(d)); + for (const d of dismissedAtRisk) notes.push(`draft ${d.id} dismissed by you, but ${this.riskText(d)}`); + if (dismissedQuietly.length > 0) { + notes.push(`draft${dismissedQuietly.length === 1 ? '' : 's'} ${idList(dismissedQuietly)} dismissed by you`); + } for (const d of inState('delivered')) { const attempt = d.attempts.find((a) => a.outcome?.status === 'delivered')!; const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; @@ -9560,11 +9587,12 @@ export class AgentFramework { : `${suppressed} plain-speech segment(s) suppressed`); } const suppressedNote = notes.join(' · '); - // Nothing is confirmed delivered, but an unconfirmed draft may have been. + // Nothing is confirmed delivered, but an unconfirmed draft (or a dismissed + // one whose own attempt is uncertain) may have been. const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` - : `[delivered] nothing${unconfirmed.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; + : `[delivered] nothing${unconfirmed.length > 0 || dismissedAtRisk.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( 'user', @@ -9766,12 +9794,18 @@ export class AgentFramework { } } const flight = unsent ? `${agent.name}\u0000${unsent.id}` : undefined; - if (flight) this.draftsInFlight.add(flight); + if (flight) { + this.draftsInFlight.add(flight); + this.draftsSending.add(flight); + } let outcome: PublishOutcome; try { outcome = await registry.deliverSpeech(agent.name, body, channelId); } finally { - if (flight) this.draftsInFlight.delete(flight); + if (flight) { + this.draftsInFlight.delete(flight); + this.draftsSending.delete(flight); + } } if (unsent && attemptId) { try { @@ -9879,10 +9913,22 @@ export class AgentFramework { } } - /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce draft aside. */ + /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce + * draft aside — unless it is being sent right now, which a dismissal + * can't call back: the resident is told instead. */ private dismissLatestBounce(agent: Agent): void { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return; + if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) { + const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.'; + try { + this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (err) { + console.error('[drafts] in-flight-dismiss notice failed:', err); + } + return; + } try { this.proseDrafts.dismiss(agent.name, draft.id); this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index bfea62ed2..09ec8f432 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -668,6 +668,46 @@ describe('held prose drafts, end to end', () => { } }); + it('a dismissal in the same round as its draft\'s resend is refused until that result is in: it can\'t call back words on their way', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('words that land'), explicitSend('s1'), text('words in doubt'), call('l1', 'channel_list', {})], 'tool_use'), + createMockResponse([]), + ]); + const sure = h.drafts().find((d) => d.text === 'words that land')!; + const unsure = h.drafts().find((d) => d.text === 'words in doubt')!; + // The resend is dispatched first, so its attempt is out when the dismissal runs. + const raced = (id: string) => [ + drafts(`r-${id}`, { action: 'resend', draftIds: [id], destination: ROOM }), + drafts(`x-${id}`, { action: 'dismiss', draftIds: [id] }), + ]; + const waits = (id: string) => new RegExp( + `${id} is being sent right now, and dismissing it can't call those words back; wait for that result before dismissing it\\. Nothing was dismissed\\.`); + + await h.turn([createMockResponse(raced(sure.id), 'tool_use'), createMockResponse([])]); + let [resent, dismissal] = h.toolResults(); + assert.match(resent!, /delivered to/); + assert.match(dismissal!, waits(sure.id), 'no "dismissed" reply over words that went out'); + assert.equal(draftState(h.store().get('scout', sure.id)!), 'delivered'); + + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse(raced(unsure.id), 'tool_use'), createMockResponse([])]); + [resent, dismissal] = h.toolResults(); + assert.match(dismissal!, waits(unsure.id)); + assert.match(resent!, /NOT confirmed .* Check the channel before sending it again \(that needs confirmDuplicate: true\)/, + 'the resend\'s advice holds: the draft is still open'); + assert.equal(draftState(h.store().get('scout', unsure.id)!), 'unconfirmed'); + + // With the result in, the resident can set it aside knowingly. + await h.turn([createMockResponse([drafts('x2', { action: 'dismiss', draftIds: [unsure.id] })], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, new RegExp(`${unsure.id}: dismissed`)); + assert.equal(draftState(h.store().get('scout', unsure.id)!), 'dismissed'); + } finally { + await h.close(); + } + }); + it('refuses unusable calls before sending anything', async () => { const h = await harness(); try { @@ -805,14 +845,17 @@ describe('drafts resend ownership', () => { modules: [], }); const pending: Array<{ text: string; resolve: (o: unknown) => void }> = []; + const held = (text: string, channelId: string) => + new Promise((resolve) => pending.push({ + text, + resolve: (o) => resolve({ destination: { serverId: 'discord', channelId }, at: Date.now(), ...(o as object) }), + })); const stub: Record = { resolveProseTarget: (spec: string) => ({ channelId: spec }), resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'discord', channelId } }), - publish: (_agent: string, text: string, target: { channelId: string }) => - new Promise((resolve) => pending.push({ - text, - resolve: (o) => resolve({ destination: { serverId: 'discord', channelId: target.channelId }, at: Date.now(), ...(o as object) }), - })), + publish: (_agent: string, text: string, target: { channelId: string }) => held(text, target.channelId), + // An envelope's delivery, `{{unsent}}` included. + deliverSpeech: (_agent: string, text: string, channelId: string) => held(text, channelId), getChannelTools: () => [], }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(stub, { @@ -821,6 +864,7 @@ describe('drafts resend ownership', () => { const internals = framework as unknown as { proseDrafts: ProseDraftStore; handleDraftsTool(agent: string, input: unknown): Promise<{ success: boolean; data?: Array<{ text: string }>; error?: string }>; + deliverProseEnvelope(agent: unknown, text: string, hold: { notice: 'later' }): Promise; }; const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => r.success ? r.data!.map((b) => b.text).join('') : r.error!; @@ -891,6 +935,68 @@ describe('drafts resend ownership', () => { await t.close(); } }); + + it('a dismissal naming a draft whose attempt is out dismisses nothing, not even the queued one beside it', async () => { + const t = await setup(); + try { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'A is out; B is queued'); + const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id, t.a.id] })); + assert.match(refused, new RegExp(`${t.a.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); + assert.equal(t.internals.proseDrafts.get('scout', t.b.id)!.dismissedAt, undefined); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + const result = t.say(await batch); + assert.match(result, new RegExp(`${t.a.id}: delivery to .+ NOT confirmed`)); + assert.equal(draftState(t.internals.proseDrafts.get('scout', t.a.id)!), 'unconfirmed'); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), new RegExp(`${t.a.id}: dismissed`), + 'with the result in, it can be dismissed'); + } finally { + await t.close(); + } + }); + + it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.tick(); + const agent = t.framework.getAgent('scout')!; + const envelope = (text: string) => t.internals.deliverProseEnvelope(agent, text, { notice: 'later' }); + await envelope('>>skip_reply {{unsent}}'); + assert.equal(t.internals.proseDrafts.get('scout', bounce!.id)!.dismissedAt, undefined); + const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.'), words.join('\n')); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + await envelope('>>skip_reply {{unsent}}'); + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed', 'with the result in, it is set aside'); + } finally { + await t.close(); + } + }); + + it('a dismissal is refused while an {{unsent}} delivery of that draft is out', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const agent = t.framework.getAgent('scout')!; + const delivering = t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}}', { notice: 'later' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['bounced words'], 'the bounce is out through {{unsent}}'); + const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })); + assert.match(refused, new RegExp(`${bounce!.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await delivering; + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'unconfirmed'); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })), new RegExp(`${bounce!.id}: dismissed`), + 'with the result in, it can be dismissed'); + } finally { + await t.close(); + } + }); }); describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end state', () => { @@ -1000,7 +1106,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end it('the turn-end receipt reports each draft as it stands: resent, dismissed, unconfirmed or still held', async () => { const h = await harness(); try { - const ids = ['d-rcpta', 'd-rcptb', 'd-rcptc', 'd-rcptd']; + const ids = ['d-rcpta', 'd-rcptb', 'd-rcptc', 'd-rcptd', 'd-rcpte']; fixIds(h, ids); await h.turn([ createMockResponse([text('resend me'), explicitSend('s1'), text('dismiss me'), call('l1', 'channel_list', {}), text('keep me'), call('l2', 'channel_list', {})], 'tool_use'), @@ -1023,6 +1129,18 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( `^\\[delivered\\] nothing confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); + + // Dismissed knowingly after an unknown resend: the dismissal sets it + // aside, and the receipt still says its attempt may have been posted. + await h.turn([ + createMockResponse([text('dismissed after doubt'), explicitSend('s3')], 'tool_use'), + createMockResponse([drafts('r3', { action: 'resend', draftIds: [ids[4]], destination: ROOM })], 'tool_use'), + createMockResponse([drafts('x2', { action: 'dismiss', draftIds: [ids[4]] })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( + `^\\[delivered\\] nothing confirmed — draft ${ids[4]} dismissed by you, but ${ids[4]}'s attempt to #room \\(Guild One\\) \\(.*${ROOM}\\) at .+ ` + + 'may already have been posted: .+$')); } finally { await h.close(); } From 378c2b33423ee5a227d9d53f413707cf5bf95c85 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sat, 10 Oct 2026 02:16:33 -0400 Subject: [PATCH 14/54] test(drafts): pin that a draft's sending mark ends with its own attempt, and with a publish that throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill pass on #247's post-approval commit 315d12c removed each of its pieces one at a time. Two survived: the resend loop's delete of the sending mark right after each publish, and the batch's finally backstop. Nothing tested either. - Without the per-publish delete, a draft delivered early in a batch stays "being sent right now" until the whole batch ends, so dismissing it is refused with the wrong reason instead of "already delivered — nothing to dismiss". - Without the backstop, a publish that throws leaves its draft marked as sending for the life of the process, and every later dismissal of it is refused. Both tests pass on 1d6d2bb's source too (no sending marks there): they pin that 315d12c's marks don't outlive their attempt. With them, all eight of 315d12c's pieces are caught when removed (D1 tool refusal, D2/D3 the loop's mark and unmark, D4 the backstop, D5 the {{unsent}} mark, D6 the bounce refusal, D7/D8 the receipt), on Node 24.21. Co-Authored-By: Claude Opus 5.5 --- test/prose-drafts.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 09ec8f432..defe3a294 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -956,6 +956,36 @@ describe('drafts resend ownership', () => { } }); + it("a draft whose own result is in isn't being sent any more, though the rest of its batch is", async () => { + const t = await setup(); + try { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.tick(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await t.tick(); + assert.deepEqual(t.pending.map((p) => p.text), ['B words'], 'A is in; B is out'); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), + new RegExp(`${t.a.id}: already delivered — nothing to dismiss`)); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); + await batch; + } finally { + await t.close(); + } + }); + + it('a publish that throws leaves its draft dismissable, not marked as being sent', async () => { + const t = await setup(); + try { + const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.publish = async () => { throw new Error('transport gone'); }; + await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }).catch(() => undefined); + assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), + new RegExp(`${t.a.id}: dismissed`)); + } finally { + await t.close(); + } + }); + it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { const t = await setup(); try { From 2c198b5df51875834a25cbe1298d0b9678ea6543 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 14:43:58 -0400 Subject: [PATCH 15/54] fix(drafts): notices count on the branch they're on, the catch-up names every draft it marks, and the store keeps what it needs sw-af247 (the house's haiku review of 02adf27..378c2b3), part 1: the drafts store and its notices. - I2: a draft counts as named while a message that named it is on the current branch. The 'noticed' record now carries the naming message's id (the catch-up's own message, or the one settleTurnDrafts finds), and the turn-start catch-up names every open draft none of whose naming messages is present (cm.getMessage), so a rollback or branch move past its notice names it again, once. A record written without a message id names nothing, so its draft is named once more. - I4 and I29: the catch-up names every draft it marks, newest first: the newest eight with state and preview, the rest by id ("and N more: ..."). unnoticed() returns newest first, as open() does. - I27: settleTurnDrafts reads only the window it scans (the last 500 messages, DRAFT_NOTICE_SCAN), not a copy of the whole history, and returns each draft's naming message. - I6: the store keeps each resident's 100 newest drafts and, beyond them, any draft something happened to (held, named, attempted, answered, dismissed) in the last 7 days; at a checkpoint every 500 entries it lets the rest go, and opening replays only the checkpoint and what follows. The latest bounce is tracked by id, so {{unsent}} means nothing once it is let go, even if an older bounce is kept; newId never reuses that id. The drafts tool's description and its unknown-id refusal say so. Retention is a constructor option, for tests. - I10: a draft whose held record reached the store before its sync failed is in the reconciled journal, so hold() counts it as held (and logs the failure); only the segments after it are reported not held. Tests (prose-drafts): the hold-failure test now expects 'x' held and one not held; new: notices counted per branch (store), the checkpoint and retention across a reopen (store, mock Date), the catch-up naming ten drafts, and a rollback past a receipt naming its draft again, once. --- src/framework.ts | 92 +++++++++++++------- src/prose-drafts.ts | 173 +++++++++++++++++++++++++++++++------- test/prose-drafts.test.ts | 142 ++++++++++++++++++++++++++++--- 3 files changed, 337 insertions(+), 70 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 8534ce4c6..052ddd180 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -85,7 +85,7 @@ import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -4676,10 +4676,13 @@ export class AgentFramework { inputSchema: { type: 'object' }, }; + /** How long, in days, a draft beyond a resident's newest is kept (KEEP_IDLE_MS). */ + private static readonly DRAFT_KEEP_DAYS = Math.round(KEEP_IDLE_MS / 86_400_000); + /** Synthesized drafts tool — present whenever MCPL channels are * configured. A resident's held drafts (src/prose-drafts.ts): plain speech - * that was not sent, never published except by its resident's resend and - * kept until resent or dismissed. Nullable + * that was not sent, never published except by its resident's resend, and + * kept as that module's retention says. Nullable * fields, cast like save_recent_image's: a caller whose provider presents * every property as required can still say "not in use". */ private static readonly DRAFTS_TOOL = { @@ -4687,8 +4690,9 @@ export class AgentFramework { description: 'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in the ' + 'same round holds plain speech back, because a routing prefix bounced, or because it had no destination. ' + - 'Drafts are never published except by your own resend, only you can list or act on them, and they stay ' + - 'until you resend or dismiss them. list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + + `Drafts are never published except by your own resend, and only you can list or act on them. Your ${KEEP_NEWEST} newest ` + + `drafts are always kept; an older one is let go after ${AgentFramework.DRAFT_KEEP_DAYS} days in which nothing happened to it. ` + + 'list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + 'resend: publish drafts verbatim, in the order given, to one destination ("#channel", "@person" or a ' + 'channel id) — an explicit send, like send_message; it stops at the first draft not confirmed delivered. ' + 'A draft already delivered returns its receipt instead of sending again. If an earlier attempt may ' + @@ -9136,38 +9140,51 @@ export class AgentFramework { } /** - * Turn start (or after a crash): name any open drafts no notice has named - * yet, privately, before the turn's compile. + * Turn start (or after a crash): name, privately and before the turn's + * compile, any open drafts no notice on the current branch names: none + * reached the history (a crash, an aborted turn), or a rollback or branch + * move took the notices away. Every draft it marks is named by id, newest + * first: the newest eight with their state and a preview, the rest by id. */ private noticeUnnoticedDrafts(agent: Agent): void { - const pending = this.proseDrafts.unnoticed(agent.name); + const cm = agent.getContextManager(); + const pending = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null); if (pending.length === 0) return; const shown = pending.slice(0, 8).map((d) => `${d.id} (${this.draftStateText(d)}; held ${this.draftTime(d.heldAt)}, ` + `${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); - const more = pending.length > shown.length ? ` · and ${pending.length - shown.length} more` : ''; + const rest = pending.slice(shown.length).map((d) => d.id); + const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : ''; const text = `[drafts] ${agent.name}: ${pending.length === 1 ? 'a held draft' : `${pending.length} held drafts`} of yours ` + - `${pending.length === 1 ? 'has' : 'have'} not been named to you yet: ${shown.join(' · ')}${more}. ` + + `${pending.length === 1 ? 'is' : 'are'} unnamed on this branch of your history: ${shown.join(' · ')}${more}. ` + 'drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; + let mid: string; try { - const mid = agent.getContextManager().addMessage( + mid = cm.addMessage( 'user', [{ type: 'text', text }], { system: true, kind: 'prose-drafts', draftOwner: agent.name, draftIds: pending.map((d) => d.id) } as MessageMetadata, ); this.emitTrace({ type: 'message:added', messageId: mid, source: 'prose-drafts' }); - this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id)); } catch (err) { console.error('[drafts] catch-up notice failed:', err); + return; + } + try { + this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id), mid); + } catch (err) { + // Named again by the next turn's catch-up. + console.error('[drafts] could not record the catch-up notice:', err); } } /** * Logical turn end, after the turn's deferred notices are flushed: record * as noticed exactly the drafts a stored message names (the receipt, a - * held-draft notice, a bounce notice). One whose notice never reached the - * history stays unnoticed, for the next turn's catch-up. + * held-draft notice, a bounce notice), each with the message that names + * it. One whose notice never reached the history stays unnoticed, for the + * next turn's catch-up. */ private settleTurnDrafts(agent: Agent): void { const held = this.turnDrafts.get(agent.name); @@ -9175,29 +9192,43 @@ export class AgentFramework { this.turnDraftFailures.delete(agent.name); this.turnProsePrivate.delete(agent.name); if (!held?.length) return; - const named = this.draftsNamedInHistory(agent, held.map((d) => d.id)); - if (named.length === 0) return; - try { - this.proseDrafts.markNoticed(agent.name, named); - } catch (err) { - // Unmarked drafts are named again by the next turn's catch-up. - console.error('[drafts] could not record notice:', err); + const byMessage = new Map(); + for (const [draftId, messageId] of this.draftsNamedInHistory(agent, held.map((d) => d.id))) { + byMessage.set(messageId, [...(byMessage.get(messageId) ?? []), draftId]); + } + for (const [messageId, ids] of byMessage) { + try { + this.proseDrafts.markNoticed(agent.name, ids, messageId); + } catch (err) { + // Unmarked drafts are named again by the next turn's catch-up. + console.error('[drafts] could not record notice:', err); + } } } + /** How far back draftsNamedInHistory looks: this turn's notices are near + * the end. One beyond it leaves its draft for the next turn's catch-up. */ + private static readonly DRAFT_NOTICE_SCAN = 500; + /** Which of these drafts a stored notice or receipt OF THIS RESIDENT names * (draft ids are unique only per resident, and residents can share one - * history), scanning the recent history, where this turn's notices are. */ - private draftsNamedInHistory(agent: Agent, ids: string[]): string[] { + * history), each with the newest message that names it, reading only the + * recent history, where this turn's notices are. */ + private draftsNamedInHistory(agent: Agent, ids: string[]): Map { const wanted = new Set(ids); - const named = new Set(); - const messages = agent.getContextManager().getAllMessages(); - for (let i = messages.length - 1, scanned = 0; i >= 0 && scanned < 500 && named.size < wanted.size; i--, scanned++) { + const named = new Map(); + const cm = agent.getContextManager(); + const total = cm.getMessageCount(); + const start = Math.max(0, total - AgentFramework.DRAFT_NOTICE_SCAN); + const { messages } = cm.getMessageWindow(start, total - start, { resolveBlobs: false }); + for (let i = messages.length - 1; i >= 0 && named.size < wanted.size; i--) { const meta = messages[i]!.metadata as { draftIds?: unknown; draftOwner?: unknown } | undefined; if (meta?.draftOwner !== agent.name || !Array.isArray(meta.draftIds)) continue; - for (const id of meta.draftIds) if (typeof id === 'string' && wanted.has(id)) named.add(id); + for (const id of meta.draftIds) { + if (typeof id === 'string' && wanted.has(id) && !named.has(id)) named.set(id, String(messages[i]!.id)); + } } - return ids.filter((id) => named.has(id)); + return new Map(ids.filter((id) => named.has(id)).map((id) => [id, named.get(id)!])); } /** Page size for drafts(action: "list"). */ @@ -9277,7 +9308,10 @@ export class AgentFramework { else missing.push(id); } if (missing.length > 0) { - return refuse(`No draft of yours with id ${missing.join(', ')}. drafts(action: "list") shows your open drafts.`); + return refuse( + `No draft of yours with id ${missing.join(', ')}: there was none, or it was let go (a draft beyond your ${KEEP_NEWEST} newest ` + + `is kept for ${AgentFramework.DRAFT_KEEP_DAYS} days after anything last happened to it). drafts(action: "list") shows your open drafts.`, + ); } if (action === 'read') { diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 7001db0fc..c290a749b 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -22,7 +22,17 @@ * rollback, suppression and branch moves never touch them. A draft written * in a turn that is later undone stays usable (the words were genuinely * written and held back), and a delivered draft stays delivered. Its - * originating branch is recorded as provenance only. + * originating branch is recorded as provenance only. The notices that name + * a draft are conversation, so they do follow the branch: a draft counts as + * named while a message naming it is on the current branch, and one a + * rollback or branch move has left unnamed is named again. + * + * Drafts are kept, not hoarded. Each resident's KEEP_NEWEST newest drafts + * are always kept; an older one is let go once nothing has happened to it + * (held, named, attempted, answered, dismissed) for KEEP_IDLE_MS. The words + * stay in the resident's history either way. The journal checkpoints what + * it keeps every CHECKPOINT_EVERY entries, so opening it replays only what + * came after. * * A delivery attempt is journaled durably BEFORE its request leaves the * host, and its outcome after: an attempt with no recorded outcome (a crash @@ -119,7 +129,10 @@ export interface Draft { source: DraftSource; attempts: DraftAttempt[]; dismissedAt?: number; - /** When an in-band notice named this draft. */ + /** The messages whose notices named this draft, by id. It counts as named + * while one of them is on the current branch. */ + noticedIn?: string[]; + /** When a notice last named it. */ noticedAt?: number; } @@ -152,41 +165,126 @@ export function uncertainAttempt(draft: Draft): DraftAttempt | undefined { } type DraftEntry = - | { op: 'held'; draft: Omit } + | { op: 'held'; draft: Omit } | { op: 'attempt'; agent: string; id: string; attemptId: string; at: number; destination: PublishDestination; via: DraftAttempt['via']; confirmedDuplicate?: true } | { op: 'outcome'; agent: string; id: string; attemptId: string; outcome: DraftAttemptOutcome } | { op: 'dismissed'; agent: string; id: string; at: number } - | { op: 'noticed'; agent: string; ids: string[]; at: number }; + /** A notice naming these drafts is in the history as message `messageId`. + * (A record without one names nowhere the drafts can be found again, so + * they are named once more.) */ + | { op: 'noticed'; agent: string; ids: string[]; at: number; messageId?: string }; + +/** What a checkpoint keeps: every kept draft, in hold order, and each + * resident's latest bounce, which `{{unsent}}` means whatever became of it. */ +interface DraftSnapshot { + v: 1; + drafts: Draft[]; + latestBounces: Record; +} /** Unambiguous lowercase alphabet for short ids (no i/l/o/0/1). */ const ID_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; +/** Each resident's newest drafts, by hold order, that are always kept. */ +export const KEEP_NEWEST = 100; +/** How long an older draft is kept after the last thing that happened to it. */ +export const KEEP_IDLE_MS = 7 * 24 * 60 * 60 * 1000; +/** Entries the journal takes between checkpoints. */ +export const CHECKPOINT_EVERY = 500; + +/** The store's retention, KEEP_NEWEST, KEEP_IDLE_MS and CHECKPOINT_EVERY + * unless given (tests give smaller ones). */ +export interface DraftRetention { + keepNewest: number; + keepIdleMs: number; + checkpointEvery: number; +} + +/** The last time anything happened to a draft: held, named, attempted, + * answered or dismissed. */ +function lastActivity(draft: Draft): number { + let last = Math.max(draft.heldAt, draft.noticedAt ?? 0, draft.dismissedAt ?? 0); + for (const attempt of draft.attempts) last = Math.max(last, attempt.at, attempt.outcome?.at ?? 0); + return last; +} + export class ProseDraftStore { - private readonly journal: RecordJournal; + private readonly journal: RecordJournal; /** agent → draft id → draft, in journal (hold) order. */ private drafts = new Map>(); + /** agent → the id of its latest bounce, kept or not. */ + private latestBounces = new Map(); + private readonly retention: DraftRetention; - constructor(store: JsStore) { - this.journal = new RecordJournal(store, { type: PROSE_DRAFT_RECORD_TYPE }); + constructor(store: JsStore, retention: Partial = {}) { + this.journal = new RecordJournal(store, { type: PROSE_DRAFT_RECORD_TYPE }); + this.retention = { + keepNewest: retention.keepNewest ?? KEEP_NEWEST, + keepIdleMs: retention.keepIdleMs ?? KEEP_IDLE_MS, + checkpointEvery: retention.checkpointEvery ?? CHECKPOINT_EVERY, + }; this.reload(); + this.checkpointIfDue(); } - /** Rebuild the projection from the journal (at open, and to reconcile). */ + /** Rebuild the projection from the journal (at open, and to reconcile): + * the latest checkpoint's drafts, then the entries after it. */ reload(): void { - const { entries } = this.journal.load(); + const { snapshot, entries } = this.journal.load(); this.drafts = new Map(); + this.latestBounces = new Map(Object.entries(snapshot?.latestBounces ?? {})); + for (const draft of snapshot?.drafts ?? []) this.agentDrafts(draft.agent).set(draft.id, draft); for (const { entry } of entries) this.reduce(entry); } + /** + * Write a checkpoint once CHECKPOINT_EVERY entries have been appended since + * the last one, letting go of the drafts that aren't kept (see the module + * header). The projection drops them only once the checkpoint is written, + * so a failed one changes nothing, and the next write tries again. + */ + private checkpointIfDue(): void { + const { keepNewest, keepIdleMs, checkpointEvery } = this.retention; + if (this.journal.entriesSinceCheckpoint < checkpointEvery) return; + const now = Date.now(); + const kept = new Map>(); + for (const [agent, drafts] of this.drafts) { + const all = [...drafts.values()]; + const keep = new Map(); + all.forEach((draft, i) => { + if (i >= all.length - keepNewest || now - lastActivity(draft) < keepIdleMs) keep.set(draft.id, draft); + }); + if (keep.size > 0) kept.set(agent, keep); + } + const snapshot: DraftSnapshot = { + v: 1, + drafts: [...kept.values()].flatMap((m) => [...m.values()]), + latestBounces: Object.fromEntries(this.latestBounces), + }; + try { + this.journal.checkpoint(snapshot); + } catch (err) { + console.error('[drafts] checkpoint failed; the next write tries again:', err); + if (this.journal.needsReconcile) this.reload(); + return; + } + this.drafts = kept; + } + private reduce(entry: DraftEntry): void { if (entry.op === 'held') { this.agentDrafts(entry.draft.agent).set(entry.draft.id, { ...entry.draft, attempts: [] }); + if (entry.draft.reason === 'bounced') this.latestBounces.set(entry.draft.agent, entry.draft.id); return; } if (entry.op === 'noticed') { + if (entry.messageId === undefined) return; for (const id of entry.ids) { const draft = this.drafts.get(entry.agent)?.get(id); - if (draft && draft.noticedAt === undefined) draft.noticedAt = entry.at; + if (!draft) continue; + draft.noticedIn ??= []; + if (!draft.noticedIn.includes(entry.messageId)) draft.noticedIn.push(entry.messageId); + draft.noticedAt = Math.max(draft.noticedAt ?? 0, entry.at); } return; } @@ -221,6 +319,7 @@ export class ProseDraftStore { throw err; } this.reduce(entry); + this.checkpointIfDue(); } private agentDrafts(agent: string): Map { @@ -232,12 +331,16 @@ export class ProseDraftStore { return map; } + /** A new id, unique among the resident's kept drafts and never its latest + * bounce's. One let go can be given again: the notices that named it are + * by then at least KEEP_IDLE_MS old and KEEP_NEWEST drafts back. */ private newId(agent: string): string { const existing = this.agentDrafts(agent); + const latestBounce = this.latestBounces.get(agent); for (;;) { let id = 'd-'; for (let i = 0; i < 5; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; - if (!existing.has(id)) return id; + if (!existing.has(id) && id !== latestBounce) return id; } } @@ -245,7 +348,9 @@ export class ProseDraftStore { * Hold words as drafts, in order, each journaled durably before this * returns. If the journal refuses a write (after reconciling), the drafts * already written stand and the rest are reported as not held: their words - * remain in the resident's history only. + * remain in the resident's history only. A draft whose record reached the + * store before its write failed (its sync did) is in the reconciled + * journal, so it is held, and counted so. */ hold( agent: string, @@ -270,7 +375,14 @@ export class ProseDraftStore { try { this.write({ op: 'held', draft }, true); } catch (err) { - return { held, notHeld: { count: segments.length - i, error: err instanceof Error ? err.message : String(err) } }; + const error = err instanceof Error ? err.message : String(err); + const landed = this.get(agent, draft.id); + if (landed) { + console.error(`[drafts] ${agent}: ${landed.id} reached the journal, but its write then failed (${error}); it is held`); + held.push(landed); + } + const count = segments.length - i - (landed ? 1 : 0); + return { held, ...(count > 0 ? { notHeld: { count, error } } : {}) }; } held.push(this.get(agent, draft.id)!); } @@ -289,21 +401,26 @@ export class ProseDraftStore { .reverse(); } - /** Open drafts no in-band notice has named yet (a crash before the notice). */ - unnoticed(agent: string): Draft[] { - return this.open(agent).filter((d) => d.noticedAt === undefined).reverse(); + /** + * Open drafts, newest first, that no notice on the current branch names: + * none was recorded (a crash before the notice), or a rollback or branch + * move took away every message that named them. `onBranch` says whether a + * message, by id, is on the current branch. + */ + unnoticed(agent: string, onBranch: (messageId: string) => boolean): Draft[] { + return this.open(agent).filter((d) => !d.noticedIn?.some(onBranch)); } /** - * Record that a notice naming these drafts is in the resident's history. - * The notice is conversation state, buffered until the store syncs, while - * this record reaches the OS at once: so the store is synced FIRST - * (afterCommittedState), and a notice that never became durable can't - * leave a record that suppresses the crash catch-up. + * Record that message `messageId`, a notice naming these drafts, is in the + * resident's history. The notice is conversation state, buffered until the + * store syncs, while this record reaches the OS at once: so the store is + * synced FIRST (afterCommittedState), and a notice that never became + * durable can't leave a record that suppresses the crash catch-up. */ - markNoticed(agent: string, ids: string[]): void { + markNoticed(agent: string, ids: string[], messageId: string): void { if (ids.length === 0) return; - this.write({ op: 'noticed', agent, ids, at: Date.now() }, true, true); + this.write({ op: 'noticed', agent, ids, at: Date.now(), messageId }, true, true); } dismiss(agent: string, id: string): void { @@ -349,14 +466,12 @@ export class ProseDraftStore { * that draft is still open (held or unconfirmed — the caller must not * resend an unconfirmed one without the resident's confirmation). * Latest-wins, like the clipboard it replaces: once the latest bounce is - * delivered or dismissed there is nothing to substitute, even if older - * bounces are still held (they stay in the collection, by id). + * delivered, dismissed or let go there is nothing to substitute, even if + * older bounces are still held (they stay in the collection, by id). */ latestBounce(agent: string): Draft | undefined { - let latest: Draft | undefined; - for (const draft of this.drafts.get(agent)?.values() ?? []) { - if (draft.reason === 'bounced') latest = draft; - } + const id = this.latestBounces.get(agent); + const latest = id === undefined ? undefined : this.get(agent, id); if (!latest) return undefined; const state = draftState(latest); return state === 'held' || state === 'unconfirmed' ? latest : undefined; diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index defe3a294..cf175e9c8 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -90,17 +90,19 @@ describe('ProseDraftStore', () => { } }); - it('reports words it could not hold, and stays consistent with its journal', () => { + it('reports words it could not hold, and stays consistent with its journal', (t) => { const { dir, store, drafts } = open(); try { const real = store.sync.bind(store); let fail = true; (store as unknown as { sync: () => void }).sync = () => { if (fail) throw new Error('EIO'); real(); }; + t.mock.method(console, 'error', () => {}); const result = drafts.hold('a', [{ text: 'x', source }, { text: 'y', source }], 'explicit-send'); - assert.equal(result.held.length, 0); - assert.deepEqual(result.notHeld, { count: 2, error: 'EIO' }); // The first record reached the store before its barrier failed: the - // journal was reconciled, so the projection shows what it holds. + // journal was reconciled, so the projection holds it, and so does the + // answer. Only the second is reported not held. + assert.deepEqual(result.held.map((d) => d.text), ['x']); + assert.deepEqual(result.notHeld, { count: 1, error: 'EIO' }); assert.deepEqual(drafts.open('a').map((d) => d.text), ['x']); fail = false; assert.equal(drafts.hold('a', [{ text: 'z', source }], 'explicit-send').held.length, 1); @@ -110,6 +112,74 @@ describe('ProseDraftStore', () => { } }); + it('a draft counts as named while a message that named it is on the branch, newest first', () => { + const { dir, store, drafts } = open(); + try { + const { held } = drafts.hold('a', [{ text: 'one', source }, { text: 'two', source }, { text: 'three', source }], 'explicit-send'); + const [one, two, three] = held; + drafts.markNoticed('a', [one!.id, two!.id], 'm-1'); + drafts.markNoticed('a', [two!.id], 'm-2'); + const unnamed = (present: string[]) => drafts.unnoticed('a', (id) => present.includes(id)).map((d) => d.text); + assert.deepEqual(unnamed(['m-1', 'm-2']), ['three'], 'only the draft no notice named'); + assert.deepEqual(unnamed(['m-2']), ['three', 'one'], 'm-1 rolled away: one is unnamed, two is still named by m-2; newest first'); + assert.deepEqual(unnamed([]), ['three', 'two', 'one']); + assert.deepEqual(drafts.get('a', three!.id)!.noticedIn, undefined); + // The record outlives a reopen; one written without a message id (an + // older build's) names nothing, so its draft is named once more. + store.appendJson(PROSE_DRAFT_RECORD_TYPE, { op: 'noticed', agent: 'a', ids: [three!.id], at: 1 }); + const again = new ProseDraftStore(store); + assert.deepEqual(again.unnoticed('a', (id) => id === 'm-2').map((d) => d.text), ['three', 'one']); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('checkpoints what it keeps: the newest drafts, recent ones, and the latest bounce as {{unsent}} means it', (t) => { + const { dir, store } = open(); + const path = join(dir, 'store'); + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const hour = 60 * 60 * 1000; + const retention = { keepNewest: 2, keepIdleMs: hour, checkpointEvery: 9 }; + let drafts = new ProseDraftStore(store, retention); + try { + const [older] = drafts.hold('a', [{ text: 'an older bounce', source }], 'bounced').held; + const [latest] = drafts.hold('a', [{ text: 'the latest bounce', source }], 'bounced').held; + const [settled] = drafts.hold('a', [{ text: 'resent long ago', source }], 'explicit-send').held; + const attempt = drafts.beginAttempt('a', settled!.id, dest(), 'resend', false); + drafts.recordOutcome('a', settled!.id, attempt, { status: 'delivered', messageId: 'm-1', at: Date.now(), destination: dest() }); + drafts.hold('b', [{ text: "another resident's", source }], 'explicit-send'); + t.mock.timers.tick(2 * hour); + // Something happens to the older bounce now: it isn't idle. + drafts.beginAttempt('a', older!.id, dest(), 'resend', false); + const newest = drafts.hold('a', [{ text: 'new one', source }, { text: 'new two', source }], 'explicit-send').held; + // That was the ninth entry: the checkpoint let go of 'a''s idle + // drafts outside its two newest, the latest bounce among them. + const kept = () => drafts.open('a').map((d) => d.text); + assert.deepEqual(kept(), ['new two', 'new one', 'an older bounce']); + assert.equal(drafts.get('a', settled!.id), undefined, 'a settled, idle draft is let go'); + assert.equal(drafts.get('a', latest!.id), undefined); + assert.equal(drafts.latestBounce('a'), undefined, 'the latest bounce was let go: {{unsent}} has nothing, though an older bounce is kept'); + assert.deepEqual(drafts.open('b').map((d) => d.text), ["another resident's"], 'within its resident\'s newest'); + assert.equal(store.getRecordIdsByType(`${PROSE_DRAFT_RECORD_TYPE}/checkpoint`).length, 1); + // A reopen reads the checkpoint and the entries after it, and gets the same. + drafts.markNoticed('a', [newest[0]!.id], 'm-9'); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + drafts = new ProseDraftStore(reopened, retention); + assert.deepEqual(kept(), ['new two', 'new one', 'an older bounce']); + assert.equal(drafts.latestBounce('a'), undefined); + assert.deepEqual(drafts.get('a', newest[0]!.id)!.noticedIn, ['m-9']); + assert.equal(draftState(drafts.get('a', older!.id)!), 'unconfirmed', 'its attempt survives the checkpoint'); + } finally { + reopened.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('records a notice only after the history holding it is synced (afterCommittedState)', () => { const { dir, store, drafts } = open(); try { @@ -118,9 +188,9 @@ describe('ProseDraftStore', () => { const real = { sync: store.sync.bind(store), appendJson: store.appendJson.bind(store) }; (store as unknown as { sync: () => void }).sync = () => { calls.push('sync'); real.sync(); }; (store as unknown as { appendJson: (t: string, p: unknown) => unknown }).appendJson = (t, p) => { calls.push(`append:${t}`); return real.appendJson(t, p); }; - drafts.markNoticed('a', [held[0]!.id]); + drafts.markNoticed('a', [held[0]!.id], 'm-1'); assert.deepEqual(calls, ['sync', `append:${PROSE_DRAFT_RECORD_TYPE}`, 'sync']); - assert.ok(drafts.get('a', held[0]!.id)!.noticedAt); + assert.deepEqual(drafts.get('a', held[0]!.id)!.noticedIn, ['m-1']); } finally { store.close(); rmSync(dir, { recursive: true, force: true }); @@ -570,7 +640,7 @@ describe('held prose drafts, end to end', () => { h.store().beginAttempt('scout', midSend.id, dest(), 'resend', false); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours have not been named to you yet: /); + assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours are unnamed on this branch of your history: /); assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); held .*"orphaned words"\)/); assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); assert.doesNotMatch(notice, /They were not sent/); @@ -581,6 +651,52 @@ describe('held prose drafts, end to end', () => { } }); + it('the catch-up names every draft it marks: the newest eight with previews, the rest by id', async () => { + const h = await harness(); + try { + const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); + await h.turn([createMockResponse([])]); + const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; + assert.match(notice, /^\[drafts\] scout: 10 held drafts of yours are unnamed on this branch of your history: /); + // Newest first: orphan 9 down to orphan 2 with their previews, then the + // two oldest by id alone. + const previews = [...notice.matchAll(/"(orphan \d)"/g)].map((m) => m[1]); + assert.deepEqual(previews, [9, 8, 7, 6, 5, 4, 3, 2].map((i) => `orphan ${i}`)); + assert.ok(notice.includes(` · and 2 more: ${held[1]!.id}, ${held[0]!.id}.`), notice); + await h.turn([createMockResponse([])]); + assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'all ten named, so none is named again'); + } finally { + await h.close(); + } + }); + + it('a rollback past the notice that named a draft names it again at the next turn, once', async () => { + const h = await harness(); + try { + await h.turn([createMockResponse([text('hello')])]); + const anchor = h.messages().at(-1)!.id; + await h.turn([ + createMockResponse([text('words a send held back'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + assert.ok(h.texts().some((t) => t.includes(`held as draft ${draft!.id}`)), 'the turn-end receipt names it'); + const catchUps = () => h.texts().filter((t) => t.includes('unnamed on this branch of your history')); + assert.deepEqual(catchUps(), []); + // The rollback takes the turn, and its notices, off the branch; the + // draft stays, as drafts do. + await h.framework.rollbackToMessage('scout', { messageId: anchor }); + assert.ok(!h.texts().some((t) => t.includes(draft!.id)), 'nothing on the branch names it now'); + await h.turn([createMockResponse([])]); + assert.equal(catchUps().length, 1); + assert.match(catchUps()[0]!, new RegExp(`a held draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\);.*"words a send held back"`)); + await h.turn([createMockResponse([])]); + assert.equal(catchUps().length, 1, 'named once on this branch'); + } finally { + await h.close(); + } + }); + it('explicit mode: a bounce is a draft that survives a restart, and {{unsent}} delivers and settles it', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); try { @@ -762,12 +878,14 @@ describe('draft notices and turn-end settlement', () => { const agent = framework.getAgent('scout')!; const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named', source }, { text: 'lost', source }], 'explicit-send').held; // Only the first draft's notice reached the history. - agent.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); + const cm = agent.getContextManager(); + const notice = cm.addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); internals.turnDrafts.set('scout', [named, lost]); internals.settleTurnDrafts(agent); - assert.ok(internals.proseDrafts.get('scout', named!.id)!.noticedAt); - assert.equal(internals.proseDrafts.get('scout', lost!.id)!.noticedAt, undefined, 'left for the catch-up'); - assert.deepEqual(internals.proseDrafts.unnoticed('scout').map((d) => d.id), [lost!.id]); + assert.deepEqual(internals.proseDrafts.get('scout', named!.id)!.noticedIn, [String(notice)], 'recorded with the message that names it'); + assert.equal(internals.proseDrafts.get('scout', lost!.id)!.noticedIn, undefined, 'left for the catch-up'); + const onBranch = (id: string) => cm.getMessage(id as never) !== null; + assert.deepEqual(internals.proseDrafts.unnoticed('scout', onBranch).map((d) => d.id), [lost!.id]); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); @@ -805,7 +923,7 @@ describe('draft identity and risk evidence', () => { { system: true, kind: 'prose-drafts', draftOwner: 'other', draftIds: ['d-same2'] } as never); t.internals.turnDrafts.set('scout', [mine]); t.internals.settleTurnDrafts(t.framework.getAgent('scout')!); - assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.noticedAt, undefined); + assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.noticedIn, undefined); } finally { await t.close(); } From 548071d19ca1510acddf962a4befdd3fd35fc791 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 14:49:29 -0400 Subject: [PATCH 16/54] fix(publish): a publish always has a deadline, a refused send opens nothing, and a failed outcome record says what the draft reads sw-af247, part 2: the publish executor and recording its outcomes. - I11: a channels/publish request always has a deadline: the server's requestTimeoutMs, or 60 s (publishDeadlineMs) when that is 0. A publish's outcome is told to the resident and recorded for its draft, so one that is never answered held both open for good. Its expiry is a no-response, which publish() reads as unknown. McplServerConfig.requestTimeoutMs's doc says so. - I35: publish() checks the server and its channels.publish grant before opening a closed channel, so a send that would be refused opens nothing and reports no opened-by-delivery. - I37: when recording an {{unsent}} delivery's outcome fails, the resident is told privately what the draft reads as now. - I38: the resend's line after a failed outcome record reports what the draft reads as now (draftStateNow): a record that failed after reaching the journal was reconciled into it, so it may read delivered, not the "will read as unconfirmed" it promised. - I3 (text): the comment above publish() says channel_publish moves onto it with shelf-355 (#256), not shelf-356. Tests: - mcpl-request-timeout: with requestTimeoutMs 0, channels/publish still times out (publishDeadlineMs shortened), as a no-response. - publish-outcome (I17): a closed destination opens first and sends; a failed open sends nothing; a send the grant forbids opens nothing. - prose-drafts (I5), through the fixture and the real connection: error mode reads unknown with the connector's words, for speech (the marker) and for a resend (the attempt); hang mode, with requestTimeoutMs 1000, reads unknown once the request times out. - prose-drafts (I37, I38): a sync that fails right after an outcome record lands, for a resend and for an {{unsent}} delivery. I32 is re-judged not-worth in the ledger: resolveDestination and publish's lookup run in the same tick, so a destination that doesn't resolve can't be sent to. --- src/framework.ts | 19 +++++- src/mcpl/channel-registry.ts | 39 ++++++----- src/mcpl/server-connection.ts | 12 +++- src/mcpl/types.ts | 4 +- test/mcpl-request-timeout.test.ts | 20 +++++- test/prose-drafts.test.ts | 105 ++++++++++++++++++++++++++++++ test/publish-outcome.test.ts | 43 ++++++++++-- 7 files changed, 215 insertions(+), 27 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 052ddd180..3cba84f6c 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9478,7 +9478,9 @@ export class AgentFramework { ...(outcome.messageId ? { messageId: outcome.messageId } : {}), }); const where = AgentFramework.destinationText(outcome.destination ?? destination); - const unrecorded = recorded ? '' : ' (this outcome could not be recorded; the draft will read as unconfirmed)'; + // A record that failed after reaching the journal was reconciled into + // it, so say what the draft reads as now rather than guess. + const unrecorded = recorded ? '' : ` (recording this outcome failed; the draft reads as ${this.draftStateNow(agentName, d.id)} now)`; if (outcome.status === 'delivered') { lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); let engaged = this.turnEngagedChannels.get(agentName); @@ -9846,11 +9848,26 @@ export class AgentFramework { this.proseDrafts.recordOutcome(agent.name, unsent.id, attemptId, outcome); } catch (err) { console.error(`[drafts] ${agent.name}: outcome of ${unsent.id} not recorded:`, err); + const text = + `[prose-routing] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` + + `failed, so the draft reads as ${this.draftStateNow(agent.name, unsent.id)} now. drafts(action: "read") shows it.`; + try { + this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (noticeErr) { + console.error('[drafts] unrecorded-outcome notice failed:', noticeErr); + } } } return outcome; } + /** A draft's state as the journal reads it now: after a write that failed + * and was reconciled, whatever reached the journal counts. */ + private draftStateNow(agentName: string, id: string): DraftState | 'let go' { + const draft = this.proseDrafts.get(agentName, id); + return draft ? draftState(draft) : 'let go'; + } + /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */ private async deliverHybridProse( agent: Agent, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 3e00ff168..222fb2d03 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3077,7 +3077,7 @@ export class ChannelRegistry { * established (PublishOutcome). The delivery executor behind plain speech * (deliverSpeech / routeSpeech) and resident resends of held drafts; * channel_publish (and publishForAgent, which shares its handler) moves - * onto it with shelf-356 — until then that tool reads its own receipt. + * onto it with shelf-355 — until then that tool reads its own receipt. * * Resolution is exact (resolveDestination): `serverId` plus `channelId` * when the caller has the server, otherwise the channel id must be @@ -3086,8 +3086,9 @@ export class ChannelRegistry { * in use"; a supplied one must be a non-empty server id, and an empty or * non-string selector is refused rather than read as omission. It is not * possible to send into a closed channel: a closed but registered - * destination is opened first (`openSource` names why), and a failed open - * sends nothing. + * destination is opened first (`openSource` names why), once its server + * and that server's channels.publish grant are known to take the send, so + * a refused send opens nothing; and a failed open sends nothing. * * Only the connector's `delivered: true` confirms a post. `failed` means * nothing was posted: the attempt stopped before the request was written, @@ -3115,6 +3116,23 @@ export class ChannelRegistry { ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}), }; + // A send that would be refused is refused before anything opens: a + // closed channel opens only for words that can go out. + const server = this.serverRegistry.getServer(entry.serverId); + if (!server) { + return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() }; + } + // §14.1: channels/publish requires channels.publish in the grant. The + // host not sending is the enforcement. + if (!CapabilityGrant.of(server).has('channels.publish')) { + return { + status: 'failed', + destination, + reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`, + at: at(), + }; + } + if (!entry.open) { try { await this.openChannelNow(entry, openSource); @@ -3145,21 +3163,6 @@ export class ChannelRegistry { } } - const server = this.serverRegistry.getServer(entry.serverId); - if (!server) { - return { status: 'failed', destination, reason: `server "${entry.serverId}" not found`, at: at() }; - } - // §14.1: channels/publish requires channels.publish in the grant. The - // host not sending is the enforcement. - if (!CapabilityGrant.of(server).has('channels.publish')) { - return { - status: 'failed', - destination, - reason: `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`, - at: at(), - }; - } - let result: unknown; try { result = await server.sendChannelsPublish({ diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 18f82d096..0a0025fca 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -240,6 +240,9 @@ export class McplServerConnection extends EventEmitter { /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; + /** The deadline a channels/publish request keeps when requestTimeoutMs is 0 + * (see sendChannelsPublish). */ + private publishDeadlineMs = DEFAULT_REQUEST_TIMEOUT_MS; // Event buffering: control-plane traffic can be released independently from // inference-bearing data-plane traffic. This lets a server finish its own @@ -775,13 +778,20 @@ export class McplServerConnection extends EventEmitter { * Send `channels/publish`. * May be sent as a request (if an ACK is desired) or notification. * When `params.stream` is true, sends as a notification (no ACK). + * + * A request always has a deadline: the server's requestTimeoutMs, or + * publishDeadlineMs when that is 0 (disabled). A publish is a side effect + * whose outcome the resident is told and a held draft's attempt records, + * so one that is never answered would hold both open for good. Its expiry + * is a no-response, which the caller reads as unknown. */ sendChannelsPublish(params: ChannelsPublishParams): Promise { if (params.stream) { this.sendNotification(McplMethod.ChannelsPublish, params as unknown as Record); return Promise.resolve(); } - return this.sendRequest(McplMethod.ChannelsPublish, params as unknown as Record) as Promise; + const timeoutMs = this.requestTimeoutMs > 0 ? this.requestTimeoutMs : this.publishDeadlineMs; + return this.sendRequest(McplMethod.ChannelsPublish, params as unknown as Record, { timeoutMs }) as Promise; } /** Send `channels/outgoing/chunk` notification. */ diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 0f913ea5a..fbc848860 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -374,7 +374,9 @@ export interface McplServerConfig { * accepts a request and never responds would otherwise freeze the awaiting * agent turn forever; with the timeout the pending request rejects with a * descriptive error, which the framework surfaces as a normal isError - * tool_result. Set 0 to disable. Default: 60000 (60 seconds). + * tool_result. Set 0 to disable, except for a channels/publish request, + * which keeps the 60 s default: its outcome is reported to the resident and + * recorded for its draft, so it must end. Default: 60000 (60 seconds). */ requestTimeoutMs?: number; diff --git a/test/mcpl-request-timeout.test.ts b/test/mcpl-request-timeout.test.ts index 2778469e3..8ebf050a6 100644 --- a/test/mcpl-request-timeout.test.ts +++ b/test/mcpl-request-timeout.test.ts @@ -8,7 +8,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { McplServerConnection } from '../src/mcpl/server-connection.js'; +import { McplRequestError, McplServerConnection } from '../src/mcpl/server-connection.js'; import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; // Stdio server: answers initialize and tools/list, but swallows tools/call — @@ -100,6 +100,24 @@ test('requestTimeoutMs: 0 disables the per-request watchdog', async () => { assert.equal(settled, true); }); +test('requestTimeoutMs: 0 leaves channels/publish its own deadline, whose expiry is a no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 0 }), HOST_CAPS); + connection.ready(); + // The default is 60 s; the test shortens it rather than wait. + (connection as unknown as { publishDeadlineMs: number }).publishDeadlineMs = 150; + const started = Date.now(); + await assert.rejects( + connection.sendChannelsPublish({ conversationId: 'c', channelId: 'x', content: [{ type: 'text', text: 'hi' }] }), + (err: Error) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /did not respond to channels\/publish .*within 150ms/); + return true; + }, + ); + assert.ok(Date.now() - started < 5000); +}); + test('the timeout error warns the model the tool may have completed server-side', async () => { connection = await McplServerConnection.connect(config({ requestTimeoutMs: 150 }), HOST_CAPS); connection.ready(); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index cf175e9c8..b264fda27 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -272,6 +272,8 @@ function xmlMode(inner: MockMembrane): MockMembrane { async function harness(opts: { agents?: Array<{ name: string; proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; sameRoundThinkTextPolicy?: 'public' | 'private' }>; xml?: boolean; + /** The fixture server's requestTimeoutMs (the framework's default when unset). */ + requestTimeoutMs?: number; } = {}) { const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-e2e-')); const commandPath = join(dir, 'commands.jsonl'); @@ -301,6 +303,7 @@ async function harness(opts: { command: process.execPath, args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + ...(opts.requestTimeoutMs !== undefined ? { requestTimeoutMs: opts.requestTimeoutMs } : {}), }], modules: [], }); @@ -549,6 +552,48 @@ describe('held prose drafts, end to end', () => { } }); + it('through the real connection, an error response is unconfirmed, with the connector\'s words', async () => { + const h = await harness(); + try { + h.command({ op: 'publish-mode', mode: 'error' }); + await h.turn([createMockResponse([text('words into a failing connector')])]); + assert.equal(h.publishes().filter((p) => p.event === 'publish-error').length, 1, 'the request reached the server'); + await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); + const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; + assert.match(marker, /was not confirmed \(.*connector exploded.*\(delivery uncertain\)\)\. It may or may not have been posted; check the channel/); + // A resend of a held draft records the same outcome. + await h.turn([ + createMockResponse([text('held beside a send'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + const [attempt] = h.store().get('scout', draft!.id)!.attempts; + assert.equal(attempt!.outcome?.status, 'unknown'); + assert.match(attempt!.outcome!.reason!, /connector exploded/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + } finally { + await h.close(); + } + }); + + it('through the real connection, a server that never answers is unconfirmed once the request times out', async () => { + const h = await harness({ requestTimeoutMs: 1_000 }); + try { + h.command({ op: 'publish-mode', mode: 'hang' }); + await h.turn([createMockResponse([text('words into silence')])]); + await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); + assert.equal(h.publishes().length, 0); + const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; + assert.match(marker, /was not confirmed \(MCPL server "discord" did not respond to channels\/publish .*within 1000ms.*\(delivery uncertain\)\)\. It may or may not have been posted/); + } finally { + await h.close(); + } + }); + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { const h = await harness(); try { @@ -697,6 +742,66 @@ describe('held prose drafts, end to end', () => { } }); + /** Make the store's sync fail once, right after a draft outcome record + * reaches it: the record lands, its write fails, and the journal is + * reconciled with it in. */ + const failOutcomeSyncOnce = (h: Awaited>) => { + const store = (h.framework as unknown as { store: JsStore }).store; + const real = { appendJson: store.appendJson.bind(store), sync: store.sync.bind(store) }; + let armed = false; + let spent = false; + (store as unknown as { appendJson: (t: string, p: unknown) => unknown }).appendJson = (t, p) => { + const ref = real.appendJson(t, p); + if (!spent && t === PROSE_DRAFT_RECORD_TYPE && (p as { op?: string }).op === 'outcome') armed = true; + return ref; + }; + (store as unknown as { sync: () => void }).sync = () => { + if (armed && !spent) { + spent = true; + throw new Error('EIO after the outcome landed'); + } + real.sync(); + }; + }; + + it('a resend whose outcome record fails after landing reports what the draft reads as now', async (t) => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('words to resend'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + failOutcomeSyncOnce(h); + t.mock.method(console, 'error', () => {}); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.toolResults()[0]!, new RegExp(`${draft!.id}: delivered to .*\\(recording this outcome failed; the draft reads as delivered now\\)\\.`)); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'delivered'); + } finally { + await h.close(); + } + }); + + it('an {{unsent}} delivery whose outcome record fails tells the resident what the draft reads as now', async (t) => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); + try { + await h.turn([createMockResponse([text('words with nowhere to go')])]); + const [bounced] = h.drafts(); + failOutcomeSyncOnce(h); + t.mock.method(console, 'error', () => {}); + await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); + assert.deepEqual(h.publishes().map((p) => p.text), ['words with nowhere to go']); + await h.until(() => h.texts().some((x) => x.includes('but recording that failed')), 'the notice'); + assert.ok(h.texts().some((x) => x === `[prose-routing] {{unsent}} was draft ${bounced!.id}; its send came back delivered, but recording that ` + + 'failed, so the draft reads as delivered now. drafts(action: "read") shows it.'), JSON.stringify(h.texts().filter((x) => x.startsWith('[prose-routing]')))); + } finally { + await h.close(); + } + }); + it('explicit mode: a bounce is a draft that survives a restart, and {{unsent}} delivers and settles it', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: 'explicit' }] }); try { diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index d9d2d79d3..13f298add 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -24,8 +24,12 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; type Publish = (params: { channelId: string }) => Promise; -function registryWith(servers: Record, failures: unknown[] = []) { +function registryWith( + servers: Record Promise }>, + failures: unknown[] = [], +) { const published: Array<{ serverId: string; channelId: string }> = []; + const opened: Array<{ serverId: string; channelId: string }> = []; const mocks = new Map(); for (const [id, s] of Object.entries(servers)) { mocks.set(id, { @@ -35,7 +39,10 @@ function registryWith(servers: Record ({}), + sendChannelsOpen: async (params: { channelId: string }) => { + opened.push({ serverId: id, channelId: params.channelId }); + return s.open ? s.open() : {}; + }, }); } const registry = new ChannelRegistry( @@ -48,9 +55,9 @@ function registryWith(servers: Record; }).channels; - const seed = (serverId: string, id: string, label: string) => - channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open: true }); - return { registry, published, seed }; + const seed = (serverId: string, id: string, label: string, open = true) => + channels.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: 'discord', label }, open }); + return { registry, published, opened, seed }; } const ok: Publish = async () => ({ delivered: true, messageId: 'posted-1' }); @@ -66,6 +73,32 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(typeof outcome.at, 'number'); }); + it('opens a closed destination first, and a failed open sends nothing', async () => { + const opens = registryWith({ discord: { publish: ok } }); + opens.seed('discord', 'c', '#c', false); + assert.equal((await opens.registry.publish('agent', 'x', { channelId: 'c' })).status, 'delivered'); + assert.deepEqual(opens.opened, [{ serverId: 'discord', channelId: 'c' }]); + assert.deepEqual(opens.published, [{ serverId: 'discord', channelId: 'c' }]); + + const fails = registryWith({ discord: { publish: ok, open: async () => { throw new Error('no such channel'); } } }); + fails.seed('discord', 'c', '#c', false); + const outcome = await fails.registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.reason, 'channel is closed and open failed: no such channel'); + assert.deepEqual(fails.opened, [{ serverId: 'discord', channelId: 'c' }]); + assert.deepEqual(fails.published, [], 'nothing dispatched after a failed open'); + }); + + it('refuses a send the grant forbids before opening anything', async () => { + const { registry, seed, opened, published } = registryWith({ discord: { publish: ok, grant: false } }); + seed('discord', 'c', '#c', false); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason!, /channels\.publish not in "discord"'s effective grant/); + assert.deepEqual(opened, [], 'a refused send opens nothing'); + assert.deepEqual(published, []); + }); + it('treats delivered:false with no posted message as failed', async () => { const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false }) } }); seed('discord', 'c', '#c'); From d80210e460936e3fa2dceea9ff0b25896bd0f092 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 14:56:13 -0400 Subject: [PATCH 17/54] fix(drafts): a refused {{unsent}} holds its envelope, dismissals name their risk, and every stop and wait says what it is sw-af247, part 3: {{unsent}}, dismissals, and what the resident is told. - I19: a deliverable envelope whose {{unsent}} is refused (its draft is being sent, queued in a resend, or unconfirmed) is held as a bounced envelope would be: one bounce draft with the whole text, {{unsent}} written out under the re-bounce rule and its risk carried, named in the refusal notice. A bare {{unsent}} holds nothing new: its words already are that draft. takeUnsent takes the envelope's body and hold. - I26 and I43: {{unsent}} says "being sent right now" only while the draft's attempt is out (draftsSending), and "queued in a resend in progress" for a draft a batch has only claimed. The risk a queued draft's words carry says it was queued. Each draft a resend batch doesn't attempt gives the stop's own reason: an earlier draft not confirmed delivered, dismissed or changed while it waited, no longer found, or its attempt not recorded. - I23: dismissing a draft that may already have been posted names that risk, in the drafts tool's line and, for >>skip_reply {{unsent}}, in a private notice. - I31: when >>skip_reply {{unsent}} can't record the dismissal, the resident is told the bounce is still held and {{unsent}} still names it. - I44: an attempt still out (its draft's latest, while being sent) is not called one whose outcome "was never recorded": riskText, drafts read and a copied draft's inherited risk say it is being sent, its outcome not known yet (attemptIsOut). Tests (prose-drafts): - I8: the resend-ownership tests wait until a publish is pending, bounded at 10 s, instead of a fixed 5 ms. - I14: the publish-throws test checks that publish was entered, that the resend rejects with the stub's error, and that the draft reads unconfirmed before the dismissal, which now names the risk. - New: stop reasons for a dismissed and an unconfirmed draft; drafts read of an attempt still out; {{unsent}} of a bounce queued in a resend, holding the envelope with the risk; >>skip_reply {{unsent}} when the dismissal can't be recorded, then for an unconfirmed bounce. - The re-bounce test's refusal wording is updated. --- src/framework.ts | 140 +++++++++++++++++++++++++++----------- test/prose-drafts.test.ts | 136 ++++++++++++++++++++++++++++++++---- 2 files changed, 222 insertions(+), 54 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3cba84f6c..783d02b31 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -85,7 +85,7 @@ import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { INBOUND_SOURCE_KEY, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; @@ -9117,7 +9117,7 @@ export class AgentFramework { const risky = uncertainAttempt(draft); if (risky) { return `${draft.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ` + - `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? 'its outcome was never recorded'}`; + `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? this.noOutcomeText(draft, risky)}`; } const inherited = draft.inheritedRisk!; const owner = inherited.sourceDraftId ?? inherited.draftId; @@ -9129,6 +9129,18 @@ export class AgentFramework { return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`; } + /** Whether an attempt with no outcome is still out: its draft's latest, + * while that draft is being sent. Otherwise it never got one (a crash). */ + private attemptIsOut(draft: Draft, attempt: DraftAttempt): boolean { + return !attempt.outcome && this.draftsSending.has(`${draft.agent}\u0000${draft.id}`) && + draft.attempts.at(-1)?.attemptId === attempt.attemptId; + } + + /** Why an attempt has no outcome, in a risk's words. */ + private noOutcomeText(draft: Draft, attempt: DraftAttempt): string { + return this.attemptIsOut(draft, attempt) ? "it is being sent right now, so its outcome isn't known yet" : 'its outcome was never recorded'; + } + /** A delivered draft's historical receipt. */ private draftReceipt(draft: Draft): string { const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); @@ -9323,7 +9335,7 @@ export class AgentFramework { ...d.attempts.map((a) => { const o = a.outcome; const status = !o - ? 'outcome never recorded — it may have been posted' + ? (this.attemptIsOut(d, a) ? 'being sent right now — no outcome yet' : 'outcome never recorded — it may have been posted') : o.status === 'delivered' ? `delivered${o.messageId ? `, message ${o.messageId}` : ''}` : o.status === 'unknown' @@ -9360,6 +9372,9 @@ export class AgentFramework { lines.push(`${d.id}: already dismissed.`); continue; } + // A dismissal sets the words aside; it can't take back an attempt + // that may already have been posted, so that is said with it. + const risk = state === 'unconfirmed' ? ` Note that ${this.riskText(d)}.` : ''; try { this.proseDrafts.dismiss(agentName, d.id); } catch (err) { @@ -9368,7 +9383,7 @@ export class AgentFramework { `${err instanceof Error ? err.message : String(err)}. It is still open.`, ); } - lines.push(`${d.id}: dismissed (your history keeps the words).`); + lines.push(`${d.id}: dismissed (your history keeps the words).${risk}`); } this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: drafts.map((d) => d.id) }); return ok(lines.join('\n')); @@ -9424,11 +9439,13 @@ export class AgentFramework { const claims = drafts.map((d) => `${agentName}\u0000${d.id}`); for (const claim of claims) this.draftsInFlight.add(claim); const lines: string[] = []; - let stopped = false; + // Why the batch stopped, said for each draft after it: the resend sends + // in order, so one that can't go stops the rest. + let stopped: string | undefined; try { for (const queued of drafts) { if (stopped) { - lines.push(`${queued.id}: not attempted (an earlier draft was not confirmed delivered).`); + lines.push(`${queued.id}: not attempted (${stopped}).`); continue; } // The current projection, not the batch's snapshot (it may have been @@ -9436,7 +9453,7 @@ export class AgentFramework { const d = this.proseDrafts.get(agentName, queued.id); if (!d) { lines.push(`${queued.id}: no longer found among your drafts — not sent.`); - stopped = true; + stopped = `${queued.id} was no longer found`; continue; } if (draftState(d) === 'delivered') { @@ -9446,7 +9463,7 @@ export class AgentFramework { const refusal = this.resendRefusal(d, confirmDuplicate); if (refusal) { lines.push(`${refusal} It changed while this resend was waiting — not sent.`); - stopped = true; + stopped = draftState(d) === 'dismissed' ? `${d.id} was dismissed while it waited` : `${d.id} changed while it waited`; continue; } let attemptId: string; @@ -9454,7 +9471,7 @@ export class AgentFramework { attemptId = this.proseDrafts.beginAttempt(agentName, d.id, destination, 'resend', draftState(d) === 'unconfirmed'); } catch (err) { lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}).`); - stopped = true; + stopped = `${d.id}'s attempt could not be recorded`; continue; } const sendingKey = `${agentName}\u0000${d.id}`; @@ -9491,7 +9508,7 @@ export class AgentFramework { engaged.add(destination.channelId); continue; } - stopped = true; + stopped = `${d.id} was not confirmed delivered`; const after = this.proseDrafts.get(agentName, d.id); const stillAtRisk = after !== undefined && draftState(after) === 'unconfirmed'; lines.push(outcome.status === 'unknown' @@ -9775,31 +9792,60 @@ export class AgentFramework { } /** - * `{{unsent}}` in an envelope body: the latest bounce draft's words. An - * unconfirmed one (its last attempt may have been posted) is never - * substituted silently — the resident gets the facts and the explicit - * route (drafts resend with confirmDuplicate) instead. + * `{{unsent}}` in an envelope body: the latest bounce draft's words. One + * that is out (being sent, or queued in a resend) or unconfirmed (its last + * attempt may have been posted) is never substituted — nothing is sent, + * and the resident gets the facts and the explicit route (drafts resend + * with confirmDuplicate) instead. The envelope's own words are held, as a + * bounced envelope's would be (unsentWords): one bounce draft with the + * whole text, `{{unsent}}` written out and its risk carried, so nothing + * the resident wrote has to be typed again. A bare `{{unsent}}` holds + * nothing new: its words already are that draft. */ - private takeUnsent(agent: Agent, prefix: '>>' | '>>>'): { draft?: Draft; refused?: true } { + private takeUnsent( + agent: Agent, + body: string, + hold: { round?: number; notice: 'now' | 'later' }, + ): { draft?: Draft; refused?: true } { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return {}; - if (this.draftsInFlight.has(`${agent.name}\u0000${draft.id}`)) { - const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. Nothing was sent; wait for that result.`; - try { - this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); - } catch (err) { - console.error('[drafts] in-flight-unsent notice failed:', err); - } - return { draft, refused: true }; + const key = `${agent.name}\u0000${draft.id}`; + let why: string; + let next: string; + if (this.draftsInFlight.has(key)) { + why = this.draftsSending.has(key) ? 'which is being sent right now' : 'which is queued in a resend in progress'; + next = 'wait for that result'; + } else if (draftState(draft) === 'unconfirmed') { + why = this.riskText(draft); + next = 'check that channel; to send that draft again anyway use ' + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true)`; + } else { + return { draft }; } - if (draftState(draft) !== 'unconfirmed') return { draft }; - const text = - `[prose-routing] {{unsent}} is draft ${draft.id}: ${this.riskText(draft)}. Nothing was sent. Check that channel; ` + - `to send it again anyway use drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true).`; + const words = this.unsentWords(agent, body); + const held = 'draft' in words + ? undefined + : this.holdProseDrafts( + agent, + [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], + 'bounced', + { round: hold.round, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, + )[0]; + const kept = held + ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` + + 'can be resent only with confirmDuplicate: true, after that.' + : ''; + const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` + + `Nothing was sent; ${next}.${kept}`; try { - this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + this.addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'prose-bounce', ...(held ? { draftOwner: agent.name, draftIds: [held.id] } : {}) } as MessageMetadata, + { forAgent: agent.name }, + ); } catch (err) { - console.error('[drafts] unconfirmed-unsent notice failed:', err); + console.error('[drafts] refused-unsent notice failed:', err); } return { draft, refused: true }; } @@ -9899,7 +9945,7 @@ export class AgentFramework { } let body = parsed.body; const usedClipboard = body.includes('{{unsent}}'); - const unsent = usedClipboard ? this.takeUnsent(agent, '>>>') : {}; + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; if (unsent.refused) { this.proseTargetPins.delete(agent.name); this.proseHybridSuppressed.set(agent.name, 'failed'); @@ -9966,26 +10012,35 @@ export class AgentFramework { /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce * draft aside — unless it is being sent right now, which a dismissal - * can't call back: the resident is told instead. */ + * can't call back: the resident is told instead. So are a dismissal that + * can't be recorded (the draft stays, and `{{unsent}}` still names it) + * and one whose draft may already have been posted. */ private dismissLatestBounce(agent: Agent): void { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return; - if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) { - const busy = `[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` + - 'since that can\'t call back words already on their way; wait for that result.'; + const tell = (text: string, what: string): void => { try { - this.addMessage('user', [{ type: 'text', text: busy }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); } catch (err) { - console.error('[drafts] in-flight-dismiss notice failed:', err); + console.error(`[drafts] ${what} notice failed:`, err); } + }; + if (this.draftsSending.has(`${agent.name}\u0000${draft.id}`)) { + tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.', 'in-flight-dismiss'); return; } + const risk = draftState(draft) === 'unconfirmed' ? this.riskText(draft) : undefined; try { this.proseDrafts.dismiss(agent.name, draft.id); this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); } catch (err) { console.error(`[drafts] ${agent.name}: could not dismiss ${draft.id}:`, err); + tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which could not be set aside ` + + `(${err instanceof Error ? err.message : String(err)}): it is still held, and {{unsent}} still names it.`, 'failed-dismiss'); + return; } + if (risk) tell(`[prose-routing] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss'); } private async deliverProseEnvelope(agent: Agent, rawText: string, hold: { round?: number; notice: 'now' | 'later' }): Promise { @@ -10025,7 +10080,7 @@ export class AgentFramework { } const usedClipboard = body.includes('{{unsent}}'); - const unsent = usedClipboard ? this.takeUnsent(agent, '>>') : {}; + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; if (unsent.refused) return; if (usedClipboard) { body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); @@ -10074,7 +10129,9 @@ export class AgentFramework { draftId: previous.id, destination: risky.destination, at: risky.at, - reason: risky.outcome?.reason ?? 'its outcome was never recorded', + reason: risky.outcome?.reason ?? (this.attemptIsOut(previous, risky) + ? "it was being sent when these words were held, so its outcome wasn't known yet" + : 'its outcome was never recorded'), } : previous.inheritedRisk ? { @@ -10084,7 +10141,12 @@ export class AgentFramework { ...(previous.inheritedRisk.at !== undefined ? { at: previous.inheritedRisk.at } : {}), reason: previous.inheritedRisk.reason, } - : { draftId: previous.id, reason: 'it was being sent when these words were held' }; + : { + draftId: previous.id, + reason: this.draftsSending.has(`${name}\u0000${previous.id}`) + ? 'it was being sent when these words were held' + : 'it was queued in a resend when these words were held', + }; return { text: expanded, inheritedRisk }; } diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index b264fda27..9635ad821 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -841,7 +841,7 @@ describe('held prose drafts, end to end', () => { const before = h.publishes().length; await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}}`)])]); assert.equal(h.publishes().length, before, 'neither draft goes out without confirmation'); - assert.ok(h.texts().some((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${fresh.id}: ${fresh.id} includes the words of ${risky!.id}`))); + assert.ok(h.texts().some((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${fresh.id}, and ${fresh.id} includes the words of ${risky!.id}`))); await h.turn([createMockResponse([drafts('r1', { action: 'resend', draftIds: [fresh.id], destination: ROOM, confirmDuplicate: true })], 'tool_use'), createMockResponse([])]); assert.deepEqual(h.publishes().slice(before).map((p) => p.text), ['risky words and a postscript']); assert.equal(draftState(h.store().get('scout', risky!.id)!), 'unconfirmed', 'the original still needs its own confirmation'); @@ -1091,10 +1091,18 @@ describe('drafts resend ownership', () => { }; const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => r.success ? r.data!.map((b) => b.text).join('') : r.error!; - const tick = () => new Promise((r) => setTimeout(r, 5)); + /** Until a publish is waiting on the test, bounded: what the next step + * needs, rather than a fixed pause that a loaded machine can outrun. */ + const untilPublishing = async () => { + const deadline = Date.now() + 10_000; + while (pending.length === 0) { + if (Date.now() > deadline) throw new Error('no publish reached the stub within 10 s'); + await new Promise((r) => setTimeout(r, 2)); + } + }; const [a, b] = internals.proseDrafts.hold('scout', [{ text: 'A words', source }, { text: 'B words', source }], 'explicit-send').held; return { - dir, framework, internals, pending, say, tick, a: a!, b: b!, + dir, framework, internals, pending, say, untilPublishing, a: a!, b: b!, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, }; }; @@ -1103,7 +1111,7 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); assert.match(t.say(await sending), /delivered to \(chan\), message m-a/); // The channel is gone: destination resolution would now fail. @@ -1125,12 +1133,12 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'the batch is waiting on A'); const concurrent = t.say(await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.b.id], destination: 'chan' })); assert.match(concurrent, new RegExp(`${t.b.id} is being sent right now \\(or is queued in a resend in progress\\)`)); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['B words']); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); const result = t.say(await batch); @@ -1146,7 +1154,7 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); const dismissed = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); assert.match(dismissed, new RegExp(`${t.b.id}: dismissed`)); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); @@ -1163,7 +1171,7 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'A is out; B is queued'); const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id, t.a.id] })); assert.match(refused, new RegExp(`${t.a.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); @@ -1183,9 +1191,9 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['B words'], 'A is in; B is out'); assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), new RegExp(`${t.a.id}: already delivered — nothing to dismiss`)); @@ -1200,10 +1208,108 @@ describe('drafts resend ownership', () => { const t = await setup(); try { const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; - registry.publish = async () => { throw new Error('transport gone'); }; - await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }).catch(() => undefined); + let entered = 0; + registry.publish = async () => { + entered++; + throw new Error('transport gone'); + }; + await assert.rejects( + t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }), + /transport gone/, + ); + assert.equal(entered, 1, 'the resend reached publish'); + assert.equal(draftState(t.internals.proseDrafts.get('scout', t.a.id)!), 'unconfirmed', 'its attempt has no outcome: it may have been posted'); assert.match(t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] })), - new RegExp(`${t.a.id}: dismissed`)); + new RegExp(`${t.a.id}: dismissed \\(your history keeps the words\\)\\. Note that ${t.a.id}'s attempt to .* may already have been posted: its outcome was never recorded\\.`)); + } finally { + await t.close(); + } + }); + + it('a batch that stops says why for each draft after the stop', async () => { + const t = await setup(); + try { + const [c] = t.internals.proseDrafts.hold('scout', [{ text: 'C words', source }], 'explicit-send').held; + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id, c!.id], destination: 'chan' }); + await t.untilPublishing(); + t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + const result = t.say(await batch); + assert.match(result, new RegExp(`${c!.id}: not attempted \\(${t.b.id} was dismissed while it waited\\)\\.`)); + + const [d] = t.internals.proseDrafts.hold('scout', [{ text: 'D words', source }], 'explicit-send').held; + const again = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [c!.id, d!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + assert.match(t.say(await again), new RegExp(`${d!.id}: not attempted \\(${c!.id} was not confirmed delivered\\)\\.`)); + } finally { + await t.close(); + } + }); + + it('an attempt still out reads as being sent, not as one whose outcome was never recorded', async () => { + const t = await setup(); + try { + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.untilPublishing(); + const read = t.say(await t.internals.handleDraftsTool('scout', { action: 'read', draftIds: [t.a.id] })); + assert.match(read, /attempt .* → \(chan\): being sent right now — no outcome yet/); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await sending; + } finally { + await t.close(); + } + }); + + it('{{unsent}} of a bounce queued in a resend says so, and holds the envelope\'s words with the risk', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + const agent = t.framework.getAgent('scout')!; + await t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}} and a postscript', { notice: 'later' }); + assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'nothing more was sent'); + const fresh = t.internals.proseDrafts.latestBounce('scout')!; + assert.notEqual(fresh.id, bounce!.id); + assert.equal(fresh.text, 'bounced words and a postscript'); + assert.equal(fresh.inheritedRisk?.draftId, bounce!.id); + assert.match(fresh.inheritedRisk!.reason, /queued in a resend when these words were held/); + const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is queued in a resend in progress. Nothing was sent; ` + + `wait for that result. Your message is held as draft ${fresh.id}, with {{unsent}} written out; since it includes ` + + `${bounce!.id}'s words, it can be resent only with confirmDuplicate: true, after that.`), words.join('\n')); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); + await batch; + } finally { + await t.close(); + } + }); + + it('>>skip_reply {{unsent}} says when the bounce may already have been posted, or could not be set aside', async () => { + const t = await setup(); + try { + const agent = t.framework.getAgent('scout')!; + const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + // The journal refuses the dismissal: the bounce stays, and the resident is told. + const dismiss = t.internals.proseDrafts.dismiss.bind(t.internals.proseDrafts); + t.internals.proseDrafts.dismiss = () => { throw new Error('EIO'); }; + await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + assert.ok(words().includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which could not be set aside (EIO): ` + + 'it is still held, and {{unsent}} still names it.'), words().join('\n')); + assert.equal(t.internals.proseDrafts.latestBounce('scout')?.id, bounce!.id); + t.internals.proseDrafts.dismiss = dismiss; + await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed'); + assert.ok(words().some((w) => w.startsWith(`[prose-routing] Draft ${bounce!.id} is set aside. Note that ${bounce!.id}'s attempt to (chan) at `) && + w.endsWith('may already have been posted: no receipt.')), words().join('\n')); } finally { await t.close(); } @@ -1214,7 +1320,7 @@ describe('drafts resend ownership', () => { try { const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); - await t.tick(); + await t.untilPublishing(); const agent = t.framework.getAgent('scout')!; const envelope = (text: string) => t.internals.deliverProseEnvelope(agent, text, { notice: 'later' }); await envelope('>>skip_reply {{unsent}}'); @@ -1237,7 +1343,7 @@ describe('drafts resend ownership', () => { const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; const agent = t.framework.getAgent('scout')!; const delivering = t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}}', { notice: 'later' }); - await t.tick(); + await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['bounced words'], 'the bounce is out through {{unsent}}'); const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })); assert.match(refused, new RegExp(`${bounce!.id} is being sent right now, and dismissing it can't call those words back; .*Nothing was dismissed\\.`)); From 9feee54ceb6842b04a49ae52d2cfba934ba23706 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:00:52 -0400 Subject: [PATCH 18/54] fix(envelope): only the framework writes the envelope, conversation keys can't collide, and the texts say what the code does sw-af247, part 4: the inbound envelope, and the texts. - I12: a module's addMessages metadata loses any inboundSource it carries before it is stored (one echoed from a server's event, say); a console or API surface event's messages carry the framework's own, as before. - I36: conversationKey encodes its server-chosen parts as a JSON array, so two distinct (kind, server, channel, thread) never share a key, and an empty thread is not no thread. It is an in-memory comparison key, never stored, so its format can change. - I33: splitProseSegments is gone; the framework trims splitProseRuns' runs inline, and the tests trim them the same way. - I1: the inbound-source header says what the envelope is and that consumers read it through readInboundSource and conversationKey, without naming readers this change doesn't have. - I41: the acceptance observer's comment says it is an internal seam the receipt work (shelf-354) assigns inside the framework; unset here. - I9, I18, I28, I42, and the round's own changes, in the fragments and the routing doc: routeSpeech now returns null unless delivery was confirmed; what {{unsent}} does with a draft that may have been posted, on each route; retention and the let-go refusal; notices counted per branch; the drafts tool's one-time prompt-cache miss; a publish's deadline and a refused send opening nothing; coalesced: true present only when it is; a module's envelope dropped. Tests: - inbound-source (I15): the strict reader refuses a missing, empty or non-string serverId, a bad binding, and non-string threadId, replyTo and eventId, for channel and unscoped envelopes. - inbound-source (I36): keys built from parts holding NUL differ, and an empty thread differs from none; the surface key's new form. - inbound-source (I12): a module relaying a forged envelope stores none; a console message carries the surface stamp, whatever its metadata said. - prose-drafts (I16): a silent turn's prose beside a send is counted private, never held or named. Red check: with suppressProse removed from both turnProseRouting and silenceCause, it fails. With only one removed it passes, since either keeps the prose private. I34 is re-judged not-real in the ledger: Agent.proseRouting is readonly, so the live value is the turn's. --- changelog.d/inbound-source-envelope.added.md | 13 +++--- changelog.d/prose-drafts.added.md | 42 ++++++++++++++------ changelog.d/publish-outcomes.fixed.md | 6 ++- docs/explicit-prose-routing.md | 25 +++++++++--- src/framework.ts | 16 ++++++-- src/mcpl/inbound-source.ts | 28 +++++++------ src/prose-segments.ts | 7 +--- test/inbound-source.test.ts | 42 +++++++++++++++++++- test/prose-drafts.test.ts | 20 +++++++++- test/prose-segments.test.ts | 6 ++- 10 files changed, 154 insertions(+), 51 deletions(-) diff --git a/changelog.d/inbound-source-envelope.added.md b/changelog.d/inbound-source-envelope.added.md index 9afd3c00c..ff472c601 100644 --- a/changelog.d/inbound-source-envelope.added.md +++ b/changelog.d/inbound-source-envelope.added.md @@ -8,12 +8,15 @@ ordinary and the RFC-006 coalesced paths; a coalesced occurrence freezes its envelope at acceptance, so deferral, fan-out and replay deliver it unchanged and a later rename never rewrites it. Adapter-supplied metadata cannot set - the field (an adapter's own `origin.source` string is untouched). - `readInboundSource`, `conversationKey` and `INBOUND_SOURCE_KEY` are exported. + the field, and one a module's messages carry is dropped (an adapter's own + `origin.source` string is untouched). `readInboundSource`, + `conversationKey` (a JSON-encoded key, so no server-chosen part can run + into another) and `INBOUND_SOURCE_KEY` are exported. - **Inbound source envelope: admission facts.** An ordinary `channels/incoming` message or `push/event` now has its envelope frozen at admission, before it is queued or acknowledged, so a rename or rebind while it waits cannot change it. Every channel and unscoped envelope names the admission `lane` - (`channels/incoming` or `push/event`) and whether it came through RFC-006 - coalesced admission (`coalesced: true`): facts a consumer needs to know what - an `eventId` is worth. `readInboundSource` validates every field it returns. + (`channels/incoming` or `push/event`), and one that came through RFC-006 + coalesced admission has `coalesced: true` (others have no `coalesced` + field): facts a consumer needs to know what an `eventId` is worth. + `readInboundSource` validates every field it returns. diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 85c6607ad..f55b6d5a2 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -22,7 +22,12 @@ re-bounce rule (a bare `{{unsent}}` leaves the bounce as the one draft). - Drafts live in typed Chronicle records (`RecordJournal`) that do not follow branch switches: undo, rollback and checkout never change them, and they - survive restarts. Nothing is ever sent from them on its own. + survive restarts. Nothing is ever sent from them on its own. Each + resident's 100 newest drafts are kept, and an older one for 7 days after + anything last happened to it (held, named, attempted, answered, dismissed); + the journal checkpoints what it keeps every 500 entries, so opening it + replays only what follows. A request naming a draft that was let go is + refused, and nothing is sent. - A private notice names held drafts at the next tool boundary when the live stream presents mid-turn messages (a draft holding words copied from one that may already have been posted is named with that risk and the @@ -32,29 +37,42 @@ (listed where it landed), unconfirmed, or dismissed. A dismissed draft whose own attempt may already have been posted is named with that risk, and counts like an unconfirmed one: a receipt that names one, with nothing - confirmed delivered, reads "nothing confirmed", not "nothing". A draft no - notice reached (a crash, an aborted turn) is named at the next turn's - start. + confirmed delivered, reads "nothing confirmed", not "nothing". A draft + counts as named while a message naming it is on the current branch, so one + no notice reached (a crash, an aborted turn), or whose notices a rollback + or branch move took away, is named at the next turn's start: every such + draft by id, the newest eight with a preview. - A draft is never published except by its resident's explicit resend (or `{{unsent}}`), and only that resident can list or act on it. Its notices go into the resident's own history, which residents sharing one message slot already share, as they share the assistant turn the words came from. - New `drafts` tool (whenever MCPL channels are configured): `list`, `read`, - `resend` and `dismiss`, scoped to the calling resident. `resend` publishes + `resend` and `dismiss`, scoped to the calling resident. Every such + resident's tool list gains it, so each one's first request after upgrading + misses the prompt cache once. `resend` publishes drafts verbatim, in order, to one destination through the normal publish path, journaling each attempt durably before the request leaves; it counts as an explicit send. A delivered draft returns its historical receipt instead of sending again; a draft with any attempt whose outcome is unknown (and none confirmed) needs `confirmDuplicate: true`, and a resend - owns its drafts until it finishes, re-checking each before it is sent. - Dismissing a draft (with the tool or `>>skip_reply {{unsent}}`) is - refused while that draft's attempt is out, since a dismissal can't call - back words already on their way; the resident is told to wait for that - result. A draft only queued in a resend can still be dismissed, and is - then not sent. Unused fields may be passed as null. + owns its drafts until it finishes, re-checking each before it is sent, and + a draft it doesn't attempt is told why the batch stopped. Dismissing a + draft (with the tool or `>>skip_reply {{unsent}}`) is refused while that + draft's attempt is out, since a dismissal can't call back words already on + their way; the resident is told to wait for that result. A draft only + queued in a resend can still be dismissed, and is then not sent. A + dismissal of a draft that may already have been posted names that risk. + Unused fields may be passed as null. +- `{{unsent}}` whose draft is being sent, queued in a resend, or unconfirmed + sends nothing, and the envelope's own words are held as a bounce draft + with `{{unsent}}` written out and the risk carried, so nothing the + resident wrote has to be typed again. - Prose segments also break at XML tool mode's refused attempts and their notices (`tool_attempt`, `tool_notice`): the words before an all-refused call and after its notice are two messages, routed or held separately. - `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and - `ChannelRegistry.resolveDestination` are new; routeSpeech is unchanged. + `ChannelRegistry.resolveDestination` are new. `routeSpeech` now delegates + to `deliverSpeech` and returns null unless delivery was confirmed, so a + missing receipt and a channel id two servers share now return null (see + the publish-outcomes entry). - The bounce notice now goes to the bouncing resident rather than the primary. diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md index 659f0742b..6dd88191e 100644 --- a/changelog.d/publish-outcomes.fixed.md +++ b/changelog.d/publish-outcomes.fixed.md @@ -16,4 +16,8 @@ `mcpl:speech-route-failed` trace carry `outcome`. A channel id registered by more than one MCPL server is refused rather than routed through whichever server registered it first, and a supplied but empty or - non-string server selector is refused rather than read as omitted. + non-string server selector is refused rather than read as omitted. A send + refused for its server or grant opens no closed channel. A + `channels/publish` request always has a deadline: with `requestTimeoutMs` + 0 it keeps the 60 s default, since its outcome is told to the resident and + recorded for its draft; its expiry reads as unknown. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index b719b400c..8096ac7fc 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -45,12 +45,25 @@ breaking any loop). `{{unsent}}` means the latest bounce while that draft is open (latest-wins, as the clipboard it replaced was), and `>>skip_reply {{unsent}}` dismisses it. -Earlier bounces stay in the agent's drafts, by id, until resent or dismissed; -drafts survive restarts and branch moves, and a delivery through `{{unsent}}` -is recorded on the draft like any resend. When the latest bounce's last -delivery attempt may already have been posted (an unknown outcome), -`{{unsent}}` is not substituted: the agent is told, and can resend it with the -`drafts` tool's `confirmDuplicate`. +Earlier bounces stay in the agent's drafts, by id, until resent, dismissed or +let go (an agent's 100 newest drafts are always kept, and an older one for 7 +days after anything last happened to it); drafts survive restarts and branch +moves, and a delivery through `{{unsent}}` is recorded on the draft like any +resend. + +When the latest bounce may already have been posted (an attempt's outcome is +unknown), or is being sent or queued in a resend right now, what happens +depends on the route: +- **Delivery** (`>>#channel … {{unsent}} …`): nothing is sent. The agent is + told, and can resend the bounce with the `drafts` tool's `confirmDuplicate` + after checking the channel. The envelope's own words are held as a new + bounce draft, with `{{unsent}}` written out and the risk carried, so that + draft too can be resent only with `confirmDuplicate`. A bare `{{unsent}}` + holds nothing new: its words already are the bounce. +- **Hold** (an envelope that bounces, or one an explicit send held back): the + bounce's words are copied into the new draft with `{{unsent}}` written out, + and the draft carries the same risk (`inheritedRisk`), so it reads + unconfirmed until a confirmed delivery. ## What explicit mode retires (for that agent) diff --git a/src/framework.ts b/src/framework.ts index 783d02b31..6656a87be 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1056,8 +1056,9 @@ export class AgentFramework { private processLoggingPersist: boolean; private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); - /** Told once per inbound acceptance (mcpl/inbound-source.ts); unset unless - * a receipt consumer installs one. */ + /** Told once per inbound acceptance (mcpl/inbound-source.ts). An internal + * seam, unset here: the receipt work (shelf-354) assigns it inside the + * framework, and tests set it by cast. */ private inboundAcceptanceObserver?: InboundAcceptanceObserver; /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved @@ -7129,10 +7130,17 @@ export class AgentFramework { /** The event's surface envelope, when it is console/API input. */ surface?: InboundSource, ): Promise { - // Add messages + // Add messages. Only the framework writes the inbound envelope: one a + // module's metadata carries (echoed from a server's event, say) is + // dropped, and a surface event's messages carry the framework's own. if (response.addMessages) { for (const msg of response.addMessages) { - const metadata = surface ? { ...msg.metadata, [INBOUND_SOURCE_KEY]: surface } : msg.metadata; + let metadata = msg.metadata; + if (metadata && INBOUND_SOURCE_KEY in metadata) { + const { [INBOUND_SOURCE_KEY]: _dropped, ...rest } = metadata; + metadata = rest; + } + if (surface) metadata = { ...metadata, [INBOUND_SOURCE_KEY]: surface }; const id = this.addMessage(msg.participant, msg.content, metadata); this.emitTrace({ type: 'message:added', messageId: id, source: event.type }); } diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts index 29c2e65e0..766eb07cf 100644 --- a/src/mcpl/inbound-source.ts +++ b/src/mcpl/inbound-source.ts @@ -2,18 +2,18 @@ * Where an inbound item came from, decided once at Agent Framework's * ingestion boundary and stored on the message as `metadata.inboundSource`. * - * Every later consumer reads this one value instead of re-deriving identity - * from the lane it happened to arrive on: speech routing compares - * conversations with it, the visible provenance header renders from it, and - * delivery bookkeeping keys on it. It is frozen when the host ACCEPTS the - * item. A coalesced occurrence (RFC-006) carries the value stamped at its - * acceptance through deferral, fan-out and replay, so a later rename, - * reconnect or re-render never rewrites where an old item says it came from. + * A consumer of where an item came from reads this one value + * (readInboundSource) rather than re-deriving identity from the lane the + * item happened to arrive on, and compares conversations with + * conversationKey. It is frozen when the host ACCEPTS the item. A coalesced + * occurrence (RFC-006) carries the value stamped at its acceptance through + * deferral, fan-out and replay, so a later rename, reconnect or re-render + * never rewrites where an old item says it came from. * * The field is framework-owned. It is written after any adapter-supplied - * metadata is spread, so an adapter cannot supply or spoof it (adapter - * `origin` objects already use a free-form `source` string, which is why this - * is not called `source`). + * metadata is spread, so an adapter cannot supply or spoof it, and one a + * module's messages carry is dropped (adapter `origin` objects already use a + * free-form `source` string, which is why this is not called `source`). * * Facts only, no policy: whether an item is a body, an edit or a deletion * stays in its tags (`chat:edited`, `chat:deleted`); `deferred` and @@ -143,12 +143,14 @@ export function readInboundSource(metadata: unknown): InboundSource | undefined /** * The conversation an item belongs to, as a comparable key: server + channel * + thread for a channel item, the surface for console input. Undefined for an - * unscoped push, which belongs to no conversation. + * unscoped push, which belongs to no conversation. The parts are server-chosen + * strings, so they are encoded as a JSON array, never joined with a separator + * one of them could contain: two keys are equal only for the same parts. */ export function conversationKey(source: InboundSource): string | undefined { if (source.kind === 'channel') { - return `channel\u0000${source.serverId}\u0000${source.channelId}\u0000${source.threadId ?? ''}`; + return JSON.stringify(['channel', source.serverId, source.channelId, source.threadId ?? null]); } - if (source.kind === 'surface') return `surface\u0000${source.surface}`; + if (source.kind === 'surface') return JSON.stringify(['surface', source.surface]); return undefined; } diff --git a/src/prose-segments.ts b/src/prose-segments.ts index 6e2b96f7e..d10ce357d 100644 --- a/src/prose-segments.ts +++ b/src/prose-segments.ts @@ -22,7 +22,7 @@ import type { ContentBlock } from '@animalabs/membrane'; * The runs exactly as written: contiguous text blocks joined with a newline, * nothing trimmed. Whitespace-only runs are dropped. Held drafts keep these, * so a draft's words (indentation included) are the resident's own, byte for - * byte. + * byte; speech routes each run trimmed of surrounding whitespace. */ export function splitProseRuns(content: readonly ContentBlock[]): string[] { const runs: string[] = []; @@ -54,8 +54,3 @@ export function splitProseRuns(content: readonly ContentBlock[]): string[] { return runs; } - -/** The runs as speech is routed: each trimmed of surrounding whitespace. */ -export function splitProseSegments(content: readonly ContentBlock[]): string[] { - return splitProseRuns(content).map((run) => run.trim()); -} diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 5516268dc..22814a316 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -50,9 +50,15 @@ class ConsoleModule implements Module { getTools(): ToolDefinition[] { return []; } async handleToolCall(_call: ToolCall): Promise { return { success: true }; } async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + // A module that relays an event's metadata as it came, envelope and all. + if ((event as { type: string }).type === 'relay') { + const relayed = event as unknown as { content: string; metadata: Record }; + return { addMessages: [{ participant: 'Relay', content: [{ type: 'text', text: relayed.content }], metadata: relayed.metadata }] }; + } if (event.type !== 'external-message') return {}; const text = String((event as { content?: unknown }).content); - return { addMessages: [{ participant: 'Operator', content: [{ type: 'text', text }] }] }; + const metadata = (event as { metadata?: Record }).metadata; + return { addMessages: [{ participant: 'Operator', content: [{ type: 'text', text }], ...(metadata ? { metadata } : {}) }] }; } } @@ -156,10 +162,24 @@ describe('inbound source envelope', () => { const message = stored().find((m) => m.participant === 'Operator')!; const source = readInboundSource(message.metadata); assert.deepEqual(source && { kind: source.kind, surface: (source as { surface: string }).surface }, { kind: 'surface', surface: 'tui' }); - assert.equal(conversationKey(source!), 'surface\u0000tui'); + assert.equal(conversationKey(source!), '["surface","tui"]'); assert.equal(observed.length, 1); }); + it("drops an envelope a module's metadata carries: only the framework writes one", async () => { + const forged = { kind: 'channel', lane: 'channels/incoming', serverId: 'evil', binding: 'x', channelId: 'discord:g9:elsewhere', acceptedAt: 1 }; + framework.pushEvent({ type: 'relay', content: 'relayed words', metadata: { inboundSource: forged, note: 'kept' } } as unknown as ProcessEvent); + await waitFor(() => stored().some((m) => m.participant === 'Relay'), 'relayed message stored'); + const relayed = stored().find((m) => m.participant === 'Relay')!; + assert.equal(readInboundSource(relayed.metadata), undefined, 'not the forged envelope'); + assert.equal((relayed.metadata as Record).note, 'kept', 'the rest of its metadata stays'); + // A surface event's message carries the framework's envelope, whatever its metadata said. + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'console words', metadata: { inboundSource: forged } } as ProcessEvent); + await waitFor(() => stored().some((m) => m.participant === 'Operator'), 'console message stored'); + const source = readInboundSource(stored().find((m) => m.participant === 'Operator')!.metadata); + assert.equal(source?.kind, 'surface'); + }); + it('keeps a stored envelope when the channel is renamed later', async () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-2', mode: 'ambient', text: 'before rename' }); await waitFor(() => !!storedWith((m) => m.messageId === 'm-2'), 'first message stored'); @@ -270,6 +290,17 @@ describe('readInboundSource', () => { { ...channel, messageId: 42 }, { ...channel, label: '' }, { ...channel, sourceTimestamp: {} }, + { ...channel, serverId: undefined }, + { ...channel, serverId: '' }, + { ...channel, serverId: 7 }, + { ...channel, binding: undefined }, + { ...channel, binding: { id: 'b' } }, + { ...channel, threadId: 3 }, + { ...channel, threadId: '' }, + { ...channel, replyTo: false }, + { ...channel, eventId: 9 }, + { kind: 'unscoped', lane: 'push/event', serverId: '', binding: 'b', acceptedAt: 1 }, + { kind: 'unscoped', lane: 'push/event', serverId: 's', binding: 'b', acceptedAt: 1, eventId: [] }, { kind: 'unscoped', lane: 'channels/incoming', serverId: 's', binding: 'b', acceptedAt: 1 }, { kind: 'surface', surface: '', acceptedAt: 1 }, { kind: 'mystery', acceptedAt: 1 }, @@ -284,6 +315,13 @@ describe('readInboundSource', () => { assert.notEqual(conversationKey(base), conversationKey({ ...base, serverId: 't' })); assert.notEqual(conversationKey(base), conversationKey({ ...base, threadId: 'th' })); assert.equal(conversationKey(base), conversationKey({ ...base, messageId: 'other', acceptedAt: 2 })); + // The parts are server-chosen: a separator inside one can't make two + // conversations one. + assert.notEqual( + conversationKey({ ...base, channelId: 'c\u0000x', threadId: 'y' }), + conversationKey({ ...base, channelId: 'c', threadId: 'x\u0000y' }), + ); + assert.notEqual(conversationKey({ ...base, threadId: '' }), conversationKey(base), 'an empty thread is not no thread'); }); }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 9635ad821..1068ff601 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -333,14 +333,17 @@ async function harness(opts: { : [], /** One addressed message in #room starts a turn (or, with `request`, a * direct inference request for just that resident); resolves when it settles. */ - turn: async (responses: Array>, agentName = 'scout', via: 'incoming' | 'request' = 'incoming') => { + turn: async (responses: Array>, agentName = 'scout', via: 'incoming' | 'request' | 'silent' = 'incoming') => { for (const r of responses) membrane.pushResponse(r); const before = membrane.calls.length; if (via === 'incoming') { h.command({ op: 'incoming', channelId: ROOM, messageId: `in-${++incoming}`, mode: 'addressed', text: `message ${incoming}` }); } else { + // 'silent': a control-plane wake whose plain prose stays private, as + // a silent heartbeat's does. (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ agentName, reason: 'test', source: 'test', timestamp: Date.now(), + ...(via === 'silent' ? { suppressProse: true } : {}), }); } await until(() => membrane.calls.length > before, 'turn start'); @@ -594,6 +597,21 @@ describe('held prose drafts, end to end', () => { } }); + it("a silent turn's prose stays private beside a send: counted, never held or named", async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('private narration'), explicitSend('s1')], 'tool_use'), + createMockResponse([text('more private words')]), + ], 'scout', 'silent'); + assert.deepEqual(h.drafts(), [], 'nothing was held as a draft'); + assert.ok(!h.texts().some((t) => t.includes('held as draft')), 'and no notice names one'); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere'], 'only the explicit send went out'); + } finally { + await h.close(); + } + }); + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { const h = await harness(); try { diff --git a/test/prose-segments.test.ts b/test/prose-segments.test.ts index 98ffda842..4fd2ff4f7 100644 --- a/test/prose-segments.test.ts +++ b/test/prose-segments.test.ts @@ -1,7 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { ContentBlock } from '@animalabs/membrane'; -import { splitProseRuns, splitProseSegments } from '../src/prose-segments.js'; +import { splitProseRuns } from '../src/prose-segments.js'; + +/** The runs as speech routes them: each trimmed, as the framework does. */ +const splitProseSegments = (content: Parameters[0]): string[] => + splitProseRuns(content).map((run) => run.trim()); const textBlock = (text: string): ContentBlock => ({ type: 'text', text } as ContentBlock); const toolUse = (id: string): ContentBlock => From 7cc9c216cf39e4620f8707159abf8bbb4ad5fd0f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 11 Oct 2026 15:02:15 -0400 Subject: [PATCH 19/54] test(publish): the refused-before-open case uses a grant that could open the channel but not publish into it So the test tells the new order (server and channels.publish checked before any open) from the old one, which opened first: with an empty grant, the old order failed at channels.lifecycle and opened nothing either, so only the refusal's wording told them apart. --- test/publish-outcome.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 13f298add..846a9835f 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -25,7 +25,7 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; type Publish = (params: { channelId: string }) => Promise; function registryWith( - servers: Record Promise }>, + servers: Record Promise }>, failures: unknown[] = [], ) { const published: Array<{ serverId: string; channelId: string }> = []; @@ -34,7 +34,9 @@ function registryWith( for (const [id, s] of Object.entries(servers)) { mocks.set(id, { id, - grant: new CapabilityGrant(new Set(s.grant === false ? [] : ALL_CAPABILITY_PATHS), []), + grant: new CapabilityGrant(new Set( + s.grant === false ? [] : ALL_CAPABILITY_PATHS.filter((p) => !(typeof s.grant === 'object' && s.grant.without.includes(p))), + ), []), sendChannelsPublish: async (params: { channelId: string }) => { published.push({ serverId: id, channelId: params.channelId }); return s.publish(params); @@ -90,7 +92,8 @@ describe('ChannelRegistry.publish outcomes', () => { }); it('refuses a send the grant forbids before opening anything', async () => { - const { registry, seed, opened, published } = registryWith({ discord: { publish: ok, grant: false } }); + // The grant could open the channel (channels.lifecycle), but not publish into it. + const { registry, seed, opened, published } = registryWith({ discord: { publish: ok, grant: { without: ['channels.publish'] } } }); seed('discord', 'c', '#c', false); const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); assert.equal(outcome.status, 'failed'); From 5082128b2f0d26f39e6768bba3de7cf6dec40cbc Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Mon, 12 Oct 2026 06:42:16 -0400 Subject: [PATCH 20/54] fix(chronicle): an append that throws inside the store blocks the journal's writes until the store is reopened Chronicle's append (v0.4.0, src/records/log.rs and src/store.rs) assigns the record's id, writes the record to its log and advances the file size, syncs the log every sync_interval writes, and only then adds the record to the index that getRecordIdsByType reads. When that periodic sync fails, the append throws with the record in the log but not in this process's view of the store. RecordJournal treated an append that threw like a failed barrier: load() cleared the unreconciled state, but the record wasn't in what load() read, so a later checkpoint covered it, and the reopened store replayed it as covered, never reduced. Nothing in the store's JS API rereads the log short of reopening the store, so an append that throws now leaves the journal unwritable for the rest of its life: every append and checkpoint throws, saying nothing more is written until the store is reopened, as at the next start. A journal on the reopened store loads that record with its tail, for the caller to reduce. needsReconcile stays false for it, since no load() resolves it. A failure after the append returned (a durable barrier, or reading the id it returned) is resolved by load() as before, and a failure before the record reaches the store (the afterCommittedState sync) still leaves the journal usable. The source change is the same as on agent-framework #248, where this journal's commits are shared. Test (record-journal, 9): an append that throws after writing its record, modelled as Chronicle's failed periodic sync (the record hidden from this view of the store until it's reopened), blocks append and checkpoint, before and after load(); a journal on the reopened store loads it and writes again; an append that throws before writing anything blocks the same way. A native probe outside the suite injected one EIO at the log's periodic fsync (an LD_PRELOAD shim) on Linux, Node 24.21.0, Chronicle 0.4.0. The 100th append threw with its record in the log. The journal then refused the next append and checkpoint, and after a reopen the tail held all 100 entries, the failed one included. With the old rule, the next checkpoint covered it. --- src/record-journal.ts | 57 ++++++++++++++++++++++++++++--------- test/record-journal.test.ts | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/record-journal.ts b/src/record-journal.ts index 0565e5422..ee6544ee0 100644 --- a/src/record-journal.ts +++ b/src/record-journal.ts @@ -32,16 +32,25 @@ * 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. + * An ambiguous write must be resolved before the journal writes again. Once + * `append` or `checkpoint` has handed a record to the store, a failure leaves + * a record the caller didn't reduce, or may leave one, and a later checkpoint + * would cover it without its ever entering the reducer. There are two kinds: + * - The store's append itself throws. Its record may still be in the store's + * log, outside this process's view of the store: the store writes a + * record to its log, syncs the log every so often, and only then indexes + * the record, which is what `getRecordIdsByType` reads. So a failed sync + * leaves a record that reopening the store will find and no `load()` on + * this store can. Nothing but a reopen rereads the log, so from then on + * every `append` and `checkpoint` throws, for the life of the journal. A + * journal on the store reopened, as at the next start, loads that record + * with its tail for the caller to reduce. + * - A failure after the append returned: a `durable` barrier, or reading + * the id it returned. The record is in this process's view, so every + * further `append` and `checkpoint` throws until `load()` has replayed the + * tail, that 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'; @@ -78,6 +87,8 @@ interface CheckpointPayload { snapshot: Snapshot; } +const asError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err))); + /** 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}"`); @@ -99,8 +110,11 @@ 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(). */ + /** Set by a failure after the store's append returned; cleared by load(). */ private unreconciled: Error | null = null; + /** Set when the store's append itself threw: its record may be in the log + * but not in this process's view of the store. Never cleared. */ + private unreadable: Error | null = null; constructor(store: JsStore, opts: { type: string; checkpointType?: string }) { if (!opts.type) throw new Error('RecordJournal: a record type is required'); @@ -156,7 +170,9 @@ export class RecordJournal { return { snapshot, entries }; } - /** True after an ambiguous write, until load() has replayed it. */ + /** True after a failure that load() can resolve, until it has replayed the + * tail. An append that threw inside the store is never resolved here: the + * journal stays unwritable until a journal on the reopened store loads it. */ get needsReconcile(): boolean { return this.unreconciled !== null; } @@ -186,19 +202,32 @@ export class RecordJournal { /** 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 { + let record: { id: string; sequence: number }; + try { + record = this.store.appendJson(type, payload); + } catch (err) { + this.unreadable = asError(err); + throw err; + } 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)); + this.unreconciled = asError(err); throw err; } } private assertReconciled(): void { + if (this.unreadable) { + throw new Error( + `RecordJournal(${this.type}): an earlier append failed inside the store ` + + `(${this.unreadable.message}), and its record may be in the store's log where this process can't ` + + `read it back; nothing more is written until the store is reopened, as at the next start`, + ); + } if (this.unreconciled) { throw new Error( `RecordJournal(${this.type}): an earlier write failed after reaching the store ` + diff --git a/test/record-journal.test.ts b/test/record-journal.test.ts index 3618ae077..300a5f0f6 100644 --- a/test/record-journal.test.ts +++ b/test/record-journal.test.ts @@ -195,6 +195,58 @@ describe('RecordJournal', () => { }); }); + it('an append that throws inside the store blocks writes for the life of the journal; a journal on the reopened store loads its record', () => { + withStore((store) => { + // As Chronicle's append can fail: the record is written to the log, the + // periodic sync then fails, and the record never reaches the index this + // process reads. Reopening the store indexes it from the log. `hidden` + // is this process's view of the store. + let failAppend: 'after-writing' | 'before-writing' | null = null; + const hidden = new Set(); + const live = { + getRecordIdsByType: (type: string) => store.getRecordIdsByType(type).filter((id) => !hidden.has(id)), + getRecord: (id: string) => (hidden.has(id) ? null : store.getRecord(id)), + appendJson: (type: string, data: unknown) => { + if (failAppend === 'before-writing') { failAppend = null; throw new Error('EIO: write failed'); } + const record = store.appendJson(type, data); + if (failAppend === 'after-writing') { failAppend = null; hidden.add(record.id); throw new Error('EIO: fsync failed'); } + return record; + }, + sync: () => store.sync(), + } as unknown as JsStore; + const journal = new RecordJournal(live, { type: 'journal-test/entry' }); + journal.append({ n: 'A' }); + + failAppend = 'after-writing'; + assert.throws(() => journal.append({ n: 'B' }), /EIO: fsync failed/); + assert.strictEqual(journal.needsReconcile, false, 'no load() on this store can read B back'); + assert.deepStrictEqual(journal.load().entries.map((e) => e.entry.n), ['A'], 'B is outside this view of the store'); + // A checkpoint now would cover B, unreduced, so nothing more is written. + const blocked = /append failed inside the store \(EIO: fsync failed\).*until the store is reopened/; + assert.throws(() => journal.append({ n: 'C' }), blocked); + assert.throws(() => journal.checkpoint({ seen: ['A'] }), blocked); + assert.throws(() => journal.append({ n: 'C' }), blocked, 'load() does not lift it'); + + // A journal on the reopened store loads B with its tail, and writes again. + const reopened = new RecordJournal(store, { type: 'journal-test/entry' }); + assert.deepStrictEqual(reopened.load().entries.map((e) => e.entry.n), ['A', 'B']); + reopened.append({ n: 'C' }); + reopened.checkpoint({ seen: ['A', 'B', 'C'] }); + assert.deepStrictEqual(new RecordJournal(store, { type: 'journal-test/entry' }).load(), + { snapshot: { seen: ['A', 'B', 'C'] }, entries: [] }); + + // An append that fails before writing anything looks the same from + // here, so it blocks the same way; the reopened store has nothing new. + const second = new RecordJournal(live, { type: 'journal-test/entry' }); + second.load(); + failAppend = 'before-writing'; + assert.throws(() => second.append({ n: 'X' }), /EIO: write failed/); + assert.throws(() => second.append({ n: 'D' }), /until the store is reopened/); + assert.deepStrictEqual(new RecordJournal(store, { type: 'journal-test/entry' }).load(), + { snapshot: { seen: ['A', 'B', 'C'] }, entries: [] }); + }); + }); + 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 38df4fd44b01b6597790665321ef83a1017f936f Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 12 Oct 2026 07:26:21 -0400 Subject: [PATCH 21/54] docs(drafts): say what a journal that refuses writes means for the drafts An append that fails inside the store now leaves the record journal refusing every write until the store is reopened (5082128b). The drafts' write comment said only what a failure after the append returned does. It now also says that until the next start each write throws, no new draft is kept (hold reports the segments it couldn't keep, and the turn's notice names the failure), and the projection lacks the entry the next start replays. --- src/prose-drafts.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index c290a749b..c7002903a 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -307,9 +307,13 @@ export class ProseDraftStore { } /** - * 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. + * Journal and apply one entry. A write that fails after the store's append + * returned leaves the journal unreconciled: rebuild from it before + * rethrowing, so the projection never disagrees with what the journal + * holds. An append that fails inside the store can't be read back until + * the store is reopened: the journal then refuses every write until the + * next start, which replays that entry, so until then each write throws, + * no new draft is kept, and the projection lacks that entry. */ private write(entry: DraftEntry, durable: boolean, afterCommittedState = false): void { try { From 3ef9f6b2d4f249fbeeb14454540fcb684da70d44 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 00:37:21 -0400 Subject: [PATCH 22/54] test(drafts): drive the explicit gateway through its admitting entry After the merge of main, deliverProseEnvelope takes an envelope admitted with its target resolved (admitProse), not raw text. Five calls in the drafts tests still passed raw text, so the envelope's text read as undefined and bounceProse threw ("Cannot read properties of undefined (reading 'includes')"). They now call deliverProse, which admits the text's envelopes and delivers each. Admission is synchronous, so the in-flight checks see the same timing as before. --- test/prose-drafts.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 1068ff601..05c7f13f6 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1105,7 +1105,8 @@ describe('drafts resend ownership', () => { const internals = framework as unknown as { proseDrafts: ProseDraftStore; handleDraftsTool(agent: string, input: unknown): Promise<{ success: boolean; data?: Array<{ text: string }>; error?: string }>; - deliverProseEnvelope(agent: unknown, text: string, hold: { notice: 'later' }): Promise; + // The explicit gateway's entry: it admits the text's envelopes, then delivers each. + deliverProse(agent: unknown, text: string, hold: { notice: 'later' }): Promise; }; const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => r.success ? r.data!.map((b) => b.text).join('') : r.error!; @@ -1286,7 +1287,7 @@ describe('drafts resend ownership', () => { const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, bounce!.id], destination: 'chan' }); await t.untilPublishing(); const agent = t.framework.getAgent('scout')!; - await t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}} and a postscript', { notice: 'later' }); + await t.internals.deliverProse(agent, '>>chan {{unsent}} and a postscript', { notice: 'later' }); assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'nothing more was sent'); const fresh = t.internals.proseDrafts.latestBounce('scout')!; assert.notEqual(fresh.id, bounce!.id); @@ -1319,12 +1320,12 @@ describe('drafts resend ownership', () => { // The journal refuses the dismissal: the bounce stays, and the resident is told. const dismiss = t.internals.proseDrafts.dismiss.bind(t.internals.proseDrafts); t.internals.proseDrafts.dismiss = () => { throw new Error('EIO'); }; - await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); assert.ok(words().includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which could not be set aside (EIO): ` + 'it is still held, and {{unsent}} still names it.'), words().join('\n')); assert.equal(t.internals.proseDrafts.latestBounce('scout')?.id, bounce!.id); t.internals.proseDrafts.dismiss = dismiss; - await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed'); assert.ok(words().some((w) => w.startsWith(`[prose-routing] Draft ${bounce!.id} is set aside. Note that ${bounce!.id}'s attempt to (chan) at `) && w.endsWith('may already have been posted: no receipt.')), words().join('\n')); @@ -1340,7 +1341,7 @@ describe('drafts resend ownership', () => { const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); await t.untilPublishing(); const agent = t.framework.getAgent('scout')!; - const envelope = (text: string) => t.internals.deliverProseEnvelope(agent, text, { notice: 'later' }); + const envelope = (text: string) => t.internals.deliverProse(agent, text, { notice: 'later' }); await envelope('>>skip_reply {{unsent}}'); assert.equal(t.internals.proseDrafts.get('scout', bounce!.id)!.dismissedAt, undefined); const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); @@ -1360,7 +1361,7 @@ describe('drafts resend ownership', () => { try { const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; const agent = t.framework.getAgent('scout')!; - const delivering = t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}}', { notice: 'later' }); + const delivering = t.internals.deliverProse(agent, '>>chan {{unsent}}', { notice: 'later' }); await t.untilPublishing(); assert.deepEqual(t.pending.map((p) => p.text), ['bounced words'], 'the bounce is out through {{unsent}}'); const refused = t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [bounce!.id] })); From b5f0adb310a9b3838c881d035a3576e4b8a6e759 Mon Sep 17 00:00:00 2001 From: Nathan-2367 Date: Tue, 13 Oct 2026 01:26:03 -0400 Subject: [PATCH 23/54] fix(prose): retain failed envelopes and settle queued delivery outcomes Explicit envelopes now retain a failed or unexpectedly thrown delivery as a bounce draft with its private notice. They follow the hybrid failure path, while structured unknown outcomes remain marker-only rather than becoming fresh words offered for an ordinary resend. Re-bounces use the authored body before unsent-token expansion so an unresolved source attempt keeps its duplication risk. Await the speech chain once, unconditionally, before final prose dispatch and the completed-turn receipt. The prior awaits were inside final-text branches; an empty completion could receipt and settle a turn while its earlier post and held prose were still queued. Hybrid skip-reply envelopes can dismiss the latest bounce inside a private round, while same-round private-think text remains uninterpreted, matching explicit mode. Forward a bounded display of connector error data through the speech failure log, trace and private failure marker. The returned publish outcome retains the original structured data. Bound both string and malformed message ids in contradictory-receipt diagnostics without rewriting a receipt id. Wrap a synchronous transport write rejection as not-sent and clear its pending entry and timer. Correct the resident help and draft-state/provenance comments. Strengthen the existing dismissal test to observe a real held draft first and its dismissed state afterward. A silent turn intentionally gets no delivery receipt, so its privacy test now asserts that observable contract rather than claiming a resident-visible count. Tests cover explicit failed and thrown envelopes, inherited risk after an unresolved unsent attempt, uncertain delivery guards and real-connection markers, private hybrid dismissal with a private-think control, empty completion behind a controlled slow post and hold, structured connector data, bounded contradictory identifiers and recovery after an unwritten request. The four touched files pass 126 tests after a clean VPS typecheck/build; against the preceding source, ten tests fail on the relevant behavior and the other cases are controls. The immutable committed tree is the input to the full per-file check and individual piece removals. Co-authored-by: GPT-6.1 Sol --- .../prose-drafts-delivery-outcomes.fixed.md | 4 + src/framework.ts | 53 +++++--- src/mcpl/channel-registry.ts | 26 +++- src/mcpl/server-connection.ts | 13 +- src/prose-drafts.ts | 17 +-- test/explicit-prose-routing.test.ts | 113 +++++++++++++++--- test/fixtures/speech-route-mcpl-server.mjs | 6 +- test/mcpl-request-outcome.test.ts | 16 +++ test/prose-drafts.test.ts | 76 +++++++++++- test/publish-outcome.test.ts | 48 +++++++- 10 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 changelog.d/prose-drafts-delivery-outcomes.fixed.md diff --git a/changelog.d/prose-drafts-delivery-outcomes.fixed.md b/changelog.d/prose-drafts-delivery-outcomes.fixed.md new file mode 100644 index 000000000..982bffac9 --- /dev/null +++ b/changelog.d/prose-drafts-delivery-outcomes.fixed.md @@ -0,0 +1,4 @@ +- Explicit prose whose delivery definitely fails is held as a bounce draft with a private notice, matching hybrid envelopes. Unknown delivery outcomes remain failure-marker-only, so their words are not offered for an ordinary resend that might duplicate a post. +- A completing turn waits for its queued speech and hybrid holds even when its final response is empty, so its receipt includes that work and its draft notices settle with the same turn. +- Plain-speech failure notices and traces include a bounded display of the connector's structured error data, such as parts already posted. Contradictory delivery receipts bound the message id in their diagnostic without changing the receipt's id. A synchronous MCPL write rejection reports that the request was not sent and removes its pending state. +- A hybrid `>>>skip_reply {{unsent}}` in a skip-reply round sets the latest bounce aside while keeping publication private; same-round private-think text stays uninterpreted, matching explicit mode. diff --git a/src/framework.ts b/src/framework.ts index 4df304853..9753c5e60 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -351,9 +351,9 @@ const PROSE_ROUTING_HELP = ' 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 held as a draft, and a notice\n' + - 'names it — reply e.g. ">>#channel {{unsent}}" to deliver your latest held text\n' + + 'names it — reply e.g. ">>#channel {{unsent}}" to deliver your latest bounce draft\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' + + '">>skip_reply {{unsent}}" sets the latest bounce aside. Send tools (send_message,\n' + 'send_dm, …) are unaffected.'; const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = { @@ -9059,6 +9059,16 @@ export class AgentFramework { ): void { if (segments.length === 0) return; if (cause === 'private' || privateThink) { + // Match explicit mode: skip_reply keeps publication private but can + // still set a bounce aside. A private-think round interprets no text. + if (cause === 'private' && !privateThink && agent.proseRouting === 'hybrid') { + for (const segment of segments) { + for (const envelope of splitHybridEnvelopes(segment)) { + const parsed = parseHybridProsePrefix(envelope); + if (parsed.kind === 'private' && parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); + } + } + } this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + segments.length); return; } @@ -10297,6 +10307,7 @@ export class AgentFramework { body = rawText; } + const envelopeBody = body; const usedClipboard = body.includes('{{unsent}}'); const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; if (unsent.refused) return; @@ -10310,9 +10321,15 @@ export class AgentFramework { try { const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft); - if (outcome.status === 'delivered') this.proseBounceStreaks.delete(name); + if (outcome.status === 'delivered') { + this.proseBounceStreaks.delete(name); + } else if (outcome.status === 'failed') { + // Unknown deliveries are marker-only: offering a fresh draft would + // invite a duplicate. Keep the authored body for the re-bounce rule. + this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', hold); + } } catch (err) { - console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); + this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', hold); } } @@ -11947,6 +11964,11 @@ export class AgentFramework { ); } + // Queued deliveries and hybrid holds belong to this turn even + // when its final response is empty. Settle them before final + // dispatch, the receipt and the turn-end draft settlement. + await turnSpeechChain; + // Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a // text-only turn (no tool calls => speechContent populated), // publish the agent's speech to the conversational locus — the @@ -11960,9 +11982,6 @@ export class AgentFramework { const speechSegments = finalSpeechSegments; const textOnlyHold = { round: draftRound + 1, notice: 'later' as const }; if (speechSegments.length > 0) { - // A turn resumed after its earlier rounds delivers after - // them: their queued deliveries are this chain's. - await turnSpeechChain; if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); this.recordProseSuppression(agent.name, speechSegments.length); @@ -12044,11 +12063,6 @@ export class AgentFramework { const runs = finalRuns; const segments = finalSegments; - // Preserve in-channel ordering: everything enqueued live must - // land before the trailing prose. Awaited even when silenced — - // the chain may still be flushing earlier rounds' posts. - await turnSpeechChain; - if (turnProseRouting === 'disabled') { if (segments.length > 0) { console.error( @@ -12118,9 +12132,9 @@ export class AgentFramework { // doesn't depend on the status field, so it correctly runs after. // Logical turn end (normal completion): drop the `[delivered]` - // receipt. All deliveries are settled — the live chain was - // awaited before trailing dispatch, and trailing/text-only - // segments were awaited in-loop. Locus mode only; explicit-mode + // receipt. Queued deliveries and holds settled before final + // dispatch; trailing/text-only segments were awaited in-loop. + // Locus and hybrid modes only; explicit-mode // envelopes acknowledge themselves through the prose gateway. if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent); @@ -14915,7 +14929,7 @@ export class AgentFramework { // addMessage() alone does not request inference, so this never wakes // her (matching the `discord-send-failed-skip` gate intent: context // yes, wake no). - onRouteFailure: ({ channelId, reason, textLen, outcome }) => { + onRouteFailure: ({ channelId, reason, textLen, outcome, detail }) => { try { // Render a human-readable channel name when we can — a bare // snowflake in the marker is unresolvable for the agent (the @@ -14934,13 +14948,14 @@ export class AgentFramework { 'user', [{ type: 'text', - text: outcome === 'unknown' + 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.`, + : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`) + + (detail !== undefined ? ` Connector detail: ${detail}` : ''), }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason, ...(outcome ? { outcome } : {}) }, + { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason, ...(outcome ? { outcome } : {}), ...(detail !== undefined ? { detail } : {}) }, ); } 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 222fb2d03..a14ebb70a 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -460,6 +460,19 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // Constructor Options // ============================================================================ +/** Bound connector values for notices/logs; the PublishOutcome keeps the original. */ +function connectorText(value: unknown): string { + let text: string; + try { + text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value); + } catch { + return '[unserializable connector value]'; + } + const limit = 2000; + const suffix = '… [truncated]'; + return text.length > limit ? text.slice(0, limit - suffix.length) + suffix : text; +} + /** Where a publish was resolved to go — named by the registry, never by * the caller's spelling. */ export interface PublishDestination { @@ -534,6 +547,8 @@ interface ChannelRegistryOptions { /** `failed`: nothing was posted; `unknown`: the request was dispatched * and no valid receipt came back, so it may or may not have been. */ outcome?: 'failed' | 'unknown'; + /** Bounded display of the connector's structured error data, when present. */ + detail?: string; }) => void; /** * Called when channels were opened WITHOUT the agent asking (subscription @@ -3208,9 +3223,9 @@ export class ChannelRegistry { destination, ...(messageId ? { messageId } : {}), reason: messageId - ? `server "${entry.serverId}" reported delivered:false but named posted message ${messageId} (delivery uncertain)` + ? `server "${entry.serverId}" reported delivered:false but named posted message ${connectorText(messageId)} (delivery uncertain)` : `server "${entry.serverId}" reported delivered:false beside a malformed message id ` + - `${JSON.stringify(receipt.messageId)} (contradictory receipt; delivery uncertain)`, + `${connectorText(receipt.messageId)} (contradictory receipt; delivery uncertain)`, at: at(), }; } @@ -3246,7 +3261,9 @@ export class ChannelRegistry { ): 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)`); + const detail = outcome.detail !== undefined ? connectorText(outcome.detail) : undefined; + const detailText = detail !== undefined ? `; connector detail: ${detail}` : ''; + console.error(`[routeSpeech] ${conversationId}: ${reason}${detailText} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, @@ -3254,8 +3271,9 @@ export class ChannelRegistry { reason, textLen: text.length, outcome: status, + ...(detail !== undefined ? { detail } : {}), }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status }); + this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) }); return outcome; }; diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 3e7658b10..9de3b48b2 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -1077,7 +1077,18 @@ export class McplServerConnection extends EventEmitter { pending.timer.unref?.(); } this.pendingRequests.set(id, pending); - this.transport!.writeLine(JSON.stringify(request)); + try { + this.transport!.writeLine(JSON.stringify(request)); + } catch (err) { + // A synchronous write rejection sent no request. Remove its pending + // state and watchdog before reporting that it is safe to retry. + this.pendingRequests.delete(id); + if (pending.timer) clearTimeout(pending.timer); + reject(new McplRequestError( + `Cannot send ${method} to MCPL server "${this.id}": ${err instanceof Error ? err.message : String(err)}`, + 'not-sent', + )); + } }); } diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index c7002903a..33bfcb3e8 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -38,9 +38,9 @@ * 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. + * connector's message id when it names one — not a claim that the message + * still exists. A draft is sent only by its resident's explicit resend or + * an envelope's {{unsent}}; nothing replays at boot or on a branch move. */ import { randomInt } from 'node:crypto'; @@ -66,8 +66,9 @@ export type DraftReason = 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. */ + /** The resident's logical turn (process-local counter). */ turn: number; + /** Round within this stream; numbering restarts at a budget restart. */ round: number; /** Position of the segment within its round's speech. */ segment: number; @@ -137,9 +138,11 @@ export interface Draft { } /** - * - `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 + * - `held`: no inherited risk, and never attempted or every attempt + * definitely failed — resend freely; + * - `unconfirmed`: no attempt was confirmed and the draft is not dismissed; + * either an attempt's outcome is unknown (or was never recorded), or the + * words carry inherited risk regardless of their own attempts. 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; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 2d6ed09e6..f338f5286 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -27,6 +27,7 @@ import type { import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { ProseStreamRouter } from '../src/mcpl/prose-stream-router.js'; +import { draftState, type ProseDraftStore } from '../src/prose-drafts.js'; import type { ContentBlock } from '@animalabs/membrane'; class ToolboxModule implements Module { @@ -67,7 +68,7 @@ class ToolboxModule implements Module { } /** Registry stub: two known channels (#alpha guild, laria's DM) + capture. */ -function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'false' | 'throw'> = []) { +function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'false' | 'unknown' | 'throw'> = []) { const routed: Array<{ text: string; locus: string | null }> = []; const known: Record = { '#alpha': { channelId: 'chan-alpha', label: '#alpha' }, @@ -78,21 +79,27 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals '@laria': { channelId: 'discord:dm:99', label: 'DM: laria' }, 'discord:dm:99': { channelId: 'discord:dm:99', label: 'DM: laria' }, }; + const deliverSpeech = async (_agent: string, text: string, locus?: string | null) => { + routed.push({ text, locus: locus ?? null }); + const behavior = plan.shift() ?? 'delivered'; + if (behavior === 'throw') throw new Error('synthetic publication failure'); + const destination = { serverId: 'stub', channelId: locus ?? '' }; + if (behavior === 'unknown') { + return { status: 'unknown' as const, destination, reason: 'stub delivery uncertain', at: Date.now() }; + } + return behavior === 'delivered' + ? { status: 'delivered' as const, destination, at: Date.now() } + : { status: 'failed' as const, destination, reason: 'stub reported delivered:false', at: Date.now() }; + }; const explicit: Record = { resolveProseTarget: (spec: string) => - known[spec] ?? { error: `no channel matches "${spec}"` }, - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { - routed.push({ text, locus: locus ?? null }); - const behavior = plan.shift() ?? 'delivered'; - if (behavior === 'throw') throw new Error('synthetic publication failure'); - return { delivered: behavior === 'delivered', channelId: locus ?? '' }; - }, - // 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() }; + known[spec] ?? { error: 'no channel matches "' + spec + '"' }, + deliverSpeech, + routeSpeech: async (agent: string, text: string, locus?: string | null) => { + const outcome = await deliverSpeech(agent, text, locus); + return outcome.status === 'delivered' + ? { delivered: true, channelId: outcome.destination.channelId } + : null; }, resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), resolveLocus: () => 'world:commons', @@ -272,10 +279,75 @@ describe('explicit prose routing', () => { }; } - type DraftsView = { open(agent: string): unknown[]; latestBounce(agent: string): unknown }; + type DraftsView = ProseDraftStore; const draftsOf = (framework: AgentFramework): DraftsView => (framework as unknown as { proseDrafts: DraftsView }).proseDrafts; + for (const failure of ['false', 'throw'] as const) { + it('an explicit ' + failure + ' delivery holds its body as a bounce and names it privately', async () => { + const framework = await createFramework(); + try { + const routed = stubRegistry(framework, [failure]); + const agent = framework.getAgent('assistant')!; + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + await gateway.deliverProse(agent, '>>#alpha keep these words', { notice: 'later' }); + assert.deepEqual(routed, [{ text: 'keep these words', locus: 'chan-alpha' }]); + const held = draftsOf(framework).open('assistant'); + assert.equal(held.length, 1, 'the failed envelope became one draft'); + assert.equal(held[0]!.reason, 'bounced'); + assert.equal(held[0]!.text, 'keep these words'); + assert.ok(context(framework).texts.some((t) => t.includes('[prose-routing]') && t.includes(held[0]!.id))); + } finally { + await framework.stop(); + } + }); + } + + for (const mode of ['explicit', 'hybrid'] as const) { + it('an unknown ' + mode + ' delivery is not offered as a fresh bounce draft', async () => { + const framework = await createFramework(mode); + try { + const routed = stubRegistry(framework, ['unknown']); + const agent = framework.getAgent('assistant')!; + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + deliverHybridProse(a: unknown, text: string, locus: null, allowLocus: boolean, hold: { notice: 'later' }): Promise; + }; + if (mode === 'explicit') await gateway.deliverProse(agent, '>>#alpha uncertain words', { notice: 'later' }); + else await gateway.deliverHybridProse(agent, '>>>#alpha uncertain words', null, true, { notice: 'later' }); + assert.deepEqual(routed, [{ text: 'uncertain words', locus: 'chan-alpha' }], 'the uncertain attempt was made'); + assert.deepEqual(draftsOf(framework).open('assistant'), []); + assert.equal(context(framework).bounces, 0); + } finally { + await framework.stop(); + } + }); + } + + it('a thrown explicit unsent delivery re-bounces the authored body with its unresolved attempt risk', async () => { + const framework = await createFramework(); + try { + stubRegistry(framework, ['throw']); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ + text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 }, + }], 'bounced').held[0]!; + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + await gateway.deliverProse(framework.getAgent('assistant')!, '>>#alpha {{unsent}} plus a line', { notice: 'later' }); + const copy = store.open('assistant').find((d) => d.id !== original.id)!; + assert.ok(copy, 'the failed envelope is retained'); + assert.equal(copy.text, 'earlier words plus a line'); + assert.equal(copy.inheritedRisk?.draftId, original.id); + assert.equal(draftState(copy), 'unconfirmed', 'a fresh draft cannot erase its source attempt risk'); + } finally { + await framework.stop(); + } + }); + it('a round that calls skip_reply keeps its unprefixed prose private: nothing delivered, bounced, retained or woken for', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Weighing whether this needs an answer from me.' }, @@ -392,6 +464,14 @@ describe('explicit prose routing', () => { const framework = await createFramework(); const routed = stubRegistry(framework); const skip = answerSkipReply(framework); + const retained: Array>> = []; + let turns = 0; + framework.onTrace((event) => { + if (event.type === 'inference:started' && ++turns === 2) { + const draft = draftsOf(framework).latestBounce('assistant'); + if (draft) retained.push(draft); + } + }); feedTurn(framework, 2, [ { type: 'text', text: '>>skip_reply {{unsent}}' }, { type: 'tool_use', id: 'sr4', name: 'skip_reply', input: {} }, @@ -402,6 +482,9 @@ describe('explicit prose routing', () => { assert.equal(skip.calls, 1, 'the bounce wake ran the skip_reply round'); assert.equal(routed.length, 0); + assert.equal(retained.length, 1, 'turn one really held a bounce before the dismissal'); + assert.equal(retained[0]!.text, 'An answer with nowhere to go.'); + assert.equal(draftState(draftsOf(framework).get('assistant', retained[0]!.id)!), 'dismissed'); assert.equal(draftsOf(framework).latestBounce('assistant'), undefined, 'the bounced draft is set aside, as >>skip_reply {{unsent}} says'); await framework.stop(); diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs index 8cebbf70c..39d2c4e4d 100644 --- a/test/fixtures/speech-route-mcpl-server.mjs +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -30,11 +30,12 @@ const log = (event, extra = {}) => { }; 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 } }); +const replyError = (id, message, data) => send({ jsonrpc: '2.0', id, error: { code: -32000, message, ...(data !== undefined ? { data } : {}) } }); let nextId = 500; let processedCommands = 0; let publishMode = 'delivered'; +let publishErrorData; let posted = 0; const histories = new Map(); @@ -122,6 +123,7 @@ function pollCommands() { log('renamed', { channelId: c.channelId, label: c.label }); } else if (c.op === 'publish-mode') { publishMode = c.mode; + publishErrorData = c.detail; log('publish-mode', { mode: c.mode }); } else if (c.op === 'history') { histories.set(c.channelId, c.history); @@ -202,7 +204,7 @@ rl.on('line', (line) => { } if (publishMode === 'error') { log('publish-error', { channelId, text }); - if (msg.id !== undefined && msg.id !== null) replyError(msg.id, 'connector exploded'); + if (msg.id !== undefined && msg.id !== null) replyError(msg.id, 'connector exploded', publishErrorData); return; } if (publishMode === 'not-delivered') { diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 1b0f45647..6a681f56e 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -90,3 +90,19 @@ test('McplRequestError is an Error that keeps its message and name', () => { assert.equal(err.name, 'McplRequestError'); assert.equal(err.message, 'boom'); }); + +test('a synchronous write rejection is not-sent, leaves no pending request, and does not poison the next request', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + const state = connection as unknown as { + transport: { writeLine(json: string): void }; + pendingRequests: Map; + }; + const write = state.transport.writeLine.bind(state.transport); + state.transport.writeLine = () => { throw new Error('synthetic write rejection'); }; + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'not-sent'); + assert.match(err.message, /synthetic write rejection/); + assert.equal(state.pendingRequests.size, 0, 'nothing remains awaiting an unwritten request'); + state.transport.writeLine = write; + const next = await rejectsWith(connection.sendToolsCall('fail', {}), 'error-response'); + assert.deepEqual(next.data, { posted: ['111'] }, 'a subsequent written request still reaches the real server'); +}); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 05c7f13f6..1ab212b5f 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -583,6 +583,79 @@ describe('held prose drafts, end to end', () => { } }); + for (const mode of ['explicit', 'hybrid'] as const) { + it('through the real connection, unknown ' + mode + ' speech is marked but not held for an ordinary resend', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: mode }] }); + try { + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([text((mode === 'explicit' ? '>>' : '>>>') + ROOM + ' uncertain words')])]); + assert.deepEqual(h.publishes().map((p) => p.text), ['uncertain words'], 'one request may already have posted'); + assert.deepEqual(h.drafts(), [], 'uncertainty is not a fresh resendable bounce'); + const markers = h.texts().filter((t) => t.startsWith('[discord-send-failed]')); + assert.equal(markers.length, 1); + assert.match(markers[0]!, /was not confirmed.*may or may not have been posted/); + assert.ok(!h.messages().some((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-bounce'), 'no bounce notice offers a retry'); + } finally { + await h.close(); + } + }); + } + + for (const privateThink of [false, true]) { + it('hybrid skip_reply unsent ' + (privateThink ? 'stays uninterpreted beside a private think' : 'dismisses the bounce while keeping the round private'), async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid', sameRoundThinkTextPolicy: 'private' }] }); + try { + const original = h.store().hold('scout', [{ text: 'held bounce', source }], 'bounced').held[0]!; + assert.equal(draftState(original), 'held'); + const blocks = [text('>>>skip_reply {{unsent}}'), call('skip1', 'skip_reply', {})]; + if (privateThink) blocks.push(call('think1', 'think', { thought: 'private reasoning' })); + await h.turn([createMockResponse(blocks, 'tool_use'), createMockResponse([])]); + assert.equal(draftState(h.store().get('scout', original.id)!), privateThink ? 'held' : 'dismissed'); + assert.deepEqual(h.publishes(), [], 'deliberate privacy posts nothing'); + assert.equal(h.store().open('scout').length, privateThink ? 1 : 0, 'no fresh draft was made from private words'); + } finally { + await h.close(); + } + }); + } + + it('an empty completion waits for earlier queued speech and holds before writing their receipt', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + let release: (() => void) | undefined; + try { + 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 queued words') { + entered = true; + await new Promise((resolve) => { release = resolve; }); + } + return deliver(...args); + }; + // Deliberately not rounds(): the last response has no prior content. + const running = h.turn([ + createMockResponse([text('>>>' + ROOM + ' slow queued words'), call('list1', 'channel_list', {})], 'tool_use'), + createMockResponse([text('held behind the slow delivery'), explicitSend('send1')], 'tool_use'), + createMockResponse([]), + ]); + await h.until(() => entered && (h.membrane.lastStream?.receivedToolResults.length ?? 0) >= 2, 'both tool rounds behind the pending delivery'); + assert.deepEqual(h.drafts(), [], 'the hold is still queued'); + release!(); + await running; + await h.until(() => h.publishes().some((p) => p.text === 'slow queued words') && h.drafts().length === 1, 'the earlier delivery and queued hold'); + const draft = h.drafts()[0]!; + assert.equal(draft.text, 'held behind the slow delivery'); + const receipt = h.texts().find((t) => t.startsWith('[delivered]')); + assert.ok(receipt, 'the completed turn receipts the queued work'); + assert.ok(receipt.includes(ROOM), 'the delayed delivery is in the receipt'); + assert.ok(receipt.includes(draft.id), 'so is the hold settled behind it'); + } finally { + release?.(); + await h.close(); + } + }); + it('through the real connection, a server that never answers is unconfirmed once the request times out', async () => { const h = await harness({ requestTimeoutMs: 1_000 }); try { @@ -597,7 +670,7 @@ describe('held prose drafts, end to end', () => { } }); - it("a silent turn's prose stays private beside a send: counted, never held or named", async () => { + it("a silent turn's prose stays private beside a send: never held, named or receipted", async () => { const h = await harness(); try { await h.turn([ @@ -607,6 +680,7 @@ describe('held prose drafts, end to end', () => { assert.deepEqual(h.drafts(), [], 'nothing was held as a draft'); assert.ok(!h.texts().some((t) => t.includes('held as draft')), 'and no notice names one'); assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere'], 'only the explicit send went out'); + assert.ok(!h.texts().some((t) => t.startsWith('[delivered]')), 'a silent turn receives no plain-speech receipt'); } finally { await h.close(); } diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 846a9835f..78f85412e 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -27,6 +27,7 @@ type Publish = (params: { channelId: string }) => Promise; function registryWith( servers: Record Promise }>, failures: unknown[] = [], + traces: Array<{ type: string; [key: string]: unknown }> = [], ) { const published: Array<{ serverId: string; channelId: string }> = []; const opened: Array<{ serverId: string; channelId: string }> = []; @@ -51,7 +52,7 @@ function registryWith( { getServer: (id: string) => mocks.get(id) } as unknown as McplServerRegistry, {} as FeatureSetManager, () => {}, - () => {}, + (event) => { traces.push(event); }, { onRouteFailure: (info) => { failures.push(info); } }, ); const channels = (registry as unknown as { @@ -176,6 +177,37 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(outcome.destination?.channelId, 'c'); }); + for (const id of ['message-' + 'x'.repeat(10_000), { malformed: 'x'.repeat(10_000) }]) { + it('bounds a contradictory ' + (typeof id === 'string' ? 'string' : 'object') + ' message id in the reason, preserving the receipt', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, messageId: id }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.ok(outcome.reason!.length < 2300, 'the diagnostic is bounded'); + assert.match(outcome.reason!, /\[truncated\]/); + assert.match(outcome.reason!, /delivery uncertain/); + assert.equal(outcome.messageId, typeof id === 'string' ? id : undefined, 'diagnostic clipping does not rewrite a connector id'); + }); + } + + it('speech failure forwards bounded connector data to both notice and trace, preserving the original outcome', async () => { + const data = { posted: ['111'], note: 'x'.repeat(10_000) }; + const failures: unknown[] = []; + const traces: Array<{ type: string; [key: string]: unknown }> = []; + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', -32000, data); } }, + }, failures, traces); + seed('discord', 'c', '#c'); + const outcome = await registry.deliverSpeech('agent', 'words', 'c'); + assert.equal(outcome.status, 'unknown'); + assert.deepEqual(outcome.detail, data, 'the outcome still carries the complete structured data'); + const info = failures[0] as { detail?: string }; + assert.ok(info.detail && info.detail.length <= 2000); + assert.match(info.detail, /"posted":\["111"\]/); + assert.match(info.detail, /\[truncated\]$/); + assert.equal(traces.find((e) => e.type === 'mcpl:speech-route-failed')?.detail, info.detail); + }); + 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'); } }, @@ -263,8 +295,10 @@ test('the resident\'s failure marker never claims an uncertain delivery did not 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'); + const traces: Array<{ type: string; detail?: unknown }> = []; + framework.onTrace((event) => traces.push(event)); + const turn = async (mode: string, n: number, detail?: unknown) => { + appendFileSync(commandPath, JSON.stringify({ op: 'publish-mode', mode, detail }) + '\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}`); @@ -278,6 +312,14 @@ test('the resident\'s failure marker never claims an uncertain delivery did not assert.doesNotMatch(uncertain!, /did not receive|Nothing was posted/); assert.match(failed!, /could not be delivered/); assert.match(failed!, /Nothing was posted/); + await turn('error', 3, { posted: ['111'], note: 'x'.repeat(10_000) }); + const partial = markers()[2]!; + assert.match(partial, /was not confirmed/); + assert.match(partial, /Connector detail:.*"posted":\["111"\]/); + assert.match(partial, /\[truncated\]$/); + assert.ok(partial.length < 2500, 'the resident receives a bounded display, not the whole error object'); + const trace = traces.find((event) => event.type === 'mcpl:speech-route-failed' && event.detail !== undefined); + assert.ok(trace?.detail, 'the same connector account reaches the failure trace'); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); From ed626b8b453ae82890f8f9e6870219eef3c774ef Mon Sep 17 00:00:00 2001 From: Nathan-2367 Date: Tue, 13 Oct 2026 01:31:45 -0400 Subject: [PATCH 24/54] test(prose): keep uncertain failures visible when connector data cannot render The bounded connector display is reporting an already uncertain send. A formatting failure must not replace that structured outcome with a thrown exception, because callers retain unexpected exceptions as bounces. Add a control through deliverSpeech for data that the formatter cannot serialize: the outcome and source value remain intact, and the private failure callback receives an explicit display fallback. Removing the formatter catch turns this into a rejection. This exercises the display guard through the exported request-error and registry APIs, using the existing typed fixture. It is a unit boundary test; wire JSON itself cannot contain the cyclic value. The publish-outcome file passes 25 tests after a clean VPS typecheck/build. Co-authored-by: GPT-6.1 Sol --- test/publish-outcome.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 78f85412e..1f94d3366 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -208,6 +208,20 @@ describe('ChannelRegistry.publish outcomes', () => { assert.equal(traces.find((e) => e.type === 'mcpl:speech-route-failed')?.detail, info.detail); }); + it('unrenderable connector data keeps an unknown speech failure visible rather than throwing', async () => { + const data: Record = { posted: ['111'] }; + data.self = data; + const failures: unknown[] = []; + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', -32000, data); } }, + }, failures); + seed('discord', 'c', '#c'); + const outcome = await registry.deliverSpeech('agent', 'words', 'c'); + assert.equal(outcome.status, 'unknown'); + assert.equal(outcome.detail, data, 'the formatter does not change the source value'); + assert.equal((failures[0] as { detail?: string }).detail, '[unserializable connector value]'); + }); + 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'); } }, From 4bb693928b84d6d4694cf3ec33437270ab6a0ec5 Mon Sep 17 00:00:00 2001 From: Nathan-2367 Date: Tue, 13 Oct 2026 01:47:14 -0400 Subject: [PATCH 25/54] test(mcpl): reject late handling for requests whose write failed Strengthen the synchronous-write fault test with a deferred render request and a controlled watchdog clock. After the write is rejected, its former deadline must not register an orphan that can surface a later response. Inject a state-less response for that unwritten id and verify that no orphaned-render event is emitted, then restore the writer and confirm that a real subsequent request still works. This protects timer cleanup through the response-handling behavior rather than a private timer flag. Also check that the stored plain-speech failure marker carries the same bounded connector display as its trace. The returned publish outcome continues to hold the original structured data. The request-outcome and publish-outcome files pass 31 tests after a clean VPS typecheck/build. Co-authored-by: GPT-6.1 Sol --- test/mcpl-request-outcome.test.ts | 16 ++++++++++++---- test/publish-outcome.test.ts | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 6a681f56e..4d5d79787 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -91,17 +91,25 @@ test('McplRequestError is an Error that keeps its message and name', () => { assert.equal(err.message, 'boom'); }); -test('a synchronous write rejection is not-sent, leaves no pending request, and does not poison the next request', async () => { +test('a synchronous write rejection is not-sent, leaves no pending request, and does not poison the next request', async (t) => { connection = await McplServerConnection.connect(config(), HOST_CAPS); const state = connection as unknown as { - transport: { writeLine(json: string): void }; + transport: { writeLine(json: string): void; emit(event: string, line: string): boolean }; pendingRequests: Map; }; const write = state.transport.writeLine.bind(state.transport); - state.transport.writeLine = () => { throw new Error('synthetic write rejection'); }; - const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'not-sent'); + t.mock.timers.enable({ apis: ['setTimeout'] }); + let unwrittenId: unknown; + const lateRenders: unknown[] = []; + connection.on('orphaned-render-response', (event) => lateRenders.push(event)); + state.transport.writeLine = (json) => { unwrittenId = JSON.parse(json).id; throw new Error('synthetic write rejection'); }; + const err = await rejectsWith(connection.sendPushRender({ featureSet: 'test', key: 'unwritten', eventId: 'e1', notices: [], dropped: 0 }), 'not-sent'); assert.match(err.message, /synthetic write rejection/); assert.equal(state.pendingRequests.size, 0, 'nothing remains awaiting an unwritten request'); + t.mock.timers.tick(5000); + state.transport.emit('line', JSON.stringify({ jsonrpc: '2.0', id: unwrittenId, result: { content: [] } })); + assert.deepEqual(lateRenders, [], 'an unwritten render cannot surface a late-response event after its old watchdog deadline'); + t.mock.timers.reset(); state.transport.writeLine = write; const next = await rejectsWith(connection.sendToolsCall('fail', {}), 'error-response'); assert.deepEqual(next.data, { posted: ['111'] }, 'a subsequent written request still reaches the real server'); diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 1f94d3366..c3721855a 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -334,6 +334,8 @@ test('the resident\'s failure marker never claims an uncertain delivery did not assert.ok(partial.length < 2500, 'the resident receives a bounded display, not the whole error object'); const trace = traces.find((event) => event.type === 'mcpl:speech-route-failed' && event.detail !== undefined); assert.ok(trace?.detail, 'the same connector account reaches the failure trace'); + const marker = framework.getAgent('scout')!.getContextManager().getAllMessages().find((m) => (m.content[0] as { text: string }).text === partial); + assert.equal((marker?.metadata as { detail?: unknown } | undefined)?.detail, trace?.detail, 'the persisted marker metadata carries the same bounded display'); } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); From e76f2c9bbe0aad6729e5a793657dade30927f1c7 Mon Sep 17 00:00:00 2001 From: Nathan-2367 Date: Tue, 13 Oct 2026 02:13:34 -0400 Subject: [PATCH 26/54] test(mcpl): arm the observer before asserting no late render response A freshly connected MCPL connection buffers data-plane events until ready(). The failed-write test attached an orphaned-render subscriber but left the plane buffered, so its missing-event assertion passed even when timer cancellation was removed. Release that plane after wiring the listener and add a positive event control before clearing the observed events and attempting the rejected write. The fixed file passes all six tests after a clean build. Removing timer cancellation now fails the intended late-response assertion after the controlled watchdog deadline. This changes only the request-outcome test fixture; all production code and other test files are unchanged from the full per-file check. The final focused runtime checks and affected removals supplement that full receipt. Co-authored-by: GPT-6.1 Sol --- test/mcpl-request-outcome.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 4d5d79787..4534fd558 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -102,6 +102,11 @@ test('a synchronous write rejection is not-sent, leaves no pending request, and let unwrittenId: unknown; const lateRenders: unknown[] = []; connection.on('orphaned-render-response', (event) => lateRenders.push(event)); + // connect() buffers data-plane events until the caller has wired listeners. + connection.ready(); + connection.emit('orphaned-render-response', { params: { featureSet: 'test', key: 'observer-probe', eventId: 'probe' }, result: { content: [] } }); + assert.equal(lateRenders.length, 1, 'the observer is armed, so absence of a later event is meaningful'); + lateRenders.length = 0; state.transport.writeLine = (json) => { unwrittenId = JSON.parse(json).id; throw new Error('synthetic write rejection'); }; const err = await rejectsWith(connection.sendPushRender({ featureSet: 'test', key: 'unwritten', eventId: 'e1', notices: [], dropped: 0 }), 'not-sent'); assert.match(err.message, /synthetic write rejection/); From b2a21eca0a0e47423ced178f7fc02a0516fd5f2c Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 02:43:45 -0400 Subject: [PATCH 27/54] fix(drafts): short notices with no draft words, one notice per failed delivery, and {{unsent}} that names nothing sends nothing Notices: - The catch-up notice (drafts a crash, an aborted turn or a rollback left unnamed) names each draft by id, with its state and why it was held, and quotes none of its words. Before, it previewed the newest eight. The held-draft notice drops its sentence about drafts staying until acted on; it keeps the ids, the reason, and how to resend or dismiss. A draft's words now appear only in what the drafts tool returns (list and read). The draftPreview helper is the listing's. - A failed delivery that the gateway holds as a bounce draft gets the bounce notice alone. deliverSpeech takes heldOnFailure, and skips the failure marker for a failed outcome then; an unknown outcome still gets its marker, since nothing re-holds those words. {{unsent}}: - An envelope whose {{unsent}} names no held or unconfirmed bounce draft (it was sent, set aside or let go, or there never was one) now sends nothing. Before, the token became empty text, so the envelope posted the rest of its words as a fragment, or dropped silently. Its other words, if any, are held as a bounce draft with its notice; otherwise a short notice says nothing was sent. A hybrid envelope refused this way clears its pin and holds later text, as any refused {{unsent}} does. Deadlines and bounds: - channels/open keeps the same fallback deadline as channels/publish when requestTimeoutMs is 0. A delivery opens a closed channel before it publishes, and the completing turn now waits for its deliveries, so an open that is never answered would hold the turn. - The connector's own error message is bounded, as its error data is, where it becomes a publish outcome's reason (an error response, or a failed open), so the trace and the marker carry a bounded reason. Text: - The completion's comment says the chain settles before the host's own final routing, receipt and draft settlement; the modules above hear the turn's speech itself. - holdSilencedProse's comment: a private think in the same tool round, in any tool mode. - The drafts header says retention lets an older draft go in any state, at the journal's next checkpoint, so a resident whose drafts change rarely keeps its older ones until then; hold() says a draft whose append threw may still be in the store's log, and reappears as held after a reload. - The changelog fragments say the above, scope the completion's receipt to hybrid and locus mode, and say notices quote no draft words. Tests: - prose-drafts: the catch-up names drafts with their state and no words (three tests rewritten from previews); through the real connection, a failed explicit or hybrid delivery is held with one notice and no failure marker. - explicit-prose-routing: {{unsent}} with nothing to name sends nothing and holds the envelope's other words; {{unsent}} after its draft was delivered sends nothing. - mcpl-request-outcome: closing the connection rejects an awaiting request as no-response; channels/open times out with requestTimeoutMs at 0. --- .../prose-drafts-delivery-outcomes.fixed.md | 10 ++-- changelog.d/prose-drafts.added.md | 4 +- src/framework.ts | 54 ++++++++++++++----- src/mcpl/channel-registry.ts | 13 +++-- src/mcpl/server-connection.ts | 12 +++-- src/prose-drafts.ts | 19 ++++--- test/explicit-prose-routing.test.ts | 47 ++++++++++++++++ test/mcpl-request-outcome.test.ts | 15 ++++++ test/prose-drafts.test.ts | 37 ++++++++++--- 9 files changed, 171 insertions(+), 40 deletions(-) diff --git a/changelog.d/prose-drafts-delivery-outcomes.fixed.md b/changelog.d/prose-drafts-delivery-outcomes.fixed.md index 982bffac9..c0bf7a4eb 100644 --- a/changelog.d/prose-drafts-delivery-outcomes.fixed.md +++ b/changelog.d/prose-drafts-delivery-outcomes.fixed.md @@ -1,4 +1,6 @@ -- Explicit prose whose delivery definitely fails is held as a bounce draft with a private notice, matching hybrid envelopes. Unknown delivery outcomes remain failure-marker-only, so their words are not offered for an ordinary resend that might duplicate a post. -- A completing turn waits for its queued speech and hybrid holds even when its final response is empty, so its receipt includes that work and its draft notices settle with the same turn. -- Plain-speech failure notices and traces include a bounded display of the connector's structured error data, such as parts already posted. Contradictory delivery receipts bound the message id in their diagnostic without changing the receipt's id. A synchronous MCPL write rejection reports that the request was not sent and removes its pending state. -- A hybrid `>>>skip_reply {{unsent}}` in a skip-reply round sets the latest bounce aside while keeping publication private; same-round private-think text stays uninterpreted, matching explicit mode. +- Explicit prose whose delivery definitely fails is held as a bounce draft with its notice, as hybrid envelopes are. A delivery whose outcome is unknown gets the failure marker alone, so its words are not offered for an ordinary resend that might duplicate a post. A failed delivery that is held gets the bounce notice alone, not a second marker. +- An envelope whose `{{unsent}}` names no held draft (the latest bounce draft was sent, set aside or let go) sends nothing, rather than the rest of the envelope. Its other words, if any, are held as a bounce draft; otherwise a short notice says so. +- In hybrid and locus mode, a completing turn waits for its queued speech and hybrid holds even when its final response is empty, so its receipt includes that work and its draft notices settle with the same turn. +- Failure markers and traces include a bounded display of the connector's structured error data, such as parts already posted, and the connector's error message is bounded too. Contradictory delivery receipts bound the message id in their diagnostic without changing the receipt's id. +- A synchronous MCPL write rejection reports that the request was not sent and removes its pending state. `channels/open` keeps a deadline when `requestTimeoutMs` is 0, as `channels/publish` does, so an unanswered open can't hold a delivery for good. +- A hybrid `>>>skip_reply {{unsent}}` in a skip-reply round sets the latest bounce aside while keeping publication private; text in a round with a private think stays uninterpreted, matching explicit mode. diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index f55b6d5a2..bcc78d337 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -41,7 +41,9 @@ counts as named while a message naming it is on the current branch, so one no notice reached (a crash, an aborted turn), or whose notices a rollback or branch move took away, is named at the next turn's start: every such - draft by id, the newest eight with a preview. + draft by id, the newest eight with its state and why it was held. Notices + name drafts and say how to resend or dismiss them, and quote none of their + words; the `drafts` tool's `list` and `read` show the words. - 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 diff --git a/src/framework.ts b/src/framework.ts index 9753c5e60..43e87ca95 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9044,8 +9044,8 @@ 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 in a native round, where that policy applies) keeps it private — + * deliberate silence (skip_reply, a silent turn, or a private think in + * the same tool 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. @@ -9249,8 +9249,7 @@ export class AgentFramework { `[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.` + `${resend.join('; ')}; drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.` ); } @@ -9259,6 +9258,7 @@ export class AgentFramework { return formatZonedDateTime(t, this.timeZone).replace(/:\d\d\.\d{3}/, ''); } + /** A draft's opening words, for the drafts tool's listing only. */ 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); @@ -9338,15 +9338,16 @@ export class AgentFramework { * compile, any open drafts no notice on the current branch names: none * reached the history (a crash, an aborted turn), or a rollback or branch * move took the notices away. Every draft it marks is named by id, newest - * first: the newest eight with their state and a preview, the rest by id. + * first: the newest eight with their state and why each was held, the rest + * by id. Like every notice, it quotes none of their words; the drafts tool + * shows them. */ private noticeUnnoticedDrafts(agent: Agent): void { const cm = agent.getContextManager(); const pending = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null); if (pending.length === 0) return; const shown = pending.slice(0, 8).map((d) => - `${d.id} (${this.draftStateText(d)}; held ${this.draftTime(d.heldAt)}, ` + - `${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); + `${d.id} (${this.draftStateText(d)}; ${AgentFramework.DRAFT_REASON_SHORT[d.reason]})`); const rest = pending.slice(shown.length).map((d) => d.id); const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : ''; const text = @@ -9996,9 +9997,32 @@ export class AgentFramework { agent: Agent, body: string, hold: { round?: number; notice: 'now' | 'later' }, + prefix: '>>' | '>>>', ): { draft?: Draft; refused?: true } { const draft = this.proseDrafts.latestBounce(agent.name); - if (!draft) return {}; + if (!draft) { + // {{unsent}} names nothing now: the latest bounce draft was sent, set + // aside or let go, or there never was one. Send nothing rather than a + // fragment. The envelope's own words, if it has any, are held as a + // bounce draft, with its notice; otherwise a short notice says so. + const own = body.replaceAll('{{unsent}}', '').trim(); + const reason = '{{unsent}} names no held draft: your latest bounce draft has been sent, set aside or let go'; + if (own) { + this.bounceProse(agent, own, reason, undefined, prefix, hold); + } else { + try { + this.addMessage( + 'user', + [{ type: 'text', text: `[prose-routing] ${reason}, so nothing was sent. drafts(action: "list") shows your open drafts.` }], + { system: true, kind: 'prose-bounce' }, + { forAgent: agent.name }, + ); + } catch (err) { + console.error('[drafts] empty-unsent notice failed:', err); + } + } + return { refused: true }; + } const key = `${agent.name}\u0000${draft.id}`; let why: string; let next: string; @@ -10072,7 +10096,9 @@ export class AgentFramework { } let outcome: PublishOutcome; try { - outcome = await registry.deliverSpeech(agent.name, body, channelId); + // Both callers hold a failed delivery's words as a bounce draft, whose + // notice says what happened, so the registry writes no second marker. + outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true }); } finally { if (flight) { this.draftsInFlight.delete(flight); @@ -10149,7 +10175,7 @@ export class AgentFramework { } let body = parsed.body; const usedClipboard = body.includes('{{unsent}}'); - const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold, '>>>') : {}; if (unsent.refused) { this.proseTargetPins.delete(agent.name); this.proseHybridSuppressed.set(agent.name, 'failed'); @@ -10309,7 +10335,7 @@ export class AgentFramework { const envelopeBody = body; const usedClipboard = body.includes('{{unsent}}'); - const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold, '>>') : {}; if (unsent.refused) return; if (usedClipboard) { body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); @@ -11965,8 +11991,10 @@ export class AgentFramework { } // Queued deliveries and hybrid holds belong to this turn even - // when its final response is empty. Settle them before final - // dispatch, the receipt and the turn-end draft settlement. + // when its final response is empty. Settle them before the + // host's own final routing below, the receipt and the turn-end + // draft settlement. (The modules above hear the turn's speech + // itself; they publish nothing of the queue's.) await turnSpeechChain; // Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index a14ebb70a..f2d327500 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3172,7 +3172,7 @@ export class ChannelRegistry { return { status: 'failed', destination, - reason: `channel is closed and open failed: ${(err as Error).message}`, + reason: `channel is closed and open failed: ${connectorText(err instanceof Error ? err.message : String(err))}`, at: at(), }; } @@ -3186,7 +3186,8 @@ export class ChannelRegistry { content: [{ type: 'text', text }], }); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + // The connector's error message is its own words: bounded, as its data is. + const message = connectorText(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 @@ -3258,6 +3259,10 @@ 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, + /** `heldOnFailure`: the caller holds a `failed` delivery's words as a + * draft with its own notice, so no failure marker is written for it. + * An `unknown` outcome still gets its marker. */ + opts?: { heldOnFailure?: boolean }, ): Promise { const fail = (channelId: string | null, reason: string, outcome: PublishOutcome): PublishOutcome => { const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; @@ -3273,7 +3278,9 @@ export class ChannelRegistry { outcome: status, ...(detail !== undefined ? { detail } : {}), }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) }); + if (status === 'unknown' || !opts?.heldOnFailure) { + this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) }); + } return outcome; }; diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 9de3b48b2..965db7c6f 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -243,8 +243,8 @@ export class McplServerConnection extends EventEmitter { /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; - /** The deadline a channels/publish request keeps when requestTimeoutMs is 0 - * (see sendChannelsPublish). */ + /** The deadline a channels/publish or channels/open request keeps when + * requestTimeoutMs is 0 (see sendChannelsPublish). */ private publishDeadlineMs = DEFAULT_REQUEST_TIMEOUT_MS; // Event buffering: control-plane traffic can be released independently from @@ -754,9 +754,13 @@ export class McplServerConnection extends EventEmitter { return this.sendRequest(McplMethod.StateRollback, params as unknown as Record) as Promise; } - /** Send `channels/open` request and await result. */ + /** Send `channels/open` request and await result. It keeps a deadline as + * channels/publish does: a delivery opens a closed channel before it + * publishes, so an open that is never answered would hold the delivery, + * and the turn that waits for it, for good. */ sendChannelsOpen(params: ChannelsOpenParams): Promise { - return this.sendRequest(McplMethod.ChannelsOpen, params as unknown as Record) as Promise; + const timeoutMs = this.requestTimeoutMs > 0 ? this.requestTimeoutMs : this.publishDeadlineMs; + return this.sendRequest(McplMethod.ChannelsOpen, params as unknown as Record, { timeoutMs }) as Promise; } /** Send `channels/close` request and await result. */ diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 33bfcb3e8..ebaad9d05 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -8,7 +8,8 @@ * 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 until the resident resends or dismisses them. + * the exact words until the resident resends or dismisses them, or the + * retention below lets it go. * * The boundary is publication and agency, not visibility: a draft is never * published except by its resident's explicit resend (or `{{unsent}}`), and @@ -28,11 +29,12 @@ * rollback or branch move has left unnamed is named again. * * Drafts are kept, not hoarded. Each resident's KEEP_NEWEST newest drafts - * are always kept; an older one is let go once nothing has happened to it - * (held, named, attempted, answered, dismissed) for KEEP_IDLE_MS. The words - * stay in the resident's history either way. The journal checkpoints what - * it keeps every CHECKPOINT_EVERY entries, so opening it replays only what - * came after. + * are always kept; an older one, in any state, is let go at the journal's + * next checkpoint once nothing has happened to it (held, named, attempted, + * answered, dismissed) for KEEP_IDLE_MS. The journal checkpoints what it + * keeps every CHECKPOINT_EVERY entries, so opening it replays only what came + * after, and a resident whose drafts change rarely keeps its older ones + * until then. The words stay in the resident's history either way. * * A delivery attempt is journaled durably BEFORE its request leaves the * host, and its outcome after: an attempt with no recorded outcome (a crash @@ -357,7 +359,10 @@ export class ProseDraftStore { * already written stand and the rest are reported as not held: their words * remain in the resident's history only. A draft whose record reached the * store before its write failed (its sync did) is in the reconciled - * journal, so it is held, and counted so. + * journal, so it is held, and counted so. When the store's append itself + * threw, the record may still be in its log: the draft is reported as not + * held, and the journal refuses further writes until a reload, which + * replays it as held if it is there. */ hold( agent: string, diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index f338f5286..7573e22ca 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -326,6 +326,53 @@ describe('explicit prose routing', () => { }); } + it('{{unsent}} that names no held draft sends nothing, and holds the envelope\'s other words', async () => { + const framework = await createFramework(); + try { + const routed = stubRegistry(framework); + const store = draftsOf(framework); + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + const agent = framework.getAgent('assistant')!; + // Nothing was ever bounced: a bare {{unsent}} sends nothing and says so. + await gateway.deliverProse(agent, '>>#alpha {{unsent}}', { notice: 'later' }); + assert.deepEqual(routed, [], 'nothing is sent'); + assert.deepEqual(store.open('assistant'), [], 'and nothing is held'); + assert.ok(context(framework).texts.some((t) => t.startsWith('[prose-routing] {{unsent}} names no held draft') && t.includes('nothing was sent'))); + // With words of its own, those are held as a bounce draft rather than + // posted as a fragment. + await gateway.deliverProse(agent, '>>#alpha Here it is again: {{unsent}}', { notice: 'later' }); + assert.deepEqual(routed, [], 'still nothing is sent'); + const [held] = store.open('assistant'); + assert.equal(held?.text, 'Here it is again:'); + assert.equal(store.latestBounce('assistant')?.id, held?.id); + } finally { + await framework.stop(); + } + }); + + it('{{unsent}} after its draft was sent names nothing, so the envelope sends nothing', async () => { + const framework = await createFramework(); + try { + const routed = stubRegistry(framework); + const store = draftsOf(framework); + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + const agent = framework.getAgent('assistant')!; + await gateway.deliverProse(agent, '>>#nowhere the words', { notice: 'later' }); + const bounce = store.latestBounce('assistant')!; + assert.equal(bounce.text, 'the words'); + await gateway.deliverProse(agent, '>>#alpha {{unsent}}', { notice: 'later' }); + assert.deepEqual(routed.map((r) => r.text), ['the words'], 'the first {{unsent}} sends the bounce'); + await gateway.deliverProse(agent, '>>#alpha {{unsent}}', { notice: 'later' }); + assert.deepEqual(routed.map((r) => r.text), ['the words'], 'a second one, with the draft delivered, sends nothing'); + } finally { + await framework.stop(); + } + }); + it('a thrown explicit unsent delivery re-bounces the authored body with its unresolved attempt risk', async () => { const framework = await createFramework(); try { diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 4534fd558..85dadaa1b 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -76,6 +76,21 @@ test('a connection lost while awaiting is no-response', async () => { await rejectsWith(connection.sendToolsCall('exit', {}), 'no-response'); }); +test('closing the connection while a request awaits its answer rejects it as no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 10_000 }), HOST_CAPS); + const pending = connection.sendToolsCall('stuck', {}); + await connection.close(); + await rejectsWith(pending, 'no-response'); + connection = null; +}); + +test('channels/open keeps a deadline when requestTimeoutMs is 0, as channels/publish does', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 0 }), HOST_CAPS); + (connection as unknown as { publishDeadlineMs: number }).publishDeadlineMs = 200; + const err = await rejectsWith(connection.sendChannelsOpen({ type: 'channel', address: { id: 'nowhere' } } as never), 'no-response'); + assert.match(err.message, /did not respond to channels\/open/); +}); + test('a request on a closed connection is not-sent', async () => { connection = await McplServerConnection.connect(config(), HOST_CAPS); await connection.close(); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 1ab212b5f..31ac240ac 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -583,6 +583,24 @@ describe('held prose drafts, end to end', () => { } }); + for (const mode of ['explicit', 'hybrid'] as const) { + it('through the real connection, failed ' + mode + ' speech is held as a bounce draft with one notice, not a second marker', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: mode }] }); + try { + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([text((mode === 'explicit' ? '>>' : '>>>') + ROOM + ' refused words')])]); + assert.deepEqual(h.publishes().map((p) => p.text), ['refused words']); + const [draft] = h.drafts(); + assert.ok(draft, 'the words are held'); + assert.equal(draft!.text, 'refused words'); + assert.ok(h.texts().some((t) => t.startsWith('[prose-routing]') && t.includes(`It is held as draft ${draft!.id}`)), 'the bounce notice names it'); + assert.deepEqual(h.texts().filter((t) => t.startsWith('[discord-send-failed]')), [], 'no second notice for the same failure'); + } finally { + await h.close(); + } + }); + } + for (const mode of ['explicit', 'hybrid'] as const) { it('through the real connection, unknown ' + mode + ' speech is marked but not held for an ordinary resend', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: mode }] }); @@ -778,9 +796,10 @@ describe('held prose drafts, end to end', () => { await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours are unnamed on this branch of your history: /); - assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); held .*"orphaned words"\)/); - assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); + assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); explicit send in the same round\)/); + assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*explicit send in the same round\\)`)); assert.doesNotMatch(notice, /They were not sent/); + assert.ok(!notice.includes('orphaned words') && !notice.includes('half-sent words'), `the notice quotes no draft's words: ${notice}`); await h.turn([createMockResponse([])]); assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'named once'); } finally { @@ -788,18 +807,19 @@ describe('held prose drafts, end to end', () => { } }); - it('the catch-up names every draft it marks: the newest eight with previews, the rest by id', async () => { + it('the catch-up names every draft it marks: the newest eight with their state, the rest by id, and no words', async () => { const h = await harness(); try { const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; assert.match(notice, /^\[drafts\] scout: 10 held drafts of yours are unnamed on this branch of your history: /); - // Newest first: orphan 9 down to orphan 2 with their previews, then the - // two oldest by id alone. - const previews = [...notice.matchAll(/"(orphan \d)"/g)].map((m) => m[1]); - assert.deepEqual(previews, [9, 8, 7, 6, 5, 4, 3, 2].map((i) => `orphan ${i}`)); + // Newest first: the eight newest with their state, then the two oldest + // by id alone. No draft's words appear. + const named = [...notice.matchAll(/(d-[a-z2-9]{5}) \(held \(not sent\); explicit send in the same round\)/g)].map((m) => m[1]); + assert.deepEqual(named, [9, 8, 7, 6, 5, 4, 3, 2].map((i) => held[i]!.id)); assert.ok(notice.includes(` · and 2 more: ${held[1]!.id}, ${held[0]!.id}.`), notice); + assert.ok(!/orphan \d/.test(notice), `the notice quotes no draft's words: ${notice}`); await h.turn([createMockResponse([])]); assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'all ten named, so none is named again'); } finally { @@ -826,7 +846,8 @@ describe('held prose drafts, end to end', () => { assert.ok(!h.texts().some((t) => t.includes(draft!.id)), 'nothing on the branch names it now'); await h.turn([createMockResponse([])]); assert.equal(catchUps().length, 1); - assert.match(catchUps()[0]!, new RegExp(`a held draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\);.*"words a send held back"`)); + assert.match(catchUps()[0]!, new RegExp(`a held draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\); explicit send in the same round\\)`)); + assert.ok(!catchUps()[0]!.includes('words a send held back'), "the notice quotes none of the draft's words"); await h.turn([createMockResponse([])]); assert.equal(catchUps().length, 1, 'named once on this branch'); } finally { From 264704ed7c6acf7fc845a6b95b13b9d6c68e67b8 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 02:44:54 -0400 Subject: [PATCH 28/54] test(mcpl): bound the channels/open deadline test by its own timeout Without the deadline it tests, the open would wait forever; the test's own timeout turns that into a failure instead of a hung run. --- test/mcpl-request-outcome.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 85dadaa1b..0389ca1fb 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -84,7 +84,7 @@ test('closing the connection while a request awaits its answer rejects it as no- connection = null; }); -test('channels/open keeps a deadline when requestTimeoutMs is 0, as channels/publish does', async () => { +test('channels/open keeps a deadline when requestTimeoutMs is 0, as channels/publish does', { timeout: 10_000 }, async () => { connection = await McplServerConnection.connect(config({ requestTimeoutMs: 0 }), HOST_CAPS); (connection as unknown as { publishDeadlineMs: number }).publishDeadlineMs = 200; const err = await rejectsWith(connection.sendChannelsOpen({ type: 'channel', address: { id: 'nowhere' } } as never), 'no-response'); From 2836849039a27d721b38de0e445dfbf7475a1ef2 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 02:59:50 -0400 Subject: [PATCH 29/54] docs(drafts): say what the notices and changelog claim exactly; a test that locus speech keeps its marker From a review of the final round. Text, one notice's wording, and a test: - The catch-up notice says "open drafts" (held or unconfirmed), and the changelog that every such open draft is named. - The refusal for an {{unsent}} that names nothing says it names no held bounce draft, without claiming there was one. - holdSilencedProse's comment: a private think in the same native round, since an XML-mode round has no verbatim content to route live and its prose is routed at the turn's end as ordinary speech. The earlier round's 'any tool mode' was wrong. - connectorText's doc: an outcome keeps the connector's structured data whole, while its reason and every display are bounded. - requestTimeoutMs's doc names channels/open beside channels/publish. - The drafts changelog's opening says what is held (speech routing holds back, and bounces, a definitely failed delivery among them) and that locus speech whose delivery fails keeps its marker; the round's fragment names publishDeadlineMs. - Stale 'clipboard' wording in the explicit routing tests and the gateway's comment now says held draft. Test (prose-drafts): through the real connection, a failed locus delivery keeps its failure marker and holds nothing. With the earlier round's tests, that pins all three rules of heldOnFailure. The {{unsent}} test after a delivered draft now asserts its notice too. --- .../prose-drafts-delivery-outcomes.fixed.md | 4 ++-- changelog.d/prose-drafts.added.md | 13 ++++++------ src/framework.ts | 10 ++++++---- src/mcpl/channel-registry.ts | 3 ++- src/mcpl/types.ts | 7 ++++--- test/explicit-prose-routing.test.ts | 10 ++++++---- test/prose-drafts.test.ts | 20 ++++++++++++++++--- 7 files changed, 44 insertions(+), 23 deletions(-) diff --git a/changelog.d/prose-drafts-delivery-outcomes.fixed.md b/changelog.d/prose-drafts-delivery-outcomes.fixed.md index c0bf7a4eb..2a2c284f2 100644 --- a/changelog.d/prose-drafts-delivery-outcomes.fixed.md +++ b/changelog.d/prose-drafts-delivery-outcomes.fixed.md @@ -1,6 +1,6 @@ - Explicit prose whose delivery definitely fails is held as a bounce draft with its notice, as hybrid envelopes are. A delivery whose outcome is unknown gets the failure marker alone, so its words are not offered for an ordinary resend that might duplicate a post. A failed delivery that is held gets the bounce notice alone, not a second marker. -- An envelope whose `{{unsent}}` names no held draft (the latest bounce draft was sent, set aside or let go) sends nothing, rather than the rest of the envelope. Its other words, if any, are held as a bounce draft; otherwise a short notice says so. +- An envelope whose `{{unsent}}` names no held draft (the latest bounce draft was sent, set aside or let go) sends nothing, rather than the rest of the envelope. Its other words, if any, are held as a bounce draft; otherwise a short notice says nothing was sent. - In hybrid and locus mode, a completing turn waits for its queued speech and hybrid holds even when its final response is empty, so its receipt includes that work and its draft notices settle with the same turn. - Failure markers and traces include a bounded display of the connector's structured error data, such as parts already posted, and the connector's error message is bounded too. Contradictory delivery receipts bound the message id in their diagnostic without changing the receipt's id. -- A synchronous MCPL write rejection reports that the request was not sent and removes its pending state. `channels/open` keeps a deadline when `requestTimeoutMs` is 0, as `channels/publish` does, so an unanswered open can't hold a delivery for good. +- A synchronous MCPL write rejection reports that the request was not sent and removes its pending state. `channels/open` keeps the same deadline as `channels/publish` when `requestTimeoutMs` is 0 (`publishDeadlineMs`, 60 s by default), so an unanswered open can't hold a delivery for good. - A hybrid `>>>skip_reply {{unsent}}` in a skip-reply round sets the latest bounce aside while keeping publication private; text in a round with a private think stays uninterpreted, matching explicit mode. diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index bcc78d337..da3bf953a 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -1,11 +1,12 @@ - **Held prose drafts: suppressed speech is kept, not destroyed.** Plain - 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 + speech that routing holds back 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 - replace the in-memory latest-wins clipboard; `{{unsent}}` keeps meaning the - latest bounce). A draft held beside a send keeps its run exactly as + destination after a failed envelope, and explicit/hybrid bounces, a + delivery that definitely failed among them (which replace the in-memory + latest-wins clipboard; `{{unsent}}` keeps meaning the latest bounce). Locus + speech whose delivery fails keeps its failure marker, and isn't held. 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 @@ -41,7 +42,7 @@ counts as named while a message naming it is on the current branch, so one no notice reached (a crash, an aborted turn), or whose notices a rollback or branch move took away, is named at the next turn's start: every such - draft by id, the newest eight with its state and why it was held. Notices + open draft by id, the newest eight with its state and why it was held. Notices name drafts and say how to resend or dismiss them, and quote none of their words; the `drafts` tool's `list` and `read` show the words. - A draft is never published except by its resident's explicit resend (or diff --git a/src/framework.ts b/src/framework.ts index 43e87ca95..601f00b35 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9045,7 +9045,9 @@ export class AgentFramework { /** * Silenced plain speech: an explicit send holds it as resendable drafts; a * deliberate silence (skip_reply, a silent turn, or a private think in - * the same tool round, where that policy applies) keeps it private — + * the same native round, where that policy applies: an XML-mode round has + * no verbatim content to route live, so its prose is routed at the turn's + * end as ordinary speech) 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. @@ -9351,7 +9353,7 @@ export class AgentFramework { const rest = pending.slice(shown.length).map((d) => d.id); const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : ''; const text = - `[drafts] ${agent.name}: ${pending.length === 1 ? 'a held draft' : `${pending.length} held drafts`} of yours ` + + `[drafts] ${agent.name}: ${pending.length === 1 ? 'an open draft' : `${pending.length} open drafts`} of yours ` + `${pending.length === 1 ? 'is' : 'are'} unnamed on this branch of your history: ${shown.join(' · ')}${more}. ` + 'drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; let mid: string; @@ -9942,7 +9944,7 @@ export class AgentFramework { * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md). * Every prose segment of an `explicit`-mode agent flows through here: * parse the `>>` prefix, resolve the target, deliver — or bounce to the - * clipboard with a resend notice. Misdelivery is structurally impossible: + * held bounce draft with a resend notice. Misdelivery is structurally impossible: * nothing is ever sent to a destination the model did not name (directly * or via the turn's sticky target). */ @@ -10006,7 +10008,7 @@ export class AgentFramework { // fragment. The envelope's own words, if it has any, are held as a // bounce draft, with its notice; otherwise a short notice says so. const own = body.replaceAll('{{unsent}}', '').trim(); - const reason = '{{unsent}} names no held draft: your latest bounce draft has been sent, set aside or let go'; + const reason = '{{unsent}} names no held bounce draft'; if (own) { this.bounceProse(agent, own, reason, undefined, prefix, hold); } else { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index f2d327500..bebfc41b7 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -460,7 +460,8 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // Constructor Options // ============================================================================ -/** Bound connector values for notices/logs; the PublishOutcome keeps the original. */ +/** Bound connector values for notices, logs and an outcome's reason. A + * PublishOutcome keeps the connector's structured data (`detail`) whole. */ function connectorText(value: unknown): string { let text: string; try { diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index fbc848860..49fe7e8c6 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -374,9 +374,10 @@ export interface McplServerConfig { * accepts a request and never responds would otherwise freeze the awaiting * agent turn forever; with the timeout the pending request rejects with a * descriptive error, which the framework surfaces as a normal isError - * tool_result. Set 0 to disable, except for a channels/publish request, - * which keeps the 60 s default: its outcome is reported to the resident and - * recorded for its draft, so it must end. Default: 60000 (60 seconds). + * tool_result. Set 0 to disable, except for channels/publish and the + * channels/open a delivery may send first, which keep the 60 s default: a + * publish's outcome is reported to the resident and recorded for its + * draft, so it must end. Default: 60000 (60 seconds). */ requestTimeoutMs?: number; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 7573e22ca..fd253ec31 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -2,7 +2,7 @@ * Explicit prose routing (docs/explicit-prose-routing.md). * * The model names every destination in-band (`>>` prefixes); unprefixed prose - * is NEVER delivered — it bounces to a clipboard for a prefixed resend. The + * is NEVER delivered — it bounces to a held draft for a prefixed resend. The * laria regression (2026-07-24): a DM reply generated while the host-inferred * locus pointed at a guild channel was misdelivered there. In explicit mode * that class is structurally impossible: the worst case is a bounce. @@ -169,7 +169,7 @@ describe('explicit prose routing', () => { }); } - it('unprefixed prose NEVER delivers — bounces to clipboard, then a prefixed {{unsent}} resend delivers verbatim (laria regression)', async () => { + it('unprefixed prose NEVER delivers — bounces to a held draft, then a prefixed {{unsent}} resend delivers verbatim (laria regression)', async () => { const original = 'laria.\n\nYes, I remember. Two words through the keyhole.'; membrane.pushResponse(createMockResponse([{ type: 'text', text: original }] as ContentBlock[])); @@ -191,7 +191,7 @@ describe('explicit prose routing', () => { assert.equal(bounceNotices.length, 1, 'exactly one bounce notice'); assert.equal(routed.length, 1, 'only the prefixed resend delivered'); assert.equal(routed[0]!.locus, 'discord:dm:99', 'delivered to the DM the model named'); - assert.equal(routed[0]!.text, original, 'clipboard substitution is verbatim'); + assert.equal(routed[0]!.text, original, '{{unsent}} substitution is verbatim'); await framework.stop(); }); @@ -339,7 +339,7 @@ describe('explicit prose routing', () => { await gateway.deliverProse(agent, '>>#alpha {{unsent}}', { notice: 'later' }); assert.deepEqual(routed, [], 'nothing is sent'); assert.deepEqual(store.open('assistant'), [], 'and nothing is held'); - assert.ok(context(framework).texts.some((t) => t.startsWith('[prose-routing] {{unsent}} names no held draft') && t.includes('nothing was sent'))); + assert.ok(context(framework).texts.some((t) => t.startsWith('[prose-routing] {{unsent}} names no held bounce draft') && t.includes('nothing was sent'))); // With words of its own, those are held as a bounce draft rather than // posted as a fragment. await gateway.deliverProse(agent, '>>#alpha Here it is again: {{unsent}}', { notice: 'later' }); @@ -368,6 +368,8 @@ describe('explicit prose routing', () => { assert.deepEqual(routed.map((r) => r.text), ['the words'], 'the first {{unsent}} sends the bounce'); await gateway.deliverProse(agent, '>>#alpha {{unsent}}', { notice: 'later' }); assert.deepEqual(routed.map((r) => r.text), ['the words'], 'a second one, with the draft delivered, sends nothing'); + assert.ok(context(framework).texts.some((t) => t.startsWith('[prose-routing] {{unsent}} names no held bounce draft')), + 'and says why'); } finally { await framework.stop(); } diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 31ac240ac..b06364b4c 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -583,6 +583,20 @@ describe('held prose drafts, end to end', () => { } }); + it('through the real connection, failed locus speech keeps its failure marker, since nothing holds it', async () => { + const h = await harness(); + try { + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([text('words a refusing connector drops')])]); + await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); + const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; + assert.match(marker, /could not be delivered to .* Nothing was posted/); + assert.deepEqual(h.drafts(), [], 'locus speech is not held'); + } finally { + await h.close(); + } + }); + for (const mode of ['explicit', 'hybrid'] as const) { it('through the real connection, failed ' + mode + ' speech is held as a bounce draft with one notice, not a second marker', async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: mode }] }); @@ -795,7 +809,7 @@ describe('held prose drafts, end to end', () => { h.store().beginAttempt('scout', midSend.id, dest(), 'resend', false); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours are unnamed on this branch of your history: /); + assert.match(notice, /^\[drafts\] scout: 2 open drafts of yours are unnamed on this branch of your history: /); assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); explicit send in the same round\)/); assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*explicit send in the same round\\)`)); assert.doesNotMatch(notice, /They were not sent/); @@ -813,7 +827,7 @@ describe('held prose drafts, end to end', () => { const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: 10 held drafts of yours are unnamed on this branch of your history: /); + assert.match(notice, /^\[drafts\] scout: 10 open drafts of yours are unnamed on this branch of your history: /); // Newest first: the eight newest with their state, then the two oldest // by id alone. No draft's words appear. const named = [...notice.matchAll(/(d-[a-z2-9]{5}) \(held \(not sent\); explicit send in the same round\)/g)].map((m) => m[1]); @@ -846,7 +860,7 @@ describe('held prose drafts, end to end', () => { assert.ok(!h.texts().some((t) => t.includes(draft!.id)), 'nothing on the branch names it now'); await h.turn([createMockResponse([])]); assert.equal(catchUps().length, 1); - assert.match(catchUps()[0]!, new RegExp(`a held draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\); explicit send in the same round\\)`)); + assert.match(catchUps()[0]!, new RegExp(`an open draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\); explicit send in the same round\\)`)); assert.ok(!catchUps()[0]!.includes('words a send held back'), "the notice quotes none of the draft's words"); await h.turn([createMockResponse([])]); assert.equal(catchUps().length, 1, 'named once on this branch'); From 2e316bd10278b5ffcefca41964f72d5898d8c921 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:06:11 -0400 Subject: [PATCH 30/54] test(drafts): the held-draft and bounce notices quote none of the words they name The catch-up notice's test already asserted that no draft words appear. The held-draft notice (a draft held beside a send) and the bounce notice (an unprefixed explicit envelope) now assert it too, so adding a quoted preview back to either fails a test. --- test/explicit-prose-routing.test.ts | 3 +++ test/prose-drafts.test.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index fd253ec31..9389594c7 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -192,6 +192,9 @@ describe('explicit prose routing', () => { assert.equal(routed.length, 1, 'only the prefixed resend delivered'); assert.equal(routed[0]!.locus, 'discord:dm:99', 'delivered to the DM the model named'); assert.equal(routed[0]!.text, original, '{{unsent}} substitution is verbatim'); + const notices = context(framework).texts.filter((t) => t.startsWith('[prose-routing]')); + assert.ok(notices.length > 0, 'the bounce notice is in the context'); + assert.ok(notices.every((t) => !t.includes('Two words through the keyhole')), `the bounce notice quotes none of the words: ${notices.join(' | ')}`); await framework.stop(); }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index b06364b4c..dc3f21948 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -512,7 +512,9 @@ describe('held prose drafts, end to end', () => { 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}`))); + const held = h.texts('scout').find((t) => t.startsWith(`[drafts] scout: not sent — a plain-speech segment held as draft ${mine.id}`)); + assert.ok(held); + assert.ok(!held!.includes('scout words'), `the held-draft notice quotes none of the draft's words: ${held}`); } finally { await h.close(); } From 77cd066399af0cce80e60a840baeaba8a8338255 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:09:17 -0400 Subject: [PATCH 31/54] fix(drafts): a failed {{unsent}} send re-bounces the draft it substituted, not the bounce that's latest when it fails From a reviewer's reproduction. When a delivery that substituted {{unsent}} definitely fails, or throws, its words are held again as a bounce draft. The re-bounce re-expanded {{unsent}} from the latest bounce at the time of the failure. An explicit turn that a tool ends doesn't hold the next turn for its deliveries, so a later turn could bounce new words while the send waited, and the failure then held the newer bounce's words with the envelope's, words that were never attempted, and lost the source draft and risk of the ones that were. bounceProse and unsentWords now take the draft that was substituted. unsentWords reads it again from the store, so its risk is its state after the attempt (a thrown send's attempt has no outcome, so the copy stays unconfirmed). The explicit and hybrid failure paths, failed and thrown, pass it. Test (explicit-prose-routing): turn one's '>>#alpha {{unsent}} plus a line' is held in delivery after skip_reply ends the turn; turn two bounces 'NEW WORDS'; turn one's send then fails, and its draft holds 'ORIGINAL WORDS plus a line', not 'NEW WORDS plus a line'. Adapted from the reviewer's reproducer. --- src/framework.ts | 26 +++++++++---- test/explicit-prose-routing.test.ts | 60 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 601f00b35..3effe0dac 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10202,13 +10202,13 @@ export class AgentFramework { // 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); + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', hold, unsent.draft); } } } catch (err) { this.proseTargetPins.delete(agent.name); 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); + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', hold, unsent.draft); } continue; } @@ -10354,10 +10354,10 @@ export class AgentFramework { } else if (outcome.status === 'failed') { // Unknown deliveries are marker-only: offering a fresh draft would // invite a duplicate. Keep the authored body for the re-bounce rule. - this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', hold); + this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', hold, unsent.draft); } } catch (err) { - this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', hold); + this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', hold, unsent.draft); } } @@ -10377,10 +10377,20 @@ export class AgentFramework { * 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 } { + private unsentWords( + agent: Agent, + text: string, + /** The draft `{{unsent}}` was substituted with, when the words are held + * after that send: the latest bounce may have changed while it waited. */ + substituted?: Draft, + ): { draft: Draft } | { text: string; inheritedRisk?: InheritedRisk } { if (!text.includes('{{unsent}}')) return { text }; const name = agent.name; - const previous = this.proseDrafts.latestBounce(name); + // A substituted draft is read again here, so its risk is its state after + // the attempt, not before it. + const previous = substituted + ? this.proseDrafts.get(name, substituted.id) ?? substituted + : 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}`))) { @@ -10425,9 +10435,11 @@ export class AgentFramework { candidates?: string[], prefix: '>>' | '>>>' = '>>', hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + /** The draft a failed send substituted for `{{unsent}}` (unsentWords). */ + substituted?: Draft, ): void { const name = agent.name; - const words = this.unsentWords(agent, text); + const words = this.unsentWords(agent, text, substituted); let draft: Draft | undefined; let inherited: InheritedRisk | undefined; if ('draft' in words) { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 9389594c7..601f55618 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -378,6 +378,66 @@ describe('explicit prose routing', () => { } }); + it('a failed {{unsent}} send that a later turn overtook holds the words it attempted, not the newer bounce', async () => { + // Turn one queues '>>#alpha {{unsent}} plus a line' and skip_reply ends + // it while that publish is held. Turn two bounces new words. When turn + // one's send then fails, its draft must hold what it attempted. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha {{unsent}} plus a line' }, + { type: 'tool_use', id: 'delayed-1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'delayed-2', name: 'skip_reply', input: {} }, + ] as ContentBlock[], 'tool_use')); + const framework = await createFramework(); + const routed = stubRegistry(framework, ['false']); + answerSkipReply(framework); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS', source: { branch: 'main', turn: 0, round: 0, segment: 0 } }], 'bounced').held[0]!; + const internals = framework as unknown as { + channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise }; + proseSpeechChains: Map>; + }; + const deliver = internals.channelRegistry.deliverSpeech; + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let entered!: () => void; + const reached = new Promise((resolve) => { entered = resolve; }); + internals.channelRegistry.deliverSpeech = async (...args: unknown[]) => { + const outcome = await deliver(...args); + entered(); + await held; + return outcome; + }; + let starts = 0; + framework.onTrace((e) => { + if ((e as { type: string }).type !== 'inference:started') return; + starts++; + if (starts === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>#nowhere NEW WORDS' }])); + if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); + }); + try { + trigger(framework); + await framework.runUntilIdle(); + await reached; + const firstChain = internals.proseSpeechChains.get('assistant')!; + assert.deepEqual(routed.map((r) => r.text), ['ORIGINAL WORDS plus a line']); + trigger(framework); + await framework.runUntilIdle(); + assert.equal(store.latestBounce('assistant')?.text, 'NEW WORDS', 'the next turn bounced before the earlier send answered'); + release(); + await firstChain; + const heldTexts = store.open('assistant').map((d) => d.text); + assert.ok(heldTexts.includes('ORIGINAL WORDS plus a line'), `the failed send holds the words it attempted: ${JSON.stringify(heldTexts)}`); + assert.ok(!heldTexts.includes('NEW WORDS plus a line'), 'not the newer bounce'); + const copy = store.open('assistant').find((d) => d.text === 'ORIGINAL WORDS plus a line')!; + assert.notEqual(copy.id, original.id); + } finally { + release(); + await framework.stop(); + } + }); + it('a thrown explicit unsent delivery re-bounces the authored body with its unresolved attempt risk', async () => { const framework = await createFramework(); try { From 9169bc99eea70ba220f08added6c9662361cbdee Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:21:50 -0400 Subject: [PATCH 32/54] fix(drafts): a bounce notice offers {{unsent}} only when {{unsent}} would send that draft; draft words go in exactly From the review of the late-bounce fix (77cd0663): - A bare {{unsent}} whose send failed re-bounces the draft it sent. When a later turn bounced new words meanwhile, that newer draft is the latest bounce, so the notice's advice to reply with {{unsent}} would send the newer words. And {{unsent}} refuses a draft that may already have been posted, though the notice offered it beside the risk warning. The notice now offers {{unsent}} (and '>>skip_reply {{unsent}}') only when the draft is the latest bounce and carries no risk; otherwise it names the drafts tool's resend (with confirmDuplicate: true when at risk) and dismiss for that draft. - A bare {{unsent}} whose send threw leaves its draft unconfirmed, and the re-bounce notice said nothing of it: it now says the draft may already have been posted and asks for confirmDuplicate. - {{unsent}} expansion used a string replacement, so `$&`, `$$` and `$1` in a draft's words were rewritten on send and in a re-held copy. All three expansions now use a function replacement. Tests (explicit-prose-routing): - the overtaken-send test runs for '{{unsent}} plus a line' and a bare '{{unsent}}': the re-held words are what was attempted, carry no risk after a definite failure, and the bare case's notice offers only the drafts tool while the newer bounce stays the latest; - a thrown bare {{unsent}} says its draft may already have been posted, with confirmDuplicate, and offers no {{unsent}}; the thrown test with other words asserts the same of its copy's notice; - hybrid: a >>> envelope whose {{unsent}} names nothing sends nothing and holds its other words; - {{unsent}} keeps `$$`, `$&` and `$1` in a draft's words. --- src/framework.ts | 36 ++++++++---- test/explicit-prose-routing.test.ts | 87 +++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3effe0dac..01c51ce98 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10183,7 +10183,8 @@ export class AgentFramework { this.proseHybridSuppressed.set(agent.name, 'failed'); continue; } - if (usedClipboard) body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); + // A function replacement: a draft's words go in exactly, `$&` and `$$` included. + if (usedClipboard) body = body.replaceAll('{{unsent}}', () => unsent.draft?.text ?? ''); this.proseTargetPins.set(agent.name, resolved.channelId); this.proseHybridSuppressed.delete(agent.name); if (!body.trim()) { @@ -10340,7 +10341,8 @@ export class AgentFramework { const unsent = usedClipboard ? this.takeUnsent(agent, body, hold, '>>') : {}; if (unsent.refused) return; if (usedClipboard) { - body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); + // A function replacement: a draft's words go in exactly, `$&` and `$$` included. + body = body.replaceAll('{{unsent}}', () => unsent.draft?.text ?? ''); } if (!body.trim()) { console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`); @@ -10392,7 +10394,7 @@ export class AgentFramework { ? this.proseDrafts.get(name, substituted.id) ?? substituted : this.proseDrafts.latestBounce(name); if (previous && text.trim() === '{{unsent}}') return { draft: previous }; - const expanded = text.replaceAll('{{unsent}}', previous?.text ?? ''); + const expanded = text.replaceAll('{{unsent}}', () => previous?.text ?? ''); if (!previous || !(draftState(previous) === 'unconfirmed' || this.draftsInFlight.has(`${name}\u0000${previous.id}`))) { return { text: expanded }; } @@ -10457,16 +10459,30 @@ export class AgentFramework { 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.'; + // A bare {{unsent}} whose send failed re-bounces the draft it sent, which + // a later bounce may have displaced as the latest; and {{unsent}} refuses + // a draft that may already have been posted. Then only the drafts tool + // reaches this one. + const ownRisk = !!draft && !inherited && draftState(draft) === 'unconfirmed'; + const byToken = !!draft && !inherited && !ownRisk && this.proseDrafts.latestBounce(name)?.id === draft.id; + const kept = !draft + ? 'It could not be held as a draft; the words remain in your history.' + : byToken + ? `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 is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, use ` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"` + + `${inherited || ownRisk ? ', confirmDuplicate: true' : ''}); ` + + `drafts(action: "dismiss", draftIds: ["${draft.id}"]) sets it aside.`; 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.' - : ''; + : ownRisk + ? ` Its last send's outcome isn't known, so it may already have been posted: resend it only with ` + + 'confirmDuplicate: true, after checking that channel.' + : ''; const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}${risky}` + (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : ''); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 601f55618..a54ed503a 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -378,12 +378,13 @@ describe('explicit prose routing', () => { } }); - it('a failed {{unsent}} send that a later turn overtook holds the words it attempted, not the newer bounce', async () => { + for (const sent of ['{{unsent}} plus a line', '{{unsent}}'] as const) { + it(`a failed '${sent}' send that a later turn overtook holds the words it attempted, not the newer bounce`, async () => { // Turn one queues '>>#alpha {{unsent}} plus a line' and skip_reply ends // it while that publish is held. Turn two bounces new words. When turn // one's send then fails, its draft must hold what it attempted. membrane.pushResponse(createMockResponse([ - { type: 'text', text: '>>#alpha {{unsent}} plus a line' }, + { type: 'text', text: `>>#alpha ${sent}` }, { type: 'tool_use', id: 'delayed-1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([ @@ -421,22 +422,37 @@ describe('explicit prose routing', () => { await framework.runUntilIdle(); await reached; const firstChain = internals.proseSpeechChains.get('assistant')!; - assert.deepEqual(routed.map((r) => r.text), ['ORIGINAL WORDS plus a line']); + const attempted = sent === '{{unsent}}' ? 'ORIGINAL WORDS' : 'ORIGINAL WORDS plus a line'; + assert.deepEqual(routed.map((r) => r.text), [attempted]); trigger(framework); await framework.runUntilIdle(); assert.equal(store.latestBounce('assistant')?.text, 'NEW WORDS', 'the next turn bounced before the earlier send answered'); release(); await firstChain; const heldTexts = store.open('assistant').map((d) => d.text); - assert.ok(heldTexts.includes('ORIGINAL WORDS plus a line'), `the failed send holds the words it attempted: ${JSON.stringify(heldTexts)}`); + assert.ok(heldTexts.includes(attempted), `the failed send holds the words it attempted: ${JSON.stringify(heldTexts)}`); assert.ok(!heldTexts.includes('NEW WORDS plus a line'), 'not the newer bounce'); - const copy = store.open('assistant').find((d) => d.text === 'ORIGINAL WORDS plus a line')!; - assert.notEqual(copy.id, original.id); + const copy = store.open('assistant').find((d) => d.text === attempted)!; + assert.equal(copy.inheritedRisk, undefined, 'a definite failure leaves no risk to inherit'); + if (sent === '{{unsent}}') { + // The bare send re-bounces the draft it sent, which the newer bounce + // displaced as the latest: {{unsent}} now names that one, so the + // notice offers only the drafts tool for this one. + assert.equal(copy.id, original.id); + assert.equal(store.latestBounce('assistant')?.text, 'NEW WORDS'); + const notice = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes(original.id)).at(-1)!; + assert.ok(notice.includes(`drafts(action: "resend", draftIds: ["${original.id}"]`), notice); + assert.ok(!notice.includes('{{unsent}}'), `the notice doesn't offer {{unsent}}, which names the newer bounce: ${notice}`); + } else { + assert.notEqual(copy.id, original.id); + assert.equal(store.latestBounce('assistant')?.id, copy.id, 'the re-held words are the latest bounce, as {{unsent}} names'); + } } finally { release(); await framework.stop(); } }); + } it('a thrown explicit unsent delivery re-bounces the authored body with its unresolved attempt risk', async () => { const framework = await createFramework(); @@ -455,6 +471,65 @@ describe('explicit prose routing', () => { assert.equal(copy.text, 'earlier words plus a line'); assert.equal(copy.inheritedRisk?.draftId, original.id); assert.equal(draftState(copy), 'unconfirmed', 'a fresh draft cannot erase its source attempt risk'); + const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]') && t.includes(copy.id))!; + assert.ok(notice.includes(`draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true`), notice); + assert.ok(!notice.includes('{{unsent}}'), `{{unsent}} would refuse it, so the notice doesn't offer it: ${notice}`); + } finally { + await framework.stop(); + } + }); + + it("{{unsent}} puts a draft's words in exactly, `$&` and `$$` included", async () => { + const framework = await createFramework(); + try { + const routed = stubRegistry(framework); + const store = draftsOf(framework); + store.hold('assistant', [{ text: 'costs $$5, see $& and $1', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced'); + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + await gateway.deliverProse(framework.getAgent('assistant')!, '>>#alpha {{unsent}} (again)', { notice: 'later' }); + assert.deepEqual(routed.map((r) => r.text), ['costs $$5, see $& and $1 (again)']); + } finally { + await framework.stop(); + } + }); + + it('a thrown bare {{unsent}} delivery says its draft may already have been posted', async () => { + const framework = await createFramework(); + try { + stubRegistry(framework, ['throw']); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ + text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 }, + }], 'bounced').held[0]!; + const gateway = framework as unknown as { + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + await gateway.deliverProse(framework.getAgent('assistant')!, '>>#alpha {{unsent}}', { notice: 'later' }); + assert.deepEqual(store.open('assistant').map((d) => d.id), [original.id], 'a bare {{unsent}} holds no copy'); + assert.equal(draftState(store.get('assistant', original.id)!), 'unconfirmed'); + const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]') && t.includes(original.id))!; + assert.match(notice, /may already have been posted/); + assert.ok(notice.includes(`draftIds: ["${original.id}"], destination: "#channel", confirmDuplicate: true`), notice); + assert.ok(!notice.includes('{{unsent}}'), notice); + } finally { + await framework.stop(); + } + }); + + it('hybrid: a >>> envelope whose {{unsent}} names no held bounce sends nothing, and holds its other words', async () => { + const framework = await createFramework('hybrid'); + try { + const routed = stubRegistry(framework); + const store = draftsOf(framework); + const gateway = framework as unknown as { + deliverHybridProse(a: unknown, text: string, locus: string | null, allowLocus: boolean, hold: { notice: 'later' }): Promise; + }; + await gateway.deliverHybridProse(framework.getAgent('assistant')!, '>>>#alpha {{unsent}} and a note', null, true, { notice: 'later' }); + assert.deepEqual(routed, [], 'no fragment goes out'); + assert.deepEqual(store.open('assistant').map((d) => d.text), ['and a note']); + assert.ok(context(framework).texts.some((t) => t.startsWith('[prose-routing]') && t.includes('{{unsent}} names no held bounce draft'))); } finally { await framework.stop(); } From 0f4d8a8c11e59d33df61167501e52dd9a2cd391d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:26:38 -0400 Subject: [PATCH 33/54] fix(drafts): a thrown send's bounce notice says it may not have been delivered; the overtaken case at every failure call site From the readers of 9169bc99: - A send that threw has no known outcome, and its draft reads unconfirmed, but its bounce notice opened "Your text ... was not delivered" and closed "it may already have been posted". bounceProse takes { substituted, outcome: 'unknown' } after a send; for an unknown outcome the notice opens "may not have been delivered", the reason says the send threw rather than failed, and with no other risk named it asks to check the channel before resending. A definite failure keeps "was not delivered". - Only the explicit failed path had the overtaken case under test, so dropping the substituted draft from the hybrid failed, hybrid thrown or explicit thrown call stayed green. A direct test now runs each of the four: the send is held, a newer bounce lands, and the failure re-holds the original expansion (with its source's risk when it threw). The thrown tests assert the notice doesn't say "was not delivered". --- src/framework.ts | 24 +++++++++------ test/explicit-prose-routing.test.ts | 48 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 01c51ce98..bf5b9ba77 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10203,13 +10203,13 @@ export class AgentFramework { // 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, unsent.draft); + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', hold, { substituted: unsent.draft }); } } } catch (err) { this.proseTargetPins.delete(agent.name); 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, unsent.draft); + this.bounceProse(agent, parsed.body, `sending to ${resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', hold, { substituted: unsent.draft, outcome: 'unknown' }); } continue; } @@ -10356,10 +10356,10 @@ export class AgentFramework { } else if (outcome.status === 'failed') { // Unknown deliveries are marker-only: offering a fresh draft would // invite a duplicate. Keep the authored body for the re-bounce rule. - this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', hold, unsent.draft); + this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', hold, { substituted: unsent.draft }); } } catch (err) { - this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', hold, unsent.draft); + this.bounceProse(agent, envelopeBody, `sending to ${targetChannel} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', hold, { substituted: unsent.draft, outcome: 'unknown' }); } } @@ -10437,11 +10437,12 @@ export class AgentFramework { candidates?: string[], prefix: '>>' | '>>>' = '>>', hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, - /** The draft a failed send substituted for `{{unsent}}` (unsentWords). */ - substituted?: Draft, + /** After a send: the draft it substituted for `{{unsent}}` + * (unsentWords), and whether its outcome is unknown (it threw). */ + after: { substituted?: Draft; outcome?: 'unknown' } = {}, ): void { const name = agent.name; - const words = this.unsentWords(agent, text, substituted); + const words = this.unsentWords(agent, text, after.substituted); let draft: Draft | undefined; let inherited: InheritedRisk | undefined; if ('draft' in words) { @@ -10482,9 +10483,14 @@ export class AgentFramework { : ownRisk ? ` Its last send's outcome isn't known, so it may already have been posted: resend it only with ` + 'confirmDuplicate: true, after checking that channel.' - : ''; + : after.outcome === 'unknown' + ? ' The send may have gone out, so check that channel before resending.' + : ''; + // A send that threw has no known outcome: the notice doesn't say it + // wasn't delivered. + const delivered = after.outcome === 'unknown' ? 'may not have been delivered' : 'was not delivered'; const notice = - `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}${risky}` + + `[prose-routing] Your text (${text.length} chars) ${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/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index a54ed503a..7a4eb4a5a 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -472,6 +472,7 @@ describe('explicit prose routing', () => { assert.equal(copy.inheritedRisk?.draftId, original.id); assert.equal(draftState(copy), 'unconfirmed', 'a fresh draft cannot erase its source attempt risk'); const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]') && t.includes(copy.id))!; + assert.doesNotMatch(notice, /was not delivered/, 'an unconfirmed send is not also declared undelivered'); assert.ok(notice.includes(`draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true`), notice); assert.ok(!notice.includes('{{unsent}}'), `{{unsent}} would refuse it, so the notice doesn't offer it: ${notice}`); } finally { @@ -479,6 +480,51 @@ describe('explicit prose routing', () => { } }); + for (const mode of ['explicit', 'hybrid'] as const) { + for (const failure of ['false', 'throw'] as const) { + it(`${mode}: a ${failure === 'false' ? 'failed' : 'thrown'} {{unsent}} send re-holds the draft it substituted, though a newer bounce landed while it waited`, async () => { + const framework = await createFramework(mode); + try { + stubRegistry(framework, [failure]); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced').held[0]!; + const internals = framework as unknown as { + channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise }; + deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; + deliverHybridProse(a: unknown, text: string, locus: string | null, allowLocus: boolean, hold: { notice: 'later' }): Promise; + }; + const deliver = internals.channelRegistry.deliverSpeech; + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let entered!: () => void; + const reached = new Promise((resolve) => { entered = resolve; }); + internals.channelRegistry.deliverSpeech = async (...args: unknown[]) => { + entered(); + await held; + return deliver(...args); + }; + const agent = framework.getAgent('assistant')!; + const sending = mode === 'explicit' + ? internals.deliverProse(agent, '>>#alpha {{unsent}} plus a line', { notice: 'later' }) + : internals.deliverHybridProse(agent, '>>>#alpha {{unsent}} plus a line', null, true, { notice: 'later' }); + await reached; + // A newer bounce becomes the latest while the send waits. + store.hold('assistant', [{ text: 'NEW WORDS', source: { branch: 'main', turn: 2, round: 1, segment: 0 } }], 'bounced'); + release(); + await sending; + const texts = store.open('assistant').map((d) => d.text); + assert.ok(texts.includes('ORIGINAL WORDS plus a line'), `it re-holds the words it attempted: ${JSON.stringify(texts)}`); + assert.ok(!texts.includes('NEW WORDS plus a line'), 'not the newer bounce'); + const copy = store.open('assistant').find((d) => d.text === 'ORIGINAL WORDS plus a line')!; + if (failure === 'throw') assert.equal(copy.inheritedRisk?.draftId, original.id, "a thrown send's copy carries its source's risk"); + else assert.equal(copy.inheritedRisk, undefined); + } finally { + await framework.stop(); + } + }); + } + } + it("{{unsent}} puts a draft's words in exactly, `$&` and `$$` included", async () => { const framework = await createFramework(); try { @@ -511,6 +557,8 @@ describe('explicit prose routing', () => { assert.equal(draftState(store.get('assistant', original.id)!), 'unconfirmed'); const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]') && t.includes(original.id))!; assert.match(notice, /may already have been posted/); + assert.doesNotMatch(notice, /was not delivered/, 'an unconfirmed send is not also declared undelivered'); + assert.match(notice, /may not have been delivered/); assert.ok(notice.includes(`draftIds: ["${original.id}"], destination: "#channel", confirmDuplicate: true`), notice); assert.ok(!notice.includes('{{unsent}}'), notice); } finally { From 1479d48a938b86b8b4f82e2ab32ed43fae97e2d6 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:27:16 -0400 Subject: [PATCH 34/54] test(drafts): the overtaken-send test reloads the store while the send waits The draft {{unsent}} substituted is captured before the send. When the store reloads its projection meanwhile (as it does after an ambiguous append), the captured object is no longer the stored one, and only reading the draft again after the attempt sees the definite failure recorded there. The four-site test now reloads while the send is held, so taking the captured draft's state as it was fails the failed-send cases (a reviewer's pointer). --- test/explicit-prose-routing.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 7a4eb4a5a..84aaaaa08 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -508,8 +508,12 @@ describe('explicit prose routing', () => { ? internals.deliverProse(agent, '>>#alpha {{unsent}} plus a line', { notice: 'later' }) : internals.deliverHybridProse(agent, '>>>#alpha {{unsent}} plus a line', null, true, { notice: 'later' }); await reached; - // A newer bounce becomes the latest while the send waits. + // A newer bounce becomes the latest while the send waits, and the + // store reloads its projection (as after an ambiguous append), so + // the draft captured before the send is no longer the stored one. store.hold('assistant', [{ text: 'NEW WORDS', source: { branch: 'main', turn: 2, round: 1, segment: 0 } }], 'bounced'); + store.reload(); + assert.notEqual(store.get('assistant', original.id), original, 'the reload replaced the stored draft'); release(); await sending; const texts = store.open('assistant').map((d) => d.text); @@ -517,7 +521,7 @@ describe('explicit prose routing', () => { assert.ok(!texts.includes('NEW WORDS plus a line'), 'not the newer bounce'); const copy = store.open('assistant').find((d) => d.text === 'ORIGINAL WORDS plus a line')!; if (failure === 'throw') assert.equal(copy.inheritedRisk?.draftId, original.id, "a thrown send's copy carries its source's risk"); - else assert.equal(copy.inheritedRisk, undefined); + else assert.equal(copy.inheritedRisk, undefined, 'read after the attempt, the failed draft carries no risk to inherit'); } finally { await framework.stop(); } From 082d9013ad654426029e2821b1353184467bf15e Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:38:00 -0400 Subject: [PATCH 35/54] fix(drafts): a thrown send with no draft behind it is marker-only, as an unknown outcome is From the readers of 1479d48a. A plain envelope (no {{unsent}}) whose send threw was held as a fresh bounce draft with no attempt and no inherited risk: the notice now said it may have gone out, but the draft read as held, the notice offered {{unsent}}, and {{unsent}} or a resend would post it again with no confirmDuplicate gate. The connector's own unknown outcome is already marker-only for such words: holding them would invite a duplicate. A send that threw has no known outcome either, so bounceProse now writes a notice for it and holds nothing: the words may not have been delivered, they aren't held because a resend could post them twice, they remain in the history, and the channel is worth checking. It doesn't wake the resident, as the unknown-outcome marker doesn't. A thrown send that substituted {{unsent}} is unchanged: the substituted draft carries its own unresolved attempt, and an expanded copy inherits that risk, so its state, listing and resend gate already agree. Test (explicit-prose-routing): a thrown explicit envelope holds no draft, and its notice says it may not have been delivered and why it isn't held, offering neither {{unsent}} nor "was not delivered". --- src/framework.ts | 17 +++++++++++++++++ test/explicit-prose-routing.test.ts | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/framework.ts b/src/framework.ts index bf5b9ba77..fd53942b7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10442,6 +10442,23 @@ export class AgentFramework { after: { substituted?: Draft; outcome?: 'unknown' } = {}, ): void { const name = agent.name; + if (after.outcome === 'unknown' && !after.substituted) { + // Words with no draft behind them, whose send threw, may have gone + // out. Like an unknown outcome, that is marker-only: holding them as a + // draft would invite a duplicate post. + const notice = + `[prose-routing] Your text (${text.length} chars) may not have been delivered — ${reason}. ` + + "It isn't held as a draft, since a resend could post it twice; the words remain in your history. " + + 'Check that channel before writing them again.'; + try { + const id = this.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'prose-bounce' } as MessageMetadata, { forAgent: name }); + this.emitTrace({ type: 'message:added', messageId: id ?? 'deferred', source: 'prose-bounce' }); + } catch (err) { + console.error('bounceProse: failed to record the unknown-send notice:', err); + } + console.error(`[prose] ${name}: ${text.length} chars may not have been delivered — ${reason}`); + return; + } const words = this.unsentWords(agent, text, after.substituted); let draft: Draft | undefined; let inherited: InheritedRisk | undefined; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 84aaaaa08..b0fe6f8fb 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -287,7 +287,7 @@ describe('explicit prose routing', () => { (framework as unknown as { proseDrafts: DraftsView }).proseDrafts; for (const failure of ['false', 'throw'] as const) { - it('an explicit ' + failure + ' delivery holds its body as a bounce and names it privately', async () => { + it('an explicit ' + failure + ' delivery holds its body as a bounce and names it privately, unless the send threw', async () => { const framework = await createFramework(); try { const routed = stubRegistry(framework, [failure]); @@ -298,6 +298,16 @@ describe('explicit prose routing', () => { await gateway.deliverProse(agent, '>>#alpha keep these words', { notice: 'later' }); assert.deepEqual(routed, [{ text: 'keep these words', locus: 'chan-alpha' }]); const held = draftsOf(framework).open('assistant'); + if (failure === 'throw') { + // The send threw, so the words may have gone out: marker-only, as + // an unknown outcome is, with no draft to resend. + assert.deepEqual(held, [], 'words whose send threw are not held'); + const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]'))!; + assert.match(notice, /may not have been delivered/); + assert.match(notice, /isn't held as a draft, since a resend could post it twice/); + assert.doesNotMatch(notice, /\{\{unsent\}\}|was not delivered/); + return; + } assert.equal(held.length, 1, 'the failed envelope became one draft'); assert.equal(held[0]!.reason, 'bounced'); assert.equal(held[0]!.text, 'keep these words'); From c8fc8317591badc1745df62e4e2fb564718eff91 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 03:40:42 -0400 Subject: [PATCH 36/54] test(drafts): the hybrid throw test follows the marker-only rule A hybrid plain send that throws now holds nothing and its notice offers no {{unsent}} (082d9013); the test still expected the held-draft advice. Its throw variant now asserts the marker-only notice and that no draft holds the words; the failed variant keeps its assertion. --- test/explicit-prose-routing.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index b0fe6f8fb..af6cef2bb 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1099,7 +1099,17 @@ describe('explicit prose routing', () => { assert.deepEqual(routed.filter(r => r.text !== 'fails'), [{ text: 'first', locus: 'discord:g:cafe' }, { text: 'recovered', locus: 'world:commons' }]); const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); - assert.ok(texts.some(t => t.includes('[prose-routing]') && t.includes('>>>#channel {{unsent}}'))); + if (failure === 'throw') { + // A plain send that threw is marker-only: nothing is held, and the + // notice offers no {{unsent}}. + const notice = texts.find(t => t.startsWith('[prose-routing]') && t.includes('fails'.length + ' chars'))!; + assert.match(notice, /may not have been delivered/); + assert.match(notice, /isn't held as a draft/); + assert.doesNotMatch(notice, /\{\{unsent\}\}/); + assert.equal(draftsOf(framework).open('assistant').some(d => d.text === 'fails'), false); + } else { + assert.ok(texts.some(t => t.includes('[prose-routing]') && t.includes('>>>#channel {{unsent}}'))); + } await framework.stop(); }); } From 07d622315b33610d589e51a8ce73195f83421a06 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 06:39:30 -0400 Subject: [PATCH 37/54] fix(drafts): a logical turn's drafts settle at its end, and the catch-up names only drafts no notice names Follow-up to the merge of main (#312's logical turns). The drafts now ride in the LogicalTurn, so they follow it the way its deliveries do. - settleTurnDrafts runs in driveStream's finally only once the stream's logical turn has ended: openLogical no longer holds it (it settled, or it was a silent start's own). A stream ends owning the agent with its turn still open when a provider-capacity hold defers the turn, whose later compile continues it. Settling there spent the drafts, so that compile's receipt left out the first stream's drafts, its kept-private count and its draft failures, though it reports the first stream's deliveries. Now the stream that ends the turn settles them, after its receipt names them. - The turn-start catch-up leaves the open logical turn's own drafts to that turn: its receipt names them and its settlement records them. Before the continuing start's compile it named the ones no notice had named yet as unnamed, and the receipt then named them again. - The catch-up records, instead of naming, a draft that a message in the recent history already names (draftsNamedInHistory, the same window settleTurnDrafts reads). A notice can reach the history after its turn's drafts were settled: in explicit mode a turn a tool ends doesn't wait for its queued deliveries, so an envelope of it that bounces later is held into that turn's settled logical turn, and its bounce notice names a draft nothing records. The catch-up then said "unnamed on this branch" of a draft a notice names. It applies equally to a notice whose recording failed. recordDraftNotices is the marking both use. - changelog: the receipt names every draft held in the turn, in any of its streams. Tests: - explicit-prose-routing: a capacity-held hybrid turn's later compile writes one receipt with its own delivery and the draft its first stream held, and no catch-up names it (an acceleration 429 replaces stream one's completion; 50 ms cooldown). An explicit turn that skip_reply ends with a queued '>>#nowhere' envelope behind a held post: the bounce notice names the draft, the next turn's catch-up doesn't, and the draft is recorded as noticed. - prose-drafts: the catch-up records a draft whose notice is in the history and names only the other; it leaves the open logical turn's draft unnamed and unrecorded. --- changelog.d/prose-drafts.added.md | 4 +- src/framework.ts | 33 +++++++-- test/explicit-prose-routing.test.ts | 100 +++++++++++++++++++++++++++- test/prose-drafts.test.ts | 58 ++++++++++++++++ 4 files changed, 186 insertions(+), 9 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index da3bf953a..ba9944f97 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -33,7 +33,9 @@ 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 + names every draft held in the turn, in any of its streams (a framework + retry, or the compile a provider-capacity hold defers the turn to), 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 dismissed draft whose own attempt may already have been posted is named with that risk, diff --git a/src/framework.ts b/src/framework.ts index a533cf24c..cf64b472f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9551,11 +9551,21 @@ export class AgentFramework { * move took the notices away. Every draft it marks is named by id, newest * first: the newest eight with their state and why each was held, the rest * by id. Like every notice, it quotes none of their words; the drafts tool - * shows them. + * shows them. A draft a recent message already names is recorded as + * noticed rather than named again: its notice landed after its turn ended, + * as a bounce does when a delivery runs after a tool ends an explicit turn. + * The open logical turn's own drafts are left to its receipt and its + * settlement (settleTurnDrafts). */ private noticeUnnoticedDrafts(agent: Agent): void { const cm = agent.getContextManager(); - const pending = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null); + const own = new Set(this.openLogical.get(agent.name)?.drafts.map((d) => d.id)); + const unnoticed = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null) + .filter((d) => !own.has(d.id)); + if (unnoticed.length === 0) return; + const named = this.draftsNamedInHistory(agent, unnoticed.map((d) => d.id)); + this.recordDraftNotices(agent, named); + const pending = unnoticed.filter((d) => !named.has(d.id)); if (pending.length === 0) return; const shown = pending.slice(0, 8).map((d) => `${d.id} (${this.draftStateText(d)}; ${AgentFramework.DRAFT_REASON_SHORT[d.reason]})`); @@ -9580,7 +9590,7 @@ export class AgentFramework { try { this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id), mid); } catch (err) { - // Named again by the next turn's catch-up. + // The next turn's catch-up finds this notice and records it. console.error('[drafts] could not record the catch-up notice:', err); } } @@ -9598,15 +9608,21 @@ export class AgentFramework { logical.keptPrivate = 0; delete logical.draftFailures; if (held.length === 0) return; + this.recordDraftNotices(agent, this.draftsNamedInHistory(agent, held.map((d) => d.id))); + } + + /** Record each named draft as noticed, with the message that names it + * (draftsNamedInHistory). */ + private recordDraftNotices(agent: Agent, named: Map): void { const byMessage = new Map(); - for (const [draftId, messageId] of this.draftsNamedInHistory(agent, held.map((d) => d.id))) { + for (const [draftId, messageId] of named) { byMessage.set(messageId, [...(byMessage.get(messageId) ?? []), draftId]); } for (const [messageId, ids] of byMessage) { try { this.proseDrafts.markNoticed(agent.name, ids, messageId); } catch (err) { - // Unmarked drafts are named again by the next turn's catch-up. + // The next turn's catch-up looks for their notices again. console.error('[drafts] could not record notice:', err); } } @@ -12973,8 +12989,11 @@ export class AgentFramework { } // 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) { + // drafts they name as noticed (the store is synced first). A stream + // whose logical turn is still open leaves its drafts to the stream + // that ends it (openLogical: the compile a provider-capacity hold + // defers the turn to), whose receipt names them. + if (frameReachedTerminal && ownsPhysicalStream && this.openLogical.get(agent.name) !== streamRouting.logical) { try { this.settleTurnDrafts(agent, streamRouting.logical); } catch (err) { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 49f1cfc8a..c54d24bd7 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -28,7 +28,7 @@ import { AgentFramework } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { ProseStreamRouter } from '../src/mcpl/prose-stream-router.js'; import { draftState, type ProseDraftStore } from '../src/prose-drafts.js'; -import type { ContentBlock } from '@animalabs/membrane'; +import { MembraneError, type ContentBlock } from '@animalabs/membrane'; class ToolboxModule implements Module { readonly name = 'robot'; @@ -1190,6 +1190,104 @@ describe('explicit prose routing', () => { }); } + it("hybrid: a capacity-held turn's later compile receipts the drafts its first stream held, and the catch-up leaves them to it", async () => { + // Stream one bounces an envelope into a draft, then its next request + // meets an organization acceleration 429: the turn is held and its later + // compile continues the same logical turn, whose receipt names the draft. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#nowhere held words' }, + { type: 'tool_use', id: 'capacity-1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + const framework = await createFramework('hybrid'); + stubRegistry(framework); + const internals = framework as unknown as { + providerAccelerationDefaultCooldownMs: number; + providerAccelerationJitterMs: number; + openLogical: Map; + }; + internals.providerAccelerationDefaultCooldownMs = 50; + internals.providerAccelerationJitterMs = 0; + let starts = 0; + framework.onTrace((event) => { + if (event.type !== 'inference:started') return; + starts++; + if (starts === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }])); + if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>>skip_reply' }])); + }); + const makeStream = membrane.streamYielding.bind(membrane); + let streams = 0; + membrane.streamYielding = (request, options) => { + const stream = makeStream(request, options); + const index = ++streams; + const iterate = stream[Symbol.asyncIterator].bind(stream); + stream[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (event.type === 'complete' && index === 1) { + yield { + type: 'error' as const, + error: new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, + }), + }; + return; + } + yield event; + } + }; + return stream; + }; + try { + trigger(framework); + await framework.runUntilIdle(); + const draft = draftsOf(framework).open('assistant').find((d) => d.text === 'held words'); + assert.ok(draft, 'the bounced envelope is held as a draft'); + const open = internals.openLogical.get('assistant'); + assert.ok(open?.drafts.some((d) => d.id === draft.id), "the held turn's logical turn is open, its draft unsettled"); + for (const end = Date.now() + 5000; starts < 2 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await framework.runUntilIdle(); + } + assert.equal(starts, 2, 'the held turn was compiled again after its cooldown'); + await framework.runUntilIdle(); + const texts = context(framework).texts; + const receipts = texts.filter((t) => t.startsWith('[delivered]')); + assert.equal(receipts.length, 1, `one receipt for the logical turn: ${receipts.join(' | ')}`); + assert.match(receipts[0]!, /world:commons/, "it reports the later compile's delivery"); + assert.ok(receipts[0]!.includes(draft.id), `and the draft the first stream held: ${receipts[0]}`); + assert.ok(!texts.some((t) => t.includes('unnamed on this branch')), 'the catch-up left the draft to the receipt'); + assert.equal(internals.openLogical.has('assistant'), false, 'the logical turn settled'); + } finally { + await framework.stop(); + } + }); + + it("explicit: a draft bounced after a tool ended its turn is recorded from its notice, not named again by the catch-up", async () => { + // The ended turn's queued envelope bounces once the held post before it + // settles, after that turn's drafts were settled. Its bounce notice names + // it; the next turn's catch-up records that notice rather than naming + // the draft as unnamed. + await withQueuedTurns('explicit', '>>#nowhere late words', [ + createMockResponse([{ type: 'text', text: '>>#cafe next' }]), + ], async ({ framework, release, drain, turns }) => { + release(); + for (const end = Date.now() + 5000; turns() < 3 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await drain(); + assert.equal(turns(), 3, "the bounce's wake started a turn"); + const store = draftsOf(framework); + const late = store.open('assistant').find((d) => d.text === 'late words'); + assert.ok(late, 'the late envelope bounced into a draft'); + const texts = context(framework).texts; + assert.ok(texts.some((t) => t.startsWith('[prose-routing]') && t.includes(late.id)), 'its bounce notice names it'); + assert.ok(!texts.some((t) => t.includes('unnamed on this branch')), "the catch-up doesn't name it again"); + assert.ok((store.get('assistant', late.id)?.noticedIn ?? []).length > 0, 'it is recorded as noticed'); + }); + }); + it("the non-streaming fallback leaves the scheduling turn's logical turn open for the re-inference", async () => { // Adapted from a reviewer's producer test: the agent's direct inference // API leaves tools pending with no yielding stream, and the tool result diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 52c9245b6..7b450ee61 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1127,6 +1127,64 @@ describe('draft notices and turn-end settlement', () => { rmSync(dir, { recursive: true, force: true }); } }); + + const catchUpFixture = async () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-catchup-')); + 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 internals = framework as unknown as { + proseDrafts: ProseDraftStore; + routingOf(name: string): { logical: { drafts: unknown[] } }; + openLogical: Map; + noticeUnnoticedDrafts(agent: unknown): void; + }; + const agent = framework.getAgent('scout')!; + const cm = agent.getContextManager(); + const catchUps = () => cm.getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .filter((t) => t.includes('unnamed on this branch')); + return { framework, internals, agent, cm, catchUps, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); } }; + }; + + it("the catch-up records a draft a notice already names, rather than naming it again", async () => { + const t = await catchUpFixture(); + try { + const [late, lost] = t.internals.proseDrafts.hold('scout', [{ text: 'late', source }, { text: 'lost', source }], 'bounced').held; + // A bounce notice reached the history after its turn's drafts were + // settled, so nothing recorded it. + const notice = t.cm.addMessage('user', [{ type: 'text', text: '[prose-routing] …' }], { system: true, kind: 'prose-bounce', draftOwner: 'scout', draftIds: [late!.id] } as never); + t.internals.noticeUnnoticedDrafts(t.agent); + assert.deepEqual(t.internals.proseDrafts.get('scout', late!.id)!.noticedIn, [String(notice)], 'recorded with the notice that names it'); + const [catchUp] = t.catchUps(); + assert.ok(catchUp?.includes(lost!.id), 'the draft no notice names is named'); + assert.ok(!catchUp!.includes(late!.id), `the one its notice names is not: ${catchUp}`); + } finally { + await t.close(); + } + }); + + it("the catch-up leaves the open logical turn's own drafts to its receipt", async () => { + const t = await catchUpFixture(); + try { + const [own, other] = t.internals.proseDrafts.hold('scout', [{ text: 'own', source }, { text: 'other', source }], 'explicit-send').held; + // A later start continues this logical turn, whose receipt names its + // drafts at the turn's end. + const logical = t.internals.routingOf('scout').logical; + logical.drafts.push(own); + t.internals.openLogical.set('scout', logical); + t.internals.noticeUnnoticedDrafts(t.agent); + const [catchUp] = t.catchUps(); + assert.ok(catchUp?.includes(other!.id), "another turn's unnamed draft is named"); + assert.ok(!catchUp!.includes(own!.id), `the open turn's own draft is not: ${catchUp}`); + assert.equal(t.internals.proseDrafts.get('scout', own!.id)!.noticedIn, undefined, 'and stays for its settlement'); + } finally { + await t.close(); + } + }); }); describe('draft identity and risk evidence', () => { From 2f50a4b38b47c7aa4a451d5492bffa259de86908 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 06:52:07 -0400 Subject: [PATCH 38/54] test(drafts): a hold beside a send writes its own round's routing; an envelope after >>>skip_reply counts as kept private Two pieces no test held in J6360's red checks at 07d62231: - I6: holdSilencedProse writing the live turn's routing instead of its round's stayed green. A direct test now holds a hybrid round (a failed '>>>#nowhere' envelope and the text after it) under a queued routing while another is live: the failed hold and both drafts are the queued round's, and the live turn is untouched. - I10: deliverHybridProse's count of an unprefixed envelope after a '>>>skip_reply' hold stayed green when removed. A direct test sends '>>>skip_reply an aside' then 'still private': nothing is sent, the hold is 'private', the second envelope counts as kept private, and nothing is drafted. --- test/explicit-prose-routing.test.ts | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index c54d24bd7..24627f647 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1721,6 +1721,52 @@ describe('explicit prose routing', () => { } }); + it('hybrid: a hold beside a send writes the routing and drafts of its own round, not the live turn\'s', async () => { + const framework = await createFramework('hybrid'); + stubRegistry(framework); + const gateway = framework as unknown as { + holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; notice: 'later' }): void; + turnRouting: Map; + }; + const agent = framework.getAgent('assistant')!; + try { + const queued: RoutingState = { destination: null, + logical: { continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0 } }; + const live: RoutingState = { ...queued, logical: { continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0 } }; + gateway.turnRouting.set('assistant', live); + // A held round that a later start has overtaken: its envelopes move + // its own turn's routing, and its drafts are that turn's. + gateway.holdSilencedProse(agent, ['>>>#nowhere held words', 'after it'], 'send', false, { routing: queued, notice: 'later' }); + assert.equal(queued.hybridHold, 'failed', 'the round that held it records the failed envelope'); + assert.deepEqual(queued.logical.drafts.map((d) => d.text), ['held words', 'after it'], "and holds the round's words as its drafts"); + assert.equal(live.hybridHold, undefined, 'the live turn is untouched'); + assert.deepEqual(live.logical.drafts, [], 'and holds no draft of that round'); + } finally { + await framework.stop(); + } + }); + + it('hybrid: after >>>skip_reply, the next unprefixed envelope stays in the context and counts as kept private', async () => { + const framework = await createFramework('hybrid'); + const routed = stubRegistry(framework); + const gateway = framework as unknown as { + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; + }; + const agent = framework.getAgent('assistant')!; + try { + const routing: RoutingState = { destination: null, + logical: { continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0 } }; + await gateway.deliverHybridProse(agent, '>>>skip_reply an aside', 'chan-locus', true, { routing, notice: 'later' }); + await gateway.deliverHybridProse(agent, 'still private', 'chan-locus', true, { routing, notice: 'later' }); + assert.deepEqual(routed, [], 'nothing is sent'); + assert.equal(routing.hybridHold, 'private'); + assert.equal(routing.logical.keptPrivate, 1, 'the unprefixed envelope after it is counted as kept private'); + assert.deepEqual(routing.logical.drafts, [], 'and is never drafted'); + } finally { + await framework.stop(); + } + }); + it('admission turns a resolver that throws into an envelope that bounces', async () => { const framework = await createFramework(); const routed = stubRegistry(framework); From 78ce9aa42c1c26b3f874bd847567a0ab9378a28a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 07:02:10 -0400 Subject: [PATCH 39/54] fix(drafts): a round carries its turn token; the catch-up says what it checks and leaves out notices on their way; the receipt names every draft From the review of the merge round (sw-af247q, judged) and the readers' notes (Arthur-2378, room-203 #84910 and #84923, J6382; Agnes-1009, #84913): - Provenance (sw-af247q I14; Arthur, Agnes): holdProseDrafts took a draft's source.turn from the agent's current turn token when it held, so a late hold or bounce journaled a later turn's token beside the old round's number. ProseRound now carries `turn`, the stream's token captured at driveStream entry, and every hold takes it from its round. The test-only deliverProse entry gives the live token, as a live round would. DraftSource's docs now say what the fields hold: `turn` is the token of the stream whose round wrote the words (a restart or retry has its own), and `segment` is the position among the words held together. - The catch-up's wording (I18; Arthur, Agnes): it reads only the newest 500 messages, so "unnamed on this branch of your history" claimed more than it checks. It now names open drafts "that no recent notice on this branch names". - The catch-up leaves out drafts that a notice still waiting in the deferred queue names (I21). A bounce that lands while a turn's start awaits push assembly is deferred, and the catch-up then named its draft just before the bounce notice landed and named it again. - The receipt names every draft by id (I20). Its metadata names them all, so settleTurnDrafts records each one as noticed, but its text named eight and counted the rest. The catch-up already names every draft it marks (2c198b5). - A refused {{unsent}} says when the envelope's own words couldn't be held (I7). Explicit mode writes no receipt to report that failed hold. - The receipt's kept-private count says "(skip_reply or a private think)": a same-round private think beside a send is counted there too. - changelog (I13, I27): a silent turn writes no receipt, so its private words aren't counted; the receipt is written in locus and hybrid modes, and explicit mode's drafts are named by their notices; the catch-up's window, and what it records and leaves out. Tests: - explicit-prose-routing: the ordered held-send test asserts the copy's source.turn is the turn that attempted it (Arthur-2378's probe), and for the bare variant that the re-bounce notice offers {{unsent}}, so the order is pinned without the timed check (I11). The hold beside a send records its round's turn. A refused {{unsent}} whose hold fails says so. A receipt with eleven held drafts names all eleven. - prose-drafts: a draft a deferred notice names is left out of the catch-up. The kept-private label and the catch-up's wording are updated where tests pin them, and the private-think turn's receipt is now asserted. --- changelog.d/prose-drafts.added.md | 27 ++++++----- src/framework.ts | 62 +++++++++++++++++-------- src/prose-drafts.ts | 6 ++- test/explicit-prose-routing.test.ts | 71 ++++++++++++++++++++++++++--- test/prose-drafts.test.ts | 42 +++++++++++++---- 5 files changed, 161 insertions(+), 47 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index ba9944f97..393da491b 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -13,8 +13,8 @@ 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. + and `proseRouting: disabled` narration stay private. The turn's receipt + counts them, except a silent turn's: a silent turn writes no 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}}` @@ -32,19 +32,24 @@ - A private notice names held drafts at the next tool boundary when the live stream presents mid-turn messages (a draft holding words copied from one that may already have been posted is named with that risk and the - `confirmDuplicate` its resend needs), and the turn-end `[delivered]` receipt - names every draft held in the turn, in any of its streams (a framework - retry, or the compile a provider-capacity hold defers the turn to), by its - state at turn end: still held + `confirmDuplicate` its resend needs). In locus and hybrid modes the + turn-end `[delivered]` receipt names every draft held in the turn, in any + of its streams (a framework retry, or the compile a provider-capacity hold + defers the turn to), by id and 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 dismissed draft whose own attempt may already have been posted is named with that risk, and counts like an unconfirmed one: a receipt that names one, with nothing - confirmed delivered, reads "nothing confirmed", not "nothing". A draft - counts as named while a message naming it is on the current branch, so one - no notice reached (a crash, an aborted turn), or whose notices a rollback - or branch move took away, is named at the next turn's start: every such - open draft by id, the newest eight with its state and why it was held. Notices + confirmed delivered, reads "nothing confirmed", not "nothing". Explicit + mode writes no receipt: its drafts are named by their bounce and refusal + notices. A draft counts as named while a message naming it is on the + current branch, so one no notice reached (a crash, an aborted turn), or + whose notices a rollback or branch move took away, is named at the next + turn's start: every such open draft by id, the newest eight with its state + and why it was held. That catch-up reads the recent history (the newest + 500 messages): it records a notice it finds there, including one that + landed after its turn had ended, and it leaves out the drafts of a turn + still open and those a notice waiting to be written names. Notices name drafts and say how to resend or dismiss them, and quote none of their words; the `drafts` tool's `list` and `read` show the words. - A draft is never published except by its resident's explicit resend (or diff --git a/src/framework.ts b/src/framework.ts index cf64b472f..5095c7ccd 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -637,6 +637,8 @@ function freshTurnRouting(): TurnRouting { interface ProseRound { /** The routing state of the turn that queued the round. */ routing: TurnRouting; + /** The turn token current when the round was built: a held draft's provenance (DraftSource.turn). */ + turn: number; round?: number; notice: 'now' | 'later'; keepPrivate?: boolean; @@ -9369,7 +9371,7 @@ export class AgentFramework { agent: Agent, texts: Array, reason: DraftReason, - opts: { routing: TurnRouting; round?: number; note?: string; notice: 'now' | 'later' }, + opts: { routing: TurnRouting; turn: number; round?: number; note?: string; notice: 'now' | 'later' }, ): Draft[] { const segments = texts .map((t) => (typeof t === 'string' @@ -9377,7 +9379,7 @@ export class AgentFramework { : { 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; + const turn = opts.turn; let branch = 'unknown'; try { branch = this.store.currentBranch().name; @@ -9554,14 +9556,22 @@ export class AgentFramework { * shows them. A draft a recent message already names is recorded as * noticed rather than named again: its notice landed after its turn ended, * as a bounce does when a delivery runs after a tool ends an explicit turn. - * The open logical turn's own drafts are left to its receipt and its - * settlement (settleTurnDrafts). + * It reads only the recent history (draftsNamedInHistory), so its notice + * claims no more than that. Left out are the open logical turn's own + * drafts, which its receipt names and its settlement records + * (settleTurnDrafts), and drafts a notice still waiting in the deferred + * queue names. */ private noticeUnnoticedDrafts(agent: Agent): void { const cm = agent.getContextManager(); - const own = new Set(this.openLogical.get(agent.name)?.drafts.map((d) => d.id)); + const leftOut = new Set(this.openLogical.get(agent.name)?.drafts.map((d) => d.id)); + for (const msg of this.deferredMessages) { + const meta = msg.metadata as { draftOwner?: unknown; draftIds?: unknown } | undefined; + if (meta?.draftOwner !== agent.name || !Array.isArray(meta.draftIds)) continue; + for (const id of meta.draftIds) if (typeof id === 'string') leftOut.add(id); + } const unnoticed = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null) - .filter((d) => !own.has(d.id)); + .filter((d) => !leftOut.has(d.id)); if (unnoticed.length === 0) return; const named = this.draftsNamedInHistory(agent, unnoticed.map((d) => d.id)); this.recordDraftNotices(agent, named); @@ -9573,7 +9583,7 @@ export class AgentFramework { const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : ''; const text = `[drafts] ${agent.name}: ${pending.length === 1 ? 'an open draft' : `${pending.length} open drafts`} of yours ` + - `${pending.length === 1 ? 'is' : 'are'} unnamed on this branch of your history: ${shown.join(' · ')}${more}. ` + + `that no recent notice on this branch names: ${shown.join(' · ')}${more}. ` + 'drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; let mid: string; try { @@ -10001,8 +10011,10 @@ export class AgentFramework { // 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` : ''); + // Every draft by id: the receipt's metadata names them all, so + // settleTurnDrafts records each one as noticed, and no later catch-up + // names one the text left out. + const idList = (ds: Draft[]): string => ds.map((d) => d.id).join(', '); const held = inState('held'); if (held.length > 0) { notes.push( @@ -10040,7 +10052,7 @@ export class AgentFramework { 'the words remain only in your history', ); } - if (kept > 0) notes.push(`${kept} plain-speech segment(s) kept private (skip_reply)`); + if (kept > 0) notes.push(`${kept} plain-speech segment(s) kept private (skip_reply or a private think)`); if (suppressed > 0) { notes.push(agent.proseRouting === 'disabled' ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` @@ -10216,9 +10228,11 @@ export class AgentFramework { rawText: string, round: { notice?: 'now' | 'later'; keepPrivate?: boolean } = {}, ): Promise { - // The direct entry delivers under the live turn's routing. + // The direct entry delivers as a live round does: under the live turn's + // routing and turn token. await this.deliverAdmittedProse(agent, this.admitProse(rawText), { routing: this.routingOf(agent.name), + turn: this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0, notice: round.notice ?? 'later', keepPrivate: round.keepPrivate, }); @@ -10317,12 +10331,17 @@ export class AgentFramework { agent, [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], 'bounced', - { routing: round.routing, round: round.round, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, + { routing: round.routing, turn: round.turn, round: round.round, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, )[0]; - const kept = held - ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` + - 'can be resent only with confirmDuplicate: true, after that.' - : ''; + // A bare {{unsent}} holds nothing new; otherwise say whether the + // envelope's own words were held, since explicit mode writes no receipt + // to report a hold that failed. + const kept = 'draft' in words + ? '' + : held + ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` + + 'can be resent only with confirmDuplicate: true, after that.' + : ' Your message could not be held as a draft; its words remain in your history.'; const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` + `Nothing was sent; ${next}.${kept}`; try { @@ -10743,7 +10762,7 @@ export class AgentFramework { agent, [{ text: words.text, ...(inherited ? { inheritedRisk: inherited } : {}) }], 'bounced', - { routing: round.routing, round: round.round, note: reason, notice: 'later' }, + { routing: round.routing, turn: round.turn, round: round.round, note: reason, notice: 'later' }, )[0]; } const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; @@ -11467,6 +11486,9 @@ export class AgentFramework { this.guardRetryTurnSilenced.delete(agent.name); /** Provider rounds of this physical stream, for draft provenance. */ let draftRound = 0; + /** This stream's turn token, for draft provenance: a round queued here + * keeps it even when its hold runs after a later turn has started. */ + const draftTurn = this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0; // Live routing is only trusted when the membrane provides verbatim // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64). @@ -11807,7 +11829,7 @@ export class AgentFramework { const presentsInjections = (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true; // Queued under this turn's routing, whenever it posts. - const holdOpts: ProseRound = { routing: streamRouting, round: draftRound, notice: presentsInjections ? 'now' : 'later' }; + const holdOpts: ProseRound = { routing: streamRouting, turn: draftTurn, round: draftRound, notice: presentsInjections ? 'now' : 'later' }; // Routing publishes trimmed segments; held drafts keep the // runs exactly as written. const roundRuns = splitProseRuns(assistantBlocks); @@ -12346,7 +12368,7 @@ export class AgentFramework { if (speechContent.length > 0 && this.channelRegistry) { const speechRuns = finalSpeechRuns; const speechSegments = finalSpeechSegments; - const textOnlyHold: ProseRound = { routing: finalRouting, round: draftRound + 1, notice: 'later' }; + const textOnlyHold: ProseRound = { routing: finalRouting, turn: draftTurn, round: draftRound + 1, notice: 'later' }; if (speechSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); @@ -12424,7 +12446,7 @@ export class AgentFramework { : turnSilenced || fallbackCause !== null; const trailingCause: 'send' | 'private' | null = silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); - const trailingHold: ProseRound = { routing: finalRouting, round: draftRound + 1, notice: 'later' }; + const trailingHold: ProseRound = { routing: finalRouting, turn: draftTurn, round: draftRound + 1, notice: 'later' }; const runs = finalRuns; const segments = finalSegments; diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index ebaad9d05..601ed2578 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -68,11 +68,13 @@ export type DraftReason = export interface DraftSource { /** Chronicle branch current when the words were held. */ branch: string; - /** The resident's logical turn (process-local counter). */ + /** The turn token (a process-local counter) of the stream whose round + * wrote the words; a budget restart or a retry has a token of its own. */ turn: number; /** Round within this stream; numbering restarts at a budget restart. */ round: number; - /** Position of the segment within its round's speech. */ + /** Position of these words among those held together, in one hold of a + * round's speech; a bounce holds its words alone, at 0. */ segment: number; } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 24627f647..1661ecc07 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -410,6 +410,7 @@ describe('explicit prose routing', () => { const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS', source: { branch: 'main', turn: 0, round: 0, segment: 0 } }], 'bounced').held[0]!; const internals = framework as unknown as { channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise }; + logicalTurnToolCalls: { get(agent: unknown): { turnToken: number } | undefined }; }; const deliver = internals.channelRegistry.deliverSpeech; let release!: () => void; @@ -423,9 +424,11 @@ describe('explicit prose routing', () => { return outcome; }; let starts = 0; + let firstTurn = 0; framework.onTrace((e) => { if ((e as { type: string }).type !== 'inference:started') return; starts++; + if (starts === 1) firstTurn = internals.logicalTurnToolCalls.get(framework.getAgent('assistant')!)!.turnToken; if (starts === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>#nowhere NEW WORDS' }])); if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); }); @@ -450,8 +453,21 @@ describe('explicit prose routing', () => { assert.ok(heldTexts.includes(attempted), `the failed send holds the words it attempted: ${JSON.stringify(heldTexts)}`); const copy = store.open('assistant').find((d) => d.text === attempted)!; assert.equal(copy.inheritedRisk, undefined, 'a definite failure leaves no risk to inherit'); - if (sent === '{{unsent}}') assert.equal(copy.id, original.id, 'the bare send re-bounces the draft it sent'); - else assert.notEqual(copy.id, original.id); + if (sent === '{{unsent}}') { + assert.equal(copy.id, original.id, 'the bare send re-bounces the draft it sent'); + // Independent of timing: the re-bounce notice offers {{unsent}} only + // when its draft is the latest bounce, so it does only if the next + // turn's bounce waited for this failure. + const notice = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes(original.id)).at(-1)!; + assert.ok(notice.includes('{{unsent}}'), `the failure came back before the next turn's bounce: ${notice}`); + } else { + // Held after the failure's copy, the next turn's bounce is the latest. + assert.notEqual(copy.id, original.id); + // Arthur-2378's probe: held while turn two runs, the copy keeps the + // turn that attempted its words. + assert.ok(firstTurn > 0, 'the first turn token was observed'); + assert.equal(copy.source.turn, firstTurn, "the copy's provenance is the turn that attempted the words"); + } assert.equal(store.latestBounce('assistant')?.text, 'NEW WORDS', "the next turn's bounce came after it, and is the latest"); } finally { release(); @@ -1257,7 +1273,7 @@ describe('explicit prose routing', () => { assert.equal(receipts.length, 1, `one receipt for the logical turn: ${receipts.join(' | ')}`); assert.match(receipts[0]!, /world:commons/, "it reports the later compile's delivery"); assert.ok(receipts[0]!.includes(draft.id), `and the draft the first stream held: ${receipts[0]}`); - assert.ok(!texts.some((t) => t.includes('unnamed on this branch')), 'the catch-up left the draft to the receipt'); + assert.ok(!texts.some((t) => t.includes('that no recent notice on this branch names')), 'the catch-up left the draft to the receipt'); assert.equal(internals.openLogical.has('assistant'), false, 'the logical turn settled'); } finally { await framework.stop(); @@ -1283,7 +1299,7 @@ describe('explicit prose routing', () => { assert.ok(late, 'the late envelope bounced into a draft'); const texts = context(framework).texts; assert.ok(texts.some((t) => t.startsWith('[prose-routing]') && t.includes(late.id)), 'its bounce notice names it'); - assert.ok(!texts.some((t) => t.includes('unnamed on this branch')), "the catch-up doesn't name it again"); + assert.ok(!texts.some((t) => t.includes('that no recent notice on this branch names')), "the catch-up doesn't name it again"); assert.ok((store.get('assistant', late.id)?.noticedIn ?? []).length > 0, 'it is recorded as noticed'); }); }); @@ -1725,7 +1741,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); stubRegistry(framework); const gateway = framework as unknown as { - holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; notice: 'later' }): void; + holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; turn: number; notice: 'later' }): void; turnRouting: Map; }; const agent = framework.getAgent('assistant')!; @@ -1736,9 +1752,10 @@ describe('explicit prose routing', () => { gateway.turnRouting.set('assistant', live); // A held round that a later start has overtaken: its envelopes move // its own turn's routing, and its drafts are that turn's. - gateway.holdSilencedProse(agent, ['>>>#nowhere held words', 'after it'], 'send', false, { routing: queued, notice: 'later' }); + gateway.holdSilencedProse(agent, ['>>>#nowhere held words', 'after it'], 'send', false, { routing: queued, turn: 7, notice: 'later' }); assert.equal(queued.hybridHold, 'failed', 'the round that held it records the failed envelope'); assert.deepEqual(queued.logical.drafts.map((d) => d.text), ['held words', 'after it'], "and holds the round's words as its drafts"); + assert.deepEqual(draftsOf(framework).open('assistant').map((d) => d.source.turn), [7, 7], "each draft's provenance is the round's turn, not the live one"); assert.equal(live.hybridHold, undefined, 'the live turn is untouched'); assert.deepEqual(live.logical.drafts, [], 'and holds no draft of that round'); } finally { @@ -1767,6 +1784,48 @@ describe('explicit prose routing', () => { } }); + it("a refused {{unsent}} says when the envelope's own words couldn't be held", async () => { + const framework = await createFramework(); + try { + const routed = stubRegistry(framework); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced').held[0]!; + // An attempt with no outcome: the latest bounce may already have been + // posted, so {{unsent}} refuses it and holds the envelope's words. + store.beginAttempt('assistant', original.id, { serverId: 'stub', channelId: 'chan-alpha' }, 'unsent-token', false); + // The journal refuses the envelope's words. + store.hold = ((_agent: string, segments: unknown[]) => + ({ held: [], notHeld: { count: segments.length, error: 'the journal refused the write' } })) as typeof store.hold; + const gateway = framework as unknown as { deliverProse(a: unknown, text: string): Promise }; + await gateway.deliverProse(framework.getAgent('assistant')!, '>>#alpha {{unsent}} and a line'); + assert.deepEqual(routed, [], 'nothing is sent'); + const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing] {{unsent}} is draft'))!; + assert.ok(notice.includes('Your message could not be held as a draft; its words remain in your history.'), notice); + } finally { + await framework.stop(); + } + }); + + it('the receipt names every draft it records, past eight', async () => { + const framework = await createFramework('hybrid'); + try { + stubRegistry(framework); + const store = draftsOf(framework); + const held = store.hold('assistant', Array.from({ length: 11 }, (_, segment) => ({ + text: `held ${segment}`, source: { branch: 'main', turn: 1, round: 1, segment }, + })), 'explicit-send').held; + const gateway = framework as unknown as { appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void }; + const routing: RoutingState = { destination: null, + logical: { continuation: false, deliveries: [], suppressed: 0, drafts: held, keptPrivate: 0 } }; + gateway.appendProseDeliveryReceipt(framework.getAgent('assistant')!, routing); + const receipt = context(framework).texts.find((t) => t.startsWith('[delivered]'))!; + for (const draft of held) assert.ok(receipt.includes(draft.id), `${draft.id} is named: ${receipt}`); + assert.doesNotMatch(receipt, /and \d+ more/, 'none is left as a count'); + } finally { + await framework.stop(); + } + }); + it('admission turns a resolver that throws into an envelope that bounces', async () => { const framework = await createFramework(); const routed = stubRegistry(framework); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 7b450ee61..054f4d866 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -465,13 +465,16 @@ describe('held prose drafts, end to end', () => { createMockResponse([]), ]); assert.deepEqual(h.drafts(), []); - assert.ok(h.texts().some((t) => t === '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply)')); + assert.ok(h.texts().some((t) => t === '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply or a private think)')); 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']); + assert.equal(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1), + '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply or a private think)', + 'the receipt counts it, and says a private think may be why'); } finally { await h.close(); } @@ -811,7 +814,7 @@ describe('held prose drafts, end to end', () => { h.store().beginAttempt('scout', midSend.id, dest(), 'resend', false); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: 2 open drafts of yours are unnamed on this branch of your history: /); + assert.match(notice, /^\[drafts\] scout: 2 open drafts of yours that no recent notice on this branch names: /); assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); explicit send in the same round\)/); assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*explicit send in the same round\\)`)); assert.doesNotMatch(notice, /They were not sent/); @@ -829,7 +832,7 @@ describe('held prose drafts, end to end', () => { const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; - assert.match(notice, /^\[drafts\] scout: 10 open drafts of yours are unnamed on this branch of your history: /); + assert.match(notice, /^\[drafts\] scout: 10 open drafts of yours that no recent notice on this branch names: /); // Newest first: the eight newest with their state, then the two oldest // by id alone. No draft's words appear. const named = [...notice.matchAll(/(d-[a-z2-9]{5}) \(held \(not sent\); explicit send in the same round\)/g)].map((m) => m[1]); @@ -854,7 +857,7 @@ describe('held prose drafts, end to end', () => { ]); const [draft] = h.drafts(); assert.ok(h.texts().some((t) => t.includes(`held as draft ${draft!.id}`)), 'the turn-end receipt names it'); - const catchUps = () => h.texts().filter((t) => t.includes('unnamed on this branch of your history')); + const catchUps = () => h.texts().filter((t) => t.includes('that no recent notice on this branch names')); assert.deepEqual(catchUps(), []); // The rollback takes the turn, and its notices, off the branch; the // draft stays, as drafts do. @@ -862,7 +865,7 @@ describe('held prose drafts, end to end', () => { assert.ok(!h.texts().some((t) => t.includes(draft!.id)), 'nothing on the branch names it now'); await h.turn([createMockResponse([])]); assert.equal(catchUps().length, 1); - assert.match(catchUps()[0]!, new RegExp(`an open draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\); explicit send in the same round\\)`)); + assert.match(catchUps()[0]!, new RegExp(`an open draft of yours that no recent notice on this branch names: ${draft!.id} \\(held \\(not sent\\); explicit send in the same round\\)`)); assert.ok(!catchUps()[0]!.includes('words a send held back'), "the notice quotes none of the draft's words"); await h.turn([createMockResponse([])]); assert.equal(catchUps().length, 1, 'named once on this branch'); @@ -1146,7 +1149,7 @@ describe('draft notices and turn-end settlement', () => { const cm = agent.getContextManager(); const catchUps = () => cm.getAllMessages() .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) - .filter((t) => t.includes('unnamed on this branch')); + .filter((t) => t.includes('that no recent notice on this branch names')); return { framework, internals, agent, cm, catchUps, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); } }; }; @@ -1167,6 +1170,29 @@ describe('draft notices and turn-end settlement', () => { } }); + it('the catch-up leaves out a draft that a notice still waiting to be written names', async () => { + const t = await catchUpFixture(); + const queue = (t.framework as unknown as { deferredMessages: Array> }).deferredMessages; + try { + const [waiting, lost] = t.internals.proseDrafts.hold('scout', [{ text: 'waiting', source }, { text: 'lost', source }], 'bounced').held; + // A bounce notice deferred while a turn is alive: not in the history + // yet, but on its way. + queue.push({ + id: 'deferred-notice', seq: 1, participant: 'user', + content: [{ type: 'text', text: '[prose-routing] …' }], + metadata: { system: true, kind: 'prose-bounce', draftOwner: 'scout', draftIds: [waiting!.id] }, + forAgent: 'scout', + }); + t.internals.noticeUnnoticedDrafts(t.agent); + const [catchUp] = t.catchUps(); + assert.ok(catchUp?.includes(lost!.id), 'the draft no notice names is named'); + assert.ok(!catchUp!.includes(waiting!.id), `the one a waiting notice names is not: ${catchUp}`); + } finally { + queue.length = 0; + await t.close(); + } + }); + it("the catch-up leaves the open logical turn's own drafts to its receipt", async () => { const t = await catchUpFixture(); try { @@ -1600,7 +1626,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end ]); assert.equal((h.framework as unknown as { turnRouting: Map }).turnRouting.get('scout')?.pin, 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\)$/); + assert.match(h.texts().find((t) => t.startsWith('[delivered]'))!, / · 3 plain-speech segment\(s\) kept private \(skip_reply or a private think\)$/); // A live `>>>skip_reply` in an earlier round binds a later silenced one. const before = h.drafts().length; @@ -1611,7 +1637,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end 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)'); + assert.equal(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1), '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply or a private think)'); } finally { await h.close(); } From 3a458ee30af7118bcce33ec69b0bd802359a9235 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 07:15:13 -0400 Subject: [PATCH 40/54] fix(drafts): notices that name a draft say so in their metadata; the dead 'ambiguous' reason goes; texts say what the code does From the haiku review of 78ce9aa4 (sw-af247r, judged): - The refused-{{unsent}} notice names the refused draft by id in its text, but its metadata listed only the copy it held, or nothing. The unrecorded-outcome notice named its draft with no metadata at all. Since the catch-up and settlement count a draft as named only through a notice's draftIds, both notices now list the drafts they name (I11, I12). - DraftReason 'ambiguous' ("two conversations competed for an unaddressed reply") was produced by no code path. It goes from the type, the reason texts and the module header (I17). - Texts: retention keeps an older draft for at least 7 days, since the journal lets it go at its next checkpoint (changelog, the drafts tool's description and its refusal; I8); `list` shows a preview and `read` the words (I15); a refused {{unsent}} whose copy the journal refuses says the words remain only in the history (changelog, I7); DraftAttempt.via's 'unsent-token' covers hybrid envelopes too (I16); keptPrivate counts a private think beside a send too (I20); ProseRound.turn is read at driveStream's entry (I22). Tests: - explicit-prose-routing: a send's held-back words that the journal refuses are reported in the receipt's "NOT held as drafts" line (I4); the receipt with eleven drafts names all of them in its metadata (I5); the refused {{unsent}} notice's metadata names the refused draft (I11); an {{unsent}} send whose outcome can't be recorded gets a notice naming its draft, in text and metadata (I12); the >>>skip_reply test also checks the journal holds nothing (I21). - prose-drafts: the catch-up finds a notice among the newest 500 messages and names a draft whose only notice is further back (I10). --- changelog.d/prose-drafts.added.md | 14 ++++--- src/framework.ts | 28 ++++++++------ src/prose-drafts.ts | 9 ++--- test/explicit-prose-routing.test.ts | 57 +++++++++++++++++++++++++++++ test/prose-drafts.test.ts | 19 ++++++++++ 5 files changed, 103 insertions(+), 24 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 393da491b..bb6c83339 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -24,10 +24,10 @@ - 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. Each - resident's 100 newest drafts are kept, and an older one for 7 days after - anything last happened to it (held, named, attempted, answered, dismissed); - the journal checkpoints what it keeps every 500 entries, so opening it - replays only what follows. A request naming a draft that was let go is + resident's 100 newest drafts are kept, and an older one for at least 7 + days after anything last happened to it (held, named, attempted, answered, + dismissed). The journal lets such a draft go when it next checkpoints what + it keeps, every 500 entries, so opening it replays only what follows. A request naming a draft that was let go is refused, and nothing is sent. - A private notice names held drafts at the next tool boundary when the live stream presents mid-turn messages (a draft holding words copied from one @@ -51,7 +51,8 @@ landed after its turn had ended, and it leaves out the drafts of a turn still open and those a notice waiting to be written names. Notices name drafts and say how to resend or dismiss them, and quote none of their - words; the `drafts` tool's `list` and `read` show the words. + words; the `drafts` tool's `read` shows the words, and its `list` a + preview of each. - 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 @@ -76,7 +77,8 @@ - `{{unsent}}` whose draft is being sent, queued in a resend, or unconfirmed sends nothing, and the envelope's own words are held as a bounce draft with `{{unsent}}` written out and the risk carried, so nothing the - resident wrote has to be typed again. + resident wrote has to be typed again. If the journal refuses that draft, + the notice says the words remain only in the history. - Prose segments also break at XML tool mode's refused attempts and their notices (`tool_attempt`, `tool_notice`): the words before an all-refused call and after its notice are two messages, routed or held separately. diff --git a/src/framework.ts b/src/framework.ts index 5095c7ccd..815ad2fee 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -612,7 +612,7 @@ interface LogicalTurn { * noticed once the turn has ended and its notices are written. */ drafts: Draft[]; - /** Plain-speech segments the turn kept private by the resident's own choice (skip_reply in the same round): counted, never drafted. */ + /** Plain-speech segments the turn kept private by the resident's own choice (skip_reply, or a same-round private think beside a send): counted, never drafted. */ keptPrivate: number; /** Segments that should have been held as drafts but could not be journaled: their words remain in the resident's history only. */ draftFailures?: { count: number; error: string }; @@ -637,7 +637,7 @@ function freshTurnRouting(): TurnRouting { interface ProseRound { /** The routing state of the turn that queued the round. */ routing: TurnRouting; - /** The turn token current when the round was built: a held draft's provenance (DraftSource.turn). */ + /** The turn token of the stream that built the round, read at driveStream's entry: a held draft's provenance (DraftSource.turn). */ turn: number; round?: number; notice: 'now' | 'later'; @@ -1307,10 +1307,10 @@ export class AgentFramework { private lastAnnouncedLocus: Map = new Map(); // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- - /** Every resident's held drafts: suppressed, bounced or ambiguous plain - * speech, never published except by their resident's resend, kept until - * resent or dismissed. A logical turn's own drafts, for its receipt, are - * its LogicalTurn's. */ + /** Every resident's held drafts: plain speech that was suppressed, bounced + * or left without a destination, never published except by their + * resident's resend, kept until resent or dismissed. A logical turn's own + * drafts, for its receipt, are its LogicalTurn's. */ private proseDrafts: ProseDraftStore; // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- @@ -5029,7 +5029,7 @@ export class AgentFramework { 'Your held drafts: plain speech of yours that was NOT sent — held back because an explicit send in the ' + 'same round holds plain speech back, because a routing prefix bounced, or because it had no destination. ' + `Drafts are never published except by your own resend, and only you can list or act on them. Your ${KEEP_NEWEST} newest ` + - `drafts are always kept; an older one is let go after ${AgentFramework.DRAFT_KEEP_DAYS} days in which nothing happened to it. ` + + `drafts are always kept; an older one may be let go once ${AgentFramework.DRAFT_KEEP_DAYS} days pass in which nothing happens to it. ` + 'list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + 'resend: publish drafts verbatim, in the order given, to one destination ("#channel", "@person" or a ' + 'channel id) — an explicit send, like send_message; it stops at the first draft not confirmed delivered. ' + @@ -9429,14 +9429,12 @@ 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', - 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', }; @@ -9742,7 +9740,7 @@ export class AgentFramework { if (missing.length > 0) { return refuse( `No draft of yours with id ${missing.join(', ')}: there was none, or it was let go (a draft beyond your ${KEEP_NEWEST} newest ` + - `is kept for ${AgentFramework.DRAFT_KEEP_DAYS} days after anything last happened to it). drafts(action: "list") shows your open drafts.`, + `is kept for at least ${AgentFramework.DRAFT_KEEP_DAYS} days after anything last happened to it). drafts(action: "list") shows your open drafts.`, ); } @@ -10348,7 +10346,8 @@ export class AgentFramework { this.addMessage( 'user', [{ type: 'text', text }], - { system: true, kind: 'prose-bounce', ...(held ? { draftOwner: agent.name, draftIds: [held.id] } : {}) } as MessageMetadata, + // It names the refused draft as well as any copy it held. + { system: true, kind: 'prose-bounce', draftOwner: agent.name, draftIds: held ? [draft.id, held.id] : [draft.id] } as MessageMetadata, { forAgent: agent.name }, ); } catch (err) { @@ -10407,7 +10406,12 @@ export class AgentFramework { `[prose-routing] {{unsent}} was draft ${unsent.id}; its send came back ${outcome.status}, but recording that ` + `failed, so the draft reads as ${this.draftStateNow(agent.name, unsent.id)} now. drafts(action: "read") shows it.`; try { - this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + this.addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'prose-bounce', draftOwner: agent.name, draftIds: [unsent.id] } as MessageMetadata, + { forAgent: agent.name }, + ); } catch (noticeErr) { console.error('[drafts] unrecorded-outcome notice failed:', noticeErr); } diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 601ed2578..a94086ebb 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -3,9 +3,8 @@ * * 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 + * it" postscript is not posted twice), a turn may have no destination, 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 until the resident resends or dismisses them, or the @@ -59,8 +58,6 @@ export type DraftReason = | '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'; @@ -90,7 +87,7 @@ export interface DraftAttempt { attemptId: string; at: number; destination: PublishDestination; - /** How the words went out: the drafts tool, or explicit mode's `{{unsent}}`. */ + /** How the words went out: the drafts tool, or a `{{unsent}}` envelope (explicit or hybrid). */ via: 'resend' | 'unsent-token'; /** The resident resent knowing an earlier attempt may have been posted. */ confirmedDuplicate?: true; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 1661ecc07..b9f7b7b94 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -282,6 +282,15 @@ describe('explicit prose routing', () => { }; } + /** The metadata of the newest message whose text starts with `prefix`. */ + function noticeOf(framework: AgentFramework, prefix: string): { draftOwner?: string; draftIds?: string[] } | undefined { + const messages = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ + content: Array<{ type: string; text?: string }>; + metadata?: { draftOwner?: string; draftIds?: string[] }; + }>; + return messages.filter((m) => m.content.some((b) => b.type === 'text' && (b.text ?? '').startsWith(prefix))).at(-1)?.metadata; + } + type DraftsView = ProseDraftStore; const draftsOf = (framework: AgentFramework): DraftsView => (framework as unknown as { proseDrafts: DraftsView }).proseDrafts; @@ -1779,6 +1788,7 @@ describe('explicit prose routing', () => { assert.equal(routing.hybridHold, 'private'); assert.equal(routing.logical.keptPrivate, 1, 'the unprefixed envelope after it is counted as kept private'); assert.deepEqual(routing.logical.drafts, [], 'and is never drafted'); + assert.deepEqual(draftsOf(framework).open('assistant'), [], 'the journal holds nothing either'); } finally { await framework.stop(); } @@ -1801,6 +1811,8 @@ describe('explicit prose routing', () => { assert.deepEqual(routed, [], 'nothing is sent'); const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing] {{unsent}} is draft'))!; assert.ok(notice.includes('Your message could not be held as a draft; its words remain in your history.'), notice); + assert.deepEqual(noticeOf(framework, '[prose-routing] {{unsent}} is draft')?.draftIds, [original.id], + 'the notice names the refused draft in its metadata too, so the catch-up counts it as named'); } finally { await framework.stop(); } @@ -1821,6 +1833,51 @@ describe('explicit prose routing', () => { const receipt = context(framework).texts.find((t) => t.startsWith('[delivered]'))!; for (const draft of held) assert.ok(receipt.includes(draft.id), `${draft.id} is named: ${receipt}`); assert.doesNotMatch(receipt, /and \d+ more/, 'none is left as a count'); + assert.deepEqual([...(noticeOf(framework, '[delivered]')?.draftIds ?? [])].sort(), held.map((d) => d.id).sort(), + 'its metadata names every draft, so settlement records each one'); + } finally { + await framework.stop(); + } + }); + + it("words a send held back that the journal refuses are reported in the receipt", async () => { + const framework = await createFramework('hybrid'); + try { + stubRegistry(framework); + const store = draftsOf(framework); + store.hold = ((_agent: string, segments: unknown[]) => + ({ held: [], notHeld: { count: segments.length, error: 'the journal refused the write' } })) as typeof store.hold; + const gateway = framework as unknown as { + holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; turn: number; notice: 'later' }): void; + appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; + }; + const agent = framework.getAgent('assistant')!; + const routing: RoutingState = { destination: null, + logical: { continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0 } }; + gateway.holdSilencedProse(agent, ['first words', 'second words'], 'send', false, { routing, turn: 1, notice: 'later' }); + gateway.appendProseDeliveryReceipt(agent, routing); + const receipt = context(framework).texts.find((t) => t.startsWith('[delivered]'))!; + assert.ok(receipt.includes('2 plain-speech segment(s) suppressed and NOT held as drafts (the journal refused the write) — ' + + 'the words remain only in your history'), receipt); + } finally { + await framework.stop(); + } + }); + + it("an {{unsent}} send whose outcome can't be recorded says so in a notice that names its draft", async () => { + const framework = await createFramework(); + try { + const routed = stubRegistry(framework); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced').held[0]!; + store.recordOutcome = (() => { throw new Error('the journal refused the write'); }) as typeof store.recordOutcome; + const gateway = framework as unknown as { deliverProse(a: unknown, text: string): Promise }; + await gateway.deliverProse(framework.getAgent('assistant')!, '>>#alpha {{unsent}}'); + assert.deepEqual(routed.map((r) => r.text), ['earlier words'], 'the words were sent'); + const notice = context(framework).texts.find((t) => t.startsWith(`[prose-routing] {{unsent}} was draft ${original.id};`))!; + assert.ok(notice.includes('but recording that failed'), notice); + assert.deepEqual(noticeOf(framework, `[prose-routing] {{unsent}} was draft ${original.id};`)?.draftIds, [original.id], + 'it names the draft in its metadata, so the catch-up counts it as named'); } finally { await framework.stop(); } diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 054f4d866..766bbd75e 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1170,6 +1170,25 @@ describe('draft notices and turn-end settlement', () => { } }); + it('the catch-up reads notices among the newest 500 messages, and names a draft whose only notice is further back', async () => { + const t = await catchUpFixture(); + try { + const [old, recent] = t.internals.proseDrafts.hold('scout', [{ text: 'old', source }, { text: 'recent', source }], 'bounced').held; + // Neither notice was recorded: one now sits 500 messages back, the + // other among the newest. + t.cm.addMessage('user', [{ type: 'text', text: '[prose-routing] …' }], { system: true, kind: 'prose-bounce', draftOwner: 'scout', draftIds: [old!.id] } as never); + for (let i = 0; i < 500; i++) t.cm.addMessage('user', [{ type: 'text', text: `filler ${i}` }]); + const notice = t.cm.addMessage('user', [{ type: 'text', text: '[prose-routing] …' }], { system: true, kind: 'prose-bounce', draftOwner: 'scout', draftIds: [recent!.id] } as never); + t.internals.noticeUnnoticedDrafts(t.agent); + assert.deepEqual(t.internals.proseDrafts.get('scout', recent!.id)!.noticedIn, [String(notice)], 'the recent notice is found and recorded'); + const [catchUp] = t.catchUps(); + assert.ok(catchUp?.includes(old!.id), `the draft whose notice is past the window is named: ${catchUp}`); + assert.ok(!catchUp!.includes(recent!.id), 'the recently named one is not'); + } finally { + await t.close(); + } + }); + it('the catch-up leaves out a draft that a notice still waiting to be written names', async () => { const t = await catchUpFixture(); const queue = (t.framework as unknown as { deferredMessages: Array> }).deferredMessages; From 1b9c5724f0cc5be21c2b239ef1837e5da9a465cb Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 07:18:46 -0400 Subject: [PATCH 41/54] fix(drafts): the drafts-held trace event takes its reason type from DraftReason Agnes-1009's leftover from sw-af247r I17 (room-203 #85015): src/types/trace.ts still typed prose:drafts-held's reason as a literal union that included 'ambiguous', which no path produces. It now imports DraftReason (type-only), so the trace and the store can't drift apart again. --- src/types/trace.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/trace.ts b/src/types/trace.ts index aed07dbb5..d7132a414 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -1,6 +1,7 @@ import type { ProcessEvent } from './events.js'; import type { ModuleProcessResponse } from './framework.js'; import type { SessionUsage } from '../usage/types.js'; +import type { DraftReason } from '../prose-drafts.js'; /** * Base for all trace events. @@ -585,7 +586,7 @@ export type TraceEvent = | (TraceEventBase & { type: 'prose:drafts-held'; agentName: string; - reason: 'explicit-send' | 'no-destination' | 'ambiguous' | 'bounced'; + reason: DraftReason; draftIds: string[]; textLen: number; }) From 355ade779d3764e74be15df4b9eb93bb23c2b5f8 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 07:35:37 -0400 Subject: [PATCH 42/54] fix(drafts): the receipt reports a draft retention let go during the turn as let go, not as held From Arthur-2378's read of 3a458ee3 (room-203 #85122, J6425), with Agnes-1009 agreeing (#85128). appendProseDeliveryReceipt read each of the turn's drafts as `proseDrafts.get(id) ?? d`. When retention had let a draft go since the turn held it, the receipt fell back to the copy the turn captured, and offered that id as held and resendable, though a resend of it is refused. The receipt now reads only the journal's current drafts, and names one it no longer has as "held this turn and since let go by retention (no longer held, so not resendable)". With the shipped constants this takes a turn left open 7 idle days with 100 newer drafts held meanwhile, as a capacity hold could leave it. But the receipt shouldn't claim a state it can't know. The changelog no longer says the receipt counts the private segments it lists: explicit turns write no receipt, and a private think without a send isn't counted (Arthur, Agnes). Test (prose-drafts), from Arthur's probe: with retention shortened (keepNewest 1, keepIdleMs 1, checkpointEvery 1), two holds let the older draft go. The receipt names the newer one as held, and the older one as let go, not as held. --- changelog.d/prose-drafts.added.md | 3 +-- src/framework.ts | 17 +++++++++++++++- test/prose-drafts.test.ts | 32 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index bb6c83339..2414fc092 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -13,8 +13,7 @@ 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. The turn's receipt - counts them, except a silent turn's: a silent turn writes no receipt. + and `proseRouting: disabled` narration stay private. - 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}}` diff --git a/src/framework.ts b/src/framework.ts index 815ad2fee..e46f84fe7 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10007,7 +10007,16 @@ export class AgentFramework { // 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); + // Each draft as the journal holds it now. One that retention let go + // since the turn held it is reported as let go: the copy the turn + // captured says nothing about its state now. + const current: Draft[] = []; + const letGo: Draft[] = []; + for (const d of drafts) { + const now = this.proseDrafts.get(agent.name, d.id); + if (now) current.push(now); + else letGo.push(d); + } const inState = (state: DraftState): Draft[] => current.filter((d) => draftState(d) === state); // Every draft by id: the receipt's metadata names them all, so // settleTurnDrafts records each one as noticed, and no later catch-up @@ -10020,6 +10029,12 @@ export class AgentFramework { '(not sent — drafts can resend them unchanged, or dismiss them)', ); } + if (letGo.length > 0) { + notes.push( + `draft${letGo.length === 1 ? '' : 's'} ${idList(letGo)} held this turn and since let go by retention ` + + '(no longer held, so not resendable)', + ); + } 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)`); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 766bbd75e..e23be2700 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1189,6 +1189,38 @@ describe('draft notices and turn-end settlement', () => { } }); + // Arthur-2378's probe: with retention shortened, the journal lets the + // turn's older draft go before the receipt is written. + it('the receipt reports a draft that retention let go during the turn as let go, not as held', async (t) => { + const h = await catchUpFixture(); + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const drafts = new ProseDraftStore(h.framework.getStore(), { keepNewest: 1, keepIdleMs: 1, checkpointEvery: 1 }); + const f = h.framework as unknown as { + proseDrafts: ProseDraftStore; + holdProseDrafts(agent: unknown, texts: Array<{ text: string }>, reason: string, options: unknown): Array<{ id: string }>; + appendProseDeliveryReceipt(agent: unknown, routing: unknown): void; + }; + f.proseDrafts = drafts; + const routing = h.internals.routingOf('scout'); + try { + const old = f.holdProseDrafts(h.agent, [{ text: 'earlier words' }], 'explicit-send', { routing, turn: 1, round: 1, notice: 'later' })[0]!; + t.mock.timers.tick(2); + const newer = f.holdProseDrafts(h.agent, [{ text: 'later words' }], 'explicit-send', { routing, turn: 1, round: 2, notice: 'later' })[0]!; + assert.equal(drafts.get('scout', old.id), undefined, 'the journal let the older draft go'); + f.appendProseDeliveryReceipt(h.agent, routing); + const receipt = h.cm.getAllMessages().flatMap((m) => m.content) + .filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((text) => text.startsWith('[delivered]'))!; + const held = receipt.match(/held as drafts? ([^(]+)\(not sent/)?.[1] ?? ''; + assert.ok(held.includes(newer.id), `the draft still held is reported as held: ${receipt}`); + assert.ok(!held.includes(old.id), `the let-go draft is not offered as held: ${receipt}`); + assert.ok(receipt.includes(`draft ${old.id} held this turn and since let go by retention (no longer held, so not resendable)`), receipt); + } finally { + t.mock.timers.reset(); + await h.close(); + } + }); + it('the catch-up leaves out a draft that a notice still waiting to be written names', async () => { const t = await catchUpFixture(); const queue = (t.framework as unknown as { deferredMessages: Array> }).deferredMessages; From 89fda1dd39da4a34cc271a6e7024ad014d22a35d Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Tue, 13 Oct 2026 07:42:19 -0400 Subject: [PATCH 43/54] fix(drafts): a receipt with a let-go draft says "nothing confirmed", never "nothing" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Arthur-2378's second probe on 355ade77 (room-203 #85176, J6442), with Agnes-1009 agreeing (#85178). A draft that retention let go since the turn held it may have had an attempt whose outcome is unknown, so it may already have been posted. The receipt's headline said "[delivered] nothing" when nothing was shown, though nothing can be known about that draft now. It now treats a let-go draft as it treats an unconfirmed one, and says "nothing confirmed". Test (prose-drafts), from Arthur's probe: the let-go draft first records an attempt with an unknown outcome, and the receipt opens "[delivered] nothing confirmed —". --- src/framework.ts | 7 +++++-- test/prose-drafts.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index e46f84fe7..8cf487038 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10073,11 +10073,14 @@ export class AgentFramework { } const suppressedNote = notes.join(' · '); // Nothing is confirmed delivered, but an unconfirmed draft (or a dismissed - // one whose own attempt is uncertain) may have been. + // one whose own attempt is uncertain) may have been, and a draft let go + // since the turn held it may have had such an attempt: its state is + // unknown now. + const uncertain = unconfirmed.length > 0 || dismissedAtRisk.length > 0 || letGo.length > 0; const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` - : `[delivered] nothing${unconfirmed.length > 0 || dismissedAtRisk.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; + : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( 'user', diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index e23be2700..e1643d24b 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1204,6 +1204,11 @@ describe('draft notices and turn-end settlement', () => { const routing = h.internals.routingOf('scout'); try { const old = f.holdProseDrafts(h.agent, [{ text: 'earlier words' }], 'explicit-send', { routing, turn: 1, round: 1, notice: 'later' })[0]!; + // An attempt whose outcome is unknown (Arthur-2378's second probe): once + // the draft is let go, nothing says whether it was posted. + const attempt = drafts.beginAttempt('scout', old.id, dest(), 'resend', false); + drafts.recordOutcome('scout', old.id, attempt, { status: 'unknown', reason: 'connection lost', at: Date.now(), destination: dest() }); + assert.equal(draftState(drafts.get('scout', old.id)!), 'unconfirmed'); t.mock.timers.tick(2); const newer = f.holdProseDrafts(h.agent, [{ text: 'later words' }], 'explicit-send', { routing, turn: 1, round: 2, notice: 'later' })[0]!; assert.equal(drafts.get('scout', old.id), undefined, 'the journal let the older draft go'); @@ -1215,6 +1220,7 @@ describe('draft notices and turn-end settlement', () => { assert.ok(held.includes(newer.id), `the draft still held is reported as held: ${receipt}`); assert.ok(!held.includes(old.id), `the let-go draft is not offered as held: ${receipt}`); assert.ok(receipt.includes(`draft ${old.id} held this turn and since let go by retention (no longer held, so not resendable)`), receipt); + assert.ok(receipt.startsWith('[delivered] nothing confirmed —'), `a let-go draft's unknown state makes no claim of non-delivery: ${receipt}`); } finally { t.mock.timers.reset(); await h.close(); From f03b9ffcc27a4081e6ed30255ff9dc5e2b3ff068 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 05:06:40 -0400 Subject: [PATCH 44/54] refactor(mcpl): drop McplRequestError's code, which nothing reads The class is exported, and a field no caller reads would be a promise of something nobody uses. data stays: the publish executor carries a connector's account of a partial send into PublishOutcome's detail. The code still shows in the error's message, as "[code]". --- changelog.d/mcpl-request-outcome.added.md | 4 ++-- src/mcpl/server-connection.ts | 2 -- test/mcpl-request-outcome.test.ts | 3 +-- test/publish-outcome.test.ts | 6 +++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/changelog.d/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md index 241ec226e..a49bfd7c0 100644 --- a/changelog.d/mcpl-request-outcome.added.md +++ b/changelog.d/mcpl-request-outcome.added.md @@ -2,8 +2,8 @@ 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 + `error-response` (the server answered with a JSON-RPC error; the error's + `data` is kept, and its code is in the message), 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, diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 965db7c6f..f098debe6 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -80,7 +80,6 @@ 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, @@ -1303,7 +1302,6 @@ export class McplServerConnection extends EventEmitter { 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, ), ); diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts index 0389ca1fb..e75cabc75 100644 --- a/test/mcpl-request-outcome.test.ts +++ b/test/mcpl-request-outcome.test.ts @@ -57,10 +57,9 @@ const rejectsWith = async (promise: Promise, outcome: McplRequestError[ return caught as McplRequestError; }; -test('an error response is error-response, with its code and data kept', async () => { +test('an error response is error-response, with its data kept and its code in the message', 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/); }); diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index c3721855a..3e0f83a0c 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -167,7 +167,7 @@ describe('ChannelRegistry.publish outcomes', () => { 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'] }); } }, + discord: { publish: async () => { throw new McplRequestError(`MCPL server "discord" returned error for channels/publish: [-32000] ${partial}`, 'error-response', { posted: ['111'] }); } }, }); seed('discord', 'c', '#c'); const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); @@ -195,7 +195,7 @@ describe('ChannelRegistry.publish outcomes', () => { const failures: unknown[] = []; const traces: Array<{ type: string; [key: string]: unknown }> = []; const { registry, seed } = registryWith({ - discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', -32000, data); } }, + discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', data); } }, }, failures, traces); seed('discord', 'c', '#c'); const outcome = await registry.deliverSpeech('agent', 'words', 'c'); @@ -213,7 +213,7 @@ describe('ChannelRegistry.publish outcomes', () => { data.self = data; const failures: unknown[] = []; const { registry, seed } = registryWith({ - discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', -32000, data); } }, + discord: { publish: async () => { throw new McplRequestError('part 2 failed', 'error-response', data); } }, }, failures); seed('discord', 'c', '#c'); const outcome = await registry.deliverSpeech('agent', 'words', 'c'); From 4a69a4fc972e3966c865d772acbab6c2961bf0d3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 05:23:38 -0400 Subject: [PATCH 45/54] refactor(drafts): drop each draft's source, which nothing reads A draft journaled a provenance of branch, turn token, round and segment. Nothing read it: neither the drafts tool's list nor its read shows it, and the PRs built on this one only write it as this one did. The turn token is a process-local counter, so after a restart it named nothing. ProseRound's turn and round, the stream's draftTurn and draftRound, and the branch read that filled it go with it, as do the two test assertions on a draft's turn. --- src/framework.ts | 36 ++++--------- src/prose-drafts.ts | 23 ++------- test/explicit-prose-routing.test.ts | 34 +++++-------- test/prose-drafts.test.ts | 79 ++++++++++++++--------------- 4 files changed, 64 insertions(+), 108 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 456380851..793b55af8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -95,7 +95,7 @@ import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; -import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type 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'; @@ -702,9 +702,6 @@ function freshTurnRouting(): TurnRouting { interface ProseRound { /** The routing state of the turn that queued the round. */ routing: TurnRouting; - /** The turn token of the stream that built the round, read at driveStream's entry: a held draft's provenance (DraftSource.turn). */ - turn: number; - round?: number; notice: 'now' | 'later'; keepPrivate?: boolean; } @@ -11266,7 +11263,7 @@ export class AgentFramework { agent: Agent, texts: Array, reason: DraftReason, - opts: { routing: TurnRouting; turn: number; round?: number; note?: string; notice: 'now' | 'later' }, + opts: { routing: TurnRouting; note?: string; notice: 'now' | 'later' }, ): Draft[] { const segments = texts .map((t) => (typeof t === 'string' @@ -11274,20 +11271,12 @@ export class AgentFramework { : { 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 = opts.turn; - let branch = 'unknown'; - try { - branch = this.store.currentBranch().name; - } catch { - // provenance only - } const { held, notHeld } = this.proseDrafts.hold( agent.name, - segments.map(({ text, note, inheritedRisk }, segment) => ({ + segments.map(({ text, note, inheritedRisk }) => ({ text, ...(note ? { note } : {}), ...(inheritedRisk ? { inheritedRisk } : {}), - source: { branch, turn, round: opts.round ?? 0, segment } satisfies DraftSource, })), reason, ); @@ -12261,10 +12250,9 @@ export class AgentFramework { round: { notice?: 'now' | 'later'; keepPrivate?: boolean } = {}, ): Promise { // The direct entry delivers as a live round does: under the live turn's - // routing and turn token. + // routing. await this.deliverAdmittedProse(agent, this.admitProse(rawText), { routing: this.routingOf(agent.name), - turn: this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0, notice: round.notice ?? 'later', keepPrivate: round.keepPrivate, }); @@ -12363,7 +12351,7 @@ export class AgentFramework { agent, [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], 'bounced', - { routing: round.routing, turn: round.turn, round: round.round, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, + { routing: round.routing, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, )[0]; // A bare {{unsent}} holds nothing new; otherwise say whether the // envelope's own words were held, since explicit mode writes no receipt @@ -12800,7 +12788,7 @@ export class AgentFramework { agent, [{ text: words.text, ...(inherited ? { inheritedRisk: inherited } : {}) }], 'bounced', - { routing: round.routing, turn: round.turn, round: round.round, note: reason, notice: 'later' }, + { routing: round.routing, note: reason, notice: 'later' }, )[0]; } const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; @@ -13694,11 +13682,6 @@ export class AgentFramework { : (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; - /** This stream's turn token, for draft provenance: a round queued here - * keeps it even when its hold runs after a later turn has started. */ - const draftTurn = this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0; // Live routing is only trusted when the membrane provides verbatim // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64). @@ -14019,7 +14002,6 @@ 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 = @@ -14039,7 +14021,7 @@ export class AgentFramework { const presentsInjections = (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true; // Queued under this turn's routing, whenever it posts. - const holdOpts: ProseRound = { routing: streamRouting, turn: draftTurn, round: draftRound, notice: presentsInjections ? 'now' : 'later' }; + const holdOpts: ProseRound = { routing: streamRouting, notice: presentsInjections ? 'now' : 'later' }; // Routing publishes trimmed segments; held drafts keep the // runs exactly as written. const roundRuns = splitProseRuns(assistantBlocks); @@ -14578,7 +14560,7 @@ export class AgentFramework { if (speechContent.length > 0 && this.channelRegistry) { const speechRuns = finalSpeechRuns; const speechSegments = finalSpeechSegments; - const textOnlyHold: ProseRound = { routing: finalRouting, turn: draftTurn, round: draftRound + 1, notice: 'later' }; + const textOnlyHold: ProseRound = { routing: finalRouting, notice: 'later' }; if (speechSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); @@ -14656,7 +14638,7 @@ export class AgentFramework { : turnSilenced || fallbackCause !== null; const trailingCause: 'send' | 'private' | null = silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); - const trailingHold: ProseRound = { routing: finalRouting, turn: draftTurn, round: draftRound + 1, notice: 'later' }; + const trailingHold: ProseRound = { routing: finalRouting, notice: 'later' }; const runs = finalRuns; const segments = finalSegments; diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index a94086ebb..74e3796a8 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -21,9 +21,8 @@ * 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. The notices that name - * a draft are conversation, so they do follow the branch: a draft counts as + * written and held back), and a delivered draft stays delivered. The + * notices that name a draft are conversation, so they do follow the branch: a draft counts as * named while a message naming it is on the current branch, and one a * rollback or branch move has left unnamed is named again. * @@ -61,20 +60,6 @@ export type DraftReason = /** 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 turn token (a process-local counter) of the stream whose round - * wrote the words; a budget restart or a retry has a token of its own. */ - turn: number; - /** Round within this stream; numbering restarts at a budget restart. */ - round: number; - /** Position of these words among those held together, in one hold of a - * round's speech; a bounce holds its words alone, at 0. */ - segment: number; -} - export interface DraftAttemptOutcome { status: PublishOutcome['status']; at: number; @@ -128,7 +113,6 @@ export interface Draft { /** Short context for the resident: the bounce reason, or the competing conversations. */ note?: string; heldAt: number; - source: DraftSource; attempts: DraftAttempt[]; dismissedAt?: number; /** The messages whose notices named this draft, by id. It counts as named @@ -365,7 +349,7 @@ export class ProseDraftStore { */ hold( agent: string, - segments: Array<{ text: string; source: DraftSource; note?: string; inheritedRisk?: InheritedRisk }>, + segments: Array<{ text: string; note?: string; inheritedRisk?: InheritedRisk }>, reason: DraftReason, note?: string, ): { held: Draft[]; notHeld?: { count: number; error: string } } { @@ -381,7 +365,6 @@ export class ProseDraftStore { ...(segmentNote ? { note: segmentNote } : {}), ...(segment.inheritedRisk ? { inheritedRisk: segment.inheritedRisk } : {}), heldAt: Date.now(), - source: segment.source, }; try { this.write({ op: 'held', draft }, true); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index b5a981658..d3d7a632a 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -417,10 +417,9 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework, ['false']); answerSkipReply(framework); const store = draftsOf(framework); - const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS', source: { branch: 'main', turn: 0, round: 0, segment: 0 } }], 'bounced').held[0]!; + const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS' }], 'bounced').held[0]!; const internals = framework as unknown as { channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise }; - logicalTurnToolCalls: { get(agent: unknown): { turnToken: number } | undefined }; }; const deliver = internals.channelRegistry.deliverSpeech; let release!: () => void; @@ -434,11 +433,9 @@ describe('explicit prose routing', () => { return outcome; }; let starts = 0; - let firstTurn = 0; framework.onTrace((e) => { if ((e as { type: string }).type !== 'inference:started') return; starts++; - if (starts === 1) firstTurn = internals.logicalTurnToolCalls.get(framework.getAgent('assistant')!)!.turnToken; if (starts === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>#nowhere NEW WORDS' }])); if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: '>>skip_reply' }])); }); @@ -473,10 +470,6 @@ describe('explicit prose routing', () => { } else { // Held after the failure's copy, the next turn's bounce is the latest. assert.notEqual(copy.id, original.id); - // Arthur-2378's probe: held while turn two runs, the copy keeps the - // turn that attempted its words. - assert.ok(firstTurn > 0, 'the first turn token was observed'); - assert.equal(copy.source.turn, firstTurn, "the copy's provenance is the turn that attempted the words"); } assert.equal(store.latestBounce('assistant')?.text, 'NEW WORDS', "the next turn's bounce came after it, and is the latest"); } finally { @@ -492,7 +485,7 @@ describe('explicit prose routing', () => { stubRegistry(framework, ['throw']); const store = draftsOf(framework); const original = store.hold('assistant', [{ - text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 }, + text: 'earlier words', }], 'bounced').held[0]!; const gateway = framework as unknown as { deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; @@ -519,7 +512,7 @@ describe('explicit prose routing', () => { try { stubRegistry(framework, [failure]); const store = draftsOf(framework); - const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced').held[0]!; + const original = store.hold('assistant', [{ text: 'ORIGINAL WORDS' }], 'bounced').held[0]!; const internals = framework as unknown as { channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise }; deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; @@ -544,7 +537,7 @@ describe('explicit prose routing', () => { // A newer bounce becomes the latest while the send waits, and the // store reloads its projection (as after an ambiguous append), so // the draft captured before the send is no longer the stored one. - store.hold('assistant', [{ text: 'NEW WORDS', source: { branch: 'main', turn: 2, round: 1, segment: 0 } }], 'bounced'); + store.hold('assistant', [{ text: 'NEW WORDS' }], 'bounced'); store.reload(); assert.notEqual(store.get('assistant', original.id), original, 'the reload replaced the stored draft'); release(); @@ -567,7 +560,7 @@ describe('explicit prose routing', () => { try { const routed = stubRegistry(framework); const store = draftsOf(framework); - store.hold('assistant', [{ text: 'costs $$5, see $& and $1', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced'); + store.hold('assistant', [{ text: 'costs $$5, see $& and $1' }], 'bounced'); const gateway = framework as unknown as { deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; }; @@ -584,7 +577,7 @@ describe('explicit prose routing', () => { stubRegistry(framework, ['throw']); const store = draftsOf(framework); const original = store.hold('assistant', [{ - text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 }, + text: 'earlier words', }], 'bounced').held[0]!; const gateway = framework as unknown as { deliverProse(a: unknown, text: string, hold: { notice: 'later' }): Promise; @@ -1794,7 +1787,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); stubRegistry(framework); const gateway = framework as unknown as { - holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; turn: number; notice: 'later' }): void; + holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; notice: 'later' }): void; turnRouting: Map; }; const agent = framework.getAgent('assistant')!; @@ -1804,10 +1797,9 @@ describe('explicit prose routing', () => { gateway.turnRouting.set('assistant', live); // A held round that a later start has overtaken: its envelopes move // its own turn's routing, and its drafts are that turn's. - gateway.holdSilencedProse(agent, ['>>>#nowhere held words', 'after it'], 'send', false, { routing: queued, turn: 7, notice: 'later' }); + gateway.holdSilencedProse(agent, ['>>>#nowhere held words', 'after it'], 'send', false, { routing: queued, notice: 'later' }); assert.equal(queued.hybridHold, 'failed', 'the round that held it records the failed envelope'); assert.deepEqual(queued.drafts.map((d) => d.text), ['held words', 'after it'], "and holds the round's words as its drafts"); - assert.deepEqual(draftsOf(framework).open('assistant').map((d) => d.source.turn), [7, 7], "each draft's provenance is the round's turn, not the live one"); assert.equal(live.hybridHold, undefined, 'the live turn is untouched'); assert.deepEqual(live.drafts, [], 'and holds no draft of that round'); } finally { @@ -1841,7 +1833,7 @@ describe('explicit prose routing', () => { try { const routed = stubRegistry(framework); const store = draftsOf(framework); - const original = store.hold('assistant', [{ text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced').held[0]!; + const original = store.hold('assistant', [{ text: 'earlier words' }], 'bounced').held[0]!; // An attempt with no outcome: the latest bounce may already have been // posted, so {{unsent}} refuses it and holds the envelope's words. store.beginAttempt('assistant', original.id, { serverId: 'stub', channelId: 'chan-alpha' }, 'unsent-token', false); @@ -1866,7 +1858,7 @@ describe('explicit prose routing', () => { stubRegistry(framework); const store = draftsOf(framework); const held = store.hold('assistant', Array.from({ length: 11 }, (_, segment) => ({ - text: `held ${segment}`, source: { branch: 'main', turn: 1, round: 1, segment }, + text: `held ${segment}`, })), 'explicit-send').held; const gateway = framework as unknown as { appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void }; const routing: RoutingState = routingState({ drafts: held }); @@ -1889,12 +1881,12 @@ describe('explicit prose routing', () => { store.hold = ((_agent: string, segments: unknown[]) => ({ held: [], notHeld: { count: segments.length, error: 'the journal refused the write' } })) as typeof store.hold; const gateway = framework as unknown as { - holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; turn: number; notice: 'later' }): void; + holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; notice: 'later' }): void; appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; }; const agent = framework.getAgent('assistant')!; const routing: RoutingState = routingState(); - gateway.holdSilencedProse(agent, ['first words', 'second words'], 'send', false, { routing, turn: 1, notice: 'later' }); + gateway.holdSilencedProse(agent, ['first words', 'second words'], 'send', false, { routing, notice: 'later' }); gateway.appendProseDeliveryReceipt(agent, routing); const receipt = context(framework).texts.find((t) => t.startsWith('[delivered]'))!; assert.ok(receipt.includes('2 plain-speech segment(s) suppressed and NOT held as drafts (the journal refused the write) — ' + @@ -1909,7 +1901,7 @@ describe('explicit prose routing', () => { try { const routed = stubRegistry(framework); const store = draftsOf(framework); - const original = store.hold('assistant', [{ text: 'earlier words', source: { branch: 'main', turn: 1, round: 1, segment: 0 } }], 'bounced').held[0]!; + const original = store.hold('assistant', [{ text: 'earlier words' }], 'bounced').held[0]!; store.recordOutcome = (() => { throw new Error('the journal refused the write'); }) as typeof store.recordOutcome; const gateway = framework as unknown as { deliverProse(a: unknown, text: string): Promise }; await gateway.deliverProse(framework.getAgent('assistant')!, '>>#alpha {{unsent}}'); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 60a90b277..3f00dd4e7 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -19,7 +19,6 @@ 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', () => { @@ -32,7 +31,7 @@ describe('ProseDraftStore', () => { 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'); + const { held } = drafts.hold('a', [{ text: ' one\n' }, { text: 'two ' }], '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); @@ -49,7 +48,7 @@ describe('ProseDraftStore', () => { 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'); + const { held } = drafts.hold('a', [{ text: 'words' }, { text: 'more' }], '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() }); @@ -70,7 +69,7 @@ describe('ProseDraftStore', () => { 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 { held } = drafts.hold('a', [{ text: 'kept' }, { text: 'set aside' }], '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); @@ -79,7 +78,7 @@ describe('ProseDraftStore', () => { 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]!; + const later = drafts.hold('a', [{ text: 'held on another branch' }], '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'); @@ -97,7 +96,7 @@ describe('ProseDraftStore', () => { let fail = true; (store as unknown as { sync: () => void }).sync = () => { if (fail) throw new Error('EIO'); real(); }; t.mock.method(console, 'error', () => {}); - const result = drafts.hold('a', [{ text: 'x', source }, { text: 'y', source }], 'explicit-send'); + const result = drafts.hold('a', [{ text: 'x' }, { text: 'y' }], 'explicit-send'); // The first record reached the store before its barrier failed: the // journal was reconciled, so the projection holds it, and so does the // answer. Only the second is reported not held. @@ -105,7 +104,7 @@ describe('ProseDraftStore', () => { assert.deepEqual(result.notHeld, { count: 1, error: 'EIO' }); assert.deepEqual(drafts.open('a').map((d) => d.text), ['x']); fail = false; - assert.equal(drafts.hold('a', [{ text: 'z', source }], 'explicit-send').held.length, 1); + assert.equal(drafts.hold('a', [{ text: 'z' }], 'explicit-send').held.length, 1); } finally { store.close(); rmSync(dir, { recursive: true, force: true }); @@ -115,7 +114,7 @@ describe('ProseDraftStore', () => { it('a draft counts as named while a message that named it is on the branch, newest first', () => { const { dir, store, drafts } = open(); try { - const { held } = drafts.hold('a', [{ text: 'one', source }, { text: 'two', source }, { text: 'three', source }], 'explicit-send'); + const { held } = drafts.hold('a', [{ text: 'one' }, { text: 'two' }, { text: 'three' }], 'explicit-send'); const [one, two, three] = held; drafts.markNoticed('a', [one!.id, two!.id], 'm-1'); drafts.markNoticed('a', [two!.id], 'm-2'); @@ -143,16 +142,16 @@ describe('ProseDraftStore', () => { const retention = { keepNewest: 2, keepIdleMs: hour, checkpointEvery: 9 }; let drafts = new ProseDraftStore(store, retention); try { - const [older] = drafts.hold('a', [{ text: 'an older bounce', source }], 'bounced').held; - const [latest] = drafts.hold('a', [{ text: 'the latest bounce', source }], 'bounced').held; - const [settled] = drafts.hold('a', [{ text: 'resent long ago', source }], 'explicit-send').held; + const [older] = drafts.hold('a', [{ text: 'an older bounce' }], 'bounced').held; + const [latest] = drafts.hold('a', [{ text: 'the latest bounce' }], 'bounced').held; + const [settled] = drafts.hold('a', [{ text: 'resent long ago' }], 'explicit-send').held; const attempt = drafts.beginAttempt('a', settled!.id, dest(), 'resend', false); drafts.recordOutcome('a', settled!.id, attempt, { status: 'delivered', messageId: 'm-1', at: Date.now(), destination: dest() }); - drafts.hold('b', [{ text: "another resident's", source }], 'explicit-send'); + drafts.hold('b', [{ text: "another resident's" }], 'explicit-send'); t.mock.timers.tick(2 * hour); // Something happens to the older bounce now: it isn't idle. drafts.beginAttempt('a', older!.id, dest(), 'resend', false); - const newest = drafts.hold('a', [{ text: 'new one', source }, { text: 'new two', source }], 'explicit-send').held; + const newest = drafts.hold('a', [{ text: 'new one' }, { text: 'new two' }], 'explicit-send').held; // That was the ninth entry: the checkpoint let go of 'a''s idle // drafts outside its two newest, the latest bounce among them. const kept = () => drafts.open('a').map((d) => d.text); @@ -183,7 +182,7 @@ 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 { held } = drafts.hold('a', [{ text: 'x' }], '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(); }; @@ -201,7 +200,7 @@ describe('ProseDraftStore', () => { 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; + const [copy] = drafts.hold('a', [{ text: 'risky words and more', 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() }); @@ -218,9 +217,9 @@ describe('ProseDraftStore', () => { 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'); + const first = drafts.hold('a', [{ text: 'first' }], 'bounced').held[0]!; + const second = drafts.hold('a', [{ text: 'second' }], 'bounced').held[0]!; + drafts.hold('a', [{ text: 'not a bounce' }], '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'); @@ -642,7 +641,7 @@ describe('held prose drafts, end to end', () => { it('hybrid skip_reply unsent ' + (privateThink ? 'stays uninterpreted beside a private think' : 'dismisses the bounce while keeping the round private'), async () => { const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid', sameRoundThinkTextPolicy: 'private' }] }); try { - const original = h.store().hold('scout', [{ text: 'held bounce', source }], 'bounced').held[0]!; + const original = h.store().hold('scout', [{ text: 'held bounce' }], 'bounced').held[0]!; assert.equal(draftState(original), 'held'); const blocks = [text('>>>skip_reply {{unsent}}'), call('skip1', 'skip_reply', {})]; if (privateThink) blocks.push(call('think1', 'think', { thought: 'private reasoning' })); @@ -809,8 +808,8 @@ describe('held prose drafts, end to end', () => { try { // 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().hold('scout', [{ text: 'orphaned words' }], 'explicit-send'); + const midSend = h.store().hold('scout', [{ text: 'half-sent words' }], '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]'))!; @@ -829,7 +828,7 @@ describe('held prose drafts, end to end', () => { it('the catch-up names every draft it marks: the newest eight with their state, the rest by id, and no words', async () => { const h = await harness(); try { - const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); + const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}` }], 'explicit-send').held[0]!); await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; assert.match(notice, /^\[drafts\] scout: 10 open drafts of yours that no recent notice on this branch names: /); @@ -1113,7 +1112,7 @@ describe('draft notices and turn-end settlement', () => { settleTurnDrafts(agent: unknown, logical: unknown): void; }; const agent = framework.getAgent('scout')!; - const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named', source }, { text: 'lost', source }], 'explicit-send').held; + const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named' }, { text: 'lost' }], 'explicit-send').held; // Only the first draft's notice reached the history. const cm = agent.getContextManager(); const notice = cm.addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); @@ -1156,7 +1155,7 @@ describe('draft notices and turn-end settlement', () => { it("the catch-up records a draft a notice already names, rather than naming it again", async () => { const t = await catchUpFixture(); try { - const [late, lost] = t.internals.proseDrafts.hold('scout', [{ text: 'late', source }, { text: 'lost', source }], 'bounced').held; + const [late, lost] = t.internals.proseDrafts.hold('scout', [{ text: 'late' }, { text: 'lost' }], 'bounced').held; // A bounce notice reached the history after its turn's drafts were // settled, so nothing recorded it. const notice = t.cm.addMessage('user', [{ type: 'text', text: '[prose-routing] …' }], { system: true, kind: 'prose-bounce', draftOwner: 'scout', draftIds: [late!.id] } as never); @@ -1173,7 +1172,7 @@ describe('draft notices and turn-end settlement', () => { it('the catch-up reads notices among the newest 500 messages, and names a draft whose only notice is further back', async () => { const t = await catchUpFixture(); try { - const [old, recent] = t.internals.proseDrafts.hold('scout', [{ text: 'old', source }, { text: 'recent', source }], 'bounced').held; + const [old, recent] = t.internals.proseDrafts.hold('scout', [{ text: 'old' }, { text: 'recent' }], 'bounced').held; // Neither notice was recorded: one now sits 500 messages back, the // other among the newest. t.cm.addMessage('user', [{ type: 'text', text: '[prose-routing] …' }], { system: true, kind: 'prose-bounce', draftOwner: 'scout', draftIds: [old!.id] } as never); @@ -1203,14 +1202,14 @@ describe('draft notices and turn-end settlement', () => { f.proseDrafts = drafts; const routing = h.internals.routingOf('scout'); try { - const old = f.holdProseDrafts(h.agent, [{ text: 'earlier words' }], 'explicit-send', { routing, turn: 1, round: 1, notice: 'later' })[0]!; + const old = f.holdProseDrafts(h.agent, [{ text: 'earlier words' }], 'explicit-send', { routing, notice: 'later' })[0]!; // An attempt whose outcome is unknown (Arthur-2378's second probe): once // the draft is let go, nothing says whether it was posted. const attempt = drafts.beginAttempt('scout', old.id, dest(), 'resend', false); drafts.recordOutcome('scout', old.id, attempt, { status: 'unknown', reason: 'connection lost', at: Date.now(), destination: dest() }); assert.equal(draftState(drafts.get('scout', old.id)!), 'unconfirmed'); t.mock.timers.tick(2); - const newer = f.holdProseDrafts(h.agent, [{ text: 'later words' }], 'explicit-send', { routing, turn: 1, round: 2, notice: 'later' })[0]!; + const newer = f.holdProseDrafts(h.agent, [{ text: 'later words' }], 'explicit-send', { routing, notice: 'later' })[0]!; assert.equal(drafts.get('scout', old.id), undefined, 'the journal let the older draft go'); f.appendProseDeliveryReceipt(h.agent, routing); const receipt = h.cm.getAllMessages().flatMap((m) => m.content) @@ -1231,7 +1230,7 @@ describe('draft notices and turn-end settlement', () => { const t = await catchUpFixture(); const queue = (t.framework as unknown as { deferredMessages: Array> }).deferredMessages; try { - const [waiting, lost] = t.internals.proseDrafts.hold('scout', [{ text: 'waiting', source }, { text: 'lost', source }], 'bounced').held; + const [waiting, lost] = t.internals.proseDrafts.hold('scout', [{ text: 'waiting' }, { text: 'lost' }], 'bounced').held; // A bounce notice deferred while a turn is alive: not in the history // yet, but on its way. queue.push({ @@ -1253,7 +1252,7 @@ describe('draft notices and turn-end settlement', () => { it("the catch-up leaves the open logical turn's own drafts to its receipt", async () => { const t = await catchUpFixture(); try { - const [own, other] = t.internals.proseDrafts.hold('scout', [{ text: 'own', source }, { text: 'other', source }], 'explicit-send').held; + const [own, other] = t.internals.proseDrafts.hold('scout', [{ text: 'own' }, { text: 'other' }], 'explicit-send').held; // A later start continues this logical turn, whose receipt names its // drafts at the turn's end. const logical = t.internals.routingOf('scout'); @@ -1293,8 +1292,8 @@ describe('draft identity and risk evidence', () => { 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'); + const [mine] = t.internals.proseDrafts.hold('scout', [{ text: 'scout words' }], 'explicit-send').held; + t.internals.proseDrafts.hold('other', [{ text: 'other words' }], '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); @@ -1311,7 +1310,7 @@ describe('draft identity and risk evidence', () => { const t = await setupFramework(); try { const agent = t.framework.getAgent('scout')!; - const [original] = t.internals.proseDrafts.hold('scout', [{ text: 'first', source }], 'bounced').held; + const [original] = t.internals.proseDrafts.hold('scout', [{ text: 'first' }], 'bounced').held; t.internals.proseDrafts.beginAttempt('scout', original!.id, dest(), 'unsent-token', false); // never resolved const round = { routing: t.internals.routingOf('scout'), notice: 'later' as const }; t.internals.bounceProse(agent, '{{unsent}} second', 'no such channel', undefined, '>>', round); @@ -1375,7 +1374,7 @@ describe('drafts resend ownership', () => { await new Promise((r) => setTimeout(r, 2)); } }; - const [a, b] = internals.proseDrafts.hold('scout', [{ text: 'A words', source }, { text: 'B words', source }], 'explicit-send').held; + const [a, b] = internals.proseDrafts.hold('scout', [{ text: 'A words' }, { text: 'B words' }], 'explicit-send').held; return { dir, framework, internals, pending, say, untilPublishing, a: a!, b: b!, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, @@ -1504,7 +1503,7 @@ describe('drafts resend ownership', () => { it('a batch that stops says why for each draft after the stop', async () => { const t = await setup(); try { - const [c] = t.internals.proseDrafts.hold('scout', [{ text: 'C words', source }], 'explicit-send').held; + const [c] = t.internals.proseDrafts.hold('scout', [{ text: 'C words' }], 'explicit-send').held; const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id, c!.id], destination: 'chan' }); await t.untilPublishing(); t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); @@ -1512,7 +1511,7 @@ describe('drafts resend ownership', () => { const result = t.say(await batch); assert.match(result, new RegExp(`${c!.id}: not attempted \\(${t.b.id} was dismissed while it waited\\)\\.`)); - const [d] = t.internals.proseDrafts.hold('scout', [{ text: 'D words', source }], 'explicit-send').held; + const [d] = t.internals.proseDrafts.hold('scout', [{ text: 'D words' }], 'explicit-send').held; const again = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [c!.id, d!.id], destination: 'chan' }); await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); @@ -1539,7 +1538,7 @@ describe('drafts resend ownership', () => { it('{{unsent}} of a bounce queued in a resend says so, and holds the envelope\'s words with the risk', async () => { const t = await setup(); try { - const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, bounce!.id], destination: 'chan' }); await t.untilPublishing(); const agent = t.framework.getAgent('scout')!; @@ -1568,7 +1567,7 @@ describe('drafts resend ownership', () => { try { const agent = t.framework.getAgent('scout')!; const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); - const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); @@ -1593,7 +1592,7 @@ describe('drafts resend ownership', () => { it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { const t = await setup(); try { - const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); await t.untilPublishing(); const agent = t.framework.getAgent('scout')!; @@ -1615,7 +1614,7 @@ describe('drafts resend ownership', () => { it('a dismissal is refused while an {{unsent}} delivery of that draft is out', async () => { const t = await setup(); try { - const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; const agent = t.framework.getAgent('scout')!; const delivering = t.internals.deliverProse(agent, '>>chan {{unsent}}', { notice: 'later' }); await t.untilPublishing(); @@ -1816,7 +1815,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end 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]!; + const original = h.store().hold('scout', [{ text: 'possibly sent' }], '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')!; From 30995428f91e770eb15f71cd967ecfdea9f50609 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 05:27:39 -0400 Subject: [PATCH 46/54] fix(publish): channel_publish answers by the receipt rule, and an unconfirmed send names its risk channel_publish, and the subconscious's speak_in_channel through it, answered success with { delivered: true } for a missing receipt and never read delivered, so an explicit delivered: false came back as success. They now read the answer as plain speech does. The executor's two readings move into publishReceiptOutcome and publishErrorOutcome, which both paths use: success only on delivered: true; failed, nothing posted, for delivered: false naming no message or a request refused before it was written; otherwise unconfirmed, saying a resend could post twice. The unknown marker and a resend's unconfirmed line now say that sending again could post it twice, where they said to check the channel before sending it again. A subconscious has no tool that reads a channel, so that advice couldn't always be followed (agent-framework#350's internal review). --- changelog.d/publish-outcomes.fixed.md | 11 +- src/framework.ts | 4 +- src/mcpl/channel-registry.ts | 168 ++++++++++++++------------ test/prose-drafts.test.ts | 4 +- test/publish-outcome.test.ts | 49 ++++++++ 5 files changed, 156 insertions(+), 80 deletions(-) diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md index 6dd88191e..4b8c18841 100644 --- a/changelog.d/publish-outcomes.fixed.md +++ b/changelog.d/publish-outcomes.fixed.md @@ -11,8 +11,8 @@ 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 + or may not have been posted, so sending it again could post it twice" 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, and a supplied but empty or @@ -21,3 +21,10 @@ `channels/publish` request always has a deadline: with `requestTimeoutMs` 0 it keeps the 60 s default, since its outcome is told to the resident and recorded for its draft; its expiry reads as unknown. + `channel_publish`, and the subconscious's `speak_in_channel`, which shares + its path, answer by the same rule. Before, they answered success with + `{ delivered: true }` for a missing receipt and never read `delivered`. + Now only `delivered: true` is success. `delivered: false` naming no + message, or a request refused before it was written, is an error saying + nothing was posted. Anything else is an error saying the publish wasn't + confirmed and that sending it again could post it twice. diff --git a/src/framework.ts b/src/framework.ts index 793b55af8..9b46dd77e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11813,7 +11813,7 @@ export class AgentFramework { 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}.` + `Sending it again could post it twice, so a resend needs confirmDuplicate: true${unrecorded}.` : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` + (stillAtRisk ? ` The draft stays unconfirmed: ${this.riskText(after!)}.` @@ -17862,7 +17862,7 @@ export class AgentFramework { text: (outcome === 'unknown' // A request went out and no valid receipt came back: it may // have been posted (in part or whole). Never claim it wasn't. - ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the channel before sending it again. It is saved in your archive.` + ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted, so sending it again could post it twice. 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.`) + (detail !== undefined ? ` Connector detail: ${detail}` : ''), }], diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 7abc5d172..3867f4bf4 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -525,6 +525,66 @@ export interface PublishOutcome { at: number; } +/** + * What a channels/publish request that threw establishes. 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, bounded, and its data kept for + * the resident to judge. The publish executor and channel_publish both read + * a failed request this way. + */ +function publishErrorOutcome(err: unknown, destination: PublishDestination, at: number): PublishOutcome { + const message = connectorText(err instanceof Error ? err.message : String(err)); + 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, + }; +} + +/** + * What a channels/publish answer establishes. A 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). + * `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, + * since something may have been posted. The publish executor and + * channel_publish both read an answer this way. + */ +function publishReceiptOutcome(result: unknown, destination: PublishDestination, at: number): PublishOutcome { + 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 }; + } + if (receipt.delivered === false) { + if (receipt.messageId === undefined || receipt.messageId === null) { + return { status: 'failed', destination, reason: `server "${destination.serverId}" reported delivered:false`, at }; + } + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: messageId + ? `server "${destination.serverId}" reported delivered:false but named posted message ${connectorText(messageId)} (delivery uncertain)` + : `server "${destination.serverId}" reported delivered:false beside a malformed message id ` + + `${connectorText(receipt.messageId)} (contradictory receipt; delivery uncertain)`, + at, + }; + } + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: `server "${destination.serverId}" returned no valid delivery receipt (delivery uncertain)`, + at, + }; +} + interface ChannelRegistryOptions { /** * An ordinary (uncoalesced) `channels/incoming` message has just been @@ -3294,60 +3354,9 @@ export class ChannelRegistry { content: [{ type: 'text', text }], }); } catch (err) { - // The connector's error message is its own words: bounded, as its data is. - const message = connectorText(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 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 ${connectorText(messageId)} (delivery uncertain)` - : `server "${entry.serverId}" reported delivered:false beside a malformed message id ` + - `${connectorText(receipt.messageId)} (contradictory receipt; delivery uncertain)`, - at: at(), - }; + return publishErrorOutcome(err, destination, 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(), - }; + return publishReceiptOutcome(result, destination, at()); } /** @@ -3484,31 +3493,42 @@ export class ChannelRegistry { }; } - try { - if (!CapabilityGrant.of(server).has('channels.publish')) { - return { - success: false, - error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`, - isError: true, - }; - } - const publishParams: ChannelsPublishParams = { - conversationId: '', // Framework will fill this when wired - channelId, - content: [{ type: 'text', text: messageText }], - }; - - const result = await server.sendChannelsPublish(publishParams); - return { - success: true, - data: result ?? { delivered: true }, - }; - } catch (err) { + if (!CapabilityGrant.of(server).has('channels.publish')) { return { success: false, - error: `Failed to publish to channel: ${(err as Error).message}`, + error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`, isError: true, }; } + const publishParams: ChannelsPublishParams = { + conversationId: '', // Framework will fill this when wired + channelId, + content: [{ type: 'text', text: messageText }], + }; + const destination: PublishDestination = { + serverId: entry.serverId, + channelId: entry.descriptor.id, + ...(entry.descriptor.label ? { label: entry.descriptor.label } : {}), + }; + + // The answer counts by the same rule as plain speech's: only the + // connector's delivered:true confirms a post. + let result: unknown; + let outcome: PublishOutcome; + try { + result = await server.sendChannelsPublish(publishParams); + outcome = publishReceiptOutcome(result, destination, Date.now()); + } catch (err) { + outcome = publishErrorOutcome(err, destination, Date.now()); + } + if (outcome.status === 'delivered') return { success: true, data: result }; + return { + success: false, + error: outcome.status === 'failed' + ? `Publish to ${channelId} failed: ${outcome.reason}. Nothing was posted.` + : `Publish to ${channelId} wasn't confirmed: ${outcome.reason}. ` + + 'It may or may not have been posted, so sending it again could post it twice.', + isError: true, + }; } } diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 3f00dd4e7..ed68787e4 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -567,7 +567,7 @@ describe('held prose drafts, end to end', () => { assert.equal(h.publishes().filter((p) => p.event === 'publish-error').length, 1, 'the request reached the server'); await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; - assert.match(marker, /was not confirmed \(.*connector exploded.*\(delivery uncertain\)\)\. It may or may not have been posted; check the channel/); + assert.match(marker, /was not confirmed \(.*connector exploded.*\(delivery uncertain\)\)\. It may or may not have been posted, so sending it again could post it twice/); // A resend of a held draft records the same outcome. await h.turn([ createMockResponse([text('held beside a send'), explicitSend('s1')], 'tool_use'), @@ -1047,7 +1047,7 @@ describe('held prose drafts, end to end', () => { await h.turn([createMockResponse(raced(unsure.id), 'tool_use'), createMockResponse([])]); [resent, dismissal] = h.toolResults(); assert.match(dismissal!, waits(unsure.id)); - assert.match(resent!, /NOT confirmed .* Check the channel before sending it again \(that needs confirmDuplicate: true\)/, + assert.match(resent!, /NOT confirmed .* Sending it again could post it twice, so a resend needs confirmDuplicate: true/, 'the resend\'s advice holds: the draft is still open'); assert.equal(draftState(h.store().get('scout', unsure.id)!), 'unconfirmed'); diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 8321fda64..3982f17a5 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -298,6 +298,55 @@ describe('ChannelRegistry.publish outcomes', () => { }); }); +describe('channel_publish answers by the receipt rule', () => { + // Ada-1017's item for #163's rule (agent-framework#163): the tool and the + // subconscious's speak_in_channel, which shares its path, count only a + // connector's delivered:true as posted, as plain speech does. + const publishWith = async (publish: Publish) => { + const { registry, seed } = registryWith({ discord: { publish } }); + seed('discord', 'c', '#c'); + return registry.handleChannelToolCall('channel_publish', { channelId: 'c', content: 'words' }) as Promise<{ + success: boolean; data?: unknown; error?: string; isError?: boolean; + }>; + }; + + it('answers delivered:true with success and the connector receipt', async () => { + const result = await publishWith(ok); + assert.equal(result.success, true); + assert.deepEqual(result.data, { delivered: true, messageId: 'posted-1' }); + }); + + for (const [what, receipt] of [['a missing receipt', undefined], ['an empty receipt', {}], ['a malformed delivered field', { delivered: 'yes' }]] as const) { + it(`answers ${what} as unconfirmed, never as success`, async () => { + const result = await publishWith(async () => receipt); + assert.equal(result.success, false); + assert.equal(result.isError, true); + assert.match(result.error ?? '', /^Publish to c wasn't confirmed: .*no valid delivery receipt.*\. It may or may not have been posted, so sending it again could post it twice\.$/); + }); + } + + it('answers delivered:false with no message id as failed: nothing was posted', async () => { + const result = await publishWith(async () => ({ delivered: false })); + assert.equal(result.success, false); + assert.match(result.error ?? '', /^Publish to c failed: server "discord" reported delivered:false\. Nothing was posted\.$/); + }); + + it('answers delivered:false beside a message id as unconfirmed', async () => { + const result = await publishWith(async () => ({ delivered: false, messageId: 'part-1' })); + assert.equal(result.success, false); + assert.match(result.error ?? '', /^Publish to c wasn't confirmed: .*named posted message part-1/); + }); + + it('answers a request refused before it was written as failed, and an error response or a plain throw as unconfirmed', async () => { + const notSent = await publishWith(async () => { throw new McplRequestError('Cannot send request: connection to "discord" is closed', 'not-sent'); }); + assert.match(notSent.error ?? '', /^Publish to c failed: Cannot send request: connection to "discord" is closed\. Nothing was posted\.$/); + const answered = await publishWith(async () => { throw new McplRequestError('MCPL server "discord" returned error for channels/publish: [-32000] part 2 failed', 'error-response', { posted: ['111'] }); }); + assert.match(answered.error ?? '', /^Publish to c wasn't confirmed: .*part 2 failed \(delivery uncertain\)\. It may or may not have been posted/); + const thrown = await publishWith(async () => { throw new Error('connector exploded'); }); + assert.match(thrown.error ?? '', /^Publish to c wasn't confirmed: connector exploded \(delivery uncertain\)\./); + }); +}); + 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'); From 05a9f149292de3f00c7c89e0c43ae2a9ce5832b6 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 05:48:40 -0400 Subject: [PATCH 47/54] fix(drafts): every notice about a possibly-posted draft says a resend could post it twice The previous commit changed the unknown marker and a resend's unconfirmed line to say that sending again could post it twice, in place of telling the agent to check the channel. The drafts' own notices still gave the old advice: the held-draft notice, the turn-end receipt, a refused resend, a refused {{unsent}}, the bounce notices for a copy of possibly posted words, for an unconfirmed draft and for words whose send threw, and the routing doc. They now state the risk the same way and name the confirmDuplicate resend, so the advice holds for every reader, whatever tools it has to read a channel. A refused {{unsent}}'s held copy ended "after that" in both cases. While the refused draft's attempt is out, it now ends "once that result is in". For an unconfirmed draft it ends at the condition, since nothing is left to wait for. Tests: the held-draft notice, the receipt line, a refused resend, a refused {{unsent}} (in flight and unconfirmed, and a new test for an unconfirmed one whose envelope's words are held), and the bounce notices for a copy, an unconfirmed draft and a thrown send with no draft assert the new texts. The bounce line for a thrown send whose draft got no recorded attempt is reached only when no attempt could be begun, and no test reaches it. --- docs/explicit-prose-routing.md | 4 ++-- src/framework.ts | 25 +++++++++++--------- test/explicit-prose-routing.test.ts | 2 ++ test/prose-drafts.test.ts | 36 ++++++++++++++++++++++++----- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index de4d12f66..3f326262f 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -59,8 +59,8 @@ When the latest bounce may already have been posted (an attempt's outcome is unknown), or is being sent or queued in a resend right now, what happens depends on the route: - **Delivery** (`>>#channel … {{unsent}} …`): nothing is sent. The agent is - told, and can resend the bounce with the `drafts` tool's `confirmDuplicate` - after checking the channel. The envelope's own words are held as a new + told, and can resend the bounce with the `drafts` tool's `confirmDuplicate`, + though that could post it twice. The envelope's own words are held as a new bounce draft, with `{{unsent}}` written out and the risk carried, so that draft too can be resent only with `confirmDuplicate`. A bare `{{unsent}}` holds nothing new: its words already are the bounce. diff --git a/src/framework.ts b/src/framework.ts index 9b46dd77e..daa5e527f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11335,8 +11335,8 @@ export class AgentFramework { ? [`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))`), + ...risky.map((d) => `${this.riskText(d)}, so resending ${d.id} could post it twice ` + + `(to send it anyway: 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. @@ -11835,7 +11835,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) { - return `${this.riskText(d)}. Check that channel; to send it again anyway, resend with confirmDuplicate: true.`; + return `${this.riskText(d)}, so sending it again could post it twice. To send it anyway, resend with confirmDuplicate: true.`; } return undefined; } @@ -11919,7 +11919,7 @@ export class AgentFramework { } 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)`); + notes.push(`draft ${d.id} is unconfirmed: ${this.riskText(d)} — sending it again could post it twice (resend needs confirmDuplicate: true)`); } // A dismissal sets a draft aside; it doesn't unsay that the draft's own // attempt may already have been posted. @@ -12334,12 +12334,16 @@ export class AgentFramework { const key = `${agent.name}\u0000${draft.id}`; let why: string; let next: string; + // While that draft's attempt is out, a copy of its words waits for the + // result too. + let afterResult = ''; if (this.draftsInFlight.has(key)) { why = this.draftsSending.has(key) ? 'which is being sent right now' : 'which is queued in a resend in progress'; next = 'wait for that result'; + afterResult = ', once that result is in'; } else if (draftState(draft) === 'unconfirmed') { why = this.riskText(draft); - next = 'check that channel; to send that draft again anyway use ' + + next = 'sending that draft again could post it twice. To send it anyway, use ' + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true)`; } else { return { draft }; @@ -12360,7 +12364,7 @@ export class AgentFramework { ? '' : held ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` + - 'can be resent only with confirmDuplicate: true, after that.' + `can be resent only with confirmDuplicate: true${afterResult}.` : ' Your message could not be held as a draft; its words remain in your history.'; const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` + `Nothing was sent; ${next}.${kept}`; @@ -12765,8 +12769,7 @@ export class AgentFramework { // draft would invite a duplicate post. const notice = `[prose-routing] Your text (${text.length} chars) may not have been delivered — ${reason}. ` + - "It isn't held as a draft, since a resend could post it twice; the words remain in your history. " + - 'Check that channel before writing them again.'; + "It isn't held as a draft, since a resend could post it twice; the words remain in your history."; try { const id = this.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'prose-bounce' } as MessageMetadata, { forAgent: name }); this.emitTrace({ type: 'message:added', messageId: id ?? 'deferred', source: 'prose-bounce' }); @@ -12813,12 +12816,12 @@ export class AgentFramework { `drafts(action: "dismiss", draftIds: ["${draft.id}"]) sets it aside.`; 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.' + 'can be resent only with the drafts tool and confirmDuplicate: true.' : ownRisk ? ` Its last send's outcome isn't known, so it may already have been posted: resend it only with ` + - 'confirmDuplicate: true, after checking that channel.' + 'confirmDuplicate: true.' : after.outcome === 'unknown' - ? ' The send may have gone out, so check that channel before resending.' + ? ' The send may have gone out, so sending it again could post it twice.' : ''; // A send that threw has no known outcome: the notice doesn't say it // wasn't delivered. diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index d3d7a632a..5e6e86050 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -315,6 +315,7 @@ describe('explicit prose routing', () => { const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]'))!; assert.match(notice, /may not have been delivered/); assert.match(notice, /isn't held as a draft, since a resend could post it twice/); + assert.ok(notice.endsWith('the words remain in your history.'), notice); assert.doesNotMatch(notice, /\{\{unsent\}\}|was not delivered/); return; } @@ -590,6 +591,7 @@ describe('explicit prose routing', () => { assert.doesNotMatch(notice, /was not delivered/, 'an unconfirmed send is not also declared undelivered'); assert.match(notice, /may not have been delivered/); assert.ok(notice.includes(`draftIds: ["${original.id}"], destination: "#channel", confirmDuplicate: true`), notice); + assert.ok(notice.includes('resend it only with confirmDuplicate: true.'), notice); assert.ok(!notice.includes('{{unsent}}'), notice); } finally { await framework.stop(); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index ed68787e4..70420ea7d 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -545,7 +545,7 @@ describe('held prose drafts, end to end', () => { 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/); + assert.match(h.toolResults()[0]!, /may already have been posted: .*, so sending it again could post it twice\. To send it anyway, resend with 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/); @@ -967,12 +967,13 @@ describe('held prose drafts, end to end', () => { 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`))); + 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.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}, and ${fresh.id} includes the words of ${risky!.id}`))); + assert.ok(h.texts().some((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${fresh.id}, and ${fresh.id} includes the words of ${risky!.id}`) && + t.includes(`Nothing was sent; sending that draft again could post it twice. To send it anyway, use drafts(action: "resend", draftIds: ["${fresh.id}"], destination: "#channel", confirmDuplicate: true).`))); 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'); @@ -981,6 +982,29 @@ describe('held prose drafts, end to end', () => { } }); + it('explicit mode: {{unsent}} of a possibly-posted bounce holds the envelope\'s words, with nothing to wait for', 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' }); + const before = h.publishes().length; + await h.turn([createMockResponse([text(`>>${ROOM} {{unsent}} and more`)])]); + assert.equal(h.publishes().length, before, 'nothing goes out without confirmation'); + const copy = h.drafts().find((d) => d.id !== risky!.id)!; + assert.equal(copy.text, 'risky words and more'); + // No attempt is out, so the notice gives the copy's condition and no wait. + const notice = h.texts().find((t) => t.startsWith(`[prose-routing] {{unsent}} is draft ${risky!.id}`))!; + assert.ok(notice.endsWith(`Your message is held as draft ${copy.id}, with {{unsent}} written out; since it includes ` + + `${risky!.id}'s words, it can be resent only with confirmDuplicate: true.`), notice); + } finally { + await h.close(); + } + }); + it('resend is an explicit send: prose beside it is held, not auto-routed', async () => { const h = await harness(); try { @@ -1552,7 +1576,7 @@ describe('drafts resend ownership', () => { const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is queued in a resend in progress. Nothing was sent; ` + `wait for that result. Your message is held as draft ${fresh.id}, with {{unsent}} written out; since it includes ` + - `${bounce!.id}'s words, it can be resent only with confirmDuplicate: true, after that.`), words.join('\n')); + `${bounce!.id}'s words, it can be resent only with confirmDuplicate: true, once that result is in.`), words.join('\n')); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); await t.untilPublishing(); t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); @@ -1761,7 +1785,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end ]); assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( `^\\[delivered\\] nothing confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + - 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); + 'may already have been posted: .+ — sending it again could post it twice \\(resend needs confirmDuplicate: true\\)$')); // Dismissed knowingly after an unknown resend: the dismissal sets it // aside, and the receipt still says its attempt may have been posted. @@ -1828,7 +1852,7 @@ describe('held drafts: exact runs, hybrid envelopes beside a send, and turn-end 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); + assert.ok(words.includes(`so resending ${copy.id} could post it twice (to send it anyway: 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(); From 0ce5c34637b0f9d01a3372b574dcd941f51d76c6 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 07:42:55 -0400 Subject: [PATCH 48/54] fix(drafts, publish): connector detail wherever an unconfirmed send is reported The internal reviews of the round after the fourth merge of main found: - channel_publish's answer for an unconfirmed send, a draft resend's line for a send that wasn't confirmed, and the drafts tool's read of that attempt dropped the connector's error data, which may say what a multi-part send already posted. Each now carries it, bounded by connectorText as the marker's is. An outcome that provably posted nothing shows none. Esther-2584 found the read's line and tested its fix. - The unconfirmed draft's bounce sentence says sending it again could post it twice, as the other notices do. - Texts: PublishOutcome's failed covers delivered:false naming no message, and a connector error's reason is its message, bounded. publish()'s doc says channel_publish reads its answer through the same two functions. ProseRound's doc no longer names the cut round. The routing doc says an in-flight bounce's result is waited for before a confirmDuplicate resend. The changelog says channel_publish and speak_in_channel still resolve a channel id by its first registered match and post into a closed channel without opening it, as before. - Tests: the connector detail in channel_publish's answer, a resend's line and the draft's read, a long string bounded at each, and none for a send that posted nothing; publishForAgent's answers; a routed publish into an open channel prints no open line; the reworded bounce sentence. --- changelog.d/publish-outcomes.fixed.md | 7 ++++- docs/explicit-prose-routing.md | 12 ++++---- src/framework.ts | 22 +++++++------- src/mcpl/channel-registry.ts | 25 +++++++++------- test/explicit-prose-routing.test.ts | 4 +-- test/prose-drafts.test.ts | 42 +++++++++++++++++++++++++++ test/publish-outcome.test.ts | 29 ++++++++++++++++++ 7 files changed, 113 insertions(+), 28 deletions(-) diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md index 4b8c18841..b40474cae 100644 --- a/changelog.d/publish-outcomes.fixed.md +++ b/changelog.d/publish-outcomes.fixed.md @@ -27,4 +27,9 @@ Now only `delivered: true` is success. `delivered: false` naming no message, or a request refused before it was written, is an error saying nothing was posted. Anything else is an error saying the publish wasn't - confirmed and that sending it again could post it twice. + confirmed and that sending it again could post it twice. That answer, a + draft resend's line for a send that wasn't confirmed, and the `drafts` + tool's read of that attempt carry the connector's data, bounded, when it + sent some. `channel_publish` and + `speak_in_channel` still resolve a channel id by its first registered + match and post into a closed channel without opening it, as before. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 3f326262f..7f01da266 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -59,11 +59,13 @@ When the latest bounce may already have been posted (an attempt's outcome is unknown), or is being sent or queued in a resend right now, what happens depends on the route: - **Delivery** (`>>#channel … {{unsent}} …`): nothing is sent. The agent is - told, and can resend the bounce with the `drafts` tool's `confirmDuplicate`, - though that could post it twice. The envelope's own words are held as a new - bounce draft, with `{{unsent}}` written out and the risk carried, so that - draft too can be resent only with `confirmDuplicate`. A bare `{{unsent}}` - holds nothing new: its words already are the bounce. + told. While the bounce is being sent or queued, it is told to wait for that + result; once the bounce may already have been posted, it can resend it with + the `drafts` tool's `confirmDuplicate`, though that could post it twice. + The envelope's own words are held as a new bounce draft, with `{{unsent}}` + written out and the risk carried, so that draft too can be resent only + with `confirmDuplicate`. A bare `{{unsent}}` holds nothing new: its words + already are the bounce. - **Hold** (an envelope that bounces, or one an explicit send held back): the bounce's words are copied into the new draft with `{{unsent}}` written out, and the draft carries the same risk (`inheritedRisk`), so it reads diff --git a/src/framework.ts b/src/framework.ts index daa5e527f..189211517 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -94,7 +94,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 } from './mcpl/channel-registry.js'; +import { ChannelRegistry, connectorText, type ChannelToolOrigin, type PublishDestination, type PublishOutcome } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, 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'; @@ -692,12 +692,11 @@ function freshTurnRouting(): TurnRouting { /** * How a round's prose is delivered: under which turn's routing, and how what - * it doesn't send is held. `round` and `notice` say how an envelope that - * bounces or is held back is held as a draft: the turn's round it came from, - * and whether its notice is presented with this round's tool results ('now') - * or at the next boundary that presents messages ('later'). `keepPrivate` is - * a skip_reply round: its envelopes all stay in the context, and none is - * sent, bounced or held. + * it doesn't send is held. `notice` says when the notice for an envelope that + * bounces or is held back as a draft is presented: with this round's tool + * results ('now') or at the next boundary that presents messages ('later'). + * `keepPrivate` is a skip_reply round: its envelopes all stay in the context, + * and none is sent, bounced or held. */ interface ProseRound { /** The routing state of the turn that queued the round. */ @@ -11647,6 +11646,7 @@ export class AgentFramework { ? `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}` + + (o?.status === 'unknown' && o.detail !== undefined ? `; connector detail: ${connectorText(o.detail)}` : '') + (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : ''); }), `Text (${d.text.length} chars, exactly as a resend publishes it):`, @@ -11811,13 +11811,15 @@ export class AgentFramework { stopped = `${d.id} was not confirmed delivered`; const after = this.proseDrafts.get(agentName, d.id); const stillAtRisk = after !== undefined && draftState(after) === 'unconfirmed'; - lines.push(outcome.status === 'unknown' + // The connector's data may say what a multi-part send already posted. + const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : ''; + 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'}. ` + `Sending it again could post it twice, so a resend needs confirmDuplicate: true${unrecorded}.` : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` + (stillAtRisk ? ` The draft stays unconfirmed: ${this.riskText(after!)}.` - : ' The draft stays held.')); + : ' The draft stays held.')) + detail); } } finally { for (const claim of claims) { @@ -12818,7 +12820,7 @@ export class AgentFramework { ? ` 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.' : ownRisk - ? ` Its last send's outcome isn't known, so it may already have been posted: resend it only with ` + + ? ` Its last send's outcome isn't known, so sending it again could post it twice: resend it only with ` + 'confirmDuplicate: true.' : after.outcome === 'unknown' ? ' The send may have gone out, so sending it again could post it twice.' diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 3867f4bf4..9188289d6 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -478,7 +478,7 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ /** Bound connector values for notices, logs and an outcome's reason. A * PublishOutcome keeps the connector's structured data (`detail`) whole. */ -function connectorText(value: unknown): string { +export function connectorText(value: unknown): string { let text: string; try { text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value); @@ -506,10 +506,12 @@ export interface PublishDestination { * 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`. + * connection), or the connector answered `delivered: false` and named no + * message. * - `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. + * error response, a timeout, a lost connection, or a missing, malformed or + * contradictory receipt, such as `delivered: false` beside a message id). + * It may or may not have been posted. */ export interface PublishOutcome { status: 'delivered' | 'failed' | 'unknown'; @@ -517,7 +519,7 @@ export interface PublishOutcome { 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). */ + * its message, bounded (which may say what was already posted). */ reason?: string; /** The connector error's structured `data`, verbatim, when it sent one. */ detail?: unknown; @@ -3246,9 +3248,10 @@ 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-355 — until then that tool reads its own receipt. + * (deliverSpeech / routeSpeech) and resident resends of held drafts. + * channel_publish (and publishForAgent, which shares its handler) resolves + * and sends on its own path, and reads its answer through the same + * publishReceiptOutcome and publishErrorOutcome. * * Resolution is exact (resolveDestination): `serverId` plus `channelId` * when the caller has the server, otherwise the channel id must be @@ -3522,12 +3525,14 @@ export class ChannelRegistry { outcome = publishErrorOutcome(err, destination, Date.now()); } if (outcome.status === 'delivered') return { success: true, data: result }; + // The connector's data may say what a multi-part send already posted. + const detail = outcome.status === 'unknown' && outcome.detail !== undefined ? ` Connector detail: ${connectorText(outcome.detail)}` : ''; return { success: false, - error: outcome.status === 'failed' + error: (outcome.status === 'failed' ? `Publish to ${channelId} failed: ${outcome.reason}. Nothing was posted.` : `Publish to ${channelId} wasn't confirmed: ${outcome.reason}. ` + - 'It may or may not have been posted, so sending it again could post it twice.', + 'It may or may not have been posted, so sending it again could post it twice.') + detail, isError: true, }; } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 5e6e86050..0241d76c1 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -572,7 +572,7 @@ describe('explicit prose routing', () => { } }); - it('a thrown bare {{unsent}} delivery says its draft may already have been posted', async () => { + it('a thrown bare {{unsent}} delivery says sending its draft again could post it twice', async () => { const framework = await createFramework(); try { stubRegistry(framework, ['throw']); @@ -587,7 +587,7 @@ describe('explicit prose routing', () => { assert.deepEqual(store.open('assistant').map((d) => d.id), [original.id], 'a bare {{unsent}} holds no copy'); assert.equal(draftState(store.get('assistant', original.id)!), 'unconfirmed'); const notice = context(framework).texts.find((t) => t.startsWith('[prose-routing]') && t.includes(original.id))!; - assert.match(notice, /may already have been posted/); + assert.match(notice, /Its last send's outcome isn't known, so sending it again could post it twice: resend it only with confirmDuplicate: true\./); assert.doesNotMatch(notice, /was not delivered/, 'an unconfirmed send is not also declared undelivered'); assert.match(notice, /may not have been delivered/); assert.ok(notice.includes(`draftIds: ["${original.id}"], destination: "#channel", confirmDuplicate: true`), notice); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 70420ea7d..39894b918 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1485,6 +1485,48 @@ describe('drafts resend ownership', () => { } }); + it("what the connector says was posted reaches an unconfirmed resend's reply and the draft's read", async () => { + const t = await setup(); + try { + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'part 2 failed (delivery uncertain)', detail: { posted: ['111'] } }); + const reply = t.say(await sending); + assert.match(reply, new RegExp(`${t.a.id}: delivery to .+ NOT confirmed`)); + assert.ok(reply.endsWith(' Connector detail: {"posted":["111"]}'), reply); + // The notices send the resident to read before a confirmDuplicate + // resend, so the attempt's line there carries it too. + const read = t.say(await t.internals.handleDraftsTool('scout', { action: 'read', draftIds: [t.a.id] })); + assert.match(read, /UNKNOWN — may have been posted: part 2 failed \(delivery uncertain\); connector detail: \{"posted":\["111"\]\}/); + // A long string comes through as itself, bounded, in both places. + const long = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.b.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'timed out (delivery uncertain)', detail: 'p'.repeat(3000) }); + const longReply = t.say(await long); + assert.ok(longReply.endsWith(` Connector detail: ${'p'.repeat(1987)}… [truncated]`), longReply.slice(-80)); + const longRead = t.say(await t.internals.handleDraftsTool('scout', { action: 'read', draftIds: [t.b.id] })); + assert.ok(longRead.includes(`; connector detail: ${'p'.repeat(1987)}… [truncated]`), longRead.slice(0, 400)); + } finally { + await t.close(); + } + }); + + it('a resend that provably posted nothing shows no connector detail', async () => { + const t = await setup(); + try { + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'failed', reason: 'refused before dispatch', detail: { posted: [] } }); + const reply = t.say(await sending); + assert.match(reply, new RegExp(`${t.a.id}: not sent to .+ Nothing was posted by this attempt`)); + assert.ok(!reply.includes('onnector detail'), reply); + const read = t.say(await t.internals.handleDraftsTool('scout', { action: 'read', draftIds: [t.a.id] })); + assert.ok(!read.includes('onnector detail'), read); + } finally { + await t.close(); + } + }); + it("a draft whose own result is in isn't being sent any more, though the rest of its batch is", async () => { const t = await setup(); try { diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts index 3982f17a5..6f322f8ef 100644 --- a/test/publish-outcome.test.ts +++ b/test/publish-outcome.test.ts @@ -107,6 +107,11 @@ describe('ChannelRegistry.publish outcomes', () => { assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]')), [ '[routeSpeech] agent: locus d was closed — opened by delivery (speech implies engagement)', ]); + // Speech into a channel that's already open opens nothing and says so nowhere. + seed('discord', 'e', '#e'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'e', routed: true })).status, 'delivered'); + assert.equal(opened.length, 2); + assert.equal(lines.filter((l) => l.includes('was closed')).length, 1, 'no open line for an open channel'); }); it('refuses a send the grant forbids before opening anything', async () => { @@ -342,8 +347,32 @@ describe('channel_publish answers by the receipt rule', () => { assert.match(notSent.error ?? '', /^Publish to c failed: Cannot send request: connection to "discord" is closed\. Nothing was posted\.$/); const answered = await publishWith(async () => { throw new McplRequestError('MCPL server "discord" returned error for channels/publish: [-32000] part 2 failed', 'error-response', { posted: ['111'] }); }); assert.match(answered.error ?? '', /^Publish to c wasn't confirmed: .*part 2 failed \(delivery uncertain\)\. It may or may not have been posted/); + // What the connector says was posted reaches the answer, as it does the marker. + assert.ok(answered.error?.endsWith(' could post it twice. Connector detail: {"posted":["111"]}'), answered.error); const thrown = await publishWith(async () => { throw new Error('connector exploded'); }); assert.match(thrown.error ?? '', /^Publish to c wasn't confirmed: connector exploded \(delivery uncertain\)\./); + assert.ok(!thrown.error?.includes('Connector detail'), 'no data, no detail'); + // A long string comes through as itself, bounded. + const long = await publishWith(async () => { throw new McplRequestError('timed out', 'no-response', 'q'.repeat(3000)); }); + assert.ok(long.error?.endsWith(` Connector detail: ${'q'.repeat(1987)}… [truncated]`), long.error?.slice(-80)); + // A request that never left posted nothing, whatever data rides on it. + const refused = await publishWith(async () => { throw new McplRequestError('Cannot send request: connection to "discord" is closed', 'not-sent', { posted: [] }); }); + assert.match(refused.error ?? '', /Nothing was posted\.$/); + }); + + it('publishForAgent, the subconscious\'s speak_in_channel path, answers by the same rule', async () => { + const answers: Array<[unknown, RegExp]> = [ + [{ delivered: true, messageId: 'posted-1' }, /^$/], + [{ delivered: false }, /^Publish to c failed: server "discord" reported delivered:false\. Nothing was posted\.$/], + [undefined, /^Publish to c wasn't confirmed: .*no valid delivery receipt.*could post it twice\.$/], + ]; + for (const [receipt, pattern] of answers) { + const { registry, seed } = registryWith({ discord: { publish: async () => receipt } }); + seed('discord', 'c', '#c'); + const result = await registry.publishForAgent('c', 'words', 'scout'); + assert.equal(result.success, receipt !== undefined && (receipt as { delivered?: boolean }).delivered === true, JSON.stringify(receipt)); + assert.match(result.error ?? '', pattern, JSON.stringify(receipt)); + } }); }); From 7f24bdc703b755a0a8ee30fb02f97040641b97bd Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 13:09:56 -0400 Subject: [PATCH 49/54] fix(drafts): seven-character draft ids, and the texts say a let-go id can come back A draft's id was `d-` and five characters from a 31-letter alphabet, about 2.9 x 10^7 ids, and newId avoids only the resident's kept drafts and its latest bounce, so an id that retention let go can be drawn again. A resend naming the old id, copied from older history, then reaches the newer draft and can publish its words, held or private ones included, instead of being refused. Greptile's review of this pull request found it. Ids are now seven characters, 31^7, about 2.75 x 10^10, through one constant, ID_LENGTH. Ids are unique per resident, so after N more of its resident's drafts a given let-go id has come back with a probability of about N in 2.75 x 10^10, and newId's doc, the changelog's retention paragraph and the description say that rather than that a let-go id is always refused. Ids stay random and sparse, so a typo almost never names a real draft. Nothing checks an id's length on the way in, so drafts already journaled under five-character ids still resolve. The tests that match a draft id now match seven characters, and the one that pins a new id's form fails with five. --- changelog.d/prose-drafts.added.md | 4 +++- src/prose-drafts.ts | 14 +++++++++++--- test/explicit-prose-routing.test.ts | 2 +- test/prose-drafts.test.ts | 8 ++++---- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md index 2414fc092..e7b838788 100644 --- a/changelog.d/prose-drafts.added.md +++ b/changelog.d/prose-drafts.added.md @@ -27,7 +27,9 @@ days after anything last happened to it (held, named, attempted, answered, dismissed). The journal lets such a draft go when it next checkpoints what it keeps, every 500 entries, so opening it replays only what follows. A request naming a draft that was let go is - refused, and nothing is sent. + refused, and nothing is sent, unless that id has since been given to a newer draft, + which the request then names instead: an id is `d-` and seven characters, so after N more of its resident's drafts a let-go id has come back + with a probability of about N in 2.75 × 10^10. - A private notice names held drafts at the next tool boundary when the live stream presents mid-turn messages (a draft holding words copied from one that may already have been posted is named with that risk and the diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts index 74e3796a8..84f11eef7 100644 --- a/src/prose-drafts.ts +++ b/src/prose-drafts.ts @@ -172,6 +172,12 @@ interface DraftSnapshot { /** Unambiguous lowercase alphabet for short ids (no i/l/o/0/1). */ const ID_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; +/** + * Characters after `d-` in a draft's id: 31^7, about 2.75 × 10^10 ids, so a + * mistyped id almost never names a real draft, and an id retention let go is + * given again only rarely (see newId). + */ +const ID_LENGTH = 7; /** Each resident's newest drafts, by hold order, that are always kept. */ export const KEEP_NEWEST = 100; @@ -324,14 +330,16 @@ export class ProseDraftStore { } /** A new id, unique among the resident's kept drafts and never its latest - * bounce's. One let go can be given again: the notices that named it are - * by then at least KEEP_IDLE_MS old and KEEP_NEWEST drafts back. */ + * bounce's. One that retention let go can be given again: after N more + * of its resident's drafts, a given let-go id has come back with a + * probability of about N in 2.75 × 10^10 (ID_LENGTH), and a request naming + * it then reaches the newer draft rather than being refused. */ private newId(agent: string): string { const existing = this.agentDrafts(agent); const latestBounce = this.latestBounces.get(agent); for (;;) { let id = 'd-'; - for (let i = 0; i < 5; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; + for (let i = 0; i < ID_LENGTH; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; if (!existing.has(id) && id !== latestBounce) return id; } } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 0241d76c1..6658b8513 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -2169,7 +2169,7 @@ 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') && /2 plain-speech segment\(s\) held as drafts d-[a-z2-9]{5}, d-[a-z2-9]{5}/.test(t))); + assert.ok(texts.some(t => t.startsWith('[delivered] nothing') && /2 plain-speech segment\(s\) held as drafts d-[a-z2-9]{7}, d-[a-z2-9]{7}/.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 }> } }) diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 39894b918..7c73ac2c9 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -33,7 +33,7 @@ describe('ProseDraftStore', () => { try { const { held } = drafts.hold('a', [{ text: ' one\n' }, { text: 'two ' }], 'explicit-send'); assert.equal(held.length, 2); - assert.match(held[0]!.id, /^d-[a-z2-9]{5}$/); + assert.match(held[0]!.id, /^d-[a-z2-9]{7}$/); 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'); @@ -736,7 +736,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\. The draft stays unconfirmed: d-[a-z2-9]{5}'s attempt to .* may already have been posted: .*delivery uncertain/); + assert.match(h.toolResults()[0]!, /Nothing was posted by this attempt\. The draft stays unconfirmed: d-[a-z2-9]{7}'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; @@ -814,7 +814,7 @@ describe('held prose drafts, end to end', () => { await h.turn([createMockResponse([])]); const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; assert.match(notice, /^\[drafts\] scout: 2 open drafts of yours that no recent notice on this branch names: /); - assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); explicit send in the same round\)/); + assert.match(notice, /d-[a-z2-9]{7} \(held \(not sent\); explicit send in the same round\)/); assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .* may have been posted; .*explicit send in the same round\\)`)); assert.doesNotMatch(notice, /They were not sent/); assert.ok(!notice.includes('orphaned words') && !notice.includes('half-sent words'), `the notice quotes no draft's words: ${notice}`); @@ -834,7 +834,7 @@ describe('held prose drafts, end to end', () => { assert.match(notice, /^\[drafts\] scout: 10 open drafts of yours that no recent notice on this branch names: /); // Newest first: the eight newest with their state, then the two oldest // by id alone. No draft's words appear. - const named = [...notice.matchAll(/(d-[a-z2-9]{5}) \(held \(not sent\); explicit send in the same round\)/g)].map((m) => m[1]); + const named = [...notice.matchAll(/(d-[a-z2-9]{7}) \(held \(not sent\); explicit send in the same round\)/g)].map((m) => m[1]); assert.deepEqual(named, [9, 8, 7, 6, 5, 4, 3, 2].map((i) => held[i]!.id)); assert.ok(notice.includes(` · and 2 more: ${held[1]!.id}, ${held[0]!.id}.`), notice); assert.ok(!/orphan \d/.test(notice), `the notice quotes no draft's words: ${notice}`); From 5b73b03aacb999d25ef1d2bc507f05f333e5419a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 13:35:01 -0400 Subject: [PATCH 50/54] fix(drafts): the drafts tool's example id has seven characters The drafts tool's draftIds description and its refusal of a malformed draftIds still gave "d-k7x3q", five characters, as a draft id's form, after ids became seven; both now give "d-k7x3qm2". A test pins the schema's example to the form newId issues, and the refusal test asserts its example's form. --- src/framework.ts | 4 ++-- test/prose-drafts.test.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 189211517..d5d65ff5b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -6498,7 +6498,7 @@ export class AgentFramework { draftIds: { type: ['array', 'null'], items: { type: 'string' }, - description: 'read, resend, dismiss: draft ids such as "d-k7x3q", in order. null for list.', + description: 'read, resend, dismiss: draft ids such as "d-k7x3qm2", in order. null for list.', }, destination: { type: ['string', 'null'], @@ -11583,7 +11583,7 @@ export class AgentFramework { 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.'); + return refuse('draftIds must be a list of draft ids such as "d-k7x3qm2", 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.'); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 7c73ac2c9..f8fb8afac 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -45,6 +45,14 @@ describe('ProseDraftStore', () => { } }); + it("the drafts tool's example id has the form newId issues", () => { + const tool = (AgentFramework as unknown as { + DRAFTS_TOOL: { inputSchema: { properties: { draftIds: { description: string } } } }; + }).DRAFTS_TOOL; + const example = /"(d-[^"]*)"/.exec(tool.inputSchema.properties.draftIds.description)?.[1]; + assert.match(example ?? '(none)', /^d-[a-z2-9]{7}$/); + }); + 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'); @@ -1099,15 +1107,17 @@ describe('held prose drafts, end to end', () => { drafts('c', { action: 'list', draftIds: [draft!.id] }), drafts('d', { action: 'read', draftIds: ['d-zzzzz'] }), drafts('e', { action: 'shout' }), + drafts('f', { action: 'read', draftIds: [''] }), ], 'tool_use'), createMockResponse([]), ]); - const [noDest, badDest, unused, unknown, badAction] = h.toolResults(); + const [noDest, badDest, unused, unknown, badAction, badIds] = 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.match(badIds!, /draftIds must be a list of draft ids such as "d-[a-z2-9]{7}", or null/, 'its example has the form newId issues'); assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); } finally { await h.close(); From c1d60be3cb253097a1ca2569c9da619e0368df23 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 13:42:51 -0400 Subject: [PATCH 51/54] fix(drafts, inbound): a puppeted drafts call reaches the drafts tool, a failed dismissal says what the draft reads as, and an edit's envelope keeps its subject's message Answers the rest of Greptile's review of this pull request at 0ce5c346. - The drafts tool was answered only in dispatchToolCall, so a puppeted call (puppetToolCall, through executeToolCallFrom) fell through to the module registry and failed with "Invalid tool name format", though the tool is on the resident's surface. executeToolCallFrom now answers an agent-origin drafts call as that resident; a module holds no drafts. Both entries take the answer from one helper, draftsToolResult, which answers a thrown failure as one. - A dismissal whose sync fails after its record reached the journal is reconciled into the projection before the store rethrows, so the draft already reads as dismissed. The drafts tool's reply said it was still open, and >>skip_reply {{unsent}}'s notice said it was still held and that {{unsent}} still named it. Both now say what the draft reads as after the failure, as a resend's line does, and the notice says whether {{unsent}} still names it; a draft that reads as dismissed and may already have been posted gets that note too. - A channel-scoped coalesced push that leaves out its origin's message and thread, as an edit may, was delivered with the subject's (delivery restores them), but its inbound source envelope, frozen at acceptance from the origin alone, had neither, so its conversation key fell back to the channel's. The envelope now takes the subject's message and thread where the origin leaves them out. Tests: a puppeted read; a dismissal and a >>skip_reply {{unsent}} whose sync fails after the record reached the store (the real store's sync, failing once); the existing refused dismissal's notice in its new wording; and a push edit with no origin identity whose envelope names its subject's message and thread. --- src/framework.ts | 57 ++++++++++++++++++++++-------- test/inbound-source.test.ts | 13 +++++++ test/prose-drafts.test.ts | 69 +++++++++++++++++++++++++++++++++++-- 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index d5d65ff5b..cefc4a4ad 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10355,14 +10355,20 @@ export class AgentFramework { 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. + // Its message and thread are the subject's where its origin leaves them + // out, as an edit's may: delivery restores them the same way. + const known = c.channelId + ? this.pushCoalescer.identity(coalescingSubjectKey( + serverId, this.coalescingBinding(serverId), { kind: 'channel', id: c.channelId }, c.key)) + : undefined; const inboundSource: InboundSource = c.channelId ? this.channelSource({ lane: 'push/event', coalesced: true, serverId, channelId: c.channelId, - threadId: origin.threadId, - messageId: origin.messageId, + threadId: str(origin.threadId) ?? known?.threadId, + messageId: str(origin.messageId) ?? known?.messageId, eventId: params.eventId, replyTo: origin.replyTo, acceptedAt: event.acceptedAt, @@ -11556,6 +11562,16 @@ export class AgentFramework { * be dismissed: the batch honours that when it reaches the draft. */ private draftsSending = new Set(); + /** The drafts tool's answer, wherever the call comes in: a failure the + * handler throws is answered as one. */ + private draftsToolResult(agentName: string, rawInput: unknown): Promise { + return this.handleDraftsTool(agentName, rawInput).catch((err): ToolResult => ({ + success: false, + error: `drafts failed: ${thrownText(err)}`, + isError: true, + })); + } + /** * The resident's `drafts` tool: list, read, resend and dismiss its own held * drafts. Private to the calling resident. `resend` is an explicit send: @@ -11683,9 +11699,12 @@ export class AgentFramework { try { this.proseDrafts.dismiss(agentName, d.id); } catch (err) { + // A write that failed after reaching the journal was reconciled + // into it, so say what the draft reads as now rather than guess. + const now = this.draftStateNow(agentName, d.id); 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.length > 0 ? `${lines.join('\n')}\n` : ''}Recording the dismissal of ${d.id} failed ` + + `(${err instanceof Error ? err.message : String(err)}): it reads as ${now} now.${now === 'dismissed' ? risk : ''}`, ); } lines.push(`${d.id}: dismissed (your history keeps the words).${risk}`); @@ -12568,9 +12587,10 @@ export class AgentFramework { /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce * draft aside — unless it is being sent right now, which a dismissal - * can't call back: the resident is told instead. So are a dismissal that - * can't be recorded (the draft stays, and `{{unsent}}` still names it) - * and one whose draft may already have been posted. */ + * can't call back: the resident is told instead. So are a dismissal whose + * recording fails (with the state the draft reads as after it, and whether + * `{{unsent}}` still names it) and one whose draft may already have been + * posted. */ private dismissLatestBounce(agent: Agent): void { const draft = this.proseDrafts.latestBounce(agent.name); if (!draft) return; @@ -12592,8 +12612,13 @@ export class AgentFramework { this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); } catch (err) { console.error(`[drafts] ${agent.name}: could not dismiss ${draft.id}:`, err); - tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which could not be set aside ` + - `(${err instanceof Error ? err.message : String(err)}): it is still held, and {{unsent}} still names it.`, 'failed-dismiss'); + // A write that failed after reaching the journal was reconciled into + // it, so say what the draft reads as now rather than guess. + const now = this.draftStateNow(agent.name, draft.id); + const named = this.proseDrafts.latestBounce(agent.name)?.id === draft.id; + tell(`[prose-routing] Recording setting aside draft ${draft.id}, which {{unsent}} named, failed ` + + `(${err instanceof Error ? err.message : String(err)}): it reads as ${now} now, and {{unsent}} ` + + `${named ? 'still names it' : 'names nothing'}.${now === 'dismissed' && risk ? ` Note that ${risk}.` : ''}`, 'failed-dismiss'); return; } if (risk) tell(`[prose-routing] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss'); @@ -15502,6 +15527,13 @@ export class AgentFramework { return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin); } + // drafts: the calling resident's own held drafts. A puppeted call has + // agent origin and acts as that resident, as the model's own call does + // through dispatchToolCall. A module holds no drafts. + if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') { + return this.draftsToolResult(origin.agentName, call.input); + } + // The utils meta-tool — programmatic callers (code_execution scripts, // ephemerals, ModuleContext.callTool) reach utilities the same way the // model does. @@ -16846,12 +16878,7 @@ export class AgentFramework { 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: ${thrownText(err)}`, - isError: true, - })) + void this.draftsToolResult(agentName, enrichedCall.input) .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, toolName: enrichedCall.name, agentName, moduleName: 'framework', result }); diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 22814a316..75d0bbb3b 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -384,4 +384,17 @@ describe('inbound source envelope under RFC-006 coalescing', () => { assert.equal(stored.sourceTimestamp, TS); assert.deepEqual(observed, [stored]); }); + + it("a channel-scoped push edit that names no message takes its subject's message and thread, as its delivery does", async (t) => { + const f = await fixture(); t.after(f.close); + await f.register(); + await f.send('channels/incoming', { messages: [{ ...f.channel('c', 'first_version', { initial: true }), threadId: 'thread' }] }); + await f.send('push/event', f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' })); + await eventually(() => !!sourceOf(f, 'second_version'), 'the edit stored'); + const stored = sourceOf(f, 'second_version'); + assert.ok(stored && stored.kind === 'channel'); + assert.equal(stored.lane, 'push/event'); + assert.equal(stored.messageId, 'm'); + assert.equal(stored.threadId, 'thread'); + }); }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index f8fb8afac..f1b027881 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1652,8 +1652,8 @@ describe('drafts resend ownership', () => { const dismiss = t.internals.proseDrafts.dismiss.bind(t.internals.proseDrafts); t.internals.proseDrafts.dismiss = () => { throw new Error('EIO'); }; await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); - assert.ok(words().includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which could not be set aside (EIO): ` + - 'it is still held, and {{unsent}} still names it.'), words().join('\n')); + assert.ok(words().includes(`[prose-routing] Recording setting aside draft ${bounce!.id}, which {{unsent}} named, failed (EIO): ` + + 'it reads as unconfirmed now, and {{unsent}} still names it.'), words().join('\n')); assert.equal(t.internals.proseDrafts.latestBounce('scout')?.id, bounce!.id); t.internals.proseDrafts.dismiss = dismiss; await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); @@ -1665,6 +1665,71 @@ describe('drafts resend ownership', () => { } }); + /** The framework's store fails the sync after the next dismissal reaches + * it, as a disk can: the journal reconciles, so the dismissal stands though + * recording it threw. */ + const failSyncAfterDismissal = (framework: AgentFramework): (() => void) => { + const store = (framework as unknown as { store: { sync(): void; appendJson(t: string, p: unknown): unknown } }).store; + const real = { sync: store.sync.bind(store), appendJson: store.appendJson.bind(store) }; + let armed = false; + store.appendJson = (type, payload) => { + const record = real.appendJson(type, payload); + if ((payload as { op?: string }).op === 'dismissed') armed = true; + return record; + }; + store.sync = () => { + if (armed) { armed = false; throw new Error('EIO'); } + real.sync(); + }; + return () => { store.sync = real.sync; store.appendJson = real.appendJson; }; + }; + + it('a dismissal whose recording fails after it reached the journal is reported as it reads: dismissed', async (t0) => { + const t = await setup(); + t0.mock.method(console, 'error', () => {}); + try { + const restore = failSyncAfterDismissal(t.framework); + const answer = await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] }); + restore(); + assert.equal(answer.success, false); + assert.equal(t.say(answer), `Recording the dismissal of ${t.a.id} failed (EIO): it reads as dismissed now.`); + assert.equal(draftState(t.internals.proseDrafts.get('scout', t.a.id)!), 'dismissed'); + } finally { + await t.close(); + } + }); + + it(">>skip_reply {{unsent}} whose recording fails after it reached the journal says the bounce is set aside, and that {{unsent}} names nothing", async (t0) => { + const t = await setup(); + t0.mock.method(console, 'error', () => {}); + try { + const agent = t.framework.getAgent('scout')!; + const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; + const restore = failSyncAfterDismissal(t.framework); + await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + restore(); + assert.ok(words().includes(`[prose-routing] Recording setting aside draft ${bounce!.id}, which {{unsent}} named, failed (EIO): ` + + 'it reads as dismissed now, and {{unsent}} names nothing.'), words().join('\n')); + assert.equal(t.internals.proseDrafts.latestBounce('scout'), undefined); + } finally { + await t.close(); + } + }); + + it("a puppeted drafts call acts as the resident, as the model's own call does", async () => { + const t = await setup(); + try { + const { result } = await t.framework.puppetToolCall('scout', 'drafts', { + action: 'read', draftIds: [t.a.id], destination: null, confirmDuplicate: null, offset: null, + }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.match(JSON.stringify(result.data), /A words/); + } finally { + await t.close(); + } + }); + it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { const t = await setup(); try { From ce572290e7de646e46bad4d1966ca0ccb6b49c1a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 14:10:49 -0400 Subject: [PATCH 52/54] fix(drafts): a failed attempt says what the draft reads as, a dismissal that stands is traced, and the round's untested branches get tests Answers the internal review of c1d60be3. - A resend's and an {{unsent}} send's attempt that reached the journal before its sync failed is reconciled into it, so the draft reads as unconfirmed though nothing was sent. Their replies said only that the attempt couldn't be recorded; they now say what the draft reads as, as a failed dismissal's and a resend's outcome line do. - prose:drafts-dismissed names the drafts a call dismissed, on the success path and when a later dismissal in the batch fails, and a dismissal that stands after its recording failed is traced too, from the drafts tool and from >>skip_reply {{unsent}}. It used to name every draft the call asked about, including ones already dismissed or delivered. - draftsToolResult's doc names the calls it answers, and the push edit's envelope comment says what it takes, not that it matches delivery, which also falls back to the event id for a message id. Tests: a failed dismissal of a possibly-posted draft that stands, from the drafts tool and from {{unsent}}, with its risk note; a dismissal that never reached the journal, after one that did, with the trace naming only the one that stands; a resend's and an {{unsent}} send's attempt whose sync failed after its append; a puppeted call whose handler throws; and a push edit whose own message and thread win over its subject's. The puppet test's name says what it checks. --- src/framework.ts | 23 ++++-- test/inbound-source.test.ts | 13 +++ test/prose-drafts.test.ts | 155 ++++++++++++++++++++++++++++++++++-- 3 files changed, 177 insertions(+), 14 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index cefc4a4ad..94e08025b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10356,7 +10356,7 @@ export class AgentFramework { // The inbound source envelope, frozen at acceptance (see the channel // lane). A channel-scoped push belongs to the channel its subject names. // Its message and thread are the subject's where its origin leaves them - // out, as an edit's may: delivery restores them the same way. + // out, as an edit's may, so it names the message delivery restores. const known = c.channelId ? this.pushCoalescer.identity(coalescingSubjectKey( serverId, this.coalescingBinding(serverId), { kind: 'channel', id: c.channelId }, c.key)) @@ -11562,8 +11562,9 @@ export class AgentFramework { * be dismissed: the batch honours that when it reaches the draft. */ private draftsSending = new Set(); - /** The drafts tool's answer, wherever the call comes in: a failure the - * handler throws is answered as one. */ + /** The drafts tool's answer to a resident's call, from the model's own + * dispatch or an agent-origin call through executeToolCall (a puppet's): + * a failure the handler throws is answered as one. */ private draftsToolResult(agentName: string, rawInput: unknown): Promise { return this.handleDraftsTool(agentName, rawInput).catch((err): ToolResult => ({ success: false, @@ -11683,6 +11684,8 @@ export class AgentFramework { ); } const lines: string[] = []; + // The drafts this call dismissed, traced even when a later one fails. + const dismissed: string[] = []; for (const d of drafts) { const state = draftState(d); if (state === 'delivered') { @@ -11702,14 +11705,17 @@ export class AgentFramework { // A write that failed after reaching the journal was reconciled // into it, so say what the draft reads as now rather than guess. const now = this.draftStateNow(agentName, d.id); + if (now === 'dismissed') dismissed.push(d.id); + if (dismissed.length > 0) this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: dismissed }); return refuse( `${lines.length > 0 ? `${lines.join('\n')}\n` : ''}Recording the dismissal of ${d.id} failed ` + `(${err instanceof Error ? err.message : String(err)}): it reads as ${now} now.${now === 'dismissed' ? risk : ''}`, ); } + dismissed.push(d.id); lines.push(`${d.id}: dismissed (your history keeps the words).${risk}`); } - this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: drafts.map((d) => d.id) }); + if (dismissed.length > 0) this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: dismissed }); return ok(lines.join('\n')); } @@ -11794,7 +11800,10 @@ export class AgentFramework { 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)}).`); + // A write that failed after reaching the journal was reconciled + // into it, so say what the draft reads as now rather than guess. + lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}), ` + + `and the draft reads as ${this.draftStateNow(agentName, d.id)} now.`); stopped = `${d.id}'s attempt could not be recorded`; continue; } @@ -12422,7 +12431,8 @@ export class AgentFramework { 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)})`; + const reason = `the attempt for draft ${unsent.id} could not be recorded durably (${err instanceof Error ? err.message : String(err)}), ` + + `and the draft reads as ${this.draftStateNow(agent.name, unsent.id)} now`; console.error(`[drafts] ${agent.name}: ${reason} — not sending`); return { status: 'failed', reason, at: Date.now() }; } @@ -12615,6 +12625,7 @@ export class AgentFramework { // A write that failed after reaching the journal was reconciled into // it, so say what the draft reads as now rather than guess. const now = this.draftStateNow(agent.name, draft.id); + if (now === 'dismissed') this.emitTrace({ type: 'prose:drafts-dismissed', agentName: agent.name, draftIds: [draft.id] }); const named = this.proseDrafts.latestBounce(agent.name)?.id === draft.id; tell(`[prose-routing] Recording setting aside draft ${draft.id}, which {{unsent}} named, failed ` + `(${err instanceof Error ? err.message : String(err)}): it reads as ${now} now, and {{unsent}} ` + diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 75d0bbb3b..f71b4943e 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -397,4 +397,17 @@ describe('inbound source envelope under RFC-006 coalescing', () => { assert.equal(stored.messageId, 'm'); assert.equal(stored.threadId, 'thread'); }); + + it("a channel-scoped push edit that names its own message and thread keeps them over its subject's", async (t) => { + const f = await fixture(); t.after(f.close); + await f.register(); + await f.send('channels/incoming', { messages: [{ ...f.channel('c', 'first_version', { initial: true }), threadId: 'thread' }] }); + const edit = f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' }); + await f.send('push/event', { ...edit, origin: { ...(edit as { origin?: object }).origin, messageId: 'm2', threadId: 'thread2' } }); + await eventually(() => !!sourceOf(f, 'second_version'), 'the edit stored'); + const stored = sourceOf(f, 'second_version'); + assert.ok(stored && stored.kind === 'channel'); + assert.equal(stored.messageId, 'm2'); + assert.equal(stored.threadId, 'thread2'); + }); }); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index f1b027881..331e4807c 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -1665,16 +1665,16 @@ describe('drafts resend ownership', () => { } }); - /** The framework's store fails the sync after the next dismissal reaches - * it, as a disk can: the journal reconciles, so the dismissal stands though - * recording it threw. */ - const failSyncAfterDismissal = (framework: AgentFramework): (() => void) => { + /** The framework's store fails the sync after the next drafts entry with + * this op reaches it, as a disk can: the journal reconciles, so the entry + * stands though recording it threw. */ + const failSyncAfter = (framework: AgentFramework, op: string): (() => void) => { const store = (framework as unknown as { store: { sync(): void; appendJson(t: string, p: unknown): unknown } }).store; const real = { sync: store.sync.bind(store), appendJson: store.appendJson.bind(store) }; let armed = false; store.appendJson = (type, payload) => { const record = real.appendJson(type, payload); - if ((payload as { op?: string }).op === 'dismissed') armed = true; + if ((payload as { op?: string }).op === op) armed = true; return record; }; store.sync = () => { @@ -1688,7 +1688,7 @@ describe('drafts resend ownership', () => { const t = await setup(); t0.mock.method(console, 'error', () => {}); try { - const restore = failSyncAfterDismissal(t.framework); + const restore = failSyncAfter(t.framework, 'dismissed'); const answer = await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] }); restore(); assert.equal(answer.success, false); @@ -1699,6 +1699,71 @@ describe('drafts resend ownership', () => { } }); + it('a dismissal of a possibly-posted draft that stands after its recording failed says so, with the risk, and is traced', async (t0) => { + const t = await setup(); + t0.mock.method(console, 'error', () => {}); + try { + const traced: string[][] = []; + t.framework.onTrace((e) => { + const ev = e as { type: string; draftIds?: string[] }; + if (ev.type === 'prose:drafts-dismissed') traced.push(ev.draftIds ?? []); + }); + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + const restore = failSyncAfter(t.framework, 'dismissed'); + const answer = await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id] }); + restore(); + const text = t.say(answer); + assert.ok(text.startsWith(`Recording the dismissal of ${t.a.id} failed (EIO): it reads as dismissed now. Note that ${t.a.id}'s attempt to (chan) at `), text); + assert.ok(text.endsWith('may already have been posted: no receipt.'), text); + assert.deepEqual(traced, [[t.a.id]], 'the dismissal that stands is traced'); + } finally { + await t.close(); + } + }); + + it('the dismissal trace names the drafts the call dismissed, not ones already dismissed', async () => { + const t = await setup(); + try { + const traced: string[][] = []; + t.framework.onTrace((e) => { + const ev = e as { type: string; draftIds?: string[] }; + if (ev.type === 'prose:drafts-dismissed') traced.push(ev.draftIds ?? []); + }); + await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] }); + const answer = await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id, t.b.id] }); + assert.equal(t.say(answer), `${t.a.id}: dismissed (your history keeps the words).\n${t.b.id}: already dismissed.`); + assert.deepEqual(traced, [[t.b.id], [t.a.id]]); + } finally { + await t.close(); + } + }); + + it('a dismissal that never reached the journal says the draft reads as it did, and traces nothing', async () => { + const t = await setup(); + try { + const traced: string[][] = []; + t.framework.onTrace((e) => { + const ev = e as { type: string; draftIds?: string[] }; + if (ev.type === 'prose:drafts-dismissed') traced.push(ev.draftIds ?? []); + }); + const dismiss = t.internals.proseDrafts.dismiss.bind(t.internals.proseDrafts); + t.internals.proseDrafts.dismiss = (agent: string, id: string) => { + if (id === t.b.id) throw new Error('EIO'); + dismiss(agent, id); + }; + const answer = await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.a.id, t.b.id] }); + t.internals.proseDrafts.dismiss = dismiss; + assert.equal(t.say(answer), + `${t.a.id}: dismissed (your history keeps the words).\nRecording the dismissal of ${t.b.id} failed (EIO): it reads as held now.`); + assert.deepEqual(traced, [[t.a.id]], 'the dismissal before the failure is traced; the one that failed is not'); + } finally { + await t.close(); + } + }); + it(">>skip_reply {{unsent}} whose recording fails after it reached the journal says the bounce is set aside, and that {{unsent}} names nothing", async (t0) => { const t = await setup(); t0.mock.method(console, 'error', () => {}); @@ -1706,7 +1771,7 @@ describe('drafts resend ownership', () => { const agent = t.framework.getAgent('scout')!; const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; - const restore = failSyncAfterDismissal(t.framework); + const restore = failSyncAfter(t.framework, 'dismissed'); await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); restore(); assert.ok(words().includes(`[prose-routing] Recording setting aside draft ${bounce!.id}, which {{unsent}} named, failed (EIO): ` + @@ -1717,7 +1782,81 @@ describe('drafts resend ownership', () => { } }); - it("a puppeted drafts call acts as the resident, as the model's own call does", async () => { + it(">>skip_reply {{unsent}} of a possibly-posted bounce that stands after its recording failed says so, with the risk", async (t0) => { + const t = await setup(); + t0.mock.method(console, 'error', () => {}); + try { + const agent = t.framework.getAgent('scout')!; + const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + const traced: string[][] = []; + t.framework.onTrace((e) => { + const ev = e as { type: string; draftIds?: string[] }; + if (ev.type === 'prose:drafts-dismissed') traced.push(ev.draftIds ?? []); + }); + const restore = failSyncAfter(t.framework, 'dismissed'); + await t.internals.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + restore(); + assert.deepEqual(traced, [[bounce!.id]], 'the dismissal that stands is traced'); + assert.ok(words().some((w) => w.startsWith(`[prose-routing] Recording setting aside draft ${bounce!.id}, which {{unsent}} named, failed (EIO): ` + + `it reads as dismissed now, and {{unsent}} names nothing. Note that ${bounce!.id}'s attempt to (chan) at `) && + w.endsWith('may already have been posted: no receipt.')), words().join('\n')); + } finally { + await t.close(); + } + }); + + it('a resend whose attempt reached the journal before its recording failed is not sent, and says the draft reads as unconfirmed', async (t0) => { + const t = await setup(); + t0.mock.method(console, 'error', () => {}); + try { + const restore = failSyncAfter(t.framework, 'attempt'); + const answer = await t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + restore(); + assert.equal(t.pending.length, 0, 'nothing was sent'); + assert.match(t.say(answer), new RegExp(`${t.a.id}: not sent — the attempt could not be recorded durably \\(EIO\\), and the draft reads as unconfirmed now\\.`)); + } finally { + await t.close(); + } + }); + + it("{{unsent}} whose attempt reached the journal before its recording failed bounces, saying the draft reads as unconfirmed", async (t0) => { + const t = await setup(); + t0.mock.method(console, 'error', () => {}); + try { + const agent = t.framework.getAgent('scout')!; + const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words' }], 'bounced').held; + const restore = failSyncAfter(t.framework, 'attempt'); + await t.internals.deliverProse(agent, '>>chan {{unsent}}', { notice: 'later' }); + restore(); + assert.equal(t.pending.length, 0, 'nothing was sent'); + assert.ok(words().some((w) => w.includes(`the attempt for draft ${bounce!.id} could not be recorded durably (EIO), and the draft reads as unconfirmed now`)), + words().join('\n')); + } finally { + await t.close(); + } + }); + + it("a puppeted drafts call whose handler throws is answered as a failure", async () => { + const t = await setup(); + try { + (t.framework as unknown as { handleDraftsTool: () => Promise }).handleDraftsTool = async () => { throw new Error('boom'); }; + const { result } = await t.framework.puppetToolCall('scout', 'drafts', { + action: 'list', draftIds: null, destination: null, confirmDuplicate: null, offset: null, + }); + assert.equal(result.success, false); + assert.equal(result.error, 'drafts failed: boom'); + } finally { + await t.close(); + } + }); + + it("a puppeted drafts call reaches the drafts tool and acts as the resident", async () => { const t = await setup(); try { const { result } = await t.framework.puppetToolCall('scout', 'drafts', { From df89fad3a13dae3ed521317065eaaa99f5220824 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 14:15:25 -0400 Subject: [PATCH 53/54] docs(changelog): a connector that answers channels/publish with no receipt now gets each publish reported as unconfirmed slimepriestess's approving review of 0ce5c346 noted that a connector answering channels/publish with {} or no receipt will now produce unconfirmed outcomes, each telling the resident a resend could post twice, and suggested the release notes say so for anyone running such a connector. The paragraph covers plain speech, where such a connector's change shows most (every reply gets the send-failure marker), as well as channel_publish and speak_in_channel, and says what a connector answers to confirm a post. --- changelog.d/publish-outcomes.fixed.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/changelog.d/publish-outcomes.fixed.md b/changelog.d/publish-outcomes.fixed.md index b40474cae..a2548b62c 100644 --- a/changelog.d/publish-outcomes.fixed.md +++ b/changelog.d/publish-outcomes.fixed.md @@ -33,3 +33,10 @@ sent some. `channel_publish` and `speak_in_channel` still resolve a channel id by its first registered match and post into a closed channel without opening it, as before. + + If you run a connector that answers `channels/publish` with `{}` or no + receipt, which earlier versions counted as delivered, every publish + through it now reads as unconfirmed. The resident is told of each reply + that it wasn't confirmed, and gets an error for each `channel_publish` and + `speak_in_channel` call, each saying that sending it again could post it + twice. A connector confirms a post by answering `delivered: true`. From aa38879f1bafada33383d9c3571217c2b8f7c193 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 18:55:29 -0400 Subject: [PATCH 54/54] Test the refused wake's saved silence: 'private' over 'send' The fifth merge of main saves the silence before a refused skip_reply with its cause, and when the round's other calls silence too, 'private' wins over 'send', as #247's other combinations of causes do. In review, slimepriestess found that no test pinned it: with the saved silence as `silenceCause ?? otherCause`, the tests still passed. An unrefused plain skip_reply ends the turn, so the precedence decides something only for a call whose bare name is skip_reply but whose full name isn't, such as a module's `tools--skip_reply`: it silences by that name, as main reads any `--skip_reply`, and neither ends the turn nor is refused. The new prose-drafts test runs that case in native tool mode: a send's round, then a refused wake beside a module's skip_reply, then a round of prose. The send's round is held as a draft, and the two rounds after it stay private. Without the precedence the test fails. In XML tool mode the turn-end scan reads the whole turn's calls, and the case comes out the same with or without the precedence. Co-Authored-By: Claude Opus 5.5 --- test/prose-drafts.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 81e35f41b..210ced87f 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -518,6 +518,37 @@ describe('held prose drafts, end to end', () => { }); } + it("native tool mode: beside a refused wake, a module's skip_reply after a send keeps the later prose private", async () => { + // An unrefused plain skip_reply ends the turn, so the saved silence's + // 'private' over 'send' decides something only for a call whose bare name + // is skip_reply but whose full name isn't, here one named like a module's + // tool: it silences by that name, as main reads any `--skip_reply`, and + // neither ends the turn nor is refused. In XML tool mode the turn-end scan + // reads the whole turn's calls, so this case doesn't turn on the saved + // silence there. + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('Beside the send.'), explicitSend('s1')], 'tool_use'), + createMockResponse([ + text('Private, beside the refused wake.'), + call('w1', 'skip_reply', { reason: 'later', wake_in_seconds: 60 }), + call('m1', 'tools--skip_reply', {}), + ], 'tool_use'), + createMockResponse([text('After the wake.')]), + ]); + assert.ok(h.toolResults().some((r) => /no wake was set/.test(r)), 'the wake was refused, and the turn went on'); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere'], 'only the explicit send went out'); + const held = h.drafts(); + assert.deepEqual(held.map((d) => d.text), ['Beside the send.'], "only the send's own round is held"); + assert.equal(h.texts().find((t) => t.startsWith('[delivered]')), + `[delivered] nothing — 1 plain-speech segment(s) held as draft ${held[0]!.id} (not sent — drafts can resend them unchanged, or dismiss them) · 2 plain-speech segment(s) kept private (skip_reply or a private think)`, + 'the refused round and the round after it stay private'); + } 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 {