diff --git a/changelog.d/inbound-source-envelope.added.md b/changelog.d/inbound-source-envelope.added.md new file mode 100644 index 000000000..ff472c601 --- /dev/null +++ b/changelog.d/inbound-source-envelope.added.md @@ -0,0 +1,22 @@ +- **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, 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 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/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md new file mode 100644 index 000000000..a49bfd7c0 --- /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; 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, + and it is still an `Error`, so existing handling keeps working. 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..2a2c284f2 --- /dev/null +++ b/changelog.d/prose-drafts-delivery-outcomes.fixed.md @@ -0,0 +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 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 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 new file mode 100644 index 000000000..e7b838788 --- /dev/null +++ b/changelog.d/prose-drafts.added.md @@ -0,0 +1,91 @@ +- **Held prose drafts: suppressed speech is kept, not destroyed.** Plain + 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, 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 + 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. +- 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. Each + 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, 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 + `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". 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 `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 + 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. 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, 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. 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. +- `ChannelRegistry.deliverSpeech` (routeSpeech with its `PublishOutcome`) and + `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 new file mode 100644 index 000000000..a2548b62c --- /dev/null +++ b/changelog.d/publish-outcomes.fixed.md @@ -0,0 +1,42 @@ +- **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 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, 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 + 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. + `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. 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. + + 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`. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index f237a61fe..7f01da266 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -34,15 +34,42 @@ 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 +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, +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, 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. 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 + unconfirmed until a confirmed delivery. ## What explicit mode retires (for that agent) diff --git a/src/framework.ts b/src/framework.ts index 194734c34..309b47dea 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -98,8 +98,10 @@ 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, 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'; import type { IncomingMessageFacts, RoutingDecision } from './mcpl/conversation-router.js'; import { safeSlice } from './safe-slice.js'; import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls, type ProviderWait } from './provider-waits.js'; @@ -132,7 +134,7 @@ import { scriptTimeLimits, validateCodeExecutionConfig, } from './code-execution/tool-definition.js'; -import { splitProseSegments, withoutRoundsProse } from './prose-segments.js'; +import { splitProseRuns, withoutRoundsProse } from './prose-segments.js'; import { cumulativeDelta } from './usage-accounting.js'; import { stampThinkingTokenEstimates } from './thinking-token-stamp.js'; @@ -201,6 +203,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; +}; + /** * The seconds a skip_reply call asks to be woken after: its wake_in_seconds * read as a number, when that's finite and above 0, and undefined when it asks @@ -377,9 +411,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 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 bounce aside. Send tools (send_message,\n' + + 'send_dm, …) are unaffected.'; const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = { name: 'prose_help', @@ -516,6 +552,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 } @@ -726,16 +767,28 @@ interface TurnRouting { */ destination: string | null; /** - * Hybrid: held back after `>>>skip_reply`, or an envelope that is malformed, - * doesn't resolve or isn't delivered, until a later `>>>` names a target. + * Hybrid: the router is fail-closed until a later `>>>` names a target that + * resolves. After a `>>>skip_reply` envelope ('private') the rest stays + * private; after a malformed, unresolved or undelivered one ('failed') the + * rest is held as drafts, having no destination. */ - hybridHold: boolean; + hybridHold?: 'private' | 'failed'; /** The turn asked for a ` !` continuation; taken once. */ continuation: boolean; /** Successful plain-prose destinations in delivery order; deduped by the receipt. */ deliveries: string[]; - /** Plain-prose segments the turn suppressed, reported by its receipt. */ + /** Plain-prose segments the turn suppressed without drafts (proseRouting=disabled), reported by its receipt. */ suppressed: number; + /** + * Drafts held during the turn (src/prose-drafts.ts), which its receipt + * names by their state at its end, and which settleTurnDrafts records as + * 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, 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 }; /** * The messages that would re-pin the locus (repinChannel) that no tool * boundary of the turn weighed, in the order they were stored @@ -771,16 +824,24 @@ let lastTurnId = 0; function freshTurnRouting(): TurnRouting { return { turnId: ++lastTurnId, - engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, arrivals: [], + engaged: new Set(), destination: null, + continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; } -/** How a round's prose is delivered: kept private, and under which turn's routing. */ +/** + * How a round's prose is delivered: under which turn's routing, and how what + * 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 { - keepPrivate?: boolean; /** The routing state of the turn that queued the round. */ routing: TurnRouting; + notice: 'now' | 'later'; + keepPrivate?: boolean; } /** What an ephemeral run ends with once stop() has begun, whatever ends it: @@ -1853,6 +1914,10 @@ export class AgentFramework { private processLoggingPersist: boolean; private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); + /** 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; // The framework ends a stream at a tool boundary from handleProcessEvent: // an endTurn result, or a context-budget or physical-window restart. Each // live stream registers here how to record that end (its usage, its log @@ -1873,9 +1938,14 @@ 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: 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 TurnRouting's. */ + private proseDrafts: ProseDraftStore; + // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- - /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */ - private proseClipboards: Map = new Map(); /** * Each agent's routing state for its current logical turn (TurnRouting): * its trigger channel, locus and engaged channels, the sticky destination, @@ -2259,8 +2329,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; @@ -2292,6 +2362,9 @@ export class AgentFramework { 'release-provider-wait; such a release lasts for this process only', { data: { model: EVERY_MODEL, until: null } }), }); + // 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; @@ -4558,6 +4631,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); } @@ -7593,6 +7669,56 @@ 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 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 = { + 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 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 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. ' + + '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-k7x3qm2", 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 @@ -10499,9 +10625,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 @@ -10523,12 +10655,22 @@ 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 + // 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 id = this.addMessage(msg.participant, msg.content, 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 }); } } @@ -10615,6 +10757,8 @@ export class AgentFramework { eventId?: string; assemblingFor?: string; deliverTo?: string; + acceptedAt?: number; + inboundSource?: InboundSource; }): Promise { // Same backstop as the push lane: no row, no wake, no fork spawned for a // message with nothing visible in it. @@ -10638,6 +10782,20 @@ 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 { + // 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; // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never @@ -11315,6 +11473,145 @@ 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: { + lane: InboundLane; + coalesced?: boolean; + 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', + lane: fields.lane, + ...(fields.coalesced ? { coalesced: true as const } : {}), + 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 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, 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, + messageId: origin.messageId, + eventId: event.eventId, + replyTo: origin.replyTo, + labelFallback: channel.label, + acceptedAt: event.acceptedAt, + sourceTimestamp: event.timestamp, + deferred, + }); + } + 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 } : {}), + 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() }; + } + /** * The push coalescer, built and restored from its stored receipts and * batches. initializeMcpl calls this before it builds anything else, so a @@ -11405,6 +11702,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) => { // Restore-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. A @@ -11528,6 +11831,20 @@ export class AgentFramework { if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required'); validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true }); const c = message.coalesce; + // The inbound source envelope, frozen at acceptance: deferral, fan-out + // and replay deliver it unchanged (mcpl/inbound-source.ts). + const inboundSource = this.channelSource({ + lane: 'channels/incoming', + coalesced: true, + serverId, + channelId: message.channelId, + threadId: message.threadId, + messageId: message.messageId, + eventId: message.eventId, + replyTo: message.metadata?.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: message.timestamp, + }); const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -11541,7 +11858,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 }; }); @@ -11562,6 +11879,29 @@ 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. + // Its message and thread are the subject's where its origin leaves them + // 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)) + : undefined; + const inboundSource: InboundSource = c.channelId + ? this.channelSource({ + lane: 'push/event', + coalesced: true, + serverId, + channelId: c.channelId, + threadId: str(origin.threadId) ?? known?.threadId, + messageId: str(origin.messageId) ?? known?.messageId, + eventId: params.eventId, + replyTo: origin.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: params.timestamp, + deferred: !!c.deferred, + }) + : this.pushSource(event, { deferred: !!c.deferred }); return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -11583,7 +11923,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 } }, }); }); } @@ -11655,6 +11995,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); @@ -11687,6 +12033,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); @@ -11702,6 +12049,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); @@ -11880,6 +12228,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) { @@ -12314,12 +12672,728 @@ export class AgentFramework { return stamp ? { silentHeartbeat: stamp } : undefined; } - /** Record plain-prose segments this turn suppressed. */ + /** Record plain-prose segments this turn suppressed without drafts (proseRouting=disabled). */ private recordProseSuppression(routing: TurnRouting, count: number): void { if (count <= 0) return; routing.suppressed += count; } + /** + * 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 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. What it holds, keeps or moves is the round's turn's + * (round.routing), whenever the hold runs. + */ + private holdSilencedProse( + agent: Agent, + segments: string[], + cause: 'send' | 'private' | null, + privateThink: boolean, + round: ProseRound, + ): void { + if (segments.length === 0) return; + const routing = round.routing; + 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); + } + } + } + routing.keptPrivate += segments.length; + return; + } + if (agent.proseRouting !== 'hybrid') { + this.holdProseDrafts(agent, segments, 'explicit-send', round); + return; + } + // 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; 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.continueTurn) routing.continuation = true; + if (parsed.kind === 'private') { + delete routing.pin; + routing.hybridHold = '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)) { + routing.pin = resolved.channelId; + delete routing.hybridHold; + } else { + delete routing.pin; + routing.hybridHold = '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. + delete routing.pin; + routing.hybridHold = 'failed'; + parts.push({ text: envelope }); + } else if (routing.hybridHold === 'private') { + privateCount++; + } else { + parts.push({ text: envelope }); + } + } + } + routing.keptPrivate += privateCount; + this.holdProseDrafts(agent, parts, 'explicit-send', round); + // The turn's receipt names them by their state at turn end; each was + // named when it bounced. + const drafts = routing.drafts; + for (const draft of kept) if (!drafts.some((d) => d.id === draft.id)) drafts.push(draft); + } + + /** + * Hold plain speech as drafts (src/prose-drafts.ts) instead of + * letting it vanish: journaled durably and recorded for the receipt of the + * turn whose round it came from (opts.routing). `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: { routing: TurnRouting; 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, inheritedRisk: t.inheritedRisk })) + .filter((t) => t.text.trim().length > 0); + if (segments.length === 0) return []; + const { held, notHeld } = this.proseDrafts.hold( + agent.name, + segments.map(({ text, note, inheritedRisk }) => ({ + text, + ...(note ? { note } : {}), + ...(inheritedRisk ? { inheritedRisk } : {}), + })), + reason, + ); + const logical = opts.routing; + if (notHeld) { + console.error(`[drafts] ${agent.name}: ${notHeld.count} segment(s) could not be held as drafts: ${notHeld.error}`); + logical.draftFailures = { count: (logical.draftFailures?.count ?? 0) + notHeld.count, error: notHeld.error }; + } + if (held.length === 0) return held; + logical.drafts.push(...held); + 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', draftOwner: agent.name, 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', + bounced: 'its routing prefix bounced', + }; + + private static readonly DRAFT_REASON_SHORT: Record = { + 'explicit-send': 'explicit send in the same round', + 'no-destination': 'no destination', + 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 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 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. + return ( + `[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]}. ` + + `${resend.join('; ')}; drafts(action: "dismiss", draftIds: [${quoted(held)}]) 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}/, ''); + } + + /** 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); + } + + 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 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); + 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)'; + } + 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 ?? this.noOutcomeText(draft, risky)}`; + } + 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 + ? `${owner}'s attempt to ${AgentFramework.destinationText(inherited.destination)}` + + (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '') + : owner; + 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'); + 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, 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 why each was held, the rest + * by id. Like every notice, it quotes none of their words; the drafts tool + * 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. + * 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 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) => !leftOut.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]})`); + 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 ? 'an open draft' : `${pending.length} open drafts`} of yours ` + + `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 { + 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' }); + } 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) { + // The next turn's catch-up finds this notice and records it. + 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), each with the message that names + * it. One whose notice never reached the history stays unnoticed, for the + * next turn's catch-up. The turn's draft tallies are spent here. + */ + private settleTurnDrafts(agent: Agent, logical: TurnRouting): void { + const held = logical.drafts; + logical.drafts = []; + 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 named) { + byMessage.set(messageId, [...(byMessage.get(messageId) ?? []), draftId]); + } + for (const [messageId, ids] of byMessage) { + try { + this.proseDrafts.markNoticed(agent.name, ids, messageId); + } catch (err) { + // The next turn's catch-up looks for their notices again. + 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), 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 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.has(id)) named.set(id, String(messages[i]!.id)); + } + } + return new Map(ids.filter((id) => named.has(id)).map((id) => [id, named.get(id)!])); + } + + /** 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 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 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, + 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: + * 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; + // The turn that sends: a resent draft's channel is recorded as engaged on + // it, as a send tool's is, even when the result comes after the turn. + const sendingTurn = this.turnRouting.get(agentName); + const refuse = (error: string): ToolResult => ({ success: false, error, isError: true }); + // 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; + 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-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.'); + 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(', ')}: there was none, or it was let go (a draft beyond your ${KEEP_NEWEST} newest ` + + `is kept for at least ${AgentFramework.DRAFT_KEEP_DAYS} days after anything last happened to it). 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.inheritedRisk && draftState(d) === 'unconfirmed' && !uncertainAttempt(d) ? [` ${this.riskText(d)}`] : []), + ...d.attempts.map((a) => { + const o = a.outcome; + const status = !o + ? (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' + ? `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):`, + d.text, + ]; + return lines.join('\n'); + }); + return ok(blocks.join('\n\n')); + } + + 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[] = []; + // 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') { + lines.push(`${d.id}: already delivered — nothing to dismiss. ${this.draftReceipt(d)}`); + continue; + } + if (state === 'dismissed') { + 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) { + // 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}`); + } + if (dismissed.length > 0) this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: dismissed }); + return ok(lines.join('\n')); + } + + // 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); + 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 (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[] = []; + // 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 (${stopped}).`); + 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 = `${queued.id} was no longer found`; + 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 = draftState(d) === 'dismissed' ? `${d.id} was dismissed while it waited` : `${d.id} changed while it waited`; + continue; + } + let attemptId: string; + try { + attemptId = this.proseDrafts.beginAttempt(agentName, d.id, destination, 'resend', draftState(d) === 'unconfirmed'); + } 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. + 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; + } + 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); + } 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); + // 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}.`); + sendingTurn?.engaged.add(destination.channelId); + continue; + } + stopped = `${d.id} was not confirmed delivered`; + const after = this.proseDrafts.get(agentName, d.id); + const stillAtRisk = after !== undefined && draftState(after) === 'unconfirmed'; + // 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.')) + detail); + } + } finally { + 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')); + } + + /** 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) { + return `${this.riskText(d)}, so sending it again could post it twice. To send it anyway, resend with confirmDuplicate: true.`; + } + return undefined; + } + /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( routing: TurnRouting, @@ -12345,7 +13419,12 @@ export class AgentFramework { private appendProseDeliveryReceipt(agent: Agent, routing: TurnRouting): void { const list = routing.deliveries; const suppressed = routing.suppressed; - if (list.length === 0 && suppressed === 0) return; + // The drafts stay for settleTurnDrafts, which records the ones this + // receipt names as noticed. + const drafts = routing.drafts; + const kept = routing.keptPrivate; + const notHeld = routing.draftFailures; + if (list.length === 0 && suppressed === 0 && drafts.length === 0 && kept === 0 && !notHeld) return; routing.deliveries = []; routing.suppressed = 0; const seen = new Set(); @@ -12360,21 +13439,93 @@ 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[] = []; + // 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. + // 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 + // 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( + `${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)', + ); + } + 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)} — 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. + const dismissed = inState('dismissed'); + 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}}'; + // A hybrid `{{unsent}}` envelope is already listed above, as the plain + // speech it was; a resend is not. + if (attempt.via === 'unsent-token' && seen.has(attempt.destination.channelId)) { + 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}) — ` + + 'the words remain only in your history', + ); + } + 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)` + : `${suppressed} plain-speech segment(s) suppressed`); + } + const suppressedNote = notes.join(' · '); + // Nothing is confirmed delivered, but an unconfirmed draft (or a dismissed + // 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 — ${suppressedNote}`; + : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( 'user', [{ type: 'text', text }], - { system: true, kind: 'delivery-receipt' } as MessageMetadata, + { + system: true, + kind: 'delivery-receipt', + ...(drafts.length > 0 ? { draftOwner: agent.name, draftIds: drafts.map((d) => d.id) } : {}), + } as MessageMetadata, ); this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' }); } catch (err) { @@ -12643,15 +13794,21 @@ 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). */ - private async deliverProse(agent: Agent, rawText: string, round?: { keepPrivate?: boolean }): Promise { - // The direct entry delivers under the live turn's routing. + private async deliverProse( + agent: Agent, + rawText: string, + round: { notice?: 'now' | 'later'; keepPrivate?: boolean } = {}, + ): Promise { + // The direct entry delivers as a live round does: under the live turn's + // routing. await this.deliverAdmittedProse(agent, this.admitProse(rawText), { - keepPrivate: round?.keepPrivate, routing: this.routingOf(agent.name), + notice: round.notice ?? 'later', + keepPrivate: round.keepPrivate, }); } @@ -12687,6 +13844,170 @@ export class AgentFramework { } } + /** + * `{{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, + body: string, + round: ProseRound, + prefix: '>>' | '>>>', + ): { draft?: Draft; refused?: true } { + const draft = this.proseDrafts.latestBounce(agent.name); + 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 bounce draft'; + if (own) { + this.bounceProse(agent, own, reason, undefined, prefix, round); + } 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; + // 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 = '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 }; + } + const words = this.unsentWords(agent, body); + const held = 'draft' in words + ? undefined + : this.holdProseDrafts( + agent, + [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], + 'bounced', + { 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 + // 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${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}`; + try { + this.addMessage( + 'user', + [{ type: 'text', text }], + // 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) { + console.error('[drafts] refused-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)}), ` + + `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() }; + } + } + } + const flight = unsent ? `${agent.name}\u0000${unsent.id}` : undefined; + if (flight) { + this.draftsInFlight.add(flight); + this.draftsSending.add(flight); + } + let outcome: PublishOutcome; + try { + // 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); + this.draftsSending.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); + 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', draftOwner: agent.name, draftIds: [unsent.id] } as MessageMetadata, + { 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'; + } + /** * Where a delivered round leaves its turn's sticky destination, as the * explicit gateway sets it once the round's envelopes run: each `>>` @@ -12707,30 +14028,19 @@ export class AgentFramework { rawText: string, locus: string | null, allowLocus: boolean, - turnRouting: TurnRouting, + round: ProseRound, ): Promise { - const routing = turnRouting; - 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+(?=>>>)/, ''); + const routing = round.routing; + for (const envelope of splitHybridEnvelopes(rawText)) { + const normalized = envelope; const attempted = normalized.startsWith('>>>'); const parsed = parseHybridProsePrefix(normalized); if (parsed.continueTurn) routing.continuation = true; if (parsed.kind === 'private') { delete routing.pin; - routing.hybridHold = true; + routing.hybridHold = '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; } @@ -12738,50 +14048,65 @@ export class AgentFramework { const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!); if ('error' in resolved) { delete routing.pin; - routing.hybridHold = true; - this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>'); + routing.hybridHold = 'failed'; + this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>', round); 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, body, round, '>>>') : {}; + if (unsent.refused) { + delete routing.pin; + routing.hybridHold = 'failed'; + continue; + } + // A function replacement: a draft's words go in exactly, `$&` and `$$` included. + if (usedClipboard) body = body.replaceAll('{{unsent}}', () => unsent.draft?.text ?? ''); routing.pin = resolved.channelId; - routing.hybridHold = false; + delete routing.hybridHold; if (!body.trim()) { console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`); continue; } try { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId); - this.recordProseDelivery(routing, outcome); - if (outcome?.delivered) { + const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); + this.recordProseDelivery(routing, 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 { delete routing.pin; - routing.hybridHold = true; - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>'); + routing.hybridHold = '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, '>>>', round, { substituted: unsent.draft }); + } } } catch (err) { delete routing.pin; - routing.hybridHold = true; - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>'); + routing.hybridHold = 'failed'; + this.bounceProse(agent, parsed.body, `sending to ${resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' }); } continue; } if (attempted) { delete routing.pin; - routing.hybridHold = true; - this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>'); + routing.hybridHold = 'failed'; + this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>', round); continue; } - if (routing.hybridHold) { - this.recordProseSuppression(routing, 1); + if (routing.hybridHold === 'private') { + routing.keptPrivate++; + continue; + } + if (routing.hybridHold === 'failed') { + this.holdProseDrafts(agent, [envelope], 'no-destination', round); continue; } const sticky = routing.pin; if (!allowLocus && !sticky) { - this.recordProseSuppression(routing, 1); + this.holdProseDrafts(agent, [envelope], 'no-destination', round); continue; } const target = sticky ?? locus; @@ -12794,6 +14119,46 @@ 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 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; + const tell = (text: string, what: string): void => { + try { + this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'prose-bounce' }, { forAgent: agent.name }); + } catch (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); + // 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}} ` + + `${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'); + } + private async deliverProseEnvelope( agent: Agent, envelope: AdmittedEnvelope, @@ -12809,9 +14174,9 @@ export class AgentFramework { 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; } @@ -12819,8 +14184,8 @@ export class AgentFramework { if (round.keepPrivate) { // skip_reply's promise: "Any plain text you wrote this turn stays // private and is NOT posted". Every envelope of the round stays in the - // context, as a >>skip_reply envelope does: it isn't sent, doesn't - // bounce to the clipboard and doesn't move the destination. + // context, as a >>skip_reply envelope does: it isn't sent, isn't held + // as a draft, and doesn't move the destination. console.error( `[prose] ${name}: ${parsed.kind === 'target' ? `>>${parsed.target}` : 'unprefixed'} — ` + `${parsed.body.length} chars kept in context (skip_reply round)`, @@ -12836,7 +14201,7 @@ export class AgentFramework { const res = envelope.resolved; if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`); if ('error' in res) { - this.bounceProse(agent, parsed.body, res.error, res.candidates); + this.bounceProse(agent, parsed.body, res.error, res.candidates, '>>', round); return; } targetChannel = res.channelId; @@ -12846,16 +14211,20 @@ export class AgentFramework { } else { const sticky = routing.pin; 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, '>>', round); return; } targetChannel = sticky; body = rawText; } + const envelopeBody = body; const usedClipboard = body.includes('{{unsent}}'); + const unsent = usedClipboard ? this.takeUnsent(agent, body, round, '>>') : {}; + if (unsent.refused) return; if (usedClipboard) { - body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? ''); + // 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`); @@ -12863,30 +14232,164 @@ export class AgentFramework { } try { - const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel); - if (result?.delivered) { + const outcome = await this.deliverWithUnsent(agent, body, targetChannel, unsent.draft); + if (outcome.status === 'delivered') { this.proseBounceStreaks.delete(name); - if (usedClipboard) this.proseClipboards.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, '>>', round, { substituted: unsent.draft }); } } catch (err) { - console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); + this.bounceProse(agent, envelopeBody, `sending to ${targetChannel} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' }); } } /** 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 { + /** + * 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, + /** 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; + // 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}`))) { + return { text: expanded }; + } + const risky = uncertainAttempt(previous); + const inheritedRisk: InheritedRisk = risky + ? { + draftId: previous.id, + destination: risky.destination, + at: risky.at, + 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 + ? { + 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: 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 }; + } + + /** + * A routing envelope that could not be delivered: its words are held as a + * draft (the `{{unsent}}` source) and the resident is told, privately. + * `{{unsent}}` in it follows the re-bounce rule (unsentWords). + */ + private bounceProse( + agent: Agent, + text: string, + reason: string, + candidates: string[] | undefined, + prefix: '>>' | '>>>', + /** The round the envelope came from: its turn holds the bounce draft. */ + round: ProseRound, + /** 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; - this.proseClipboards.set(name, text); + 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."; + 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; + if ('draft' in words) { + draft = words.draft; + } else { + inherited = words.inheritedRisk; + // The bounce notice below names the draft, so no separate held notice. + draft = this.holdProseDrafts( + agent, + [{ text: words.text, ...(inherited ? { inheritedRisk: inherited } : {}) }], + 'bounced', + { routing: round.routing, 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(', ')}.` : ''; + // 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.' + : ownRisk + ? ` 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.' + : ''; + // 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} ` + - '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) ${delivered} — ${reason}.${cand} ${kept}${risky}` + (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : ''); try { // Through framework.addMessage, NOT the context manager directly: while @@ -12895,11 +14398,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 ? { draftOwner: name, 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' }); @@ -13299,6 +14803,9 @@ export class AgentFramework { this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); } else { 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. @@ -13831,9 +15338,13 @@ 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); // Live routing is only trusted when the membrane provides verbatim @@ -13852,11 +15363,11 @@ export class AgentFramework { const refusedWakeRounds = new Set(); let toolRounds = 0; // A refused skip_reply silences only its own round: the silence before it, - // saved as its round is dispatched, comes back at the next round or at - // 'complete'. It's this stream's: a budget restart starts from its - // trigger, and a tool-result-guard retry takes the turn's silence as + // saved with its cause as its round is dispatched, comes back at the next + // round or at 'complete'. It's this stream's: a budget restart starts from + // its trigger, and a tool-result-guard retry takes the turn's silence as // 'complete' restored it, through guardRetryTurnSilenced. - let silenceBeforeRefusal: boolean | undefined; + let silenceBeforeRefusal: 'send' | 'private' | null | undefined; // Typing indicator: show " is typing…" for the whole duration of // this turn. Started here (paired with the finally below, so it can never @@ -13964,7 +15475,8 @@ export class AgentFramework { const adoptInjectedRound = (): void => { if (silenceBeforeRefusal !== undefined) { - turnSilenced = silenceBeforeRefusal; + silenceCause = silenceBeforeRefusal; + turnSilenced = silenceCause !== null; silenceBeforeRefusal = undefined; } if (!this.midTurnInputSignals.has(agent.name)) return; @@ -13974,6 +15486,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 { @@ -14001,7 +15514,7 @@ export class AgentFramework { channelId: typingChannel ?? undefined, }; // Delivery joins a round's text blocks with a newline - // (splitProseSegments), so a later block starts a new line for + // (splitProseRuns), so a later block starts a new line for // the stream too. let text = event.meta.type === 'text' ? event.content : undefined; if (text !== undefined) { @@ -14161,10 +15674,13 @@ export class AgentFramework { ); // A refused call doesn't silence: the silence before it, and its - // round's other silencing calls, come back at the next round. + // round's other silencing calls, come back at the next round, each + // with why it silences. if (event.calls.some((call) => this.refusesSelfWake(call))) { - silenceBeforeRefusal = turnSilenced - || event.calls.some((call) => isSilencingTool(call.name) && !this.refusesSelfWake(call)); + const otherCause = silenceCauseOf(event.calls.filter((call) => !this.refusesSelfWake(call))); + silenceBeforeRefusal = silenceCause === 'private' || otherCause === 'private' + ? 'private' + : (silenceCause ?? otherCause); refusedWakeRounds.add(toolRound); } @@ -14195,12 +15711,25 @@ export class AgentFramework { 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; - const roundSegments = splitProseSegments(assistantBlocks); + // 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; + // Queued under this turn's routing, whenever it posts. + 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); + const roundSegments = roundRuns.map((run) => run.trim()); if (roundSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error( @@ -14209,13 +15738,17 @@ export class AgentFramework { this.recordProseSuppression(streamRouting, roundSegments.length); } else if (turnProseRouting === 'hybrid') { if (turnSilenced) { - this.recordProseSuppression(streamRouting, roundSegments.length); + // In order behind earlier rounds' envelopes: the router + // state they leave is where this round's starts. + const cause = silenceCause; + chainSpeech( + async () => this.holdSilencedProse(agent, roundRuns, cause, hasSameRoundPrivateThink, holdOpts), + 'mid-turn hybrid prose hold failed:', + ); } else if (!hasSameRoundPrivateThink) { const locus = resolveTurnLocus(); - // Queued under this turn's routing, whenever it posts. - const routing = streamRouting; for (const seg of roundSegments) { - chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true, routing), 'mid-turn hybrid prose delivery failed:'); + chainSpeech(() => this.deliverHybridProse(agent, seg, locus, true, holdOpts), 'mid-turn hybrid prose delivery failed:'); } } } else if (turnProseRouting === 'explicit') { @@ -14234,8 +15767,7 @@ export class AgentFramework { // once, for the turn's destination and for its queued // delivery alike. const admitted = roundSegments.map((seg) => this.admitProse(seg)); - // Queued under this turn's routing, whenever it posts. - const routing = streamRouting; + const routing = holdOpts.routing; if (!keepPrivate) { routing.destination = this.roundDestination(routing.destination, admitted.flat()); } @@ -14243,7 +15775,7 @@ export class AgentFramework { `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway${keepPrivate ? ', kept private (skip_reply)' : ''}`, ); for (const envelopes of admitted) { - chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { keepPrivate, routing }), 'mid-turn prose delivery failed:'); + chainSpeech(() => this.deliverAdmittedProse(agent, envelopes, { ...holdOpts, keepPrivate }), 'mid-turn prose delivery failed:'); } } // The stream's next round starts where delivery sends @@ -14254,11 +15786,11 @@ export class AgentFramework { proseStream?.retarget(streamRouting.destination); } 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(streamRouting, 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, 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)`, @@ -14333,7 +15865,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, { @@ -14471,17 +16003,32 @@ export class AgentFramework { // Explicit text is admitted as its round ends, before anything // below waits (a module's speech callback, the turn's earlier // deliveries): its targets resolve now, as a live round's do. - const finalSpeechText = speechContent.map((b) => b.text).join('\n').trim(); - const finalSegments = hadToolCalls && allText.length > 0 - ? splitProseSegments(liveProseRouting + // Routing publishes trimmed segments; held drafts keep the runs + // exactly as written. + // + // 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 speechRun = speechContent.map((b) => b.text).join('\n'); + const finalSpeechRuns = speechContent.length === 0 + ? [] + : response.content.some((b) => (b.type as string) === 'tool_attempt') + ? splitProseRuns(response.content) + : speechRun.trim() ? [speechRun] : []; + const finalSpeechSegments = finalSpeechRuns.map((run) => run.trim()); + const finalRuns = hadToolCalls && allText.length > 0 + ? splitProseRuns(liveProseRouting ? terminalContent : withoutRoundsProse(response.content, refusedWakeRounds)) : []; + const finalSegments = finalRuns.map((run) => run.trim()); const admitFinal = turnProseRouting === 'explicit' && !!this.channelRegistry; // The final text is delivered under this turn's routing, as its // queued rounds are. const finalRouting = streamRouting; - const admittedSpeech = admitFinal && finalSpeechText ? this.admitProse(finalSpeechText) : null; + const admittedSpeechSegments = admitFinal ? finalSpeechSegments.map((seg) => this.admitProse(seg)) : []; const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : []; const du = response.details?.usage; @@ -14644,6 +16191,15 @@ export class AgentFramework { ); } + // Queued deliveries and hybrid holds belong to this turn even + // when its final response is empty. Settle them before the + // host's own final routing below, the receipt and the turn-end + // draft settlement. Every stream's chain starts from the agent's + // latest link, so this also waits for an earlier turn's posts + // still queued. (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 // text-only turn (no tool calls => speechContent populated), // publish the agent's speech to the conversational locus — the @@ -14653,37 +16209,38 @@ 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 = finalSpeechText; - if (speechText) { - // After the agent's earlier deliveries: every stream's chain - // starts from the agent's latest link, so this waits for the - // turn's earlier rounds and for an earlier turn's posts still - // queued. - await turnSpeechChain; + const speechRuns = finalSpeechRuns; + const speechSegments = finalSpeechSegments; + 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)`); - this.recordProseSuppression(streamRouting, 1); + this.recordProseSuppression(streamRouting, 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(streamRouting, 1); + console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced: ${silenceCause})`); + this.holdSilencedProse(agent, speechRuns, silenceCause, false, textOnlyHold); } else if (turnProseRouting === 'hybrid') { const locus = resolveTurnLocus(); console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`); - try { - await this.deliverHybridProse(agent, speechText, locus, true, finalRouting); - } 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.deliverAdmittedProse(agent, admittedSpeech!, { routing: finalRouting }); - } catch (err) { - console.error('text-only prose delivery failed:', err); + for (const envelopes of admittedSpeechSegments) { + try { + await this.deliverAdmittedProse(agent, envelopes, textOnlyHold); + } catch (err) { + console.error('text-only prose delivery failed:', err); + } } } else { // Route to the TURN-FROZEN locus, like every other speech @@ -14696,11 +16253,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(streamRouting, 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(streamRouting, outcome); + } catch (err) { + console.error('speech routing failed:', err); + } } } } @@ -14719,23 +16278,25 @@ 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 toolUses = response.content + const toolCalls = response.content .filter((b) => b.type === 'tool_use') .map((b) => b as unknown as { name?: unknown; input?: unknown }) - .filter((b): b is { name: string; input?: unknown } => typeof b.name === 'string'); - const toolNames = toolUses.map((b) => b.name); + .filter((c): c is { name: string; input?: unknown } => typeof c.name === 'string'); + const toolNames = toolCalls.map((c) => c.name); // A refused skip_reply isn't a silencing call; a plain one is. + const fallbackCause = liveProseRouting + ? null + : silenceCauseOf(toolCalls.filter((c) => !this.refusesSelfWake(c))); const silenced = liveProseRouting ? turnSilenced - : turnSilenced || toolUses.some((b) => isSilencingTool(b.name) && !this.refusesSelfWake(b)); + : turnSilenced || fallbackCause !== null; + const trailingCause: 'send' | 'private' | null = + silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); + const trailingHold: ProseRound = { routing: finalRouting, notice: 'later' }; + 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( @@ -14745,12 +16306,12 @@ export class AgentFramework { } } else if (turnProseRouting === 'hybrid') { if (silenced && segments.length > 0) { - this.recordProseSuppression(streamRouting, segments.length); + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); } else if (segments.length > 0) { const locus = resolveTurnLocus(); for (const seg of segments) { try { - await this.deliverHybridProse(agent, seg, locus, true, finalRouting); + await this.deliverHybridProse(agent, seg, locus, true, trailingHold); } catch (err) { console.error('trailing hybrid prose delivery failed:', err); } @@ -14763,7 +16324,7 @@ export class AgentFramework { ); for (const envelopes of admittedSegments) { try { - await this.deliverAdmittedProse(agent, envelopes, { routing: finalRouting }); + await this.deliverAdmittedProse(agent, envelopes, trailingHold); } catch (err) { console.error('trailing prose delivery failed:', err); } @@ -14772,10 +16333,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(streamRouting, segments.length); + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold); } } else { // The turn's own locus, read from this stream's routing @@ -14803,13 +16364,12 @@ export class AgentFramework { // doesn't depend on the status field, so it correctly runs after. // Logical turn end (normal completion): drop the `[delivered]` - // receipt, once every delivery has settled. The text-only and - // trailing paths awaited the chain when there was final text; a - // turn whose last response is empty awaits it here, so the - // receipt and the continuation see its last rounds' deliveries. - // Locus and hybrid mode; explicit-mode envelopes acknowledge + // receipt. Queued deliveries and holds settled before final + // dispatch, even when the last response is empty, so the receipt + // and the continuation see the last rounds' deliveries; + // trailing/text-only segments were awaited in-loop. Locus and + // hybrid modes only; explicit-mode envelopes acknowledge // themselves through the prose gateway. - await turnSpeechChain; if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent, streamRouting); } @@ -15470,6 +17030,19 @@ 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). 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) { + try { + this.settleTurnDrafts(agent, streamRouting); + } catch (err) { + console.error('[drafts] turn-end settlement failed:', err); + } + } + // A conversation fork whose TTL closure turn just finished is done for // good — dispose it so the agent map doesn't grow monotonically. The // frame that ends the turn disposes it, after the flush above, so what @@ -15710,6 +17283,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. @@ -17148,6 +18728,20 @@ 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.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 }); + }); + 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). @@ -18285,6 +19879,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) @@ -18313,6 +19914,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) { @@ -18352,7 +19960,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, detail }) => { try { // Render a human-readable channel name when we can — a bare // snowflake in the marker is unresolvable for the agent (the @@ -18373,16 +19981,20 @@ export class AgentFramework { // debugging a Discord problem that doesn't exist. The text names // the real situation; the machine-readable `kind` is kept stable // for downstream consumers (gate intents, surfaces) that key on - // it. Neither text claims the reply reached no one: only channel + // it. None of these texts claims the reply reached no one: only channel // routing failed here, and other dispatchSpeech handlers may // have shown it. const text = channelId - ? `[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.` + ? (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, 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.`) : `[send-undeliverable] Your previous reply (${textLen} chars) had no channel to go to — ${reason}. This is a routing/configuration situation, not a channel failure. This route delivered it nowhere; if another surface showed it (a reply thread, the console), that copy stands. It is saved in your archive.`; this.addMessage( 'user', - [{ type: 'text', text }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason }, + [{ type: 'text', text: text + (detail !== undefined ? ` Connector detail: ${detail}` : '') }], + { 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/index.ts b/src/index.ts index dfe5a60c8..4263295e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,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'; @@ -91,7 +100,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, McplConnectionCloseInfo } from './mcpl/index.js'; // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index f7d7cd947..8c906bfa6 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -42,6 +42,8 @@ import { isVisiblyEmptyConversion } from './visible-content.js'; import { logDetail, logValue } from './log-value.js'; import { RefusalLines } from './refusal-lines.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) @@ -239,6 +241,10 @@ interface McplChannelIncomingEvent { tags?: string[]; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the message is admitted. */ + acceptedAt?: number; + /** Source envelope frozen at admission (mcpl/inbound-source.ts). */ + inboundSource?: InboundSource; } // ============================================================================ @@ -389,7 +395,125 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // Constructor Options // ============================================================================ +/** Bound connector values for notices, logs and an outcome's reason. A + * PublishOutcome keeps the connector's structured data (`detail`) whole. */ +export 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 { + 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` 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, 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'; + /** 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, bounded (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; +} + +/** + * 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 + * 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 @@ -418,6 +542,11 @@ 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'; + /** Bounded display of the connector's structured error data, when present. */ + detail?: string; }) => void; /** * Called when channels were opened WITHOUT the agent asking (subscription @@ -498,12 +627,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; @@ -602,6 +726,7 @@ export class ChannelRegistry { private legacyPolicies = new Map(); private migratedLegacyPolicies = new Set(); private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming']; + private acceptInbound?: ChannelRegistryOptions['acceptInbound']; private channelTools: ToolDefinition[]; constructor( @@ -619,6 +744,7 @@ export class ChannelRegistry { }, ) { this.handleCoalescedIncoming = options?.handleCoalescedIncoming; + this.acceptInbound = options?.acceptInbound; this.channelTools = CHANNEL_TOOL_DEFINITIONS.map((tool) => { if (tool.name !== 'skip_reply' || options?.eventGatePresent === true) return tool; const schema = tool.inputSchema as { @@ -1033,6 +1159,7 @@ export class ChannelRegistry { metadata: message.metadata, ...(message.tags ? { tags: message.tags } : {}), triggerInference, + acceptedAt: Date.now(), }; if (coalesced) { @@ -1058,6 +1185,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. @@ -1285,6 +1418,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; } @@ -2995,7 +3135,179 @@ export class ChannelRegistry { return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; } - async routeSpeech( + /** + * 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 }, + ): { 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 } { + // 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), + ); + if (matches.length === 0) { + return { + error: target.serverId + ? `no registered channel "${target.channelId}" on server "${target.serverId}"` + : `no registered channel "${target.channelId}"`, + }; + } + if (matches.length > 1) { + return { + error: `channel id "${target.channelId}" is registered by more than one MCPL server; the destination must name its server`, + }; + } + 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) 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 + * 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), 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, + * 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, + target: { + serverId?: string; + channelId: string; + /** Set by speech routing (deliverSpeech): opening a closed channel + * for this publish is logged under `[routeSpeech]`, with speech + * routing's other lines. */ + routed?: boolean; + }, + 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, + ...(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); + if (target.routed) { + console.error( + `[routeSpeech] ${conversationId}: locus ${logValue(entry.descriptor.id)} was closed — opened by delivery (speech implies engagement)`, + ); + } + 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: ${connectorText(err instanceof Error ? err.message : String(err))}`, + at: at(), + }; + } + } + + let result: unknown; + try { + result = await server.sendChannelsPublish({ + conversationId, + channelId: entry.descriptor.id, + content: [{ type: 'text', text }], + }); + } catch (err) { + return publishErrorOutcome(err, destination, at()); + } + return publishReceiptOutcome(result, destination, at()); + } + + /** + * 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 @@ -3006,118 +3318,82 @@ 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 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)`); + /** `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'; + 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, channelId: channelId ?? '', reason, textLen: text.length, + outcome: status, + ...(detail !== undefined ? { detail } : {}), }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length }); - return null; + if (status === 'unknown' || !opts?.heldOnFailure) { + this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome: status, ...(detail !== undefined ? { detail } : {}) }); + } + 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() }); } - 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 reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)'; + return fail(null, reason, { status: 'failed', reason, at: Date.now() }); } - // 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 ${logValue(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 outcome = await this.publish(conversationId, text, { channelId: locusChannelId, routed: true }); + const channelId = outcome.destination?.channelId ?? locusChannelId; + if (outcome.status !== 'delivered') { + return fail(channelId, outcome.reason ?? 'delivery not confirmed', outcome); } - 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 -> ${logValue(channelId)} (server=${entry.serverId}, delivered=${delivered})`); + console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${logValue(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 outcome; + } - return { delivered, channelId, ...(messageId !== undefined ? { 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 { @@ -3159,31 +3435,44 @@ 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 }; + // 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' + ? `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.') + detail, + isError: true, + }; } } diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts new file mode 100644 index 000000000..766eb07cf --- /dev/null +++ b/src/mcpl/inbound-source.ts @@ -0,0 +1,156 @@ +/** + * Where an inbound item came from, decided once at Agent Framework's + * ingestion boundary and stored on the message as `metadata.inboundSource`. + * + * 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, 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 + * `materialized` record what RFC-006 did with it. Nothing here is a version + * 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 + * 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'; + lane: 'push/event'; + coalesced?: true; + 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 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, 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' || Array.isArray(raw)) return undefined; + const s = raw as Record; + 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 (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') { + return s.lane === 'push/event' ? (s as unknown as InboundUnscopedSource) : undefined; + } + 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. 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 JSON.stringify(['channel', source.serverId, source.channelId, source.threadId ?? null]); + } + if (source.kind === 'surface') return JSON.stringify(['surface', source.surface]); + return undefined; +} diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index 978fcaff7..6d8862e9d 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 type { McplConnectionCloseInfo } from './server-connection.js'; export type { TransportCloseInfo } from './transport.js'; export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js'; @@ -159,6 +159,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 1bccc21df..9787152f4 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -209,6 +209,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 (an fsync; the coalescer requests each * commit under its lock, so no two acceptances share one). * Awaited before an acceptance is acknowledged and before push/render is @@ -496,6 +500,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 c86027f5e..b9bae6421 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -18,6 +18,7 @@ import type { FeatureSetManager } from './feature-set-manager.js'; import { McplFeatureSetError } from './feature-set-manager.js'; import { expandCoreTags } from './tags.js'; import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js'; +import type { InboundSource } from './inbound-source.js'; import { isSilentHeartbeatMarker, isVisiblyEmptyConversion } from './visible-content.js'; import { logDetail, logValue } from './log-value.js'; import { RefusalLines } from './refusal-lines.js'; @@ -49,6 +50,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; } // ============================================================================ @@ -113,6 +119,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; @@ -240,6 +250,7 @@ export class PushHandler { timestamp: params.timestamp, inferenceId, triggerInference, + acceptedAt: Date.now(), }; if (coalesced) { // RFC-006: the coalescer decides whether this occurrence replaces an @@ -258,6 +269,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/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 7f7eb4337..771e993ce 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -119,6 +119,33 @@ 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', + /** 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. */ @@ -287,6 +314,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 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 // inference-bearing data-plane traffic. This lets a server finish its own @@ -874,9 +904,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. */ @@ -901,13 +935,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. */ @@ -1000,7 +1041,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(); @@ -1176,7 +1217,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++; @@ -1197,20 +1238,32 @@ 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. 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', + )); + } }); } @@ -1486,7 +1539,11 @@ export class McplServerConnection extends EventEmitter { // code as much as a message, since a server can send a string, so both // are bounded as errors.ts says. pending.reject( - new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${boundedForLog(describeCause(response.error.code))}] ${boundedForLog(describeCause(response.error.message))}`), + new McplRequestError( + `MCPL server "${this.id}" returned error for ${pending.method}: [${boundedForLog(describeCause(response.error.code))}] ${boundedForLog(describeCause(response.error.message))}`, + 'error-response', + (response.error as { data?: unknown }).data, + ), ); } else { pending.resolve(response.result); @@ -1539,8 +1596,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=${closeReasonText(info)}) while awaiting ${pending.method} (id=${id})`, + 'no-response', ), ); } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 78d876e08..1d2973cb6 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -386,7 +386,10 @@ export interface McplServerConfig { * stops reading its stdin without closing it: the host's writes are * buffered, no error is raised, and the connection stays up. This is the * timeout of every request that names none; the handshake's initialize has - * its own. Set 0 to disable. Default: 60000 (60 seconds). + * its own. 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/src/prose-drafts.ts b/src/prose-drafts.ts new file mode 100644 index 000000000..84f11eef7 --- /dev/null +++ b/src/prose-drafts.ts @@ -0,0 +1,481 @@ +/** + * 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, 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 + * 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 + * 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, + * 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. 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, 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 + * 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 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'; +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' + /** An explicit/hybrid routing prefix was missing, malformed or unresolvable. */ + | 'bounced'; + +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 a `{{unsent}}` envelope (explicit or hybrid). */ + 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; +} + +/** + * 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; + /** 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; +} + +export interface Draft { + id: string; + agent: string; + /** 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. */ + inheritedRisk?: InheritedRisk; + /** Short context for the resident: the bounce reason, or the competing conversations. */ + note?: string; + heldAt: number; + attempts: DraftAttempt[]; + dismissedAt?: number; + /** 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; +} + +/** + * - `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; + * - `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'; + return uncertainAttempt(draft) || draft.inheritedRisk ? '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 = + | { 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 } + /** 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'; +/** + * 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; +/** 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; + /** 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, 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): + * the latest checkpoint's drafts, then the entries after it. */ + reload(): void { + 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) continue; + draft.noticedIn ??= []; + if (!draft.noticedIn.includes(entry.messageId)) draft.noticedIn.push(entry.messageId); + draft.noticedAt = Math.max(draft.noticedAt ?? 0, 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 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 { + this.journal.append(entry, { durable, afterCommittedState }); + } catch (err) { + if (this.journal.needsReconcile) this.reload(); + throw err; + } + this.reduce(entry); + this.checkpointIfDue(); + } + + private agentDrafts(agent: string): Map { + let map = this.drafts.get(agent); + if (!map) { + map = new Map(); + this.drafts.set(agent, map); + } + return map; + } + + /** A new id, unique among the resident's kept drafts and never its latest + * 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 < ID_LENGTH; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; + if (!existing.has(id) && id !== latestBounce) 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. 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. 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, + segments: Array<{ text: string; note?: string; inheritedRisk?: InheritedRisk }>, + 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 } : {}), + ...(segment.inheritedRisk ? { inheritedRisk: segment.inheritedRisk } : {}), + heldAt: Date.now(), + }; + try { + this.write({ op: 'held', draft }, true); + } catch (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)!); + } + 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, 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 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[], messageId: string): void { + if (ids.length === 0) return; + this.write({ op: 'noticed', agent, ids, at: Date.now(), messageId }, true, 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, 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 { + 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/src/prose-segments.ts b/src/prose-segments.ts index 9d1d67bbf..c992fd90d 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; speech routes each run trimmed of surrounding whitespace. + */ +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,7 +52,7 @@ export function splitProseSegments(content: readonly ContentBlock[]): string[] { } flush(); - return segments; + return runs; } /** diff --git a/src/types/events.ts b/src/types/events.ts index 5f4c6395f..976731ff9 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -260,6 +260,8 @@ export interface McplPushEvent { inferenceId: string; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the push is admitted. */ + acceptedAt?: number; } /** @@ -278,4 +280,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 aab8f00a6..effcc74a1 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. @@ -463,6 +464,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 @@ -676,6 +680,40 @@ 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; + }) + + // 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: DraftReason; + 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 5159458e4..6658b8513 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. @@ -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 { MembraneError } from '@animalabs/membrane'; import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; @@ -68,7 +69,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' }, @@ -79,15 +80,29 @@ 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 ?? '' }; + 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', getDefaultPublishChannel: () => null, isChannelOpen: () => true, @@ -155,7 +170,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[])); @@ -177,7 +192,10 @@ 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'); + 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(); }); @@ -265,8 +283,338 @@ describe('explicit prose routing', () => { }; } - const clipboardOf = (framework: AgentFramework): string | undefined => - (framework as unknown as { proseClipboards: Map }).proseClipboards.get('assistant'); + /** 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; + + for (const failure of ['false', 'throw'] as const) { + 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]); + 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'); + 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.ok(notice.endsWith('the words remain in your history.'), notice); + 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'); + 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, round: { routing: unknown; notice: 'later' }): Promise; + routingOf(name: string): unknown; + }; + if (mode === 'explicit') await gateway.deliverProse(agent, '>>#alpha uncertain words', { notice: 'later' }); + else await gateway.deliverHybridProse(agent, '>>>#alpha uncertain words', null, true, { routing: gateway.routingOf('assistant'), 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('{{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 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' }); + 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'); + assert.ok(context(framework).texts.some((t) => t.startsWith('[prose-routing] {{unsent}} names no held bounce draft')), + 'and says why'); + } finally { + await framework.stop(); + } + }); + + for (const sent of ['{{unsent}} plus a line', '{{unsent}}'] as const) { + it(`a later turn's bounce waits behind an earlier turn's held '${sent}' send, which holds the words it attempted when it fails`, async () => { + // Turn one queues '>>#alpha ${sent}' and skip_reply ends it while that + // publish is held. Turn two's speech queues behind it (each stream's + // deliveries wait for the agent's earlier ones), so turn two's bounce + // can't land first: turn one's failed send holds the words it + // attempted, and turn two's bounce, the latest, follows it. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: `>>#alpha ${sent}` }, + { 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' }], 'bounced').held[0]!; + const internals = framework as unknown as { + channelRegistry: { deliverSpeech: (...args: unknown[]) => 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[]) => { + 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 attempted = sent === '{{unsent}}' ? 'ORIGINAL WORDS' : 'ORIGINAL WORDS plus a line'; + assert.deepEqual(routed.map((r) => r.text), [attempted]); + trigger(framework); + const settled = framework.runUntilIdle(); + for (const end = Date.now() + 5000; starts < 2 && Date.now() < end;) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(starts, 2, 'the next turn has started'); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(store.latestBounce('assistant')?.id, original.id, "the next turn's bounce waits behind the held send"); + release(); + await within(settled, 'the next turn to settle'); + await framework.runUntilIdle(); + const heldTexts = store.open('assistant').map((d) => d.text); + 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'); + // 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); + } + assert.equal(store.latestBounce('assistant')?.text, 'NEW WORDS', "the next turn's bounce came after it, and is the latest"); + } 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 { + stubRegistry(framework, ['throw']); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ + text: 'earlier words', + }], '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'); + 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 { + await framework.stop(); + } + }); + + 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' }], '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, round: { routing: unknown; notice: 'later' }): Promise; + routingOf(name: string): unknown; + }; + 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, { routing: internals.routingOf('assistant'), notice: 'later' }); + await reached; + // 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' }], '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); + 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, 'read after the attempt, the failed draft carries no risk to inherit'); + } 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' }], '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 sending its draft again could post it twice', async () => { + const framework = await createFramework(); + try { + stubRegistry(framework, ['throw']); + const store = draftsOf(framework); + const original = store.hold('assistant', [{ + text: 'earlier words', + }], '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, /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); + assert.ok(notice.includes('resend it only with 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, round: { routing: unknown; notice: 'later' }): Promise; + routingOf(name: string): unknown; + }; + await gateway.deliverHybridProse(framework.getAgent('assistant')!, '>>>#alpha {{unsent}} and a note', null, true, { routing: gateway.routingOf('assistant'), 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(); + } + }); it('a round that calls skip_reply keeps its unprefixed prose private: nothing delivered, bounced, retained or woken for', async () => { membrane.pushResponse(createMockResponse([ @@ -294,7 +642,8 @@ describe('explicit prose routing', () => { const seen = context(framework); assert.equal(seen.bounces, 0, 'no bounce notice reaches the context'); assert.ok(seen.texts.some((t) => t.includes('Weighing whether this needs an answer')), 'the text stays in the context'); - assert.equal(clipboardOf(framework), undefined, 'nothing is retained for {{unsent}}'); + assert.deepEqual(draftsOf(framework).open('assistant'), [], 'no draft is held'); + assert.equal(draftsOf(framework).latestBounce('assistant'), undefined, 'and {{unsent}} names nothing'); assert.equal(turns, 1, 'no bounce wake starts another turn'); await framework.stop(); @@ -355,16 +704,34 @@ describe('explicit prose routing', () => { type RoutingState = { - pin?: string; destination: string | null; hybridHold: boolean; - continuation: boolean; deliveries: string[]; suppressed: number; + pin?: string; destination: string | null; hybridHold?: 'private' | 'failed'; + continuation: boolean; deliveries: string[]; suppressed: number; drafts: Array<{ id: string; text: string }>; keptPrivate: number; triggerChannel?: string; locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }>; }; /** A turn's routing state, as the framework makes a fresh one. */ const routingState = (fields: Partial = {}): RoutingState => ({ - destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, + destination: null, continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, engaged: new Set(), arrivals: [], ...fields, }); + /** + * Run `around` on each post the stub registry takes, on both paths a post + * can take (routeSpeech for locus prose, deliverSpeech for a named or + * sticky destination), after the stub records it and before its outcome + * returns: a test holds a post by awaiting there. + */ + function aroundPosts(framework: AgentFramework, around: (text: string) => Promise): void { + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + for (const key of ['routeSpeech', 'deliverSpeech'] as const) { + const post = registry[key] as (...args: unknown[]) => Promise; + registry[key] = async (...args: unknown[]) => { + const outcome = await post(...args); + await around(String(args[1])); + return outcome; + }; + } + } + async function within(promise: Promise, label: string, ms = 5000): Promise { let timer: NodeJS.Timeout | undefined; try { @@ -414,15 +781,11 @@ describe('explicit prose routing', () => { const framework = await createFramework(mode); const routed = stubRegistry(framework); const skip = answerSkipReply(framework); - const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; - const route = registry.routeSpeech as (agent: string, text: string, locus?: string | null) => Promise; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); - registry.routeSpeech = async (agent: string, text: string, locus?: string | null) => { - const outcome = await route(agent, text, locus); + aroundPosts(framework, async (text) => { if (text.trim() === 'first') await held; - return outcome; - }; + }); let turns = 0; framework.onTrace((event) => { if (event.type !== 'inference:started') return; @@ -504,7 +867,7 @@ describe('explicit prose routing', () => { inspect: (fixture: { framework: AgentFramework; routed: Array<{ text: string; locus: string | null }>; gateway: { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, routing: RoutingState): Promise; + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; }; earlier: RoutingState; next: RoutingState; agent: unknown; @@ -515,28 +878,24 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const gateway = framework as unknown as { turnRouting: Map; - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, routing: RoutingState): Promise; + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void; }; const agent = framework.getAgent('assistant')!; const earlier: RoutingState = routingState(); const next: RoutingState = routingState(); gateway.turnRouting.set('assistant', earlier); - const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; - const route = registry.routeSpeech as (agent: string, text: string, locus?: string | null) => Promise; let release!: () => void; let resolveEntered!: () => void; const held = new Promise((resolve) => { release = resolve; }); const entered = new Promise((resolve) => { resolveEntered = resolve; }); - registry.routeSpeech = async (name: string, text: string, locus?: string | null) => { - const outcome = await route(name, text, locus); + aroundPosts(framework, async () => { resolveEntered(); await held; - return outcome; - }; - const first = gateway.deliverHybridProse(agent, '>>>#alpha first', 'world:commons', true, earlier); + }); + const first = gateway.deliverHybridProse(agent, '>>>#alpha first', 'world:commons', true, { routing: earlier, notice: 'later' }); const pending = lateText === null ? first - : first.then(() => gateway.deliverHybridProse(agent, lateText, 'world:commons', true, earlier)); + : first.then(() => gateway.deliverHybridProse(agent, lateText, 'world:commons', true, { routing: earlier, notice: 'later' })); void pending.catch(() => {}); try { await within(entered, 'the earlier callback to enter routing'); @@ -648,17 +1007,13 @@ describe('explicit prose routing', () => { if ((e as { type: string }).type === 'inference:started') turns++; }); feedTurn(framework, 2, [{ type: 'text', text: arrows + 'skip_reply' }] as ContentBlock[]); - const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; - const route = registry.routeSpeech as (agent: string, text: string, locus?: string | null) => Promise; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); let entered!: () => void; const reached = new Promise((resolve) => { entered = resolve; }); - registry.routeSpeech = async (agent: string, text: string, locus?: string | null) => { - const outcome = await route(agent, text, locus); + aroundPosts(framework, async (text) => { if (text.trim() === 'first') { entered(); await held; } - return outcome; - }; + }); const internals = framework as unknown as { pendingRequests: InferenceRequest[] }; const ended = nextTrace(framework, 'inference:turn_ended'); trigger(framework); @@ -794,18 +1149,14 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); const internals = framework as unknown as { proseSpeechChains: Map>; - channelRegistry: { routeSpeech: (...args: unknown[]) => Promise }; }; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); let reached!: () => void; const entered = new Promise((resolve) => { reached = resolve; }); - const route = internals.channelRegistry.routeSpeech; - internals.channelRegistry.routeSpeech = async (...args: unknown[]) => { - const result = await route(...args); - if (String(args[1]).trim() === 'first') { reached(); await held; } - return result; - }; + aroundPosts(framework, async (text) => { + if (text.trim() === 'first') { reached(); await held; } + }); let firstChain: Promise | undefined; let restarted!: () => void; const retryStarted = new Promise((resolve) => { restarted = resolve; }); @@ -865,6 +1216,108 @@ 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.ok(receipts[0]!.includes(draft.id), `it names the draft the first stream held: ${receipts[0]}`); + // The later compile continues the logical turn and keeps its routing, + // the bounce's hold included, so its unprefixed text is held too. + const later = draftsOf(framework).open('assistant').find((d) => d.text === 'carrying on'); + assert.ok(later, "the later compile's unprefixed text is held under the turn's hold"); + assert.ok(receipts[0]!.includes(later.id), `and the receipt names that draft as well: ${receipts[0]}`); + 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(); + } + }); + + 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('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'); + }); + }); + 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 @@ -943,7 +1396,7 @@ describe('explicit prose routing', () => { try { // Everything the open turn has decided about where its speech goes. const decided = (): RoutingState => routingState({ - continuation: true, pin: 'chan-alpha', hybridHold: true, triggerChannel: 'chan-alpha', + continuation: true, pin: 'chan-alpha', hybridHold: 'failed', triggerChannel: 'chan-alpha', locus: 'world:commons', engaged: new Set(['discord:g:cafe']), arrivals: [{ messageId: 'cafe-message', channelId: 'discord:g:cafe' }], }); const open = decided(); @@ -1052,7 +1505,6 @@ describe('explicit prose routing', () => { stubRegistry(framework); answerSkipReply(framework); const internals = framework as unknown as { - channelRegistry: { routeSpeech: (...args: unknown[]) => Promise }; proseSpeechChains: Map>; turnRouting: Map; pendingRequests: InferenceRequest[]; @@ -1062,11 +1514,10 @@ describe('explicit prose routing', () => { const held = new Promise((resolve) => { release = resolve; }); let entered!: () => void; const reached = new Promise((resolve) => { entered = resolve; }); - internals.channelRegistry.routeSpeech = async () => { + aroundPosts(framework, async () => { entered(); await held; - return { delivered: true, channelId: 'chan-alpha' }; - }; + }); try { trigger(framework); await within(framework.runUntilIdle(), 'the tool-ended turn to settle'); @@ -1092,12 +1543,12 @@ describe('explicit prose routing', () => { it("hybrid: a held callback's late malformed envelope holds only its supplied turn", async () => { await withHeldHybridCallback('>>>', async ({ earlier, next, routed, gateway, agent, pending, release }) => { - assert.equal(earlier.hybridHold, false, 'the malformed envelope has not run before release'); + assert.equal(earlier.hybridHold, undefined, 'the malformed envelope has not run before release'); release(); await within(pending, 'the late malformed envelope'); - assert.equal(earlier.hybridHold, true, 'the late hold belongs to the supplied earlier turn'); - assert.equal(next.hybridHold, false, 'the newer object stays unheld'); - await gateway.deliverHybridProse(agent, 'next', 'world:commons', true, next); + assert.equal(earlier.hybridHold, 'failed', 'the late hold belongs to the supplied earlier turn'); + assert.equal(next.hybridHold, undefined, 'the newer object stays unheld'); + await gateway.deliverHybridProse(agent, 'next', 'world:commons', true, { routing: next, notice: 'later' }); assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), [ { text: 'first', locus: 'chan-alpha' }, { text: 'next', locus: 'world:commons' }, @@ -1107,7 +1558,7 @@ describe('explicit prose routing', () => { it("hybrid callbacks and receipts keep each turn's counts while a post is held", async () => { await withHeldHybridCallback(null, async ({ framework, earlier, next, gateway, agent, pending, release }) => { - await gateway.deliverHybridProse(agent, 'quiet without a destination', 'world:commons', false, next); + await gateway.deliverHybridProse(agent, 'quiet without a destination', 'world:commons', false, { routing: next, notice: 'later' }); release(); await within(pending, 'the earlier delivery confirmation'); gateway.appendProseDeliveryReceipt(agent, earlier); @@ -1115,12 +1566,12 @@ describe('explicit prose routing', () => { const receipts = context(framework).texts.filter((text) => text.startsWith('[delivered]')); assert.equal(receipts.length, 2, 'each supplied turn gets its own receipt'); const delivered = receipts.find((text) => text.includes('chan-alpha')); - const suppressed = receipts.find((text) => text.includes('suppressed')); + const held = receipts.find((text) => text.includes('held as draft')); assert.ok(delivered, "the earlier turn's delivery is acknowledged"); - assert.doesNotMatch(delivered, /suppressed/, "the earlier receipt excludes the newer suppression"); - assert.ok(suppressed, "the newer turn's suppression is acknowledged"); - assert.match(suppressed, /1 plain-speech segment.*suppressed/); - assert.doesNotMatch(suppressed, /chan-alpha/, "the newer receipt excludes the earlier delivery"); + assert.doesNotMatch(delivered, /held as draft/, "the earlier receipt excludes the newer turn's held draft"); + assert.ok(held, "the newer turn's held draft is acknowledged"); + assert.match(held, /1 plain-speech segment\(s\) held as draft/); + assert.doesNotMatch(held, /chan-alpha/, "the newer receipt excludes the earlier delivery"); }); }); @@ -1154,6 +1605,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: {} }, @@ -1164,7 +1623,10 @@ describe('explicit prose routing', () => { assert.equal(skip.calls, 1, 'the bounce wake ran the skip_reply round'); assert.equal(routed.length, 0); - assert.equal(clipboardOf(framework), undefined, 'the retained text is set aside, as >>skip_reply {{unsent}} says'); + 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(); }); @@ -1260,7 +1722,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); const gateway = framework as unknown as { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, routing: RoutingState): Promise; + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; turnRouting: Map; }; const agent = framework.getAgent('assistant')!; @@ -1273,9 +1735,9 @@ describe('explicit prose routing', () => { const queued: RoutingState = routingState(); const live: RoutingState = routingState(); gateway.turnRouting.set('assistant', live); - await gateway.deliverHybridProse(agent, text, 'chan-locus', true, queued); - assert.equal(queued.hybridHold, true, 'the supplied turn receives the hold'); - assert.equal(live.hybridHold, false, 'the live turn stays unheld'); + await gateway.deliverHybridProse(agent, text, 'chan-locus', true, { routing: queued, notice: 'later' }); + assert.equal(queued.hybridHold, 'failed', 'the supplied turn receives the hold'); + assert.equal(live.hybridHold, undefined, 'the live turn stays unheld'); const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); const streamed = [...router.feed(text), ...router.finish()]; assert.equal(routed.length, 0, `delivery bounces ${JSON.stringify(text)}`); @@ -1290,7 +1752,7 @@ describe('explicit prose routing', () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); const gateway = framework as unknown as { - deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, routing: RoutingState): Promise; + deliverHybridProse(agent: unknown, text: string, locus: string, allowLocus: boolean, round: { routing: RoutingState; notice: 'later' }): Promise; turnRouting: Map; }; const agent = framework.getAgent('assistant')!; @@ -1301,13 +1763,13 @@ describe('explicit prose routing', () => { const queued: RoutingState = routingState(); const live: RoutingState = routingState(); gateway.turnRouting.set('assistant', live); - await gateway.deliverHybridProse(agent, '>>>#alpha first', 'chan-locus', true, queued); - await gateway.deliverHybridProse(agent, prefix, 'chan-locus', true, queued); - await gateway.deliverHybridProse(agent, 'secret', 'chan-locus', true, queued); + await gateway.deliverHybridProse(agent, '>>>#alpha first', 'chan-locus', true, { routing: queued, notice: 'later' }); + await gateway.deliverHybridProse(agent, prefix, 'chan-locus', true, { routing: queued, notice: 'later' }); + await gateway.deliverHybridProse(agent, 'secret', 'chan-locus', true, { routing: queued, notice: 'later' }); assert.deepEqual(queued.deliveries, ['chan-alpha'], 'the supplied turn records its delivery'); - assert.equal(queued.suppressed, 1, 'the supplied turn records its suppression'); + assert.ok(queued.drafts.some((d) => d.text === 'secret'), 'the supplied turn holds the held-back segment as its draft'); assert.deepEqual(live.deliveries, [], 'the live turn receives no delivery'); - assert.equal(live.suppressed, 0, 'the live turn receives no suppression'); + assert.deepEqual(live.drafts, [], 'the live turn holds no draft'); const router = new ProseStreamRouter({ mode: 'hybrid', initialTarget: 'chan-locus', resolve }); const streamed = [ ...router.feed('>>>#alpha first'), ...router.endSegment(), @@ -1323,6 +1785,138 @@ 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 = routingState(); + const live: RoutingState = routingState(); + 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.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.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 = routingState(); + 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.keptPrivate, 1, 'the unprefixed envelope after it is counted as kept private'); + assert.deepEqual(routing.drafts, [], 'and is never drafted'); + assert.deepEqual(draftsOf(framework).open('assistant'), [], 'the journal holds nothing either'); + } finally { + await framework.stop(); + } + }); + + 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' }], '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); + 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(); + } + }); + + 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}`, + })), 'explicit-send').held; + const gateway = framework as unknown as { appendProseDeliveryReceipt(agent: unknown, routing: RoutingState): void }; + const routing: RoutingState = routingState({ drafts: held }); + 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'); + 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; 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, 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' }], '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(); + } + }); + it('admission turns a resolver that throws into an envelope that bounces', async () => { const framework = await createFramework(); const routed = stubRegistry(framework); @@ -1543,7 +2137,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(); }); } @@ -1565,7 +2169,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]{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 }> } }) + .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(); }); @@ -1727,7 +2339,7 @@ describe('explicit prose routing', () => { } for (const [label, mode, left, expected] of [ - ['hybrid: the hold keeps later unprefixed prose private', 'hybrid', { hybridHold: true, locus: 'world:commons' }, []], + ['hybrid: the hold keeps later unprefixed prose private', 'hybrid', { hybridHold: 'private', locus: 'world:commons' }, []], ['hybrid: the sticky destination', 'hybrid', { pin: 'chan-alpha', locus: 'world:commons' }, [{ text: 'carrying on', locus: 'chan-alpha' }]], ['explicit: the sticky destination, and the stream starts at the turn\'s destination', 'explicit', { pin: 'chan-alpha', destination: 'chan-alpha' }, [{ text: 'carrying on', locus: 'chan-alpha' }]], ] as const) { @@ -1826,18 +2438,13 @@ describe('explicit prose routing', () => { const framework = await createFramework(mode); const routed = stubRegistry(framework); const { chunks } = captureStream(framework); - const internals = framework as unknown as { - turnRouting: Map; - channelRegistry: { routeSpeech: (agent: string, text: string, locus?: string | null) => Promise }; - }; + const internals = framework as unknown as { turnRouting: Map }; let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); - const route = internals.channelRegistry.routeSpeech; - internals.channelRegistry.routeSpeech = async (agent, text, locus) => { - const outcome = await route(agent, text, locus); + // A named target's post goes through deliverSpeech, so hold it on both paths. + aroundPosts(framework, async (text) => { if (text.trim() === 'zero') await held; - return outcome; - }; + }); let starts = 0; let retryChunks = 0; framework.onTrace((event) => { @@ -1883,7 +2490,7 @@ describe('explicit prose routing', () => { assert.equal(routing.pin, 'discord:g:cafe', "round two's delivery hasn't run yet"); assert.equal(routing.destination, 'chan-alpha', 'though round two set the destination as it ended'); } else { - assert.equal(routing.hybridHold, false, "round two's delivery hasn't run yet"); + assert.equal(routing.hybridHold, undefined, "round two's delivery hasn't run yet"); } release(); await within(running, 'the retried turn to settle', 10000); @@ -1902,7 +2509,7 @@ describe('explicit prose routing', () => { const receipt = context(framework).texts.filter((t) => t.startsWith('[delivered]')); assert.equal(receipt.length, 1, `one receipt for the logical turn: ${receipt.join(' | ')}`); assert.match(receipt[0]!, /discord:g:cafe/, "it reports the replaced stream's late post"); - assert.match(receipt[0]!, /1 plain-speech segment\(s\) suppressed/, "and the retry's held segment"); + assert.match(receipt[0]!, /1 plain-speech segment\(s\) kept private/, "and the retry's segment the hold kept private"); } assert.equal(context(framework).bounces, 0, 'nothing bounced'); } finally { diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs new file mode 100644 index 000000000..39d2c4e4d --- /dev/null +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -0,0 +1,232 @@ +// 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, 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(); + +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; + publishErrorData = c.detail; + 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', publishErrorData); + 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; + } + 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/inbound-source.test.ts b/test/inbound-source.test.ts new file mode 100644 index 000000000..f71b4943e --- /dev/null +++ b/test/inbound-source.test.ts @@ -0,0 +1,413 @@ +/** + * 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 { + // 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); + const metadata = (event as { metadata?: Record }).metadata; + return { addMessages: [{ participant: 'Operator', content: [{ type: 'text', text }], ...(metadata ? { metadata } : {}) }] }; + } +} + +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.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'); + 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","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'); + 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)'); + }); + + /** + * 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); }); + (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', () => { + 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', () => { + 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: {} }, + { ...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 }, + [channel], + ]) { + assert.equal(read(bad), undefined, JSON.stringify(bad)); + } + }); + + it('keys conversations by server, channel and thread', () => { + 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 })); + // 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'); + }); +}); + +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.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'); + }); + + 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.lane, 'channels/incoming'); + assert.equal(stored.coalesced, true); + assert.equal(stored.messageId, 'm'); + assert.equal(stored.eventId, 'e1'); + 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'); + }); + + 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/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts new file mode 100644 index 000000000..e75cabc75 --- /dev/null +++ b/test/mcpl-request-outcome.test.ts @@ -0,0 +1,135 @@ +/** + * 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 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.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('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', { 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'); + 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(); + 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'); +}); + +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; emit(event: string, line: string): boolean }; + pendingRequests: Map; + }; + const write = state.transport.writeLine.bind(state.transport); + t.mock.timers.enable({ apis: ['setTimeout'] }); + 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/); + 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/mcpl-request-timeout.test.ts b/test/mcpl-request-timeout.test.ts index 1491ff093..c04d8e147 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'; // What the stub's tools/list answer says when it wrote a held reply ahead of it. @@ -128,6 +128,24 @@ test('requestTimeoutMs: 0 disables the per-request watchdog', async (t) => { 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/present-while-acting.test.ts b/test/present-while-acting.test.ts index df37b7484..7e5d7c8ad 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1091,7 +1091,7 @@ describe('present while acting', () => { // fallback's re-inference. Messages arrive before that starts. const open: Routing = { locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(['discord:guild:portables']), - destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [], + destination: null, continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.turnRouting.set('assistant', open); internals.openLogical.set('assistant', open); @@ -1120,7 +1120,7 @@ describe('present while acting', () => { }; const open: Routing = { locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), - destination: null, hybridHold: false, continuation: false, deliveries: [], suppressed: 0, arrivals: [], + destination: null, continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.turnRouting.set('assistant', open); internals.openLogical.set('assistant', open); @@ -1271,8 +1271,8 @@ describe('present while acting', () => { recordTurnCheckpoint(agentName: string): void; }; const open: Routing = { - locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, arrivals: [], + locus: 'chan-turn', engaged: new Set(), destination: null, + continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.openLogical.set('assistant', open); internals.recordTurnCheckpoint = () => { throw new Error('checkpoint failed'); }; @@ -1300,8 +1300,8 @@ describe('present while acting', () => { // live, and stop() ended every open turn. Its budget restart now runs, // and its stream fails and is retried. const turn: Routing = { - locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, arrivals: [], + locus: 'chan-turn', triggerChannel: 'chan-turn', engaged: new Set(), destination: null, + continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.turnRouting.set('assistant', turn); internals.pendingRequests.push({ @@ -1347,8 +1347,8 @@ describe('present while acting', () => { // Built by hand, as #312's silent-start test does: production runs no // silent turn while a prose turn is open. const open: Routing = { - locus: 'chan-turn', engaged: new Set(), destination: null, hybridHold: false, - continuation: false, deliveries: [], suppressed: 0, arrivals: [], + locus: 'chan-turn', engaged: new Set(), destination: null, + continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], }; internals.openLogical.set('assistant', open); internals.pendingRequests.push({ @@ -1711,8 +1711,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(); @@ -1752,8 +1757,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..210ced87f --- /dev/null +++ b/test/prose-drafts.test.ts @@ -0,0 +1,2241 @@ +/** + * 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 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' }, { text: 'two ' }], 'explicit-send'); + assert.equal(held.length, 2); + 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'); + 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("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'); + 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() }); + 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' }, { 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); + 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' }], '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', (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' }, { 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. + 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' }], 'explicit-send').held.length, 1); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + 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' }, { 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'); + 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' }], '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" }], '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' }, { 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); + 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 { + 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(); }; + (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], 'm-1'); + assert.deepEqual(calls, ['sync', `append:${PROSE_DRAFT_RECORD_TYPE}`, 'sync']); + assert.deepEqual(drafts.get('a', held[0]!.id)!.noticedIn, ['m-1']); + } 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', 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 { + 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'); + 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; + /** 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'); + 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 }, + ...(opts.requestTimeoutMs !== undefined ? { requestTimeoutMs: opts.requestTimeoutMs } : {}), + }], + 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' | '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'); + 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 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'); + 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 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(); + } + }); + + for (const xml of [false, true]) { + it(`${xml ? 'XML tool mode' : 'native tool mode'}: a refused wake keeps only its own round private, so a later send's prose is held as drafts`, async () => { + // With no EventGate, skip_reply's wake_in_seconds is refused and the + // turn goes on: the silence before the refusal comes back at the next + // round, with why it silences, so the send's round is an explicit send's. + const h = await harness({ xml }); + try { + const wake = call('w1', 'skip_reply', { reason: 'later', wake_in_seconds: 60 }); + const done = (id: string) => ({ type: 'tool_result', toolUseId: id, content: 'done', isError: false }) as ContentBlock; + await h.turn([ + createMockResponse([text('Private, beside the refused wake.'), wake], 'tool_use'), + createMockResponse([text('Beside the send.'), explicitSend('s1')], 'tool_use'), + // XML mode completes with the turn's whole content, as membrane + // parses it: each round's calls and then their results. + xml + ? { ...createMockResponse([text('Private, beside the refused wake.'), wake, done('w1'), text('Beside the send.'), explicitSend('s1'), done('s1'), text('After the send.')]), toolCalls: [] } + : createMockResponse([text('After the send.')]), + ]); + assert.match(h.toolResults()[0]!, /no wake was set/, '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().reverse(); + assert.deepEqual(held.map((d) => d.text), ['Beside the send.', 'After the send.'], + 'the refused round stays private; the send\'s rounds are held, not kept private'); + assert.ok(held.every((d) => d.reason === 'explicit-send')); + assert.ok(h.texts().some((t) => t.startsWith('[delivered] nothing — 2 plain-speech segment(s) held as drafts '))); + } finally { + await h.close(); + } + }); + } + + 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 { + 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. + 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(); + } + }); + + 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 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' }); + 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: .*, 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/); + 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('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, 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'), + 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, 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 }] }); + 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 }] }); + 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' }], '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 { + 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("a silent turn's prose stays private beside a send: never held, named or receipted", 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'); + assert.ok(!h.texts().some((t) => t.startsWith('[delivered]')), 'a silent turn receives no plain-speech receipt'); + } 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 { + 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\. 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; + 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 { + 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('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; + // the second also died mid-send, its attempt never resolved. + 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]'))!; + 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]{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}`); + await h.turn([createMockResponse([])]); + assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'named once'); + } finally { + await h.close(); + } + }); + + 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}` }], '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: /); + // 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]{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}`); + 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('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. + 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(`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'); + } finally { + await h.close(); + } + }); + + /** 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 { + 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('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')])]); + 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: 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, '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.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}`) && + 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'); + } finally { + await h.close(); + } + }); + + 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 { + 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 \(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(); + } + }); + + 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 .* 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'); + + // 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 { + 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' }), + drafts('f', { action: 'read', draftIds: [''] }), + ], 'tool_use'), + createMockResponse([]), + ]); + 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(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Concurrent resends through the actual drafts handler, with publish +// 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; + routingOf(name: string): { drafts: unknown[] }; + settleTurnDrafts(agent: unknown, logical: unknown): void; + }; + const agent = framework.getAgent('scout')!; + 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); + const logical = internals.routingOf('scout'); + logical.drafts.push(named, lost); + internals.settleTurnDrafts(agent, logical); + assert.deepEqual(logical.drafts, [], "the turn's draft tally is spent"); + 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 }); + } + }); + + 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): { 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('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 }); } }; + }; + + 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' }, { 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); + 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 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' }, { 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); + 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(); + } + }); + + // 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, 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, 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); + 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(); + } + }); + + 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' }, { text: 'lost' }], '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 { + 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'); + 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', () => { + 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; + routingOf(name: string): { drafts: unknown[] }; + settleTurnDrafts(agent: unknown, logical: unknown): void; + bounceProse(agent: unknown, text: string, reason: string, candidates: undefined, prefix: '>>', round: { routing: unknown; notice: 'later' }): 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' }], '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); + const logical = t.internals.routingOf('scout'); + logical.drafts.push(mine); + t.internals.settleTurnDrafts(t.framework.getAgent('scout')!, logical); + assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.noticedIn, 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' }], '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); + 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', undefined, '>>', round); + 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-')); + 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 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 }) => 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, { + 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 }>; + // 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!; + /** 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' }, { 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 }); }, + }; + }; + + 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.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. + 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 { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + 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.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); + 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.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' }); + 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(); + } + }); + + 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.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\\.`)); + 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("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 { + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + 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`)); + 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; + 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 \\(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' }], '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' }], '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' }], '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.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); + 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, 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' }); + 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' }], '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.deliverProse(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + 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' }); + 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(); + } + }); + + /** 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 === op) 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 = failSyncAfter(t.framework, 'dismissed'); + 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('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', () => {}); + 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, '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): ` + + '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(">>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', { + 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 { + 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')!; + 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 ?? ''); + 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' }], 'bounced').held; + const agent = t.framework.getAgent('scout')!; + 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] })); + 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', () => { + /** 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++]!; + }; + /** 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(); + 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 { 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 or a private think\)$/); + + // A live `>>>skip_reply` in an earlier round binds a later silenced one. + const before = h.drafts().length; + 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). + 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(); + } + }); + + 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(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(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)`); + + // 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(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(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'], + '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', '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'), + 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 confirmed — draft ${ids[3]} is unconfirmed: ${ids[3]}'s attempt to #room \\(Guild One\\) \\(${ROOM}\\) at .+ ` + + '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. + 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(); + } + }); + + 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' }], '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.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 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(); + } + }); + + 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 { + 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 24b31af42..f8ff8da4f 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 { splitProseSegments, withoutRoundsProse } from '../src/prose-segments.js'; +import { splitProseRuns, withoutRoundsProse } 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 => @@ -61,6 +65,27 @@ 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.']); +}); + test('withoutRoundsProse drops a round\'s text by its place: the text before its calls, after the previous round\'s tool blocks', () => { const content: ContentBlock[] = [ textBlock('SAME'), diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts new file mode 100644 index 000000000..6f322f8ef --- /dev/null +++ b/test/publish-outcome.test.ts @@ -0,0 +1,438 @@ +/** + * 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 Promise }>, + failures: unknown[] = [], + traces: Array<{ type: string; [key: string]: 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, { + id, + 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); + }, + sendChannelsOpen: async (params: { channelId: string }) => { + opened.push({ serverId: id, channelId: params.channelId }); + return s.open ? s.open() : {}; + }, + }); + } + const registry = new ChannelRegistry( + { 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 { + channels: Map; + }).channels; + 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' }); + +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('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('prints speech routing\'s open line only for speech routing\'s publishes', async (t) => { + const lines: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }); + const { registry, seed, opened } = registryWith({ discord: { publish: ok } }); + seed('discord', 'c', '#c', false); + seed('discord', 'd', '#d', false); + // A draft resend isn't speech routing's: the channel opens, and no + // [routeSpeech] line is printed for it. + assert.equal((await registry.publish('agent', 'x', { serverId: 'discord', channelId: 'c' })).status, 'delivered'); + assert.deepEqual(lines.filter((l) => l.startsWith('[routeSpeech]')), []); + await registry.publish('agent', 'x', { channelId: 'd', routed: true }); + assert.deepEqual(opened, [{ serverId: 'discord', channelId: 'c' }, { serverId: 'discord', channelId: 'd' }]); + 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 () => { + // 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'); + 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'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + 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'); + 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', { 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'); + }); + + 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', 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('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', 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'); } }, + }); + 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']); + }); +}); + +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/); + // 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)); + } + }); +}); + +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 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}`); + 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/); + 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'); + 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 }); + } +}); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index d9f5aa55b..87d2a181e 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -118,6 +118,23 @@ function toolResults(framework: AgentFramework) { .flatMap((message) => message.content.filter((block) => block.type === 'tool_result')); } +/** + * A registry stub's routeSpeech, also offered as the outcome-returning + * deliverSpeech that explicit and hybrid delivery post through. + */ +function withDeliverSpeech>(stub: T): T { + const route = stub.routeSpeech as (agent: string, text: string, channelId: string) => Promise<{ delivered?: boolean; channelId?: string } | undefined>; + return Object.assign(stub, { + deliverSpeech: async (agent: string, text: string, channelId: string) => { + const result = await route(agent, text, channelId); + return result?.delivered + ? { status: 'delivered', destination: { serverId: 'stub', channelId: result.channelId ?? channelId }, at: Date.now() } + : { status: 'failed', destination: { serverId: 'stub', channelId }, reason: 'the stub reported delivered: false', at: Date.now() }; + }, + resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), + }); +} + test('default off retains existing refusal behavior and original tool output', async () => { const h = await harness([[calls('one'), refused()]]); try { @@ -330,7 +347,7 @@ test('native Membrane observes the first refusal even when guard is enabled by a }); const routed: string[] = []; const outgoing: string[] = []; - (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, speech: string) => { routed.push(speech); return { delivered: true, channelId: 'world:test' }; @@ -338,7 +355,7 @@ test('native Membrane observes the first refusal even when guard is enabled by a sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const tokenTraces: string[] = []; framework.onTrace((event) => { if (event.type === 'inference:tokens') tokenTraces.push(String((event as { content?: unknown }).content)); @@ -371,7 +388,7 @@ test('explicit mode: a guard retry streams on to the sticky destination delivery ], { toolResultGuard: true, proseRouting: 'explicit', refusalHandling: { retries: 3 } }); const routed: Array<{ text: string; channelId: string }> = []; const streamed: Array<{ channelId: string; delta: string }> = []; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, speech: string, channelId: string) => { routed.push({ text: speech, channelId }); return { delivered: true, channelId }; @@ -381,7 +398,7 @@ test('explicit mode: a guard retry streams on to the sticky destination delivery }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); try { await h.run(); assert.equal(h.membrane.requests.length, 2, 'the guard retried once'); @@ -446,7 +463,7 @@ test('explicit mode: a budget restart streams on to the destination the turn las h.framework.onTrace((event) => { if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); }); - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, text: string, channelId: string) => { @@ -460,7 +477,7 @@ test('explicit mode: a budget restart streams on to the destination the turn las }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); try { await h.run(); assert.equal(h.membrane.requests.length, 2); @@ -506,7 +523,7 @@ test("explicit mode: a private round's prefix doesn't move where a budget restar h.framework.onTrace((event) => { if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); }); - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, text: string, channelId: string) => { @@ -519,7 +536,7 @@ test("explicit mode: a private round's prefix doesn't move where a budget restar handleChannelToolCall: async () => ({ success: true, data: { noted: true } }), getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); try { await h.run(); assert.equal(h.membrane.requests.length, 2); @@ -584,7 +601,7 @@ async function heldRestart(mode: 'explicit' | 'locus', texts: { first: string; s } return stream; }; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : spec === '#cafe' ? { channelId: directory.cafe } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, text: string, channelId: string | null) => { @@ -597,7 +614,7 @@ async function heldRestart(mode: 'explicit' | 'locus', texts: { first: string; s }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const restarts: string[] = []; h.framework.onTrace((event) => { if (event.type === 'inference:stream_restarted') restarts.push(String((event as { reason?: string }).reason)); @@ -657,7 +674,7 @@ test("locus mode: a resumed stream's deliveries queue behind the turn's earlier function explicitRegistry(h: Awaited>) { const routed: Array<{ text: string; channelId: string }> = []; const streamed: Array<{ channelId: string; delta: string }> = []; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : spec === '#cafe' ? { channelId: 'chan-cafe' } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, text: string, channelId: string) => { @@ -670,7 +687,7 @@ function explicitRegistry(h: Awaited>) { handleChannelToolCall: async () => ({ success: true, data: { noted: true } }), getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [{ name: 'think', description: 'Reason privately', inputSchema: { type: 'object', properties: { content: { type: 'string' } } } }], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); return { routed, streamed }; } @@ -847,12 +864,12 @@ test("a fresh turn and its restart wait behind the earlier turn's speech chain", if (text.trim() === 'earlier') await held; return { delivered: true, channelId }; }; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: 'unknown channel' }, routeSpeech, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const prior = routeSpeech('assistant', 'earlier', 'chan-alpha').then(() => {}); (h.framework as unknown as { proseSpeechChains: Map> }) .proseSpeechChains.set('assistant', prior); @@ -902,7 +919,7 @@ test("explicit: a restarted turn whose last response is empty checks for its ' ! let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); const routed: string[] = []; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, resolveLocus: () => 'chan-locus', routeSpeech: async (_agent: string, text: string, channelId: string) => { @@ -912,7 +929,7 @@ test("explicit: a restarted turn whose last response is empty checks for its ' ! }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const internals = h.framework as unknown as { requestProseContinuation(agent: unknown, routing: { continuation: boolean }): void; }; @@ -958,7 +975,7 @@ test("hybrid: a restarted turn whose last response is empty writes its receipt a let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); const routed: string[] = []; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : { error: `no channel ${spec}` }, resolveLocus: () => 'chan-locus', routeSpeech: async (_agent: string, text: string, channelId: string) => { @@ -968,7 +985,7 @@ test("hybrid: a restarted turn whose last response is empty writes its receipt a }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const receipts = () => h.framework.getAgent('assistant')!.getContextManager().getAllMessages() .flatMap((message) => message.content) .filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text' && (block as { text: string }).text.startsWith('[delivered]')) @@ -996,12 +1013,12 @@ test("hybrid: a restarted turn whose last response is empty writes its receipt a test("a fresh turn starts new routing state in every mode, and leaves the earlier turn's as it was", async () => { for (const mode of ['explicit', 'hybrid', 'locus'] as const) { const h = await harness([[answer()]], { proseRouting: mode }); - type Routing = { pin?: string; destination: string | null; hybridHold: boolean; - continuation: boolean; deliveries: string[]; suppressed: number; + type Routing = { pin?: string; destination: string | null; hybridHold?: 'private' | 'failed'; + continuation: boolean; deliveries: string[]; suppressed: number; drafts: unknown[]; keptPrivate: number; triggerChannel?: string; locus?: string; engaged: Set; arrivals: Array<{ messageId: string; channelId: string }> }; const internals = h.framework as unknown as { turnRouting: Map }; - const earlierFields = (): Routing => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: true, - continuation: true, deliveries: ['chan-earlier'], suppressed: 2, + const earlierFields = (): Routing => ({ pin: 'chan-earlier', destination: 'chan-earlier', hybridHold: 'failed', + continuation: true, deliveries: ['chan-earlier'], suppressed: 2, drafts: [{ id: 'd-earlier' }], keptPrivate: 1, triggerChannel: 'chan-earlier', locus: 'chan-earlier', engaged: new Set(['chan-earlier']), arrivals: [{ messageId: 'earlier-message', channelId: 'chan-earlier' }] }); const earlier = earlierFields(); internals.turnRouting.set('assistant', earlier); @@ -1011,10 +1028,12 @@ test("a fresh turn starts new routing state in every mode, and leaves the earlie assert.notEqual(now, earlier, `${mode}: the new turn has its own routing state`); assert.equal(now?.pin, undefined, `${mode}: no sticky destination`); assert.equal(now?.destination, null, `${mode}: no turn destination`); - assert.equal(now?.hybridHold, false, `${mode}: no hybrid hold`); + assert.equal(now?.hybridHold, undefined, `${mode}: no hybrid hold`); assert.equal(now?.continuation, false, `${mode}: no continuation`); assert.deepEqual(now?.deliveries, [], `${mode}: no deliveries carried into the new turn`); assert.equal(now?.suppressed, 0, `${mode}: no suppression carried into the new turn`); + assert.deepEqual(now?.drafts, [], `${mode}: no drafts carried into the new turn`); + assert.equal(now?.keptPrivate, 0, `${mode}: no private count carried into the new turn`); assert.equal(now?.triggerChannel, undefined, `${mode}: no trigger channel carried into the new turn`); assert.notEqual(now?.locus, 'chan-earlier', `${mode}: no locus carried into the new turn`); assert.deepEqual([...(now?.engaged ?? [])], [], `${mode}: no engaged channels carried into the new turn`); @@ -1044,7 +1063,7 @@ for (const shape of ['text-only', 'trailing'] as const) { let cafe = 'chan-cafe'; const routed: Array<{ text: string; channelId: string }> = []; const streamed: Array<{ channelId: string; delta: string }> = []; - (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (h.framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveProseTarget: (spec: string) => spec === '#alpha' ? { channelId: 'chan-alpha' } : spec === '#cafe' ? { channelId: cafe } : { error: `no channel ${spec}` }, routeSpeech: async (_agent: string, text: string, channelId: string) => { @@ -1056,7 +1075,7 @@ for (const shape of ['text-only', 'trailing'] as const) { }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const modules = (h.framework as unknown as { moduleRegistry: { dispatchSpeech: (...args: unknown[]) => Promise } }).moduleRegistry; const dispatch = modules.dispatchSpeech.bind(modules); modules.dispatchSpeech = async (...args: unknown[]) => { @@ -1305,7 +1324,7 @@ test("an abandoned guarded round with no usage sample counts and notes its respo function fakeRegistry(framework: AgentFramework) { const routed: string[] = []; - (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(withDeliverSpeech({ resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, speech: string) => { routed.push(speech); return { delivered: true, channelId: 'world:test' }; @@ -1313,7 +1332,7 @@ function fakeRegistry(framework: AgentFramework) { sendOutgoingChunk: () => {}, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], - }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); + }), { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); return routed; } diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index f1097a671..0852c2ca5 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -160,6 +160,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -217,6 +218,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -282,6 +284,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: () => {}, @@ -300,6 +303,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: () => {}, @@ -317,6 +321,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i3.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => undefined, stopAll: () => {}, }; @@ -340,6 +345,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, getDescriptor: (id: string) => ({ label: labels[id] }), + getChannelLabel: (_serverId: string, id: string) => labels[id], stopAll: () => {}, }; const textOf = () =>