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/ingestion-body-digests.added.md b/changelog.d/ingestion-body-digests.added.md new file mode 100644 index 000000000..0242f0bf8 --- /dev/null +++ b/changelog.d/ingestion-body-digests.added.md @@ -0,0 +1,32 @@ +- **Delivered-body digests on every stored MCPL inbound item.** Both MCPL + lanes stamp two fields, outside the frozen source envelope (console and API + input, and the host's own notices, carry neither): + - `metadata.sourceBodyDigest` is the version identity of the body as + delivered, taken at ingestion, after MCPL conversion and before any host + decoration or storage sharding. It is the SHA-256 (hex) of `[blocks]` + as canonical JSON (keys sorted at every level, `undefined` object entries + dropped, and an `undefined` array element written as `null` as JSON + writes it, though the store refuses a body holding one), with the blocks + as the store keeps them: inline media as + the store hands it back (base64 re-encoded from its bytes, an image's + media type taken from its bytes' signature, only `type` and `source` + kept), strings well-formed (a lone surrogate as U+FFFD) and an entry + whose key isn't well-formed dropped, as the store's append drops it. So + the body hashes alike as delivered, as handed to storage and as read + back, and an undecorated, unsharded stored copy hashes to it. + - `metadata.storedBodyDigest` uses the same function over exactly the + blocks handed to storage, decorations included, taken where each path + stores. + + Both are always the host's own: values an adapter supplies are replaced. + A stored copy, read back with its blobs resolved, that no longer hashes to + its `storedBodyDigest` was changed after it arrived (`editMessage` keeps + metadata). That check is of an unsharded copy: a body the store shards + (context-manager's adaptive ingress chunking) carries the whole body's two + digests on every shard (`bodyGroupId` and `shardIndex` mark them), and no + shard hashes to them. A `blob_ref` block is digested as given, while the + store resolves it on read, so a copy holding one doesn't match; the MCPL + lanes never store one. `sourceBodyDigest(blocks)` is exported from the + package root as the one implementation consumers share; it carries the + framing and the store's normalization, so check a copy with it rather than + hashing your own serialization. diff --git a/changelog.d/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md new file mode 100644 index 000000000..241ec226e --- /dev/null +++ b/changelog.d/mcpl-request-outcome.added.md @@ -0,0 +1,10 @@ +- **`McplRequestError`: why an MCPL request produced no result.** A request + to an MCPL server that fails now rejects with `McplRequestError` (exported), + whose `outcome` says which of three things happened: `not-sent` (refused + before anything was written: the connection was already closed), + `error-response` (the server answered with a JSON-RPC error; `code` and the + error's `data` are kept), or `no-response` (the request was handed to the + transport and no answer came back: a timeout, or the connection closed + while awaiting; it may or may not have reached the server). Only + `not-sent` proves the server never saw the request. Messages are unchanged, + and it is still an `Error`, so existing handling keeps working. diff --git a/changelog.d/prose-drafts.added.md b/changelog.d/prose-drafts.added.md new file mode 100644 index 000000000..f55b6d5a2 --- /dev/null +++ b/changelog.d/prose-drafts.added.md @@ -0,0 +1,78 @@ +- **Held prose drafts: suppressed speech is kept, not destroyed.** Plain + speech that is not sent is now held as a draft with its exact words, + instead of surviving only as a count: speech suppressed by an + explicit send in the same round (locus and hybrid modes, mid-turn, trailing + and text-only, native and XML tool modes), hybrid prose left without a + destination after a failed envelope, and explicit/hybrid bounces (which + replace the in-memory latest-wins clipboard; `{{unsent}}` keeps meaning the + latest bounce). A draft held beside a send keeps its run exactly as + written, indentation and surrounding newlines included; routing still + publishes trimmed segments. Deliberate privacy is never drafted: + `skip_reply` (the tool, or a hybrid `>>>skip_reply` envelope, which keeps + the rest private until a destination is named), a same-round private + `think` in a native round (where that policy applies: XML tool mode routes + such prose as ordinary speech, so a send holds it as a draft), silent turns + and `proseRouting: disabled` narration stay private and are counted in the + receipt. +- In hybrid mode a send withholds publication, not what an envelope does: a + held `>>>` envelope moves the routing state as live routing would (a + destination it names becomes the sticky target), `>>>skip_reply {{unsent}}` + still sets the latest bounce aside, and the draft holds the message the + envelope would have published, with `{{unsent}}` expanded under the + re-bounce rule (a bare `{{unsent}}` leaves the bounce as the one draft). +- Drafts live in typed Chronicle records (`RecordJournal`) that do not follow + branch switches: undo, rollback and checkout never change them, and they + survive restarts. Nothing is ever sent from them on its own. Each + resident's 100 newest drafts are kept, and an older one for 7 days after + anything last happened to it (held, named, attempted, answered, dismissed); + the journal checkpoints what it keeps every 500 entries, so opening it + replays only what follows. A request naming a draft that was let go is + refused, and nothing is sent. +- A private notice names held drafts at the next tool boundary when the live + stream presents mid-turn messages (a draft holding words copied from one + that may already have been posted is named with that risk and the + `confirmDuplicate` its resend needs), and the turn-end `[delivered]` receipt + names every draft held in the turn by its state at turn end: still held + ("held as drafts d-… (not sent …)"), delivered by the resident's resend + (listed where it landed), unconfirmed, or dismissed. A dismissed draft + whose own attempt may already have been posted is named with that risk, + and counts like an unconfirmed one: a receipt that names one, with nothing + confirmed delivered, reads "nothing confirmed", not "nothing". A draft + counts as named while a message naming it is on the current branch, so one + no notice reached (a crash, an aborted turn), or whose notices a rollback + or branch move took away, is named at the next turn's start: every such + draft by id, the newest eight with a preview. +- A draft is never published except by its resident's explicit resend (or + `{{unsent}}`), and only that resident can list or act on it. Its notices go + into the resident's own history, which residents sharing one message slot + already share, as they share the assistant turn the words came from. +- New `drafts` tool (whenever MCPL channels are configured): `list`, `read`, + `resend` and `dismiss`, scoped to the calling resident. 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. +- 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..6dd88191e --- /dev/null +++ b/changelog.d/publish-outcomes.fixed.md @@ -0,0 +1,23 @@ +- **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; check the channel before sending it again" + for an uncertain outcome. The marker's metadata and the + `mcpl:speech-route-failed` trace carry `outcome`. A channel id registered + by more than one MCPL server is refused rather than routed through + whichever server registered it first, 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. diff --git a/changelog.d/speech-routes.breaking.md b/changelog.d/speech-routes.breaking.md new file mode 100644 index 000000000..adc6eb1d3 --- /dev/null +++ b/changelog.d/speech-routes.breaking.md @@ -0,0 +1,30 @@ +- **Hosts and module authors using `ChannelRegistry` directly:** the registry + no longer decides where speech goes. It still remembers where the last + message a channel delivered came in, which the framework uses only for a + turn whose wake names no conversation. `getDefaultPublishChannel()` + returns its channel id, and `getLastInbound()` its server, channel and + thread. A reaction or a withdrawal doesn't count as a message. + - **Removed:** the `activeChannelResolver` option. The new + `speechRouteResolver(agentName)` option replaces it, and the framework + supplies it. + - **Changed:** `resolveLocus(conversationId)`, which the framework calls + with an agent's name, returns only a conversation fork's home channel, + or null. `getChannelServerId(id)` names a server only when + exactly one registered the id, and returns null for an id several + servers share, where it used to return the first registrant. `buildChannelContext(agent)` advertises the + agent's own route and reply edge, and nothing for a held, surface-only or + absent route. + - **Migration:** read a route from the framework's turn rather than the + registry, and pass `channelId` explicitly when publishing outside a turn. + `routeSpeech`/`deliverSpeech` also accept `{ serverId, channelId }` to + publish to an exact server. + - **Also changed:** `resolveProseTarget` returns the `serverId` its + target resolved to beside the channel id. `startTyping` and + `stopTyping` take `{ serverId, channelId }`. A bare id still works: + `startTyping` resolves it only when one server registers it, and + `stopTyping` stops that id's typing on every server that has it. The registry no longer + decides a coalesced message's trigger before `handleCoalescedIncoming`: + the event carries the filter's inputs as `triggerInputs`, and a host's + own handler decides from them once it admits the occurrence. + - **Unchanged:** fork homes, explicit sends naming a channel, and + `resolveDestination`. No sibling package version changes. diff --git a/changelog.d/speech-routes.changed.md b/changelog.d/speech-routes.changed.md new file mode 100644 index 000000000..2b5c5c040 --- /dev/null +++ b/changelog.d/speech-routes.changed.md @@ -0,0 +1,229 @@ +- **Speech routes: unaddressed plain speech goes where the turn was + deliberately or actually addressed, and to the last channel that saw + traffic only when nothing in its wake named a conversation.** A turn's + route is decided once at a true new turn: + - a conversation fork's home channel always wins; + - otherwise it is inferred from the whole wake. The addressed messages count + if there are any, else all of the conversational ones. A local surface + such as the console, the TUI or the API counts as addressed. The route is + inferred only when they name one conversation (server, channel and + thread), and its newest triggering message is the reply edge. + + Several competing conversations start the turn **held**: unaddressed speech + is kept as `ambiguous` drafts, and a `[routing]` notice names each + conversation's address. + + **A resident's wake that names no conversation at all keeps the + fallback,** as before: a heartbeat, a timer, a self-wake, a background + script's wake, an admin nudge, or an `api:inference-request` nudge. + - **Where it goes:** where the last message any channel delivered came in, + now with its server and thread named. An ordinary message counts once + it's queued, and a coalesced one once its coalescer admits it afresh, + never for a producer's retry the coalescer acknowledges again. A + reaction, a deletion or a withdrawal is no message, so it never becomes + that place. + - **What it answers:** no message. It has no reply edge, so the + `beforeInference` context names where the speech lands + (`defaultOutgoing`) but no `incoming` message. + - **How it's checked:** like every route. A channel that declares no + publish target, a thread on a `root` channel, or a channel a + conversation fork owns gives no route, and the speech is held as + `no-destination` drafts. + - **What the resident is told:** a `[routing]` notice says the speech goes + where the last message came in because nothing in the wake named a + conversation. A mid-turn arrival that addresses the resident from + elsewhere holds a fallback route as it holds an inferred one, and the + notice says what the route was. + - **Which wakes name a conversation:** any event in the wake that names a + channel, a thread, an author or a local surface does, whether or not + it's a route candidate, on every lane. So does a conversation's closure + and a tune-out wake about a channel. A reaction, a system marker or a + channel another agent's fork owns names one but answers nothing, so such + a wake gets no route, never the fallback. An ambient-only batch names + its own conversations: it routes to one, or holds when it names several. + - **A framework wake that continues a turn names that turn's + conversations.** Such wakes are the retry after a rewind (for a refusal, + an unstick's forced rewind, or an inference failure), an unstick itself, + the results of a non-streaming tool round, an explicit-prose + continuation or bounce, and a provider-cooldown retry that holds no wake + of its own. The framework reads the turn's record when the wake's batch + starts, which is after the continued turn has ended, and gives the wake + that turn's conversations as addressed ones. So its words go where the + turn's had gone by its end, after a `channel_open` or a hold. A bounce + queues its wake at once; one that lands while a later turn is alive + wakes after that turn and names its conversations instead, where the + resident last spoke. Beside a heartbeat those conversations keep the + turn, beside an ambient arrival from elsewhere they win, and beside an + addressed one from elsewhere the turn is held. An addressed message + batched in the same conversation takes the reply edge if it is newer + than the one the turn answered, and an older one doesn't. A held turn's + continuation names the conversations its hold named. With inferred prose + routing, a continuation names a conversation exactly when the turn it + continues did, or when it carries that turn's conversations. So a turn + whose words had no place although its wake named a conversation (a + reaction, a channel a fork owns) gives its continuation no place either, + never the fallback, and a turn whose wake named none, and that found no + fallback place, gives a continuation that takes the fallback as it + stands then. A fallback turn's continuation keeps that turn's place as + its own fallback, so a message that comes in elsewhere without waking + the agent doesn't move the rest of the reply. A wake batched with that + continuation that names a conversation takes the turn, as any arrival + does. A kept place that the fork rule or its channel's publish target + now refuses gives no place, to the continuation and to those after it. A + continuation names an unroutable conversation too. One its channel's + publish target refused is checked again when the continuation starts, so + it takes a place once its channel can take one. One that came from a + candidate marked unroutable stays so: a channel with no server becomes a + route only once the framework finds its server, and a raw id named with + its server never does. A silent tick's own continuations, such as its + retries, are silent ticks too, with the tick's prompt and stamp, and a + restart or a guard retry keeps the tick's record. The exception is a + batch that also holds an ordinary wake: it runs as an ordinary turn, as + on main, and so do the continuations after it. A restart in such a batch + had no route to keep, so its route is decided from the batch's wakes, as + a new turn's is. An unstick and a bounce's resend are repairs rather + than part of the turn they follow, and are always ordinary turns, as on + main. The new turn is otherwise fresh, as any is: a rewind sheds the + turn whose choices those were. An unstick is refused while a turn is in + progress, including the moments the agent reads idle at a turn's start + and end. + - **An ephemeral subagent never takes the fallback.** Its prose isn't a + channel's, and before speech routes a subagent's prose between its tool + calls could reach the resident's live channel this way. + + A surface route shows speech on that surface and publishes it to no + channel, so a console turn's reply stays on the console. That's so + whether its module added the input as a message or only requested + inference. anima-research/agent-framework#190, by @rufalupa666-netizen + with the resident qa-engineer, found and diagnosed such a reply published + to the channel that last spoke. +- Context-budget restarts, guard retries and a stream's own retries (its + later attempts) keep the turn's route, and everything else the turn has + chosen or done: its explicit-send engagement, its `>>` or `>>>` choice, and + the deliveries and drafts its receipt reports. Only the next true new turn + decides again, and a queued wake that continues a turn starts one. +- **Speech goes where the route stood when its words were written.** A + `channel_open`, or a hold, that lands while earlier words still wait to be + published governs only words written after it; the same goes for a + `channel_open` superseding a hybrid `>>>` choice. A text-only or trailing + reply is published along its own turn's route, even once the agent is idle + and a next turn may have begun. +- **Mid-turn ambiguity hold replaces the addressed re-pin.** When a presented + mid-turn arrival from another conversation addresses the resident, or + continues a conversation the resident explicitly sent into this turn, an + inferred route is held for the rest of the turn instead of moving to the + newcomer. A notice rides the same injection batch. A send whose place the + host knows (a resend or `channel_publish`, delivered or unconfirmed) + engages exactly that conversation, thread included; a connector's own send + tool chooses its place itself, so it engages its whole channel, and the + notice says where in the channel isn't known. Ambient chatter in a + conversation the resident hasn't sent into this turn, reactions, and + arrivals in the same conversation change nothing, and the reply edge stays + on the message that woke the turn. Deliberate routes are + never held: a fork's home, a `channel_open`, and a hybrid `>>>` target. +- **`channel_open` sets the speech route** for the rest of the turn by + default, replacing an inferred route, a hold, a hybrid `>>>` target, or the + suppression a `>>>skip_reply` or a failed envelope left. The + new `setSpeechTarget: false` opens the channel for reading only, and the + result says where speech goes then. +- **`channel_publish` is an explicit send through the publish executor.** + Without `channelId` it goes to the caller's current route, the fallback + included. With no route (held, a local surface, or none) it is refused + with the reason, rather than going to the most recent inbound channel. A new optional `serverId` + disambiguates a channel id that several servers register; a conversation + fork may name a server only as its home's sole registrant, since the same + id on another server is another conversation. Its receipt + names `serverId`, `channelId` and `channelLabel`. A failed or unknown + outcome is an error that keeps its `status` and the attempted destination; + `unknown` warns that the message may already have been posted. +- The beforeInference channel context (`defaultOutgoing` and `incoming`), + refusal reactions, rewind notices and the failure log now use the agent's + own route and its reply edge, so what the agent is told matches where its + words go. A fork's reply edge is the wake's newest addressed message in + its home conversation, or else its newest message there. +- A batched gate wake reports every conversation in its batch to the + framework (`WakeProvenance.routeCandidates`). An addressed event whose + registered channel the host can't resolve still competes with the other + addressed conversations: beside one, the turn is held; alone, the turn + has no route, since it is never itself a route. A coalesced item is gated with the + host's source envelope too, the one its coalescer then freezes, and an + `inboundSource` key in a connector's origin or metadata is never read as + one. +- **A channel id no single server registers is never given a server by + guess.** A fork's home, or a wake candidate whose event named no server, + takes the server only from the id's sole registrant. On an id that several + servers share, the turn records the conversation as `unresolved`, and its + speech is held as drafts rather than published to whichever server + registered first. The server is looked for again when the turn starts, so + one that registered while the wake waited is found. `unresolved` also now + covers a route whose channel was withdrawn, which used to read as + undeclared (`untargetable`). +- **A fork's channel is its own on every wake path.** A push event, a + coalesced push and a `channels/incoming` broadcast now give no other agent + a speech route or a typing indicator in a channel a conversation fork owns, + and a fork takes neither in a channel other than its home. Batched gate + wakes already worked this way. A route is judged when the turn starts, as + ownership stands then, so a fork's claim, or its giving a channel up, + while a wake waits in the queue counts. A `channel_open` into a fork's + channel opens it but sets no route, and its result says why. An arrival + in a fork's channel never holds another agent's route mid-turn. +- The `[discord-send-failed]` marker for a reply on a thread route names the + thread with its channel, and an unconfirmed one says to check the thread. +- **Routing names each conversation with its server.** Routing notices, + holds, the route announcement, `channel_publish`'s refusals and failures, + the `[discord-send-failed]` marker, the `[channels] Now open` notice, the + gate's batched-wake lines and the `[delivered]` receipt all write a + conversation one way: its label, then `server / channel-id`, then its + thread. The label is that server's own for the channel, never another + server's for the same id, and a batched wake counts each server's channel + apart. Two servers' channels with the same id and label no longer read + alike. A refusal for a channel id that several servers register lists + those servers, so the sender has a `serverId` to give. +- **More of routing keeps the server and the thread.** A `>>` or `>>>` pin + keeps the server its target resolved to, so the same id registered by a + second server later changes nothing. Typing shows on the route's own + server's channel, and an id two servers share, named alone, shows no + indicator rather than one on a guessed server. `inference:started` and + the failure log name the route's server beside its channel. The gate's + batched-wake notice counts a thread apart from its channel's root, and + the beforeInference context's `defaultOutgoing` carries the route's place + as its publish does: the thread, or `null` for the root (MCPL RFC-011 + revision 5's amendment of §14.4). The `mcpl:speech-routed`, + `mcpl:speech-route-failed` and `prose:draft-resent` traces carry + `threadId` (null for the root). +- **A thread id is read one way** by `channel_publish`, `channel_open` and + `drafts` resend: surrounding whitespace is trimmed, and a blank or + non-string id is refused with nothing sent, opened or set. +- **Unaddressed speech that provably wasn't posted is kept.** A publish + that fails with nothing posted keeps its words as a bounce draft, on the + route, a sticky `>>>` pin or the sleep announcement, and the one + `[discord-send-failed]` notice names the draft and how to resend it. An + unconfirmed publish keeps none, so as not to invite a duplicate. +- **The sleep announcement is unaddressed speech.** It follows a hybrid + `>>>` pin, else the turn's route, after the words written before it. A + held or routeless turn keeps it as a draft, and a surface shows it. +- **A mid-turn hold names every arrival that suspends the route** in that + round, each with its own cause. A local surface's message reads as + addressing the resident, tagged or not. +- **In a silent wake, `channel_open` opens the channel and sets no route.** + Its result says the wake's plain speech stays private and that a send + tool posts there. +- **Only the host marks machinery.** `system: true` in a push's origin or + an incoming message's metadata is dropped at ingestion and never makes + the item non-conversational, so it is still a route candidate. +- **A route candidate isn't lost or invented on the way to a wake.** An + unroutable candidate in a fork's channel follows the fork rule, so it + holds no other agent's turn. A module event that names a channel is that + channel's conversation, unroutable, never a local surface's. An event the + gate's inference buffer evicts keeps its route candidate, so an + addressed conversation still competes. A coalesced item is gated (and so + becomes a candidate) only once its coalescer admits it for delivery, + never for a retry, a retraction or a refused occurrence. +- **An ordinary push or incoming message is accepted** (the acceptance + observer is told) only once its trigger filter returns, right before it + is queued; a filter that throws leaves nothing accepted. +- `publishForAgent` traces `mcpl:speech-routed` only for a confirmed + delivery, once the outcome is in, and a failed or unconfirmed one as + `mcpl:speech-route-failed`. `publish()` rechecks the server and its + `channels.publish` grant after opening a closed channel, as well as + before. A label shows as its connector gave it: no `#` is added. diff --git a/changelog.d/stop-with-work-in-flight.fixed.md b/changelog.d/stop-with-work-in-flight.fixed.md new file mode 100644 index 000000000..f67776883 --- /dev/null +++ b/changelog.d/stop-with-work-in-flight.fixed.md @@ -0,0 +1,25 @@ +- **Late tool results, and MCPL pushes or incoming messages, at `stop()` no + longer end in an unhandled rejection.** `stop()` closes the process queue + before it closes MCPL connections, and nothing caught the "Queue is + closed" that this meant for late arrivals. The race is older than the + speech-route work. + - A tool's result that arrives after `stop()` is logged and dropped. The + stream that asked for it is gone, and the tool has already run. Before, + its push threw, the throw was reported as the tool's own failure, and + that failure's push threw again, unhandled. tune-out's results now go + through the same `pushEvent` as every other tool's. + - A server's `push/event` or `channels/incoming` that arrives once the + host is stopping is refused before its handler runs, with a JSON-RPC + error (`-32603`, "the host is stopping"). It is never answered + "accepted". + - A handler that throws, because the host stopped while it ran or because + of a fault, is answered with an error too. Part of the request may + already have been admitted. The error is never thrown out of the + connection's listener. A notification is logged. + - Direct input to `pushEvent` after `stop()` still throws to its caller. +- **`stop()` lets speech already written publish.** It waits for every + agent's speech chain before the MCPL connections close, so words a turn + wrote still go out, and again before the store closes, so what became of + each is recorded (a publish that sent nothing keeps its words as a + draft). Each wait is bounded at 10 s, and a chain still busy then is + logged and left. diff --git a/changelog.d/targeted-publish.breaking.md b/changelog.d/targeted-publish.breaking.md new file mode 100644 index 000000000..b57c214f5 --- /dev/null +++ b/changelog.d/targeted-publish.breaking.md @@ -0,0 +1,30 @@ +- **The framework publishes only where a connector declares where its posts + land (MCPL RFC-011, anima-research/mcpl#17).** A channel is published to only + when its descriptor declares `capabilities.publish.target`: `exact` (posts + into a named thread or at the root) or `root` (a channel without threads). + On an undeclared channel, a connector might choose the place itself, for + example the thread of the newest incoming message (slack-mcpl#6). + - **Every publication names its place.** It carries `threadId`: the + route's thread, or `null` for the channel root. A delivery counts only + when the connector's echo names that place. A missing or different echo + is `unknown`. + - **Migration:** upgrade connectors before, or with, the framework. Until a + connector declares its channels: + - the framework's own publication there is unavailable. Plain speech is + held as drafts, with a notice naming the connector's own tools to + consult. + - `channel_publish`, draft resends and `>>` envelopes to such a channel + are refused, with nothing sent. + + The connector's own send tools are unaffected. The declaring changes are + open as discord-mcpl#67, slack-mcpl#20, telegram-mcpL#2, portal#46 and + eidoverse-worlds#223. anima-dnd's awaits its maintainers' choice of route + (anima-dnd#1). pocket-body stays undeclared by its owner's decision, so its + deliberate tools (`ring`, `say`, `listen`) remain the way to reach it. + - **Host-minted channels count as undeclared.** That covers a DM channel + the framework registers lazily from a push event, because the connector + never described it. Connectors that register their DM channels with a + declaration, as discord-mcpl does, are unaffected. + - **Hosts calling `ChannelRegistry.publish` directly:** a target may name + `threadId`. `PublishDestination` gains `threadId`, and an undeclared + channel is refused before anything is opened or sent. diff --git a/changelog.d/targeted-publish.changed.md b/changelog.d/targeted-publish.changed.md new file mode 100644 index 000000000..893dd4133 --- /dev/null +++ b/changelog.d/targeted-publish.changed.md @@ -0,0 +1,41 @@ +- **A thread can be a speech route, where its connector posts into named + threads.** A conversation in a thread is routed into that thread when its + channel declares `exact` (MCPL RFC-011), and the routing notice and the + `[delivered]` receipt name the thread. + - On a channel that declares `root`, a thread is a contradiction. The turn + records it as unroutable, and speech is held rather than posted to the + root. + - An undeclared channel is unroutable as `untargetable`. + - Gate-batched wakes now carry each event's thread, so a thread and its + channel's root are different conversations on every path. +- **Residents can choose a thread deliberately.** + - `channel_publish`, `drafts.resend` and `channel_open` (with + `setSpeechTarget`) take an optional `threadId`. + - A channel named without one means its root, never a thread borrowed from + an earlier route. + - A supplied selector is checked before any default applies: an empty or + non-string `threadId` or `serverId` on `channel_open`, or a non-boolean + `setSpeechTarget`, is refused with nothing opened, and a conversation + fork's empty `channelId` is refused rather than read as its home. + - `channel_open` on an undeclared channel still opens it, but sets no + speech target, and its result explains why. +- **Outgoing streams carry only published speech.** A channel's outgoing + stream (MCPL §14.3) now carries prose once a publish has confirmed it, at + the channel's root, on the server and channel that publish resolved; it no + longer streams words while they are generated. A later call in the same + round can still make them private (a same-round private `think`, a + `skip_reply`), held (an explicit send's silence) or unsent, so held, + private, failed and unconfirmed words never stream, and a voice surface + speaks only what was posted. A thread placement isn't streamed, so a + channel's stream names one place. Each publish is its own message, so + within one stream a later one on the same channel opens its delta with a + paragraph break (`\n\n`), and the stream's text keeps the messages apart. + Each physical stream completes, after its last speech, exactly what it + streamed. Voice therefore follows each published round rather than + overlapping generation; speech still lands between tool rounds. +- **Publishing rechecks after opening.** When a publish has to open a closed + channel first, it checks the destination again before sending: a channel + removed, or a declaration withdrawn, while the open was pending refuses the + send, rather than relying on the connector to. +- Refusal results (`delivered: false` with a `reason` and no message) keep + the connector's reason. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 3f78c6ff4..8096ac7fc 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -30,15 +30,40 @@ 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, and can resend the bounce with the `drafts` tool's `confirmDuplicate` + after checking the channel. The envelope's own words are held as a new + bounce draft, with `{{unsent}}` written out and the risk carried, so that + draft too can be resent only with `confirmDuplicate`. A bare `{{unsent}}` + holds nothing new: its words already are the bounce. +- **Hold** (an envelope that bounces, or one an explicit send held back): the + bounce's words are copied into the new draft with `{{unsent}}` written out, + and the draft carries the same risk (`inheritedRisk`), so it reads + unconfirmed until a confirmed delivery. ## What explicit mode retires (for that agent) diff --git a/docs/hybrid-prose-routing.md b/docs/hybrid-prose-routing.md index 0a1382089..726bdb2ad 100644 --- a/docs/hybrid-prose-routing.md +++ b/docs/hybrid-prose-routing.md @@ -1,10 +1,10 @@ # Hybrid prose routing (`>>>destination`) -Hybrid routing preserves ordinary frozen-locus speech while allowing a resident -to explicitly publish one or more prose envelopes elsewhere. +Hybrid routing preserves ordinary speech on the turn's speech route while +allowing a resident to explicitly publish one or more prose envelopes elsewhere. ```text -ordinary prose # current frozen locus +ordinary prose # the turn's speech route (held as a draft without one) >>>#cafe prose for the café # unique authorized channel by name >>> world:commons prose for the world # canonical channel id ``` @@ -23,7 +23,9 @@ Two views are intentionally preserved: At logical turn end, a `[delivered]` receipt names canonical destinations (or a bounce explains failure), so authored intent cannot impersonate delivery. Unprefixed prose after a successful envelope follows that explicit destination -for the remainder of the turn; a fresh turn begins at its ordinary locus. +for the remainder of the turn — a deliberate target, which a mid-turn ambiguity +hold never suspends, and which a later `channel_open` replaces; a fresh turn +begins at its own speech route. This is publication routing only. It creates no privacy meaning for `` or other prose tags, and grants no channel authority beyond the existing diff --git a/src/framework.ts b/src/framework.ts index 349bdd693..2de10a635 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -81,11 +81,27 @@ import { import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js'; import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './mcpl/visible-content.js'; import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; -import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; -import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js'; +import { ChannelRegistry, publishPlaceRefusal, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SpeechRouteView } from './mcpl/channel-registry.js'; +import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftSource, type DraftState, type InheritedRisk } from './prose-drafts.js'; +import { + continuationOf, + conversationKey, + describeConversation, + inferTurnRoute, + isConversational, + readThreadId, + routeConversation, + routeRefusal, + suspendsRoute, + type ConversationRef, + type RouteCandidate, + type SpeechRoute, + type TurnRoute, +} from './speech-routes.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; +import { INBOUND_SOURCE_KEY, readInboundSource, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -110,7 +126,7 @@ import { scriptTimeLimits, validateCodeExecutionConfig, } from './code-execution/tool-definition.js'; -import { splitProseSegments } from './prose-segments.js'; +import { splitProseRuns } from './prose-segments.js'; import { cumulativeDelta } from './usage-accounting.js'; import { stampThinkingTokenEstimates } from './thinking-token-stamp.js'; @@ -177,6 +193,38 @@ const isSilencingTool = (name: string): boolean => { return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare); }; +/** True when a model-issued call silences the round's auto-routed prose: a + * silencing tool by name, or the resident's `drafts` resend (an explicit + * send, though inspecting drafts is not). */ +const isSilencingCall = (call: { name: string; input?: unknown }): boolean => { + if (isSilencingTool(call.name)) return true; + return bareToolName(call.name) === 'drafts' + && (call.input as { action?: unknown } | null | undefined)?.action === 'resend'; +}; + +/** Hybrid prose envelopes: every line starting with `>>>` opens a new one. + * Leading whitespace before a `>>>` is dropped; blank envelopes are skipped. */ +const splitHybridEnvelopes = (rawText: string): string[] => { + const envelopes: string[] = []; + let current: string[] = []; + for (const line of rawText.split('\n')) { + if (line.trimStart().startsWith('>>>') && current.length > 0) { + envelopes.push(current.join('\n')); + current = []; + } + current.push(line); + } + if (current.length > 0) envelopes.push(current.join('\n')); + return envelopes.filter((e) => e.trim()).map((e) => e.replace(/^\s+(?=>>>)/, '')); +}; + +/** Why a round's calls silence its prose: `skip_reply` deliberately keeps it + * private; an explicit send holds it back as resendable drafts. */ +const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send' | 'private' | null => { + if (calls.some((c) => bareToolName(c.name) === 'skip_reply')) return 'private'; + return calls.some(isSilencingCall) ? 'send' : null; +}; + /** * True when an injected message is real conversational input — something the * agent might actually be replying to — rather than ambient machinery. @@ -187,9 +235,7 @@ const isSilencingTool = (name: string): boolean => { const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; const m = metadata as Record; - if (m.system === true) return false; - if (Array.isArray(m.tags) && m.tags.includes('chat:reaction')) return false; - return true; + return isConversational(Array.isArray(m.tags) ? (m.tags as string[]) : undefined, m); }; /** @@ -229,6 +275,28 @@ function estimateAppendedRoundTokens( return Math.ceil(chars / 4) + images * 1600 + blockCount * 20; } +/** + * A turn's routing state, and what a continuation of the turn needs from it + * after the turn's teardown: for a silent wake (suppressProse), its prompt + * and stamp, so the continuation is a silent tick too (continuationFor). + */ +type TurnRecord = TurnRoute & { + silent?: Pick; +}; + +/** + * What decides where one piece of speech goes, captured when its words were + * produced: the turn's route then. Speech is published later, in order, on + * the agent's speech chain, and a tool result (a channel_open) or an arrival + * (a hold) can land in between; neither may change where words already + * written go. `inferenceId` names the physical stream that wrote them: once + * published, they go to that inference's outgoing stream (MCPL §14.3). + */ +interface SpeechMoment { + turn: TurnRoute | undefined; + inferenceId?: string; +} + /** * Explicit delivery tools whose successful use into a closed channel OPENS * it (send implies engagement — see openIfClosedForSend). Bare tool names, @@ -271,19 +339,20 @@ const isAddressedMessage = ( _metadata?: Record, ): boolean => Array.isArray(tags) && tags.includes('chat:addressed'); -// parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with -// the outgoing-stream router (Spec 14.3), so streamed chunks and delivered -// envelopes can never disagree on what is a prefix. +// parseProsePrefix lives in ./mcpl/prose-grammar.ts. Outgoing streams (Spec +// 14.3) carry only what delivery published, so no second parser reads it. /** One-time primer appended when an agent's proseRouting mode changes. */ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): string { if (mode === 'locus') { - return '[prose-routing] Mode change: your plain text auto-routes to the ' + - 'conversational locus again. `>>` destination prefixes are no longer needed.'; + return '[prose-routing] Mode change: your plain text goes again to the conversation ' + + 'that woke your turn (or a channel you open); when that is unclear it is held as a ' + + 'draft. `>>` destination prefixes are no longer needed.'; } if (mode === 'hybrid') { - return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' + - 'current conversational locus. A leading `>>>destination` envelope instead routes ' + + return '[prose-routing] Hybrid routing enabled: unprefixed text still goes to the ' + + 'conversation that woke your turn (held as a draft when that is unclear). A leading ' + + '`>>>destination` envelope instead routes ' + 'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' + 'envelope remains in your memory but recipients see only its body; delivery or failure ' + 'is reported back to you.'; @@ -315,9 +384,11 @@ const PROSE_ROUTING_HELP = ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' + ' immediately when this one ends, instead of pausing until the next event.\n' + ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' + - 'Text without a destination is not delivered: it is retained, and a notice will\n' + - 'prompt you to resend — reply e.g. ">>#channel {{unsent}}" to deliver the retained\n' + - 'text unchanged. Send tools (send_message, send_dm, …) are unaffected.'; + 'Text without a destination is not delivered: it is held as a draft, and a notice\n' + + 'names it — reply e.g. ">>#channel {{unsent}}" to deliver your latest held text\n' + + 'unchanged, or use the drafts tool to list, resend or dismiss any draft by id.\n' + + '">>skip_reply {{unsent}}" sets the latest one aside. Send tools (send_message,\n' + + 'send_dm, …) are unaffected.'; const PROSE_HELP_TOOL: import('./types/index.js').ToolDefinition = { name: 'prose_help', @@ -444,6 +515,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 } @@ -952,6 +1028,15 @@ function truncateReason(reason: string, max = 160): string { return reason.length <= max ? reason : reason.slice(0, max) + '…'; } +/** Where one segment of a turn's plain speech was delivered, as resolved then. */ +interface ProseDelivery { + serverId?: string; + channelId: string; + /** The channel's label when the words were delivered. */ + label?: string; + threadId?: string; +} + export class AgentFramework { private toolPresentations = new Map(); private presentationPreviews = new WeakMap(); @@ -968,16 +1053,6 @@ export class AgentFramework { private inferencePolicy: InferencePolicy; private errorPolicy: ErrorPolicy; private pendingRequests: InferenceRequest[] = []; - /** - * Per-agent channel that triggered the agent's CURRENT inference turn, if any - * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to - * route a single-trunk agent's plain-text speech back to the channel it is - * answering, instead of the process-global most-recent-inbound locus that a - * concurrent message elsewhere can hijack. Set at turn start (or cleared for a - * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next - * turn. Never read between turns (a given agent runs one turn at a time). - */ - private activeTriggerChannels: Map = new Map(); private running = false; private loopPromise: Promise | null = null; /** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from @@ -1015,33 +1090,43 @@ export class AgentFramework { private processLoggingPersist: boolean; private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); - - /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved - * eagerly in startAgentStream (home → addressed trigger → global default) - * and never moved until the next turn: mid-turn injected messages — ambient - * chatter, reactions, system markers — must not hijack where the agent's - * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in - * driveStream locals — so the pin survives a context-budget stream restart, - * which continues the same logical turn in a fresh driveStream. Cleared or - * re-set at the start of every non-restart turn. Since 2026-07-31 the pin - * is no longer fully frozen: an ADDRESSED conversational injection — or a - * conversational follow-up in a channel the agent itself explicitly sent - * into this turn (turnEngagedChannels) — re-pins it at the boundary (see - * the addressed re-pin block in the tool-result handler). Ambient chatter - * elsewhere, reactions, and system markers still cannot move it. */ - private turnLocusPins: Map = new Map(); - /** Channels the agent EXPLICITLY sent into during the CURRENT turn - * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin - * signal: a conversational reply in a channel the agent just engaged is - * addressed by context even without a mention (2026-07-31 n=7: q's - * #portables follow-up — no @mention — was answered in trailing prose - * that followed the stale pin into repligate's DM). No author-kind - * filter: agent-residents are full participants, and the bot flag - * tracks nothing that matters (antra). Reactions/system markers are - * excluded by isConversationalInjection; the engaged-this-turn scope - * keeps unrelated channels from moving the pin. Cleared at every fresh - * turn's start; budget restarts keep it. */ - private turnEngagedChannels: 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; + + /** Per-agent speech route for the CURRENT logical turn (shelf-355, + * src/speech-routes.ts): where unaddressed plain speech goes. Decided at a + * true new turn — a fork's home, else inferred from the wake's candidates + * (addressed first; one conversation only, else held) — and changed during + * the turn only by the resident's own channel_open, or suspended by a + * competing addressed/engaged arrival (the ambiguity hold). Incoming + * traffic never selects a new destination. Lives here, not in driveStream, + * so context-budget restarts and retries keep it. */ + private turnRoutes: Map = new Map(); + /** Where the agent EXPLICITLY sent during the CURRENT turn, per agent: the + * second ambiguity signal. A conversational reply where the agent just + * sent competes for its unaddressed words even without a mention + * (2026-07-31 n=7: q's #portables follow-up — no @mention — was answered + * in trailing prose that followed the stale pin into repligate's DM), so + * it holds an inferred route (shelf-355). Recorded as exactly as the host + * knows the place (noteSendEngagement, noteChannelEngagement): + * - `conversations`: the exact conversation (conversationKey: server, + * channel, thread) of the host's own publications — a resend or + * channel_publish whose outcome was delivered, or unknown (something + * may have been posted there: a possible reply claim, not a delivery); + * - `channels`: the server and channel of a connector's own send tool + * (SEND_ENGAGEMENT_TOOLS), which chooses the place in the channel + * itself, so any conversation there may be the one it engaged. + * No author-kind filter: agent-residents are full participants, and the + * bot flag tracks nothing that matters (antra). Reactions/system markers + * are excluded by isConversationalInjection; the engaged-this-turn scope + * keeps unrelated conversations from holding anything. Cleared at every + * fresh turn's start; budget restarts keep it. */ + private turnEngagement: Map; + channels: Set; + }> = new Map(); /** Channels this turn's PLAIN PROSE was actually delivered to, in delivery * order (deduped at render). Feeds the `[delivered]` receipt appended at * logical turn end: explicit sends receipt themselves via their @@ -1050,7 +1135,7 @@ export class AgentFramework { * words landed (2026-07-31 misroute series; antra: minimal receipts). * Cleared at every fresh turn's start; budget restarts keep it (same * logical turn, receipt covers the whole turn). */ - private turnProseDeliveries: Map = new Map(); + private turnProseDeliveries: Map = new Map(); /** Count of plain-prose segments SUPPRESSED this turn by sticky * explicit-send silencing. The suppression rule itself is untouched * (antra, 2026-07-31: visibility is enough) — but it must never be a @@ -1067,20 +1152,60 @@ export class AgentFramework { * driveStream to clear sticky explicit-send suppression before handling * the next model round. Never affects routing: the turn locus is frozen. */ private midTurnInputSignals: Set = new Set(); - /** Last outbound locus announced to each agent as a durable `[routing]` - * window message. Announce-on-change only: steady state emits nothing - * (KV-safe, no per-turn chatter). In-memory — after a process restart the - * first turn's locus is announced once to re-establish the baseline. */ - private lastAnnouncedLocus: Map = new Map(); + /** The route last announced to each agent in a durable `[routing]` window + * message, as its identity key. Announce-on-change only (KV-safe, no + * per-turn chatter). In-memory: after a restart the first turn's route is + * announced once to re-establish the baseline. */ + private lastAnnouncedRoute: Map = new Map(); + /** Whether the agent's current live round presents mid-turn injections + * (native rounds do; XML rounds don't), for the ambiguity hold. */ + private roundPresentsInjections: Map = new Map(); + + // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- + /** Every resident's held drafts: suppressed, bounced or ambiguous plain + * speech, never published except by their resident's resend, kept until + * resent or dismissed. */ + private proseDrafts: ProseDraftStore; + /** Drafts held during the current logical turn, for its `[delivered]` + * receipt. Cleared each fresh turn; restarts keep it. */ + private turnDrafts: Map = new Map(); + /** Plain-speech segments this turn kept private by the resident's own + * choice (skip_reply in the same round) — counted, never drafted. */ + private turnProsePrivate: Map = new Map(); + /** Segments that should have been held as drafts but could not be + * journaled (the words remain in the resident's history only). */ + private turnDraftFailures: Map = new Map(); // ---- Explicit prose routing (docs/explicit-prose-routing.md) ---------- - /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */ - private proseClipboards: Map = new Map(); - /** Sticky per-TURN delivery target set by the turn's first `>>` prefix. - * Cleared at every non-restart turn start; survives context restarts. */ - private proseTargetPins: Map = new Map(); - /** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */ - private proseHybridSuppressed: Set = new Set(); + /** Sticky per-TURN delivery target set by a `>>` (explicit) or `>>>` + * (hybrid) prefix. Written and read on the speech chain, in the order the + * words were written. Cleared at a true new turn; a context restart or + * framework retry continues the turn and keeps it. */ + private proseTargetPins: Map = new Map(); + /** Hybrid router is fail-closed after a `>>>skip_reply` envelope (the + * rest is private) or a malformed/unresolved/undelivered one (the rest is + * held as drafts, having no destination) until a new valid target. On the + * speech chain, like the pins. */ + private proseHybridSuppressed: Map = new Map(); + /** Per agent: the number of its current logical turn, advanced only at a + * true new turn (a context restart or framework retry keeps it). A tool + * completion that arrives after a newer turn has begun compares it, so it + * can't change that turn's speech (channel_open). */ + private logicalTurns: Map = new Map(); + /** Outgoing streams (MCPL §14.3) by inference id: what each physical + * stream's published prose has sent to each channel so far, and the next + * chunk index. Completed and removed after the stream's last speech + * (completeOutgoingStreams). */ + private outgoingStreams: Map; + }> = new Map(); + /** Per agent: the ordered chain of speech deliveries (chainSpeech). One + * chain for all of an agent's physical streams, so a restart's or + * retry's speech can never overtake words still being published from + * the stream before it. Each link catches its own error. */ + private speechChains: Map> = new Map(); /** Agents whose current turn requested `!` continuation — re-woken when the * turn completes instead of pausing until the next external event. */ private proseContinuations: Set = new Set(); @@ -1403,8 +1528,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; @@ -1427,6 +1552,9 @@ export class AgentFramework { this.operatorLog = operatorLog; this.store = store; this.ownsStore = ownsStore; + // Held prose drafts live in typed Chronicle records that do not follow + // branch switches (RecordJournal): replayed here, never re-sent. + this.proseDrafts = new ProseDraftStore(store); this.membrane = membrane; this.inferencePolicy = inferencePolicy; this.errorPolicy = errorPolicy; @@ -1678,6 +1806,25 @@ export class AgentFramework { emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }), addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined), requestInference: (agentName, reason, source, provenance) => { + // The batch's conversations as speech-route candidates. The fork + // rule is applied to them at turn start (beginAgentTurn), as for + // every wake. + const routeCandidates = (provenance?.routeCandidates ?? []) + .map((c): RouteCandidate => c.kind === 'surface' + ? { conversation: { kind: 'surface', surface: c.surface }, addressed: true, at: c.at } + : c.unroutable + ? { + conversation: { + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + }, + addressed: c.addressed, + at: c.at, + unroutable: true, + } + : framework.candidateForChannel(c.channelId, c.addressed, c.at, c.serverId, c.messageId, c.threadId)); framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now(), // gate-requested wakes carry where/who (EventGate wakeProvenance) @@ -1686,21 +1833,22 @@ export class AgentFramework { ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}), ...(provenance?.at ? { wakeAt: provenance.at } : {}), // A batch that contains an ADDRESSED message (mention / reply / - // DM) routes like the direct path does: the reply goes to that - // message's channel. Without it the turn froze on the - // process-global most-recent-inbound channel, which an ambient - // message elsewhere could retarget between the DM's arrival and - // the debounce firing (2026-09-30). Ambient-only batches set no - // locus and keep the legacy fallback. + // DM) marks the wake addressed, with that message's channel as + // the request's channelId, as the direct path does; an + // ambient-only batch sets neither. Neither is the speech route: a + // true new turn infers that from the batch's route candidates + // below (shelf-355). // // The wake is broadcast to every agent, conversation forks // included, and a fork speaks only in its home channel: the // route applies to a fork only when it IS that home, and never to // another agent when a fork owns that channel. Otherwise a fork // bound elsewhere would show typing where it isn't replying. - ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId) + ...(provenance?.routeChannelId && framework.mayTakeRoute(agentName, provenance.routeChannelId) ? { channelId: provenance.routeChannelId, addressed: true } : {}), + ...(routeCandidates.length > 0 ? { routeCandidates } : {}), + ...(provenance?.namesConversation ? { namesConversation: true as const } : {}), }); }, getAgentNames: () => [...framework.agents.keys()].filter( @@ -1849,9 +1997,7 @@ export class AgentFramework { addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata as MessageMetadata, forAgent ? { forAgent } : undefined), - requestInference: (agentName, reason, source) => { - framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() }); - }, + requestInference: framework.queueTuneOutWake.bind(framework), subconsciousName: () => framework.subconsciousAgentName, primaryName: () => framework.primaryAgentName, getStoredMessages: () => { @@ -2011,6 +2157,10 @@ export class AgentFramework { await this.loopPromise; } + // Speech the streams already wrote is still on its agents' speech + // chains: let it publish while the connections are open, bounded. + await this.settleSpeechChains('before the connections close'); + // Stop typing indicators this.channelRegistry?.stopAll(); @@ -2025,6 +2175,12 @@ export class AgentFramework { } await Promise.all(shutdownPromises); + // A publish still waiting has its answer now that the connections are + // closed, and speech queued behind it can no longer go out: let the + // chains record what became of their words while the store is open (a + // publish that provably sent nothing keeps its words as a draft). + await this.settleSpeechChains('before the store closes'); + // Streams may queue storage repairs while being cancelled above. Retry // after their teardown, while the store is still open, before final sync. for (const agent of this.agents.values()) { @@ -2206,8 +2362,8 @@ export class AgentFramework { } const recovery = this.providerAccelerationRecoveries.get(agentName); if (recovery) { recovery.releasedAt = Date.now(); recovery.heldRequests = cooldown.heldRequests.length; } - const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName, - reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() } satisfies InferenceRequest]; + const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests + : [this.continuationRequest(agentName, 'provider-acceleration-retry')]; this.pendingRequests.push(...requests); console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` + `fresh compile queued with ${requests.length} retained request(s)`); @@ -2356,10 +2512,58 @@ export class AgentFramework { : undefined; } + /** + * A server's push or incoming message the host couldn't handle, answered + * with a JSON-RPC error when it is a request (a notification has no one to + * answer, and is logged), rather than thrown out of the connection's + * listener, where it was an unhandled rejection and the server's request + * waited for its timeout. + * - `refused`: the listener turned it away before its handler ran; the + * host is stopping, and none of it was taken. + * - `failed`: the handler threw (the host stopped while it ran, or a + * fault; whatever it threw, `undefined` included). The request failed, + * but the handler may have admitted part of it first (an earlier message + * of a batch), so nothing here claims that none of it was taken. + */ + private refuseServerItem( + method: string, + serverId: string, + responder: { respondError?: (code: number, message: string) => void } | undefined, + outcome: { kind: 'refused' } | { kind: 'failed'; error: unknown }, + ): void { + // Optional chaining: prototype-built harnesses leave the queue unset. + const reason = outcome.kind === 'refused' + ? 'the host is stopping' + : this.queue?.isClosed + ? 'the host stopped while handling this request' + : outcome.error instanceof Error + ? outcome.error.message + : `the handler failed${outcome.error === undefined ? '' : `: ${String(outcome.error)}`}`; + console.error(outcome.kind === 'refused' + ? `[mcpl] ${method} from ${serverId} refused: ${reason}` + : `[mcpl] ${method} from ${serverId} failed: ${reason} (part of it may already have been admitted)`); + try { + responder?.respondError?.(-32603, reason); + } catch { + // The connection is going too; there is no one left to tell. + } + } + /** * Push a process event to the queue. + * + * After stop() the queue is closed and this throws, so a direct caller + * learns the framework is stopped — except for a tool's result. A call + * still running at stop() completes later, into a callback with nothing + * to catch the throw (an unhandled rejection), and nothing could take its + * result anyway: the stream that asked is gone, and the tool has already + * run. That result is dropped, and the drop is logged. */ pushEvent(event: ProcessEvent): void { + if (event.type === 'tool-result' && this.queue?.isClosed) { + console.error(`[framework] ${event.agentName}: result of tool call ${event.callId} (${event.moduleName}) arrived after stop — dropped`); + return; + } this.queue.push(event); this.emitTrace({ type: 'process:received', processEvent: event }); } @@ -2466,6 +2670,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); } @@ -4611,6 +4818,63 @@ 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 is let go after ${AgentFramework.DRAFT_KEEP_DAYS} days in which nothing happened to it. ` + + 'list: your open drafts, newest first. read: drafts in full, with every delivery attempt. ' + + 'resend: publish drafts verbatim, in the order given, to one destination ("#channel", "@person" or a ' + + 'channel id) — an explicit send, like send_message; it stops at the first draft not confirmed delivered. ' + + 'A draft already delivered returns its receipt instead of sending again. If an earlier attempt may ' + + 'already have been posted (its outcome is unknown), resend needs confirmDuplicate: true. dismiss: set ' + + 'drafts aside (your history keeps the words). A field you are not using may be omitted or passed as null.', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['list', 'read', 'resend', 'dismiss'], description: 'What to do.' }, + draftIds: { + type: ['array', 'null'], + items: { type: 'string' }, + description: 'read, resend, dismiss: draft ids such as "d-k7x3q", in order. null for list.', + }, + destination: { + type: ['string', 'null'], + description: 'resend: where to publish — "#channel", "@person" or a channel id. null otherwise.', + }, + threadId: { + type: ['string', 'null'], + description: + 'resend: a thread of the destination channel to post in (the id from a [source: … · thread ] ' + + 'line), where its connector can post into a named thread. null, like omitting it, means the ' + + "channel's root.", + }, + confirmDuplicate: { + type: ['boolean', 'null'], + description: + '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 @@ -4769,14 +5033,14 @@ export class AgentFramework { }; /** - * Mark an inference refusal visibly: react on the message that holds the - * conversational locus (the most recent incoming channel message) with a - * category-specific emoji. Best-effort — failures are logged, never thrown, - * and non-Discord loci are silently skipped. + * Mark an inference refusal visibly: react on the message the agent's + * speech route answers (its reply edge) with a category-specific emoji. + * Best-effort — failures are logged, never thrown; a turn without a + * channel route (held, surface, none) and non-Discord routes are skipped. */ private async reactToRefusal(agentName: string, category: string): Promise { try { - const incoming = this.channelRegistry?.buildChannelContext()?.incoming; + const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming; if (!incoming) return; // incoming.channelId is the MCPL composite id ("discord::"); // the reaction tool wants the raw Discord channel (or thread) id — the @@ -4933,7 +5197,7 @@ export class AgentFramework { /** * Announce a refusal-rewind on the conversational surface (Discord), used * when the withheld turn was a *human* message so it isn't dropped silently. - * Best-effort; mirrors reactToRefusal's locus resolution. + * Best-effort; mirrors reactToRefusal: the agent's own route's reply edge. */ private async announceRewind( agentName: string, @@ -4941,7 +5205,7 @@ export class AgentFramework { category: string, ): Promise { try { - const incoming = this.channelRegistry?.buildChannelContext()?.incoming; + const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming; if (!incoming) return; const parts = incoming.channelId.split(':'); if (parts[0] !== 'discord') return; @@ -5180,6 +5444,12 @@ export class AgentFramework { if (agent.state.status !== 'idle') { return { ok: false, error: `Cannot unstick while agent is ${agent.state.status}` }; } + // A turn is alive from its dequeue to its settled teardown, which + // includes moments the state machine reads idle: the start of a turn + // before its inference, and the end of one after endTurn's reset. + if (this.activeTurnTokens.has(agentName)) { + return { ok: false, error: 'Cannot unstick while a turn is in progress' }; + } const cap = Math.max(1, Math.min(10, Math.floor(params.maxRewinds ?? agent.refusalHandling?.maxRewinds ?? 3))); this.forcedRewind.set(agentName, { @@ -5189,12 +5459,7 @@ export class AgentFramework { channelId: params.channelId ?? '', }); this.refusalRewinds.set(agentName, 0); - this.pendingRequests.push({ - agentName, - reason: 'unstick', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agentName, 'unstick')); console.error( `[unstick] agent=${agentName} started cap=${cap} ` + `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`, @@ -6768,74 +7033,106 @@ export class AgentFramework { : 0; const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity); - // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified): - // when someone ADDRESSES the agent mid-turn from another channel - // (mention / reply / DM — the same chat:addressed signal the - // turn-START batch policy prefers), the agent's subsequent plain - // prose almost always answers THEM. Six recorded misroutes - // delivered those answers to the stale frozen locus instead (the - // LabClaude answer into #hospital_commons mid-examination; the - // hospital scene into antra's DM). Re-pin the turn locus to the - // LAST addressed conversational injection, at segment granularity: - // prose routed in earlier rounds already went to the old pin, - // which was correct when it was delivered. Ambient chatter, - // reactions, and system markers still cannot move the pin — the - // 2026-07-21 Cairn hijack protection is unchanged; this extends - // the ratified addressed-outranks-ambient principle from turn - // start to boundaries. Locus mode only (explicit mode has no pin). - // Skipped on endTurn/overBudget: no further prose this stream, and - // a stale "for this turn" notice would be pure noise. + // Ambiguity hold (shelf-355; replaces the 2026-07-31 addressed + // re-pin). When an arrival PRESENTED in this batch is a + // conversational message from another conversation that addressed + // the resident (mention / reply / DM / its local surface), or that + // continues a conversation the resident explicitly sent into this + // turn, the resident's next unaddressed words could answer either + // conversation. Re-pinning to the newcomer guessed — and sent words + // still meant for the first conversation into the second. The + // inferred route is held instead: unaddressed speech becomes drafts + // until the turn ends or a deliberate target is set (`>>>target`, + // channel_open), and explicit sends keep naming their own + // destination. Deliberate routes (a fork's home, a channel_open) + // are never held; ambient chatter, reactions and system markers + // never hold anything; an arrival in the SAME conversation keeps + // the route and its reply edge. Only a presented batch counts: on a + // round that cannot present injections (XML tool mode) the arrival + // isn't in the turn, so it can't make the resident's words + // ambiguous. Skipped on endTurn/overBudget: no further prose this + // stream, and the notice would be noise. // - // The notice rides the SAME injection batch — stored right after - // the flushed messages and appended to midTurnInjections — so the - // window byte-matches the wire (short event-style notice: the - // classifier-safe D-class from the 2026-07-24 ablation). It also - // updates lastAnnouncedLocus so the next turn's announce-on-change - // diffs against what the agent was actually last told. + // The notice rides the SAME injection batch (stored right after the + // flushed messages and appended to midTurnInjections), so the + // window byte-matches the wire, and the next turn's + // announce-on-change diffs against what the resident was told. if ( (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') && - !shouldEndTurn && !overBudget && currentState.stream + !shouldEndTurn && !overBudget && currentState.stream && + this.roundPresentsInjections.get(agent.name) === true ) { - // Two signals qualify an injection to move the pin (n=6 + n=7): - // - chat:addressed — someone explicitly spoke TO the agent - // (mention / reply / DM); - // - a conversational follow-up in a channel the agent itself - // explicitly sent into THIS turn (turnEngagedChannels) — - // conversation the agent just engaged continues without - // re-mentioning it (2026-07-31: q's #portables reply, no @, - // answered in prose that followed the stale pin into - // repligate's DM). Deliberately NO author-kind filter: this - // fleet's participants include agent-residents whose speech - // is as conversational as anyone's — the Discord bot flag - // tracks nothing that matters here (antra, 2026-07-31). - // Reactions and system markers are already excluded by - // isConversationalInjection, and the engaged-this-turn scope - // keeps unrelated channels from moving the pin. - const engaged = this.turnEngagedChannels.get(agent.name); - const lastQualifying = [...midTurnInjections].reverse().find((inj) => { - const m = inj.metadata as Record | undefined; - if (!isConversationalInjection(inj.metadata)) return false; - if (typeof m?.channelId !== 'string') return false; - const tags = m.tags as string[] | undefined; - if (isAddressedMessage(tags, m)) return true; - return engaged?.has(m.channelId as string) === true; - }); - const newLocus = (lastQualifying?.metadata as Record | undefined) - ?.channelId as string | undefined; - if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) { - const prevPin = this.turnLocusPins.get(agent.name) ?? null; - this.turnLocusPins.set(agent.name, newLocus); - this.lastAnnouncedLocus.set(agent.name, newLocus); - const label = this.channelRegistry?.getDescriptor(newLocus)?.label; - const shown = label && label !== newLocus - ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})` - : newLocus; + const turn = this.turnRoutes.get(agent.name); + const isEngaged = (c: ConversationRef): boolean => this.engagementOf(agent.name, c) !== undefined; + // Every arrival in this round that suspends the route, each + // conversation once (its newest arrival), oldest first. + const competing: RouteCandidate[] = []; + if (turn) { + const seen = new Set(); + for (const inj of [...midTurnInjections].reverse()) { + if (!isConversationalInjection(inj.metadata)) continue; + const m = inj.metadata as Record | undefined; + const candidate = this.candidateFromSource( + readInboundSource(m), + isAddressedMessage(m?.tags as string[] | undefined, m), + ); + // A channel a conversation fork owns is never another + // agent's place, so it doesn't compete for this one's. + if (!candidate || !this.mayTakeCandidate(agent.name, candidate)) continue; + if (!suspendsRoute(turn, candidate, isEngaged)) continue; + const key = conversationKey(candidate.conversation); + if (seen.has(key)) continue; + seen.add(key); + competing.unshift(candidate); + } + } + if (turn?.route && competing.length > 0) { + const current = routeConversation(turn.route); + const arrivals = competing.map((c) => c.conversation); + // A mid-turn arrival's candidate comes from its inbound source, + // which always names a registered channel and its server, so + // none is marked unroutable (TurnRoute.hold.neverRoutes). + const held: TurnRecord = { ...turn, hold: { conversations: [current, ...arrivals], since: 'mid-turn' } }; + this.turnRoutes.set(agent.name, held); + this.lastAnnouncedRoute.set(agent.name, AgentFramework.routeKey(held)); + // Name each actual cause, as certain as the host is of it: + // someone addressed the resident (the arrival's own addressed + // flag: a local surface's message always addresses it), or a + // conversation continued where it sent this turn — there + // exactly, there maybe (the send's delivery is unconfirmed), or + // somewhere in the channel (a connector's own tool chose where). + const causeOf = (candidate: RouteCandidate): string => { + const arrival = candidate.conversation; + const engagement = this.engagementOf(agent.name, arrival); + return candidate.addressed + ? `${describeConversation(arrival)} addressed you mid-turn` + : engagement === 'delivered' + ? `${describeConversation(arrival)}, where you sent a message this turn, continued mid-turn` + : engagement === 'unknown' + ? `${describeConversation(arrival)}, where you tried to send a message this turn (its delivery wasn't confirmed), continued mid-turn` + : `${describeConversation(arrival)} continued mid-turn, in a channel you sent something into this turn (where in the channel isn't known)`; + }; + const causes = competing.map(causeOf); + const cause = causes.length === 1 ? causes[0]! : `${causes.slice(0, -1).join('; ')}; and ${causes.at(-1)}`; + // A fallback route answered no conversation: it was only where + // the last message had come in when the reply began (a + // continuation keeps that place), so it isn't one the speech + // could be answering. + const fallback = turn.route.origin === 'fallback'; const noticeContent: ContentBlock[] = [{ type: 'text', - text: - `[routing] The conversation moved to ${shown} — your plain ` + - 'speech now lands there for the rest of this turn. Other ' + - 'channels need an explicit send tool.', + text: fallback + ? `[routing] ${cause}. Your unaddressed plain speech was going to ${describeConversation(current)}, ` + + 'where the last message had come in when your reply began (a continuation of it keeps that place), because nothing in the wake ' + + 'that began it named a conversation. ' + + 'For the rest of this turn it is held as drafts instead. ' + + `Answer ${competing.length === 1 ? 'it' : 'any of them'} with an explicit send (or a channel_open); ` + + 'held words can be resent from your drafts.' + : `[routing] ${cause}, so your unaddressed plain speech could now answer ` + + `${competing.length === 1 ? 'either conversation' : 'any of these conversations'}. ` + + 'For the rest of this turn it is held as drafts ' + + `instead of going to ${describeConversation(current)}. Answer ${competing.length === 1 ? 'either one' : 'any of them'} ` + + 'with an explicit send (or a channel_open); held words can be resent from your drafts.', }]; const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata; try { @@ -6843,10 +7140,10 @@ export class AgentFramework { this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta }); console.error( - `[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`, + `[routing] ${agent.name}: mid-turn ambiguity hold — ${[current, ...arrivals].map(describeConversation).join(' vs ')}`, ); } catch (err) { - console.error('mid-turn locus re-pin: failed to record routing notice:', err); + console.error('mid-turn ambiguity hold: failed to record routing notice:', err); } } } @@ -6926,12 +7223,7 @@ export class AgentFramework { }); } else { // Non-streaming fallback: schedule re-inference - this.pendingRequests.push({ - agentName: agent.name, - reason: 'tool_results_ready', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agent.name, 'tool_results_ready')); } } } @@ -6980,9 +7272,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 @@ -7004,12 +7302,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 }); } } @@ -7036,14 +7344,21 @@ export class AgentFramework { // Queue inference requests if (response.requestInference) { const source = 'source' in event ? (event as { source: string }).source : 'unknown'; + // What a module event names in its metadata, if anything. + const namedMetadata = extractGateFields(event).metadata; + const named = namedMetadata.channelId; // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry) if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') { + const fields = extractGateFields(event); const decision = this.eventGate.evaluate({ - ...extractGateFields(event), + ...fields, eventType: event.type, serverId: source, - channelId: '', + // A module event that names a channel isn't a local surface's + // conversation (surfaceSourceFor): the gate's wake carries that + // channel, unroutable, never a surface candidate. + channelId: !surface && typeof named === 'string' ? named : '', }); if (!decision.trigger) return; } @@ -7056,6 +7371,17 @@ export class AgentFramework { (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName) : response.requestInference; + // Console/API input is a conversation with a local surface, which + // counts as addressed: the turn's speech route can be that surface, + // however its module put the input in context (with a message or + // without one), so a console reply never leaves the console. A module + // event that names a channel names that conversation too, though it + // is no candidate here: neither takes the fallback. An event naming + // neither (an `api:inference-request` nudge, a timer) names none. + const surfaceCandidate = surface ? this.candidateFromSource(surface, true) : undefined; + const nonEmpty = (v: unknown): boolean => typeof v === 'string' && v.length > 0; + const namesConversation = !!surface || nonEmpty(named) || nonEmpty(namedMetadata.threadId) + || nonEmpty(namedMetadata.authorId); for (const agentName of targetAgents) { const agent = this.agents.get(agentName); if (agent && agent.canBeTriggeredBy(source)) { @@ -7064,6 +7390,8 @@ export class AgentFramework { reason: event.type, source, timestamp: Date.now(), + ...(surfaceCandidate ? { routeCandidates: [surfaceCandidate] } : {}), + ...(namesConversation ? { namesConversation: true as const } : {}), }); } } @@ -7096,6 +7424,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. @@ -7117,8 +7447,30 @@ export class AgentFramework { triggered: event.triggerInference ?? false, serverId: event.serverId, }; + // `system` marks host machinery (isConversational): host-written only, + // so an adapter's is dropped. + delete metadata.system; if (event.threadId) metadata.threadId = event.threadId; if (event.tags) metadata.tags = event.tags; + // The inbound source envelope (mcpl/inbound-source.ts): frozen at the + // 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; + // The delivered body's version identity (mcpl/inbound-source.ts + // sourceBodyDigest; room-220 #46752–#47210), from the body as delivered, + // before any host decoration. The stored copy's own digest is taken at + // each storage site below, over exactly the blocks stored there. + metadata.sourceBodyDigest = sourceBodyDigest(event.content); // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never @@ -7131,7 +7483,7 @@ export class AgentFramework { if (!target) return undefined; if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) { metadata.triggered = event.triggerInference ?? false; - const id = target.getContextManager().addMessage('user', event.content, metadata); + const id = target.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(metadata, event.content)); // A correction may target an older engagement. Refresh only the // binding whose agent actually received the message. if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { @@ -7139,11 +7491,18 @@ export class AgentFramework { } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference) { + const addressedHere = isAddressedMessage(event.tags, event.metadata); + const candidate = isConversational(event.tags) + ? this.candidateFromSource(source, addressedHere) + : undefined; this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, + // Its channel is named even when it is no candidate (a reaction). + namesConversation: true, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - addressed: isAddressedMessage(event.tags, event.metadata), + addressed: addressedHere, + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -7200,7 +7559,7 @@ export class AgentFramework { } const placement: CoalescingPlacement = { agent: '' }; - const id = this.addMessage('user', incomingContent, metadata, { placement, bypassDeferralFor: event.assemblingFor }); + const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor }); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (event.triggerInference && !divert) { @@ -7212,20 +7571,28 @@ export class AgentFramework { // tune-out's wake routing is the first setter.) const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName); + // A reaction or system marker can wake a turn, but answers nothing: + // it is never a route candidate (the same predicate as mid-turn). + const candidate = isConversational(event.tags) ? this.candidateFromSource(source, addressed) : undefined; for (const agentName of targetAgents) { if (!this.agents.has(agentName)) continue; + // The broadcast reaches forks too: the fork rule keeps each agent's + // typing to a channel it may take (and its route, at turn start). + const takesChannel = this.mayTakeRoute(agentName, event.channelId); this.pendingRequests.push({ agentName, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - // Route this turn's auto-published speech back to THIS channel, not - // the global most-recent-inbound locus (item-3 redux, trunk agents). - channelId: event.channelId, + ...(takesChannel ? { channelId: event.channelId } : {}), + // Its channel is named even when it is no candidate here (a + // reaction, or a channel a fork owns): no fallback for this wake. + namesConversation: true, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - // Addressed messages outrank ambient chatter when a batched wake - // picks the turn's frozen speech locus. addressed, + // This message's conversation, as a speech-route candidate: the + // turn's route is inferred from every candidate of its batch. + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -7315,21 +7682,26 @@ export class AgentFramework { const trigger = decision.trigger && event.triggerInference !== false; messageMetadata.triggered = trigger; - const id = agent.getContextManager().addMessage('user', event.content, messageMetadata); + const id = agent.getContextManager().addMessage('user', event.content, AgentFramework.withStoredDigest(messageMetadata, event.content)); router.touch(event.channelId); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { + const addressedHere = isAddressedMessage(event.tags, event.metadata); + // A fork's home route always wins; the candidate is carried for parity. + const candidate = isConversational(event.tags) + ? this.candidateFromSource(readInboundSource(messageMetadata), addressedHere) + : undefined; this.pendingRequests.push({ agentName: agent.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - // A fork's home channel wins in routeSpeech regardless, but carry the - // triggering channel too so the trunk/active path stays consistent. channelId: event.channelId, + namesConversation: true, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - addressed: isAddressedMessage(event.tags, event.metadata), + addressed: addressedHere, + ...(candidate ? { routeCandidates: [candidate] } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); } @@ -7497,6 +7869,8 @@ export class AgentFramework { reason: 'conversation:closure', source: 'framework', timestamp: now, + // It closes the conversation in its binding's channel. + namesConversation: true, }); // Disposed when the closure stream ends (driveStream finally), with // the reaper below as fallback. @@ -7628,6 +8002,166 @@ 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 } : {}), + }; + } + + /** + * An inbound item's metadata with its stored copy's own digest + * (`storedBodyDigest`): the delivered-body digest's function over exactly + * the blocks handed to storage here — every decoration included, before + * storage shards them. That function hashes blocks as the store keeps them + * (inline media re-encoded and relabeled from its bytes), so an unsharded + * copy read back hashes to this too. A body the store shards (adaptive + * ingress chunking) carries this on every shard, with its metadata, and no + * shard hashes to it. Taken at each storage site, never earlier, because + * a path can still decorate the body after ingestion stamped it (the + * closed-channel invitation; room-220 #48282). An edit through + * editMessage keeps metadata but not this hash. + */ + private static withStoredDigest( + metadata: Record, + content: readonly ContentBlock[], + ): Record { + metadata.storedBodyDigest = sourceBodyDigest(content); + return metadata; + } + + /** Source envelope of a channels/incoming event (lane facts as given). */ + private channelEventSource( + event: { + 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() }; + } + private initializePushCoalescer(): void { const coalescer = new PushCoalescer({ isUnread: (p) => this.isUnreadPlacement(p), @@ -7651,6 +8185,21 @@ 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); + // A channel message admitted afresh is where the last message came + // in: the fallback route's place. This hook never runs for a + // producer's retry, which the coalescer acknowledges again without + // delivering anything. A withdrawal is never the place, even when + // the coalescer shows the resident its notice, as before speech + // routes (noteDelivered). + if (occ.event.lane === 'channel' && occ.scope.kind === 'channel' && !occ.retract) { + this.channelRegistry?.noteDelivered(occ.serverId, occ.scope.id, occ.identity?.threadId, occ.tags); + } + }, wasPublished: (subject, eventId) => { // Boot-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. @@ -7737,6 +8286,80 @@ export class AgentFramework { return run; } + /** + * The source envelope of a coalesced `channels/incoming` message, built at + * admission (before it is gated) and frozen by its coalescer. Building it + * observes no acceptance: the coalescer does that if it admits the + * occurrence. + */ + private coalescedIncomingSource( + serverId: string, + message: ChannelIncomingMessage, + event: { acceptedAt?: number }, + ): InboundSource { + return this.channelSource({ + lane: 'channels/incoming', + coalesced: true, + serverId, + channelId: message.channelId, + threadId: message.threadId, + messageId: message.messageId, + eventId: message.eventId, + replyTo: message.metadata?.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: message.timestamp, + }); + } + + /** + * The source envelope of a coalesced push, built at admission (before it is + * gated) and frozen by its coalescer; like coalescedIncomingSource, it + * observes no acceptance. A channel-scoped push belongs to the channel its + * subject names — never one its `origin` names. + */ + private coalescedPushSource(serverId: string, params: PushEventParams, event: McplPushEvent): InboundSource { + const c = params.coalesce; + const origin = params.origin ?? {}; + const deferred = !!c?.deferred; + return c?.channelId + ? this.channelSource({ + lane: 'push/event', + coalesced: true, + serverId, + channelId: c.channelId, + threadId: origin.threadId, + messageId: origin.messageId, + eventId: params.eventId, + replyTo: origin.replyTo, + acceptedAt: event.acceptedAt, + sourceTimestamp: params.timestamp, + deferred, + }) + : this.pushSource(event, { deferred }); + } + + /** The trigger filter both MCPL lanes use (a server's + * shouldTriggerInference, else the event gate's), for coalesced items, + * whose trigger is decided at their first delivery or wake. */ + private coalescedTriggerFilter?: (content: string, metadata: Record) => boolean; + + /** + * Make a coalesced item's trigger decision (the gate's, which records the + * item in its batch as a route candidate), once: when the coalescer first + * delivers or wakes for an occurrence it admitted, from the filter inputs + * the event carries (triggerInputs). A refused occurrence, a retry it + * deduplicates, or a retraction is never delivered or woken for, so it + * never reaches the gate. The decision replaces the inputs on the event, + * which is plain data either way, so a persisted occurrence keeps + * whichever it has. + */ + private settleCoalescedTrigger(event: { triggerInference?: boolean; triggerInputs?: { content: string; metadata: Record } }): void { + const inputs = event.triggerInputs; + if (!inputs) return; + delete event.triggerInputs; + if (this.coalescedTriggerFilter) event.triggerInference = this.coalescedTriggerFilter(inputs.content, inputs.metadata); + } + private handleCoalescedIncoming( serverId: string, message: ChannelIncomingMessage, @@ -7751,6 +8374,11 @@ export class AgentFramework { if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required'); validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true }); const c = message.coalesce; + // The inbound source envelope the gate read, frozen at acceptance: + // deferral, fan-out and replay deliver it unchanged + // (mcpl/inbound-source.ts). + const inboundSource = event.inboundSource ?? this.coalescedIncomingSource(serverId, message, event); + const delivery = { ...event, eventId: message.eventId, inboundSource }; const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7764,7 +8392,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: delivery }, }); return { messageId: message.messageId, accepted: true, coalesce: result.coalesce }; }); @@ -7785,6 +8413,10 @@ 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 the gate read, frozen at acceptance (see + // the channel lane). + const inboundSource = event.inboundSource ?? this.coalescedPushSource(serverId, params, event); + const delivery = { ...event, inboundSource }; return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7806,7 +8438,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: delivery }, }); }); } @@ -7878,8 +8510,15 @@ export class AgentFramework { materialized?: McplContentBlock[], assemblingFor?: string, ): Promise { + this.settleCoalescedTrigger(occ.event.event); const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; + // The envelope frozen at acceptance travels unchanged; this delivery only + // 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); @@ -7912,6 +8551,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); @@ -7927,6 +8567,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); @@ -7936,21 +8577,34 @@ export class AgentFramework { private async wakeForCoalescedBatch(occ: CoalescedOccurrence): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); this.cancelCoalescedWakes(subject); + this.settleCoalescedTrigger(occ.event.event); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; const origin = occ.event.event.origin; // The wake carries the batch's channel so the turn freezes its reply // locus there (the materialized delivery at assembly wakes nobody). const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId; const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined); + const addressedHere = isAddressedMessage(occ.tags, origin); + // The conversation the batch names, whether or not it is a candidate (a + // reaction names its channel but answers nothing). + const named = this.candidateFromSource(occ.event.event.inboundSource, addressedHere); + const candidate = isConversational(occ.tags) ? named : undefined; + // A channel, a thread or an author names a conversation, as for a gate + // batch (wakeProvenance) and an uncoalesced push: the batch's merged + // thread or its origin's, either one. + const originThread = typeof origin?.threadId === 'string' && origin.threadId.length > 0; + const namesConversation = !!named || !!channelId || !!authorId || !!occ.identity?.threadId || originThread; for (const agentName of await this.coalescedAudience(occ)) { this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', source: occ.serverId, timestamp: Date.now(), - channelId, + channelId: channelId && this.mayTakeRoute(agentName, channelId) ? channelId : undefined, + ...(namesConversation ? { namesConversation: true as const } : {}), counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, - addressed: isAddressedMessage(occ.tags, origin), + addressed: addressedHere, + ...(candidate ? { routeCandidates: [candidate] } : {}), coalescingSubject: subject, coalescingBatch: true, }); @@ -8096,6 +8750,9 @@ export class AgentFramework { ...(event.tags ? { tags: event.tags } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }; + // `system` marks host machinery (isConversational): host-written only, + // so an origin's is dropped. + delete metadata.system; // `origin.channelId` is the server-internal raw id (a bare Discord // snowflake) — unroutable as a locus and unresolvable by the agent. Store // the composite MCPL id instead wherever we can derive it, so anything @@ -8105,8 +8762,24 @@ 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]; + // The delivered body's version identity, before any host decoration + // (the closed-channel invitation below). Every push that reaches here + // gets one, written over any value the adapter's origin carried. (A + // visibly-empty push is refused at admission; the silent-heartbeat marker + // stores nothing, so its metadata is never kept.) + metadata.sourceBodyDigest = sourceBodyDigest(content); if (triggerChannel) { const origin = (event.origin ?? {}) as Record; const invitation = this.buildClosedChannelInvitation({ @@ -8135,7 +8808,7 @@ export class AgentFramework { const placement: CoalescingPlacement = { agent: '' }; if (!silentHeartbeat) { - const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor }); + const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor }); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' }); } else { console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`); @@ -8146,17 +8819,38 @@ export class AgentFramework { const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter( (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); + const pushAddressed = isAddressedMessage(event.tags, event.origin); + // A push that names a conversation (a DM, an addressed-while-closed + // message) is a route candidate like its channels/incoming + // counterpart; an unscoped push (a heartbeat, a reminder) names none, + // and only such a wake takes the fallback route. A reaction names its + // conversation without being a candidate. + const pushNamed = this.candidateFromSource(source, pushAddressed); + // A channel, a thread or an author names a conversation, as for a gate + // batch (wakeProvenance): a reaction that names only its author too. + const originNames = (key: string): boolean => { + const value = event.origin?.[key]; + return typeof value === 'string' && value.length > 0; + }; + const pushNamesConversation = !!pushNamed || !!triggerChannel?.channelId + || originNames('authorId') || originNames('threadId'); + const pushCandidate = silentHeartbeat || !isConversational(event.tags) ? undefined : pushNamed; for (const agentName of targetAgents) { + // The fork rule for typing, as for a gate batch: no agent types in a + // channel a fork owns, and a fork only in its home. (Its route is + // judged at turn start.) + const typingChannel = triggerChannel?.channelId && this.mayTakeRoute(agentName, triggerChannel.channelId) + ? triggerChannel.channelId + : undefined; this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', source: event.serverId, timestamp: Date.now(), - channelId: triggerChannel?.channelId, - // DMs and addressed-while-closed messages arrive as push events; - // they must outrank ambient chatter in a batched wake's locus - // selection just like their channels/incoming counterparts. - addressed: isAddressedMessage(event.tags, event.origin), + channelId: typingChannel, + addressed: pushAddressed, + ...(pushCandidate ? { routeCandidates: [pushCandidate] } : {}), + ...(pushNamesConversation ? { namesConversation: true as const } : {}), ...(silentHeartbeat ? { suppressProse: true, ephemeralSystemPrompt: @@ -8172,12 +8866,13 @@ export class AgentFramework { } /** - * Whether a batched gate wake's addressed route may become `agentName`'s - * speech locus. The gate broadcasts to every agent, conversation forks - * included. A fork speaks only in its home channel, and a channel bound to - * a fork is that fork's conversation, so no other agent takes it. + * Whether a channel may become `agentName`'s speech route or typing + * channel: read for a wake's typing channel when it is queued, and for its + * route candidates and the fallback when its turn starts. A fork speaks + * only in its home channel, and a channel bound to a fork is that fork's + * conversation, so no other agent takes it. */ - private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean { + private mayTakeRoute(agentName: string, routeChannelId: string): boolean { const home = this.conversationAgentHomes.get(agentName); if (home !== undefined) return home === routeChannelId; for (const boundChannel of this.conversationAgentHomes.values()) { @@ -8186,6 +8881,11 @@ export class AgentFramework { return true; } + /** mayTakeRoute for a route candidate: a local surface is never a fork's. */ + private mayTakeCandidate(agentName: string, candidate: RouteCandidate): boolean { + return candidate.conversation.kind === 'surface' || this.mayTakeRoute(agentName, candidate.conversation.channelId); + } + /** * Derive the MCPL composite channel id (the outbound routing locus) for a * push event from its server-defined `origin`, if it carries one. @@ -8200,7 +8900,9 @@ export class AgentFramework { * discord-mcpl's `mcplChannelId()` / `parseMcplChannelId()` convention so the * fix works even against a discord-mcpl build that predates `mcplChannelId`. * Returns undefined for push events with no channel provenance (heartbeats, - * timers), which correctly keep the global fallback. + * reminders, timers): they name no conversation, so they are no route + * candidate, and a turn they alone wake takes the fallback route, the last + * delivered message's conversation (shelf-355). */ private derivePushEventChannel( origin: Record | undefined, @@ -8345,9 +9047,17 @@ export class AgentFramework { // must not absorb an unrelated wake that would vanish with it. const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}`; const prev = newestByKey.get(key); - if (!prev || request.timestamp >= prev.timestamp) { - newestByKey.set(key, request); - } + // The kept request carries both requests' route candidates: a + // parked wake's conversation still competes for the turn's route, + // and one that named a conversation keeps the turn off the fallback. + const candidates = [...(prev?.routeCandidates ?? []), ...(request.routeCandidates ?? [])]; + const named = !!prev?.namesConversation || !!request.namesConversation; + const kept = !prev || request.timestamp >= prev.timestamp ? request : prev; + newestByKey.set(key, { + ...kept, + ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), + ...(named ? { namesConversation: true as const } : {}), + }); } this.pendingRequests.push(...newestByKey.values()); if (passThrough.length === 0) continue; @@ -8410,6 +9120,11 @@ export class AgentFramework { continue; } + // A request that continues the agent's turn reads that turn's record + // now, as the turn left it (continuationFor), before the policy sees + // the batch and before its fields are merged below. + requests = requests.map((r) => this.continuationFor(r)); + // Check policy if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) { // Loud drop: a queued request that dies here is otherwise invisible — @@ -8438,17 +9153,19 @@ export class AgentFramework { // requests[0] in that mixed batch bypassed the turn lock because a // restart existed, then treated the continuation as a fresh turn. const trigger = budgetRestart ?? requests[0]; - // Route this turn's auto-published speech to the channel that triggered - // it (item-3 redux). A batched wake may carry several triggering channels - // (messages arrived in >1 channel while the agent was busy/idle): + // The channel that triggered this turn (the trigger's channelId), and + // the provenance below. Neither is the speech route, which a true new + // turn infers from every request's route candidates (shelf-355). A + // batched wake may carry several triggering channels (messages arrived + // in >1 channel while the agent was busy/idle): // 1. the most recent ADDRESSED channel wins (mention / reply / DM — // someone explicitly spoke TO the agent); // 2. else the most recent channel-bearing request (ambient message in - // an open channel — legacy last-inbound semantics). + // an open channel). // Ambient chatter must not outrank an addressed message just by being // newest (2026-07-21 Cairn lounge misroute, turn-start variant). // Non-channel wakes (heartbeats, module events, reactions — which never - // carry channelId) leave both undefined → global fallback. + // carry channelId) leave both undefined: the turn has no trigger channel. // Track the winning REQUEST, not just its channel: channel, addressed // and counterparty must come from the same message, or a batch of // "ambient from A, then addressed from B" would report B's channel @@ -8509,6 +9226,17 @@ export class AgentFramework { counterparty: budgetRestart ? undefined : provReq?.counterparty, wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId, wakeAt: budgetRestart ? undefined : provReq?.wakeAt, + // Every conversation this batch carries: a true new turn infers its + // speech route from all of them (shelf-355), never from one pick. A + // restart keeps its turn's route and reads them only when it + // continues a silent tick that this batch makes ordinary, which had + // no route to keep (beginAgentTurn). + routeCandidates: requests.flatMap((r) => r.routeCandidates ?? []), + // Whether any of them named a conversation, candidate or not: only a + // batch none of whose requests did takes the fallback route. + namesConversation: requests.some((r) => r.namesConversation) ? true : undefined, + // A continued fallback turn's place, kept as this batch's fallback. + fallbackPlace: requests.find((r) => r.fallbackPlace)?.fallbackPlace, }); } } @@ -8530,117 +9258,1409 @@ export class AgentFramework { return stamp ? { silentHeartbeat: stamp } : undefined; } - /** Record prose segments suppressed by explicit-send silencing. */ + /** Record prose segments suppressed without drafts (proseRouting=disabled). */ private recordProseSuppression(agentName: string, count: number): void { if (count <= 0) return; this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count); } - /** Record a successful plain-prose delivery for this turn's receipt. */ - private recordProseDelivery( - agentName: string, - outcome: { delivered: boolean; channelId: string } | null | undefined, + /** + * Silenced plain speech: an explicit send holds it as resendable drafts; a + * deliberate silence (skip_reply, a silent turn, a same-round private + * think in a native round, where that policy applies) keeps it private — + * counted for the receipt, never drafted. `segments` are the runs exactly + * as written (splitProseRuns), so a draft keeps the resident's words byte + * for byte. + */ + private holdSilencedProse( + agent: Agent, + segments: string[], + cause: 'send' | 'private' | null, + privateThink: boolean, + opts: { round?: number; notice: 'now' | 'later' }, ): void { - if (!outcome?.delivered) return; - let list = this.turnProseDeliveries.get(agentName); - if (!list) { - list = []; - this.turnProseDeliveries.set(agentName, list); + if (segments.length === 0) return; + if (cause === 'private' || privateThink) { + this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + segments.length); + return; + } + if (agent.proseRouting !== 'hybrid') { + this.holdProseDrafts(agent, segments, 'explicit-send', opts); + return; + } + // Hybrid prose may carry `>>>` envelopes. A 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) this.proseContinuations.add(agent.name); + if (parsed.kind === 'private') { + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'private'); + if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); + privateCount++; + } else if (parsed.kind === 'target') { + const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!); + if (resolved && !('error' in resolved)) { + this.proseTargetPins.set(agent.name, { serverId: resolved.serverId, channelId: resolved.channelId }); + this.proseHybridSuppressed.delete(agent.name); + } else { + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'failed'); + } + const words = this.unsentWords(agent, parsed.body); + if ('draft' in words) { + if (!kept.includes(words.draft)) kept.push(words.draft); + } else { + parts.push({ + text: words.text, + note: `written for >>>${parsed.target}`, + ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}), + }); + } + } else if (envelope.startsWith('>>>')) { + // Malformed: an attempted destination that names none. + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'failed'); + parts.push({ text: envelope }); + } else if (this.proseHybridSuppressed.get(agent.name) === 'private') { + privateCount++; + } else { + parts.push({ text: envelope }); + } + } + } + if (privateCount > 0) { + this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + privateCount); + } + this.holdProseDrafts(agent, parts, 'explicit-send', opts); + if (kept.length > 0) { + // The turn's receipt names them by their state at turn end; each was + // named when it bounced. + const list = this.turnDrafts.get(agent.name) ?? []; + for (const draft of kept) if (!list.some((d) => d.id === draft.id)) list.push(draft); + this.turnDrafts.set(agent.name, list); } - list.push(outcome.channelId); } /** - * Append the turn's `[delivered]` receipt: one compact system message - * naming where this turn's plain prose actually landed (channels in - * delivery order, deduped). Explicit sends receipt themselves via their - * tool_result; auto-routed prose previously left no in-window trace, so - * the agent could never see where its own words went — misroutes were - * invisible to it until a human reported them (2026-07-31 series). - * Called ONLY at logical turn end (stream 'complete', or the endTurn - * cancel path after the speech chain settles) — never mid-stream, where a - * window-only insert would diverge from the wire. No-op when the turn - * delivered nothing. Failures are already marked separately - * ([discord-send-failed]); this is the success half. + * Hold plain speech as drafts (src/prose-drafts.ts) instead of + * letting it vanish: journaled durably and recorded for this turn's + * receipt. `notice: 'now'` also queues a private notice naming them, which + * the resident hears at its next tool boundary — only when the live stream + * 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 appendProseDeliveryReceipt(agent: Agent): void { - const list = this.turnProseDeliveries.get(agent.name); - const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0; - if ((!list || list.length === 0) && suppressed === 0) return; - this.turnProseDeliveries.delete(agent.name); - this.turnProseSuppressed.delete(agent.name); - const seen = new Set(); - const shown: string[] = []; - for (const id of list ?? []) { - if (seen.has(id)) continue; - seen.add(id); - const label = this.channelRegistry?.getDescriptor(id)?.label; - shown.push( - label && label !== id - ? `${label.startsWith('#') ? label : `#${label}`} (${id})` - : id, - ); + private holdProseDrafts( + agent: Agent, + texts: Array, + reason: DraftReason, + opts: { round?: number; note?: string; notice: 'now' | 'later' }, + ): Draft[] { + const segments = texts + .map((t) => (typeof t === 'string' + ? { text: t, note: opts.note } + : { text: t.text, note: t.note ?? opts.note, inheritedRisk: t.inheritedRisk })) + .filter((t) => t.text.trim().length > 0); + if (segments.length === 0) return []; + const turn = this.logicalTurnToolCalls.get(agent)?.turnToken ?? 0; + let branch = 'unknown'; + try { + branch = this.store.currentBranch().name; + } catch { + // provenance only + } + const { held, notHeld } = this.proseDrafts.hold( + agent.name, + segments.map(({ text, note, inheritedRisk }, segment) => ({ + text, + ...(note ? { note } : {}), + ...(inheritedRisk ? { inheritedRisk } : {}), + source: { branch, turn, round: opts.round ?? 0, segment } satisfies DraftSource, + })), + reason, + ); + if (notHeld) { + console.error(`[drafts] ${agent.name}: ${notHeld.count} segment(s) could not be held as drafts: ${notHeld.error}`); + const prior = this.turnDraftFailures.get(agent.name); + this.turnDraftFailures.set(agent.name, { count: (prior?.count ?? 0) + notHeld.count, error: notHeld.error }); + } + if (held.length === 0) return held; + const list = this.turnDrafts.get(agent.name) ?? []; + list.push(...held); + this.turnDrafts.set(agent.name, list); + console.error(`[drafts] ${agent.name}: held ${held.length} segment(s) as ${held.map((d) => d.id).join(', ')} (${reason})`); + this.emitTrace({ + type: 'prose:drafts-held', + agentName: agent.name, + reason, + draftIds: held.map((d) => d.id), + textLen: held.reduce((n, d) => n + d.text.length, 0), + }); + if (opts.notice === 'now') { + try { + this.addMessage( + 'user', + [{ type: 'text', text: this.draftsHeldNotice(agent.name, held, reason) }], + { system: true, kind: 'prose-drafts', draftOwner: agent.name, draftIds: held.map((d) => d.id) } as MessageMetadata, + { forAgent: agent.name }, + ); + } catch (err) { + console.error('[drafts] held-draft notice failed:', err); + } } - 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)` - : ''; + 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 — nothing this turn named a conversation it could speak into, or a failed routing envelope left plain speech waiting for a new valid target', + ambiguous: 'more than one place could have taken it, so unaddressed speech was held rather than guessed', + bounced: 'its routing prefix bounced', + }; + + private static readonly DRAFT_REASON_SHORT: Record = { + 'explicit-send': 'explicit send in the same round', + 'no-destination': 'no destination', + ambiguous: 'competing conversations', + bounced: 'routing prefix bounced', + }; + + private draftsHeldNotice(agentName: string, held: Draft[], reason: DraftReason): string { + const ids = held.map((d) => d.id); + const many = held.length > 1; + const quoted = (ds: Draft[]): string => ds.map((d) => `"${d.id}"`).join(', '); + // Words copied from a draft that may already have been posted resend + // only with the resident's confirmation; the rest resend freely. + const free = held.filter((d) => draftState(d) !== 'unconfirmed'); + const risky = held.filter((d) => draftState(d) === 'unconfirmed'); + const resend = [ + ...(free.length > 0 + ? [`drafts(action: "resend", draftIds: [${quoted(free)}], destination: "#channel") delivers ` + + `${free.length > 1 ? 'them' : 'it'} unchanged`] + : []), + ...risky.map((d) => `${this.riskText(d)}, so check that channel before resending ${d.id} ` + + `(drafts(action: "resend", draftIds: ["${d.id}"], destination: "#channel", confirmDuplicate: true))`), + ]; + // Named: residents that share one message slot (#197) share this window. + // "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]}. ` + + `Nothing publishes drafts but your own resend, and they stay until you act: ${resend.join('; ')}; ` + + `drafts(action: "dismiss", draftIds: [${quoted(held)}]) sets ${many ? 'them' : 'it'} aside.` + ); + } + + /** Compact local time for draft listings. */ + private draftTime(t: number): string { + return formatZonedDateTime(t, this.timeZone).replace(/:\d\d\.\d{3}/, ''); + } + + private static draftPreview(text: string, max = 80): string { + const flat = text.replace(/\s+/g, ' ').trim(); + return JSON.stringify(flat.length > max ? `${flat.slice(0, max)}…` : flat); + } + + private static destinationText(d: PublishDestination): string { + return AgentFramework.placeText(d); + } + + /** A destination in words, as every conversation is named to a resident + * (describeConversation): its label as recorded, then `server / channel-id` + * and its thread. */ + private static placeText(d: { serverId?: string; channelId: string; label?: string; threadId?: string | null }): string { + return describeConversation({ + kind: 'channel', + ...(d.serverId ? { serverId: d.serverId } : {}), + channelId: d.channelId, + ...(d.threadId ? { threadId: d.threadId } : {}), + ...(d.label ? { label: d.label } : {}), + }); + } + + /** One line per draft: id, state, when, why, size, preview. */ + 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 a preview, the rest by id. + */ + private noticeUnnoticedDrafts(agent: Agent): void { + const cm = agent.getContextManager(); + const pending = this.proseDrafts.unnoticed(agent.name, (id) => cm.getMessage(id as MessageId) !== null); + if (pending.length === 0) return; + const shown = pending.slice(0, 8).map((d) => + `${d.id} (${this.draftStateText(d)}; held ${this.draftTime(d.heldAt)}, ` + + `${AgentFramework.DRAFT_REASON_SHORT[d.reason]}, ${d.text.length} chars: ${AgentFramework.draftPreview(d.text, 60)})`); + const rest = pending.slice(shown.length).map((d) => d.id); + const more = rest.length > 0 ? ` · and ${rest.length} more: ${rest.join(', ')}` : ''; const text = - shown.length > 0 - ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` - : `[delivered] nothing — ${suppressedNote}`; + `[drafts] ${agent.name}: ${pending.length === 1 ? 'a held draft' : `${pending.length} held drafts`} of yours ` + + `${pending.length === 1 ? 'is' : 'are'} unnamed on this branch of your history: ${shown.join(' · ')}${more}. ` + + 'drafts(action: "list") shows your open drafts; resend or dismiss them by id.'; + let mid: string; try { - const mid = agent.getContextManager().addMessage( + mid = cm.addMessage( 'user', [{ type: 'text', text }], - { system: true, kind: 'delivery-receipt' } as MessageMetadata, + { system: true, kind: 'prose-drafts', draftOwner: agent.name, draftIds: pending.map((d) => d.id) } as MessageMetadata, ); - this.emitTrace({ type: 'message:added', messageId: mid, source: 'delivery-receipt' }); + this.emitTrace({ type: 'message:added', messageId: mid, source: 'prose-drafts' }); } catch (err) { - console.error('delivery receipt append failed:', err); + console.error('[drafts] catch-up notice failed:', err); + return; + } + try { + this.proseDrafts.markNoticed(agent.name, pending.map((d) => d.id), mid); + } catch (err) { + // Named again by the next turn's catch-up. + console.error('[drafts] could not record the catch-up notice:', err); } } /** - * Announce-on-change locus notice (turn-frozen routing). Appends a durable - * `[routing]` window message when the turn's effective outbound locus - * differs from the last one announced — the agent must never have to guess - * where its plain prose lands. Durable append (never retracted) keeps the - * KV prefix stable; change-only keeps it out of steady-state turns. The - * first resolution after boot announces only a non-null locus ("prose has - * no destination" is the unremarkable boot default). Never triggers - * inference; never locus-eligible itself (system + no channel metadata). + * 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. */ - private announceLocusIfChanged(agentName: string, locus: string | null): void { - const hasBaseline = this.lastAnnouncedLocus.has(agentName); - const prev = this.lastAnnouncedLocus.get(agentName) ?? null; - if (hasBaseline ? prev === locus : locus === null) { - if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus); - return; + private settleTurnDrafts(agent: Agent): void { + const held = this.turnDrafts.get(agent.name); + this.turnDrafts.delete(agent.name); + this.turnDraftFailures.delete(agent.name); + this.turnProsePrivate.delete(agent.name); + if (!held?.length) return; + const byMessage = new Map(); + for (const [draftId, messageId] of this.draftsNamedInHistory(agent, held.map((d) => d.id))) { + byMessage.set(messageId, [...(byMessage.get(messageId) ?? []), draftId]); + } + for (const [messageId, ids] of byMessage) { + try { + this.proseDrafts.markNoticed(agent.name, ids, messageId); + } catch (err) { + // Unmarked drafts are named again by the next turn's catch-up. + console.error('[drafts] could not record notice:', err); + } + } + } + + /** How far back draftsNamedInHistory looks: this turn's notices are near + * the end. One beyond it leaves its draft for the next turn's catch-up. */ + private static readonly DRAFT_NOTICE_SCAN = 500; + + /** Which of these drafts a stored notice or receipt OF THIS RESIDENT names + * (draft ids are unique only per resident, and residents can share one + * history), 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 resident's `drafts` tool: list, read, resend and dismiss its own held + * drafts. Private to the calling resident. `resend` is an explicit send: + * each draft is published verbatim, in order, through the normal publish + * executor, its attempt journaled durably before the request leaves. + */ + private async handleDraftsTool(agentName: string, rawInput: unknown): Promise { + const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record; + const refuse = (error: string): ToolResult => ({ success: false, error, isError: true }); + // Plain text, not a JSON-quoted string: draft text must read exactly as 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-k7x3q", or null.'); + } + ids = (input.draftIds as string[]).map((id) => id.trim()); + if (new Set(ids).size !== ids.length) return refuse('draftIds names a draft more than once.'); + if (ids.length > 20) return refuse('draftIds names more than 20 drafts; act on them in smaller groups.'); + } + if (present(input.destination) && action !== 'resend') return unused('destination'); + if (present(input.threadId) && action !== 'resend') return unused('threadId'); + 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 ${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}` + + (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[] = []; + 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) { + return refuse( + `${lines.length > 0 ? `${lines.join('\n')}\n` : ''}Could not record dismissing ${d.id}: ` + + `${err instanceof Error ? err.message : String(err)}. It is still open.`, + ); + } + lines.push(`${d.id}: dismissed (your history keeps the words).${risk}`); + } + this.emitTrace({ type: 'prose:drafts-dismissed', agentName, draftIds: drafts.map((d) => d.id) }); + 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.`); + if (home && target.destination.channelId !== home) { + return refuse(`This conversation is bound to channel ${home}; resending to ${target.destination.channelId} is not allowed. Nothing was sent.`); + } + // The place inside the channel (MCPL RFC-011): the thread named with it, + // else the root — never a thread borrowed from where the words were held. + const thread = readThreadId(input.threadId); + if ('error' in thread) return refuse(`${thread.error} Nothing was sent.`); + const destination: PublishDestination = { ...target.destination, threadId: thread.threadId }; + const placeRefusal = publishPlaceRefusal(registry.publishTarget(destination), destination.threadId ?? null); + if (placeRefusal) { + const why = ChannelRegistry.placeRefusalText(placeRefusal, destination, destination.threadId ?? null); + const consult = this.connectorToolsText(destination.serverId); + return refuse(`${why}.${consult ? ` ${consult}` : ''} Nothing was sent.`); + } + + // Refuse up front, before anything is published, rather than half-way. + 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) { + lines.push(`${d.id}: not sent — the attempt could not be recorded durably (${err instanceof Error ? err.message : String(err)}).`); + 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, threadId: destination.threadId ?? null }); + 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, + threadId: outcome.destination?.threadId ?? destination.threadId ?? null, + ...(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)`; + this.noteSendEngagement(agentName, outcome.status, outcome.destination ?? destination); + if (outcome.status === 'delivered') { + lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); + continue; + } + stopped = `${d.id} was not confirmed delivered`; + const after = this.proseDrafts.get(agentName, d.id); + const stillAtRisk = after !== undefined && draftState(after) === 'unconfirmed'; + lines.push(outcome.status === 'unknown' + ? `${d.id}: delivery to ${where} NOT confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. ` + + `Check the channel before sending it again (that needs confirmDuplicate: true)${unrecorded}.` + : `${d.id}: not sent to ${where} — ${outcome.reason ?? 'refused'}. Nothing was posted by this attempt${unrecorded}.` + + (stillAtRisk + ? ` The draft stays unconfirmed: ${this.riskText(after!)}.` + : ' The draft stays held.')); + } + } 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)}. Check that channel; to send it again anyway, resend with confirmDuplicate: true.`; + } + return undefined; + } + + /** + * Record a successful plain-prose delivery for this turn's receipt: the + * destination as resolved when it was delivered — its server, channel, + * the label it had then, and its thread — so the receipt says exactly + * where the words went, whatever is renamed or shares an id later. + */ + private recordProseDelivery( + agentName: string, + outcome: { delivered: boolean; channelId: string; serverId?: string; label?: string; threadId?: string | null } | null | undefined, + ): void { + if (!outcome?.delivered) return; + let list = this.turnProseDeliveries.get(agentName); + if (!list) { + list = []; + this.turnProseDeliveries.set(agentName, list); } - this.lastAnnouncedLocus.set(agentName, locus); + const surface = outcome.channelId.startsWith('surface:'); + const label = surface ? undefined : outcome.label ?? (outcome.serverId + ? this.channelRegistry?.resolveDestination({ serverId: outcome.serverId, channelId: outcome.channelId }) + : this.channelRegistry?.resolveDestination({ channelId: outcome.channelId })); + list.push({ + channelId: outcome.channelId, + ...(outcome.serverId ? { serverId: outcome.serverId } : {}), + ...(typeof label === 'string' ? { label } : label && 'destination' in label && label.destination.label ? { label: label.destination.label } : {}), + ...(outcome.threadId ? { threadId: outcome.threadId } : {}), + }); + } + + /** A delivery's identity for the receipt: server, channel and thread. */ + private static deliveryKey(d: { serverId?: string; channelId: string; threadId?: string | null }): string { + return `${d.serverId ?? ''}\u0000${d.channelId}\u0000${d.threadId ?? ''}`; + } + /** + * Append the turn's `[delivered]` receipt: one compact system message + * naming where this turn's plain prose actually landed (channels in + * delivery order, deduped). Explicit sends receipt themselves via their + * tool_result; auto-routed prose previously left no in-window trace, so + * the agent could never see where its own words went — misroutes were + * invisible to it until a human reported them (2026-07-31 series). + * Called ONLY at logical turn end (stream 'complete', or the endTurn + * cancel path after the speech chain settles) — never mid-stream, where a + * window-only insert would diverge from the wire. No-op when the turn + * delivered nothing. Failures are already marked separately + * ([discord-send-failed]); this is the success half. + */ + private appendProseDeliveryReceipt(agent: Agent): void { + const list = this.turnProseDeliveries.get(agent.name); + const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0; + const drafts = this.turnDrafts.get(agent.name) ?? []; + const kept = this.turnProsePrivate.get(agent.name) ?? 0; + const notHeld = this.turnDraftFailures.get(agent.name); + if ((!list || list.length === 0) && suppressed === 0 && drafts.length === 0 && kept === 0 && !notHeld) return; + this.turnProseDeliveries.delete(agent.name); + this.turnProseSuppressed.delete(agent.name); + const seen = new Set(); + const shown: string[] = []; + const unique: ProseDelivery[] = []; + for (const d of list ?? []) { + const key = AgentFramework.deliveryKey(d); + if (seen.has(key)) continue; + seen.add(key); + unique.push(d); + } + for (const d of unique) { + if (d.channelId.startsWith('surface:')) { + // Speech on a surface route was shown there, never published. + shown.push(`${d.channelId.slice('surface:'.length)} (the local surface that messaged you; not published to any channel)`); + continue; + } + shown.push(AgentFramework.placeText(d)); + } + 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. + const current = drafts.map((d) => this.proseDrafts.get(agent.name, d.id) ?? d); + const inState = (state: DraftState): Draft[] => current.filter((d) => draftState(d) === state); + const idList = (ds: Draft[]): string => + ds.slice(0, 8).map((d) => d.id).join(', ') + (ds.length > 8 ? `, and ${ds.length - 8} more` : ''); + const held = inState('held'); + if (held.length > 0) { + notes.push( + `${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)', + ); + } + const unconfirmed = inState('unconfirmed'); + for (const d of unconfirmed) { + notes.push(`draft ${d.id} is unconfirmed: ${this.riskText(d)} — check that channel before sending it again (resend needs confirmDuplicate: true)`); + } + // 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(AgentFramework.deliveryKey(attempt.destination))) { + 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)`); + 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. + const text = + shown.length > 0 + ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` + : `[delivered] nothing${unconfirmed.length > 0 || dismissedAtRisk.length > 0 ? ' confirmed' : ''} — ${suppressedNote}`; + try { + const mid = agent.getContextManager().addMessage( + 'user', + [{ type: 'text', text }], + { + 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) { + console.error('delivery receipt append failed:', err); + } + } + + /** + * A route candidate from an accepted item's source envelope: its channel + * conversation (server, channel, thread; the message is the reply edge) or + * the local surface that messaged the resident (always addressed). + */ + private candidateFromSource( + source: InboundSource | undefined, + addressed: boolean, + ): RouteCandidate | undefined { + if (!source) return undefined; + if (source.kind === 'surface') { + return { conversation: { kind: 'surface', surface: source.surface }, addressed: true, at: source.acceptedAt }; + } + if (source.kind !== 'channel') return undefined; + return { + conversation: { + kind: 'channel', + serverId: source.serverId, + channelId: source.channelId, + ...(source.threadId ? { threadId: source.threadId } : {}), + ...(source.label ? { label: source.label } : {}), + }, + addressed, + ...(source.messageId ? { messageId: source.messageId } : {}), + at: source.acceptedAt, + }; + } + + /** + * A route candidate for a registered channel id (gate and coalesced wakes). + * The server is the event's own, else the id's sole registrant. An id no + * single server holds (shared across servers, or withdrawn since) gets no + * guessed server: the candidate competes for the turn but is unroutable. + */ + private candidateForChannel( + channelId: string, + addressed: boolean, + at: number, + serverId?: string, + messageId?: string, + threadId?: string, + ): RouteCandidate { + const server = serverId || this.channelRegistry?.getChannelServerId(channelId) || undefined; + // The label is the server's own for the id: another server's channel + // with the same id may carry another. With no server, any label would be + // a guess, so there is none. + const label = server ? this.channelRegistry?.getChannelLabel(server, channelId) : undefined; + return { + conversation: { + kind: 'channel', + ...(server ? { serverId: server } : {}), + channelId, + ...(threadId ? { threadId } : {}), + ...(label ? { label } : {}), + }, + addressed, + ...(messageId ? { messageId } : {}), + at, + ...(server ? {} : { unroutable: true as const }), + }; + } + + /** + * A candidate as it stands when its turn starts. One whose channel had no + * server when its wake was queued (candidateForChannel) is resolved again, + * so a server that registered while the wake waited makes it routable. A + * gate's unroutable candidate that names its server is a raw id the host + * couldn't resolve to a registered channel, and stays as it is. + */ + private resolvedCandidate(c: RouteCandidate): RouteCandidate { + if (!c.unroutable || c.conversation.kind !== 'channel' || c.conversation.serverId) return c; + const { channelId, threadId } = c.conversation; + return this.candidateForChannel(channelId, c.addressed, c.at, undefined, c.messageId, threadId); + } + + /** + * The fallback for a wake that names no conversation: where the last + * message any channel delivered came in (its server, channel and thread), + * as plain speech went before speech routes. Not when a conversation fork + * owns that channel and this agent isn't the fork: the fork rule that keeps + * every other route out of a fork's channel. + */ + private fallbackCandidate(agentName: string): RouteCandidate | null { + const last = this.channelRegistry?.getLastInbound?.(); + if (!last || !this.mayTakeRoute(agentName, last.channelId)) return null; + return this.candidateForChannel(last.channelId, false, Date.now(), last.serverId, undefined, last.threadId); + } + + /** + * A tune-out coordinator's wake: the subconscious's cadence and wake + * turns, and a cancel's wakes of the resident and the subconscious. One + * about a channel names that conversation, so its turn never takes the + * fallback: a resident's speech just after a cancel is held rather than + * sent where the last message came in (the subconscious is explicit-prose, + * so this changes nothing for it). + */ + private queueTuneOutWake(agentName: string, reason: string, source: string, channelId?: string): void { + this.pendingRequests.push({ + agentName, reason, source, timestamp: Date.now(), + ...(channelId ? { namesConversation: true as const } : {}), + }); + } + + /** + * A framework wake that continues `agentName`'s turn: an unstick, a retry + * after a rewind (a refusal, an unstick or an inference failure), the + * results of a non-streaming tool round, an explicit-prose continuation or + * bounce, or a bare provider-cooldown retry. It carries only that it + * continues the turn; continuationFor fills in the rest when its batch + * starts. A budget restart and a guard retry aren't built here: they + * continue the same logical turn, which keeps its route record + * (continuingTurn). + */ + private continuationRequest(agentName: string, reason: string): InferenceRequest { + return { agentName, reason, source: 'framework', timestamp: Date.now(), continuesTurn: true }; + } + + /** + * A request that continues its agent's turn, as its batch starts + * (processInferenceRequests): what that turn's record gives it now + * (continuationOf). The conversations the turn answered become the + * request's candidates, so the true new turn it starts, alone or batched + * with other wakes, infers its route from them by the rules every wake + * follows, the fork rule included. A fallback turn's place becomes its + * fallback. A silent wake's own continuations are silent ticks too, with + * the turn's prompt and stamp, as a budget restart is; an unstick and a + * bounce's resend never are (ORDINARY_CONTINUATIONS). + * + * The record read is the agent's most recent turn's, as that turn left + * it, which is the turn the request continues: a resident's true new turn + * starts only here, an agent's requests wait while a turn is alive, and + * the next turn takes every request the agent has pending. So a request + * queued during its turn or in its teardown names where that turn's words + * went by its end, after a channel_open or a hold. An unstick waits for an + * idle agent, and a provider cooldown holds every request of the agent. + * Only a bounce can be queued after a successor turn has begun, from the + * previous turn's speech landing while the successor waits for it; its + * wake then runs after the successor and names the successor's + * conversations, where the resident last spoke, but never takes the + * successor's silence. + */ + private continuationFor(request: InferenceRequest): InferenceRequest { + if (!request.continuesTurn) return request; + const turn = this.turnRoutes.get(request.agentName); + // The turn's conversations count as older than anything batched with + // them: whatever else is in the batch arrived after that turn began, so + // a message in the same conversation gives the reply edge. + const { candidates, namesConversation, fallback } = continuationOf(turn, 0); + const silent = turn?.silent && !AgentFramework.ORDINARY_CONTINUATIONS.has(request.reason); + return { + ...request, + continuesTurn: undefined, + ...(namesConversation ? { namesConversation: true as const } : {}), + ...(candidates.length > 0 ? { routeCandidates: candidates } : {}), + ...(fallback ? { fallbackPlace: fallback } : {}), + ...(silent ? { suppressProse: true, ...turn.silent } : {}), + }; + } + + /** + * Wakes that follow a turn without being part of it: an operator's unstick, + * and a bounce's resend, which can land after a later turn has begun. They + * take the turn's conversations but never its silence, so each is an + * ordinary turn, as on main. + */ + private static readonly ORDINARY_CONTINUATIONS: ReadonlySet = new Set(['unstick', 'prose-bounce']); + + /** + * A conversation fork's home as its standing route (it always wins). Homes + * are bare channel ids: the server is the id's sole registrant, and a + * shared id gets none, so the route is unresolved rather than guessed. + */ + private homeRoute(agentName: string): SpeechRoute | null { + const home = this.conversationAgentHomes.get(agentName) ?? this.channelRegistry?.resolveLocus(agentName) ?? null; + if (!home) return null; + const serverId = this.channelRegistry?.getChannelServerId(home) ?? undefined; + const label = serverId ? this.channelRegistry?.getChannelLabel(serverId, home) : undefined; + return { + kind: 'channel', + ...(serverId ? { serverId } : {}), + channelId: home, + ...(label ? { label } : {}), + origin: 'home', + }; + } + + /** The current logical turn's number (logicalTurns). */ + private logicalTurn(agentName: string): number { + return this.logicalTurns?.get(agentName) ?? 0; + } + + /** + * Prose the resident's speech has just published, confirmed, at a channel + * root goes to that inference's outgoing stream too (MCPL §14.3): a voice + * surface speaks only what was published, where it was published — + * never held, private, failed or unconfirmed words. The chunk names the + * outcome's own server and channel, and the root; a thread placement isn't + * streamed, so a channel's stream names one place. Each publish is its own + * message, so a later one on a channel that has already streamed opens with + * a paragraph break: deltas concatenated (what a voice consumer speaks) and + * the completion keep the messages apart instead of running "Let me + * look.Found it." together. + */ + private streamPublished( + agentName: string, + moment: SpeechMoment, + published: { serverId: string; channelId: string; threadId?: string | null }, + text: string, + ): void { + if (!moment.inferenceId || !this.channelRegistry || published.threadId || !text) return; + const streams = (this.outgoingStreams ??= new Map()); + let stream = streams.get(moment.inferenceId); + if (!stream) { + stream = { conversationId: agentName, index: 0, channels: new Map() }; + streams.set(moment.inferenceId, stream); + } + const key = `${published.serverId}\u0000${published.channelId}`; + // Only what this channel actually streamed counts: a chunk that didn't go + // out leaves no entry, so it never earns the next one a separator. + const channel = stream.channels.get(key); + const delta = channel ? `\n\n${text}` : text; + const sent = this.channelRegistry.sendOutgoingChunk( + { serverId: published.serverId, channelId: published.channelId }, + agentName, moment.inferenceId, stream.index++, delta, + ); + if (!sent) return; + if (channel) channel.text += delta; + else stream.channels.set(key, { serverId: published.serverId, channelId: published.channelId, text: delta }); + } + + /** + * Close the outgoing streams of these inferences: each channel gets its + * one completion, with exactly the text its stream carried. Chained at the + * physical stream's end, so it follows that stream's last speech. + */ + private completeOutgoingStreams(inferenceIds: Iterable): void { + for (const inferenceId of inferenceIds) { + const stream = this.outgoingStreams?.get(inferenceId); + if (!stream) continue; + this.outgoingStreams.delete(inferenceId); + for (const channel of stream.channels.values()) { + this.channelRegistry?.sendOutgoingComplete( + { serverId: channel.serverId, channelId: channel.channelId }, + stream.conversationId, inferenceId, channel.text, + ); + } + } + } + + /** Append a link to the agent's speech chain (speechChains); it runs after every earlier one. */ + private chainSpeech(agentName: string, what: string, work: () => Promise | void): void { + const chains = (this.speechChains ??= new Map()); + chains.set(agentName, (chains.get(agentName) ?? Promise.resolve()) + .then(work) + .catch((err) => console.error(`${what} failed:`, err))); + } + + /** Settles once every link chained so far for the agent has. */ + private speechSettled(agentName: string): Promise { + return this.speechChains?.get(agentName) ?? Promise.resolve(); + } + + /** How long stop() waits, at each of its two points, for the speech + * chains to settle. A chain still busy then is left, and logged. */ + private static SPEECH_DRAIN_MS = 10_000; + + /** stop(): wait for every agent's speech chain, bounded. */ + private async settleSpeechChains(when: string): Promise { + const chains = [...(this.speechChains?.entries() ?? [])]; + if (chains.length === 0) return; + let timer: ReturnType | undefined; + const settled = new Set(); + const all = Promise.all(chains.map(([agent, chain]) => chain.then(() => { settled.add(agent); }))); + const bound = new Promise<'timeout'>((resolve) => { timer = setTimeout(() => resolve('timeout'), AgentFramework.SPEECH_DRAIN_MS); }); + const outcome = await Promise.race([all.then(() => 'settled' as const), bound]); + clearTimeout(timer); + if (outcome === 'timeout') { + const busy = chains.map(([agent]) => agent).filter((agent) => !settled.has(agent)); + console.error(`[stop] speech of ${busy.join(', ')} still settling after ${AgentFramework.SPEECH_DRAIN_MS} ms, ${when}; going on without it`); + } + } + + /** + * Record an explicit send by the host itself — a resend or channel_publish + * — as engagement (turnEngagement), from its outcome: delivered, or + * unknown (something may have been posted: a possible reply claim, not a + * delivery), engages the exact conversation asked for; failed engages + * nothing. The one rule for every host-publication path. + */ + private noteSendEngagement( + agentName: string, + status: PublishOutcome['status'], + place: { serverId?: string; channelId: string; threadId?: string | null }, + ): void { + if (status === 'failed') return; + const key = conversationKey({ + kind: 'channel', + ...(place.serverId ? { serverId: place.serverId } : {}), + channelId: place.channelId, + ...(place.threadId ? { threadId: place.threadId } : {}), + }); + const engaged = this.engagementFor(agentName); + if (engaged.conversations.get(key) !== 'delivered') engaged.conversations.set(key, status); + } + + /** Record a connector's own send tool, whose place in the channel the host can't know, as engagement of the channel (turnEngagement). */ + private noteChannelEngagement(agentName: string, serverId: string | undefined, channelId: string): void { + this.engagementFor(agentName).channels.add(AgentFramework.channelScopeKey(serverId, channelId)); + } + + private engagementFor(agentName: string): { conversations: Map; channels: Set } { + const all = (this.turnEngagement ??= new Map()); + let engaged = all.get(agentName); + if (!engaged) { + engaged = { conversations: new Map(), channels: new Set() }; + all.set(agentName, engaged); + } + return engaged; + } + + /** + * How the resident engaged a conversation this turn, if it did: `delivered` + * or `unknown` — the host published there itself, confirmed or not — or + * `channel`: a connector's send tool posted somewhere in its channel. + */ + private engagementOf(agentName: string, c: ConversationRef): 'delivered' | 'unknown' | 'channel' | undefined { + if (c.kind !== 'channel') return undefined; + const engaged = this.turnEngagement?.get(agentName); + if (!engaged) return undefined; + return engaged.conversations.get(conversationKey(c)) + ?? (engaged.channels.has(AgentFramework.channelScopeKey(c.serverId, c.channelId)) ? 'channel' : undefined); + } + + private static channelScopeKey(serverId: string | undefined, channelId: string): string { + return `${serverId ?? ''}\u0000${channelId}`; + } + + /** The channel the agent's unaddressed speech goes to now, if it is a channel and not held. */ + private routeChannelId(agentName: string): string | null { + // Optional chaining: prototype-built harnesses leave the map unset. + const turn = this.turnRoutes?.get(agentName); + return turn && !turn.hold && turn.route?.kind === 'channel' ? turn.route.channelId : null; + } + + /** The channel route's channel with its server, where typing shows and + * logs name it: the same id on another server is another channel. */ + private routeChannel(agentName: string): { serverId?: string; channelId: string } | null { + const turn = this.turnRoutes?.get(agentName); + if (!turn || turn.hold || turn.route?.kind !== 'channel') return null; + return { ...(turn.route.serverId ? { serverId: turn.route.serverId } : {}), channelId: turn.route.channelId }; + } + + /** + * The exact place the agent's unaddressed speech is published to now — its + * channel route's server, channel and thread (null: the channel root) — or + * null when it has no channel route or is held. + */ + private routeTarget(agentName: string): { serverId?: string; channelId: string; threadId: string | null } | null { + const turn = this.turnRoutes?.get(agentName); + if (!turn || turn.hold || turn.route?.kind !== 'channel') return null; + const route = turn.route; + return { ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, threadId: route.threadId ?? null }; + } + + /** A channel's declared publish target (MCPL RFC-011), read live from the registry. */ + private publishTargetOf(channel: { serverId?: string; channelId: string }): 'exact' | 'root' | 'unresolved' | undefined { + const registry = this.channelRegistry; + if (!registry) return undefined; + const declared = registry.publishTarget(channel); + if (declared) return declared; + // No declaration read: a registered channel that declares none, or a + // channel that isn't exactly one registration (unknown, withdrawn, or a + // shared id named without its server). Only the second is unresolved. + return 'error' in registry.resolveDestination(channel) ? 'unresolved' : undefined; + } + + /** + * Connector tools worth consulting for a conversation the framework can't + * publish into, as the resident calls them — only what the connector + * actually lists: its tools of class `comms` (MCPL RFC-008), or whose own + * names begin send/reply/post/say; failing those, the prefix its tools + * share. A match says where to look, not that the tool can post there: + * the resident judges from the tool's own description. + */ + private connectorSendTools(serverId: string | undefined): { tools: string[]; prefix?: string } { + if (!serverId) return { tools: [] }; + const tools: string[] = []; + let prefix: string | undefined; + for (const tool of this.mcplTools ?? []) { + if (this.mcplToolServers?.get(tool.name) !== serverId) continue; + const hit = this.resolveMcplTool(tool.name); + prefix ??= hit?.[1]; + const own = hit ? tool.name.slice(hit[1].length + 2) : tool.name; + const comms = this.effectiveToolClass(tool.name, true).classes.includes('comms' as ToolClass); + if (comms || /^(send|reply|post|say)(_|$)/i.test(own)) tools.push(tool.name); + } + return { tools: tools.slice(0, 4), ...(prefix ? { prefix } : {}) }; + } + + /** The connector tools to consult, in words; empty when it lists none. */ + private connectorToolsText(serverId: string | undefined): string { + const { tools, prefix } = this.connectorSendTools(serverId); + if (tools.length > 0) return `Its connector's own tools may reach it; consult them: ${tools.map((t) => `\`${t}\``).join(', ')}.`; + if (prefix) return `Its connector's own tools (\`${prefix}--…\`) may reach it; consult their descriptions.`; + return ''; + } + + /** Why plain speech can't go to a conversation, and which connector tools to consult. */ + private unroutableText(unroutable: NonNullable): string { + const c = unroutable.conversation; + const where = describeConversation(c); + const consult = c.kind === 'channel' ? this.connectorToolsText(c.serverId) : ''; + const tail = consult ? ` ${consult}` : ''; + switch (unroutable.reason) { + case 'untargetable': + return `${where}: its connector doesn't declare where a post lands (MCPL RFC-011) — it may choose a thread ` + + `itself — so publication from here is unavailable until it does.${tail}`; + case 'thread': + return `${where} is a thread, but its connector declares the channel has no threads, so publication from ` + + `here can't target it.${tail}`; + case 'unresolved': + return `${where} doesn't resolve to exactly one registered channel.${tail || ' Use an explicit send tool to reach it.'}`; + } + } + + /** The agent's route as the registry needs it (context, channel_publish default). */ + private speechRouteView(agentName: string): SpeechRouteView { + const turn = this.turnRoutes.get(agentName); + if (!turn) return { kind: 'none' }; + if (turn.hold) return { kind: 'held', conversations: turn.hold.conversations.map(describeConversation) }; + const route = turn.route; + if (!route) return { kind: 'none' }; + if (route.kind === 'surface') return { kind: 'surface', surface: route.surface }; + return { + kind: 'channel', + ...(route.serverId ? { serverId: route.serverId } : {}), + channelId: route.channelId, + ...(route.threadId ? { threadId: route.threadId } : {}), + ...(route.replyTo ? { replyTo: route.replyTo } : {}), + }; + } + + /** + * Deliver one segment of unaddressed plain speech along the route it was + * written under (its SpeechMoment, captured when the words were produced: + * a channel_open or a hold that lands while they wait on the speech chain + * governs only words written after it): a channel route publishes it; a + * surface route shows it there (the + * surface already holds the turn's words) and publishes nothing; a held + * turn keeps it as an `ambiguous` draft naming the competing + * conversations; no route keeps it as a `no-destination` draft. + */ + private async speakUnaddressed( + agent: Agent, + text: string, + hold: { round?: number; notice: 'now' | 'later' }, + /** When the words were produced: the route they were written under, and their stream. */ + moment: SpeechMoment = { turn: this.turnRoutes?.get(agent.name) }, + ): Promise { + const turn = moment.turn; + if (turn?.hold) { + this.holdProseDrafts(agent, [text], 'ambiguous', { + ...hold, + note: `competing conversations: ${turn.hold.conversations.map(describeConversation).join('; ')}`, + }); + return; + } + const route = turn?.route; + if (!route) { + const unroutable = turn?.unroutable; + this.holdProseDrafts(agent, [text], 'no-destination', { + ...hold, + ...(unroutable ? { note: this.unroutableText(unroutable) } : {}), + }); + return; + } + if (route.kind === 'surface') { + this.recordProseDelivery(agent.name, { delivered: true, channelId: `surface:${route.surface}` }); + return; + } + if (!this.channelRegistry) { + // No channel subsystem to publish through: keep the words. + this.holdProseDrafts(agent, [text], 'no-destination', hold); + return; + } + // The route's exact place: its server, channel and thread (or root). + const target = { ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId, threadId: route.threadId ?? null }; + const outcome = await this.channelRegistry.routeSpeech(agent.name, text, target, { holdFailed: this.holdUndelivered(agent, text, hold) }); + this.recordProseDelivery(agent.name, outcome); + if (outcome?.delivered) this.streamPublished(agent.name, moment, outcome, text); + } + + /** deliverSpeech's holdFailed for unaddressed speech: words that provably + * weren't posted are kept as a bounce draft (as a failed `>>>` delivery's + * are), named by the one failure notice. An unknown outcome keeps none, + * so as not to invite a duplicate. */ + private holdUndelivered(agent: Agent, text: string, hold: { round?: number }): (reason: string) => string | undefined { + return (reason) => this.holdProseDrafts(agent, [text], 'bounced', { round: hold.round, note: `delivery failed: ${reason}`, notice: 'later' })[0]?.id; + } + + /** A turn route in words, for logs. */ + private static routeText(turn: TurnRoute): string { + if (turn.hold) return `held between ${turn.hold.conversations.map(describeConversation).join(' and ')}`; + if (turn.route) return describeConversation(routeConversation(turn.route)); + return turn.unroutable ? `none (${turn.unroutable.reason}: ${describeConversation(turn.unroutable.conversation)})` : 'none'; + } + + /** Identity of a turn route for announce-on-change. */ + private static routeKey(turn: TurnRoute): string { + if (turn.hold) return `held:${turn.hold.conversations.map(conversationKey).sort().join('|')}`; + if (!turn.route) return turn.unroutable ? `unroutable:${conversationKey(turn.unroutable.conversation)}` : 'none'; + return conversationKey(routeConversation(turn.route)); + } + + /** + * Announce-on-change route notice. Appends a durable `[routing]` window + * message when the turn's route differs from the last one announced — the + * agent must never have to guess where its plain speech goes, or why it is + * held. Durable append (never retracted) keeps the KV prefix stable; + * change-only keeps it out of steady-state turns. The first resolution + * after boot announces only a route that exists ("no destination" is the + * unremarkable boot default). Never triggers inference. + */ + private announceRouteIfChanged(agentName: string, turn: TurnRoute): void { + const key = AgentFramework.routeKey(turn); + const hasBaseline = this.lastAnnouncedRoute.has(agentName); + const prev = this.lastAnnouncedRoute.get(agentName); + if (hasBaseline ? prev === key : key === 'none') { + if (!hasBaseline) this.lastAnnouncedRoute.set(agentName, key); + return; + } + this.lastAnnouncedRoute.set(agentName, key); const agent = this.agents.get(agentName); if (!agent) return; let text: string; - if (locus === null) { + if (turn.hold) { + text = + `[routing] More than one conversation is waiting for you: ${turn.hold.conversations.map(describeConversation).join('; ')}. ` + + 'Your unaddressed plain speech is held as drafts this turn rather than guessed: answer one with an ' + + 'explicit send (or channel_open it), and resend held words with the drafts tool.'; + } else if (!turn.route && turn.unroutable) { + text = + `[routing] Your plain speech can't go to the conversation in front of you, so it is held as drafts. ` + + this.unroutableText(turn.unroutable); + } else if (!turn.route) { + text = + '[routing] Your plain speech currently has no destination — it is held as drafts you can resend. ' + + 'Use an explicit send tool to reach a channel.'; + } else if (turn.route.kind === 'surface') { text = - '[routing] Your plain speech currently has no channel — it stays in ' + - 'your archive. Use an explicit send tool to reach a channel.'; + `[routing] Your plain speech now goes to ${describeConversation(routeConversation(turn.route))}: ` + + 'it is shown there and published to no channel. Channels need an explicit send tool.'; + } else if (turn.route.origin === 'fallback') { + text = + `[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message ` + + `came in: ${describeConversation(routeConversation(turn.route))}. Other channels need an explicit send tool.`; } else { - const label = this.channelRegistry?.getDescriptor(locus)?.label; - const shown = - label && label !== locus - ? `${label.startsWith('#') ? label : `#${label}`} (${locus})` - : locus; text = - `[routing] Your plain speech now lands in ${shown}. ` + + `[routing] Your plain speech now lands in ${describeConversation(routeConversation(turn.route))}. ` + 'Other channels need an explicit send tool.'; } - try { const id = agent.getContextManager().addMessage( 'user', @@ -8648,14 +10668,13 @@ export class AgentFramework { { system: true, kind: 'routing-notice' }, ); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); - console.error( - `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`, - ); + console.error(`[routing] ${agentName}: route -> ${AgentFramework.routeText(turn)} (announced in window)`); } catch (err) { - console.error('announceLocusIfChanged: failed to record routing notice:', err); + console.error('announceRouteIfChanged: failed to record routing notice:', err); } } + /** * Durable window notice for a channel the agent did not ask to open — * subscription-policy admission, or an open forced by the agent's own @@ -8666,12 +10685,18 @@ export class AgentFramework { */ private recordChannelAutoOpenNotice( agentName: string | undefined, - channels: Array<{ channelId: string; label?: string }>, + channels: Array<{ serverId?: string; channelId: string; label?: string }>, cause: 'subscription-policy' | 'opened-by-delivery' | 'opened-by-reply', ): void { if (channels.length === 0) return; + // Named as every conversation is (describeConversation), server included. const shown = channels - .map((c) => (c.label && c.label !== c.channelId ? `${c.label} (${c.channelId})` : c.channelId)) + .map((c) => describeConversation({ + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.label ? { label: c.label } : {}), + })) .join(', '); const plural = channels.length > 1; const causeText = @@ -8704,7 +10729,13 @@ export class AgentFramework { * nothing is ever sent to a destination the model did not name (directly * or via the turn's sticky target). */ - private async deliverProse(agent: Agent, rawText: string): Promise { + private async deliverProse( + agent: Agent, + rawText: string, + hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + /** When the words were produced: their outgoing stream, once published. */ + moment: SpeechMoment = { turn: undefined }, + ): Promise { // Multi-envelope: a single prose block may address SEVERAL destinations — // every line beginning with `>>` opens a new envelope, routed // independently (first live use: Tilde 2026-07-24, one block carrying a @@ -8728,38 +10759,152 @@ export class AgentFramework { for (const envelope of envelopes) { if (!envelope.trim()) continue; - await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, '')); + await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''), hold, moment); + } + } + + /** + * `{{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, + hold: { round?: number; notice: 'now' | 'later' }, + ): { draft?: Draft; refused?: true } { + const draft = this.proseDrafts.latestBounce(agent.name); + if (!draft) return {}; + const key = `${agent.name}\u0000${draft.id}`; + let why: string; + let next: string; + if (this.draftsInFlight.has(key)) { + why = this.draftsSending.has(key) ? 'which is being sent right now' : 'which is queued in a resend in progress'; + next = 'wait for that result'; + } else if (draftState(draft) === 'unconfirmed') { + why = this.riskText(draft); + next = 'check that channel; to send that draft again anyway use ' + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel", confirmDuplicate: true)`; + } else { + return { draft }; } + const words = this.unsentWords(agent, body); + const held = 'draft' in words + ? undefined + : this.holdProseDrafts( + agent, + [{ text: words.text, ...(words.inheritedRisk ? { inheritedRisk: words.inheritedRisk } : {}) }], + 'bounced', + { round: hold.round, note: `{{unsent}} was draft ${draft.id}, which could not be sent`, notice: 'later' }, + )[0]; + const kept = held + ? ` Your message is held as draft ${held.id}, with {{unsent}} written out; since it includes ${draft.id}'s words, it ` + + 'can be resent only with confirmDuplicate: true, after that.' + : ''; + const text = `[prose-routing] {{unsent}} is draft ${draft.id}, ${why.startsWith('which') ? why : `and ${why}`}. ` + + `Nothing was sent; ${next}.${kept}`; + try { + this.addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'prose-bounce', ...(held ? { draftOwner: agent.name, draftIds: [held.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, + /** The envelope's destination, on the server its target resolved to. */ + destination: { serverId: string; channelId: string }, + unsent: Draft | undefined, + ): Promise { + const registry = this.channelRegistry!; + let attemptId: string | undefined; + if (unsent) { + const target = registry.resolveDestination(destination); + if (!('error' in target)) { + try { + attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false); + } catch (err) { + const reason = `the attempt for draft ${unsent.id} could not be recorded durably (${err instanceof Error ? err.message : String(err)})`; + console.error(`[drafts] ${agent.name}: ${reason} — not sending`); + return { status: 'failed', reason, at: Date.now() }; + } + } + } + const flight = unsent ? `${agent.name}\u0000${unsent.id}` : undefined; + if (flight) { + this.draftsInFlight.add(flight); + this.draftsSending.add(flight); + } + let outcome: PublishOutcome; + try { + outcome = await registry.deliverSpeech(agent.name, body, destination); + } 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' }, { forAgent: agent.name }); + } catch (noticeErr) { + console.error('[drafts] unrecorded-outcome notice failed:', noticeErr); + } + } + } + return outcome; + } + + /** A draft's state as the journal reads it now: after a write that failed + * and was reconciled, whatever reached the journal counts. */ + private draftStateNow(agentName: string, id: string): DraftState | 'let go' { + const draft = this.proseDrafts.get(agentName, id); + return draft ? draftState(draft) : 'let go'; } /** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */ private async deliverHybridProse( agent: Agent, rawText: string, - locus: string | null, - allowLocus: boolean, + hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + /** When the words were produced: the route they were written under, and their stream. */ + moment: SpeechMoment = { turn: this.turnRoutes?.get(agent.name) }, ): Promise { - const lines = rawText.split('\n'); - const envelopes: string[] = []; - let current: string[] = []; - for (const line of lines) { - if (line.trimStart().startsWith('>>>') && current.length > 0) { - envelopes.push(current.join('\n')); - current = []; - } - current.push(line); - } - if (current.length > 0) envelopes.push(current.join('\n')); - - for (const envelope of envelopes) { - if (!envelope.trim()) continue; - const normalized = envelope.replace(/^\s+(?=>>>)/, ''); + for (const envelope of splitHybridEnvelopes(rawText)) { + const normalized = envelope; const attempted = normalized.startsWith('>>>'); const parsed = parseHybridProsePrefix(normalized); if (parsed.continueTurn) this.proseContinuations.add(agent.name); if (parsed.kind === 'private') { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); + this.proseHybridSuppressed.set(agent.name, 'private'); + // `>>>skip_reply {{unsent}}`: the resident sets the bounced draft aside. + if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`); continue; } @@ -8767,101 +10912,162 @@ export class AgentFramework { const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!); if ('error' in resolved) { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>', hold); continue; } let body = parsed.body; const usedClipboard = body.includes('{{unsent}}'); - if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? ''); - this.proseTargetPins.set(agent.name, resolved.channelId); + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; + if (unsent.refused) { + this.proseTargetPins.delete(agent.name); + this.proseHybridSuppressed.set(agent.name, 'failed'); + continue; + } + if (usedClipboard) body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); + const pin = { serverId: resolved.serverId, channelId: resolved.channelId }; + this.proseTargetPins.set(agent.name, pin); this.proseHybridSuppressed.delete(agent.name); if (!body.trim()) { console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`); continue; } try { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId); - this.recordProseDelivery(agent.name, outcome); - if (outcome?.delivered) { + const outcome = await this.deliverWithUnsent(agent, body, pin, unsent.draft); + this.recordProseDelivery(agent.name, outcome.status === 'delivered' ? { delivered: true, ...outcome.destination! } : null); + if (outcome.status === 'delivered') { + this.streamPublished(agent.name, moment, outcome.destination!, body); this.proseBounceStreaks.delete(agent.name); - if (usedClipboard) this.proseClipboards.delete(agent.name); } else { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + // An uncertain delivery is reported by the failure marker; it is + // not bounced, so its words are not re-held as a fresh draft that + // invites a duplicate. + if (outcome.status === 'failed') { + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', hold); + } } } catch (err) { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', hold); } continue; } if (attempted) { this.proseTargetPins.delete(agent.name); - this.proseHybridSuppressed.add(agent.name); - this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>'); + this.proseHybridSuppressed.set(agent.name, 'failed'); + this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>', hold); continue; } - if (this.proseHybridSuppressed.has(agent.name)) { - this.recordProseSuppression(agent.name, 1); + const suppressedBy = this.proseHybridSuppressed.get(agent.name); + if (suppressedBy === 'private') { + this.turnProsePrivate.set(agent.name, (this.turnProsePrivate.get(agent.name) ?? 0) + 1); continue; } - const sticky = this.proseTargetPins.get(agent.name); - if (!allowLocus && !sticky) { - this.recordProseSuppression(agent.name, 1); + if (suppressedBy === 'failed') { + this.holdProseDrafts(agent, [envelope], 'no-destination', hold); continue; } - const target = sticky ?? locus; + // A `>>>target` set this turn is deliberate and wins; otherwise the + // envelope is unaddressed speech, which follows the route it was + // written under. + const sticky = this.proseTargetPins.get(agent.name); try { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target); - this.recordProseDelivery(agent.name, outcome); + if (sticky) { + const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, sticky, { holdFailed: this.holdUndelivered(agent, envelope, hold) }); + this.recordProseDelivery(agent.name, outcome); + if (outcome?.delivered) this.streamPublished(agent.name, moment, outcome, envelope); + } else { + await this.speakUnaddressed(agent, envelope, hold, moment); + } } catch (err) { console.error('hybrid locus delivery failed:', err); } } } - private async deliverProseEnvelope(agent: Agent, rawText: string): Promise { + /** `>>skip_reply {{unsent}}` and its hybrid form: set the latest bounce + * draft aside — unless it is being sent right now, which a dismissal + * can't call back: the resident is told instead. So are a dismissal that + * can't be recorded (the draft stays, and `{{unsent}}` still names it) + * and one whose draft may already have been posted. */ + 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); + tell(`[prose-routing] {{unsent}} is draft ${draft.id}, which could not be set aside ` + + `(${err instanceof Error ? err.message : String(err)}): it is still held, and {{unsent}} still names it.`, 'failed-dismiss'); + return; + } + if (risk) tell(`[prose-routing] Draft ${draft.id} is set aside. Note that ${risk}.`, 'at-risk-dismiss'); + } + + private async deliverProseEnvelope( + agent: Agent, + rawText: string, + hold: { round?: number; notice: 'now' | 'later' }, + moment: SpeechMoment, + ): Promise { const name = agent.name; const parsed = parseProsePrefix(rawText); if (parsed.continueTurn) this.proseContinuations.add(name); if (parsed.kind === 'private') { // The text already lives in the assistant message (window/chronicle); - // "delivery" is deliberately a no-op. Consumes the retained text if the - // segment embedded it. - if (parsed.body.includes('{{unsent}}')) this.proseClipboards.delete(name); + // "delivery" is deliberately a no-op. `>>skip_reply {{unsent}}` sets + // the bounced draft aside. + if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); console.error(`[prose] ${name}: >>skip_reply — ${parsed.body.length} chars kept in context (not sent)`); return; } - let targetChannel: string; + let target: { serverId: string; channelId: string }; let body: string; if (parsed.kind === 'target') { const res = this.channelRegistry!.resolveProseTarget(parsed.target!); if ('error' in res) { - this.bounceProse(agent, parsed.body, res.error, res.candidates); + this.bounceProse(agent, parsed.body, res.error, res.candidates, '>>', hold); return; } - targetChannel = res.channelId; - // Sticky within the turn: later unprefixed segments follow this target. - this.proseTargetPins.set(name, targetChannel); + target = { serverId: res.serverId, channelId: res.channelId }; + // Sticky within the turn: later unprefixed segments follow this + // target, on its server. + this.proseTargetPins.set(name, target); body = parsed.body; } else { const sticky = this.proseTargetPins.get(name); if (!sticky) { - this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn'); + this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn', undefined, '>>', hold); return; } - targetChannel = sticky; + target = sticky; body = rawText; } const usedClipboard = body.includes('{{unsent}}'); + const unsent = usedClipboard ? this.takeUnsent(agent, body, hold) : {}; + if (unsent.refused) return; if (usedClipboard) { - body = body.replaceAll('{{unsent}}', this.proseClipboards.get(name) ?? ''); + body = body.replaceAll('{{unsent}}', unsent.draft?.text ?? ''); } if (!body.trim()) { console.error(`[prose] ${name}: empty body after prefix/substitution — nothing to send`); @@ -8869,30 +11075,112 @@ export class AgentFramework { } try { - const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel); - if (result?.delivered) { + const outcome = await this.deliverWithUnsent(agent, body, target, unsent.draft); + if (outcome.status === 'delivered') { + this.streamPublished(name, moment, outcome.destination!, body); this.proseBounceStreaks.delete(name); - if (usedClipboard) this.proseClipboards.delete(name); } } catch (err) { - console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); + console.error(`[prose] ${name}: delivery to ${target.channelId} failed:`, err); } } /** 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): { draft: Draft } | { text: string; inheritedRisk?: InheritedRisk } { + if (!text.includes('{{unsent}}')) return { text }; const name = agent.name; - this.proseClipboards.set(name, text); + const previous = this.proseDrafts.latestBounce(name); + if (previous && text.trim() === '{{unsent}}') return { draft: previous }; + const expanded = text.replaceAll('{{unsent}}', previous?.text ?? ''); + if (!previous || !(draftState(previous) === 'unconfirmed' || this.draftsInFlight.has(`${name}\u0000${previous.id}`))) { + return { text: expanded }; + } + const risky = uncertainAttempt(previous); + const inheritedRisk: InheritedRisk = risky + ? { + draftId: previous.id, + destination: risky.destination, + at: risky.at, + reason: risky.outcome?.reason ?? (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[], + prefix: '>>' | '>>>' = '>>', + hold: { round?: number; notice: 'now' | 'later' } = { notice: 'later' }, + ): void { + const name = agent.name; + const words = this.unsentWords(agent, text); + 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', + { round: hold.round, note: reason, notice: 'later' }, + )[0]; + } const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; this.proseBounceStreaks.set(name, streak); const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : ''; + const kept = draft + ? `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, reply with a destination plus the ` + + `token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}", or use ` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"). ` + + `"${prefix}skip_reply {{unsent}}" sets it aside.` + : 'It could not be held as a draft; the words remain in your history.'; + const risky = inherited && draft + ? ` It includes the words of draft ${inherited.draftId}, which may already have been posted, so ${draft.id} ` + + 'can be resent only with the drafts tool and confirmDuplicate: true, after checking that channel.' + : ''; const notice = - `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` + - 'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' + - `destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` + - `"${prefix}skip_reply {{unsent}}" keeps it in context only.` + + `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ${kept}${risky}` + (prefix === '>>' ? ' The prose_help tool shows the full syntax.' : ''); try { // Through framework.addMessage, NOT the context manager directly: while @@ -8901,11 +11189,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' }); @@ -8919,13 +11208,10 @@ export class AgentFramework { ); if (!capped) { // Wake so the resend can happen immediately — an unsent DM reply must - // not sit invisible until the next unrelated event. - this.pendingRequests.push({ - agentName: name, - reason: 'prose-bounce', - source: 'framework', - timestamp: Date.now(), - }); + // not sit invisible until the next unrelated event. A bounce in a live + // turn wakes once that turn has ended, and names the conversations it + // answered by then (continuationFor). + this.pendingRequests.push(this.continuationRequest(name, 'prose-bounce')); } } @@ -9069,6 +11355,59 @@ export class AgentFramework { } } + /** + * Decides a turn's speech route from its wake: at a true new turn, and at + * a restart that continues a silent tick a batched ordinary wake made + * ordinary, which had no route to keep. + */ + private decideTurnRoute(agent: Agent, trigger: InferenceRequest | undefined): void { + // The route comes from a fork's home, else from the + // wake's candidates — addressed ones first, one conversation only, + // else held (src/speech-routes.ts). A wake that names no + // conversation at all (a heartbeat, a timer, a self-wake) takes the + // fallback, the last delivered message's conversation, as before + // speech routes. A framework wake that continues a turn (a rewind + // retry, an unstick, a prose continuation or bounce) carries the + // conversations that turn answered as its candidates, or a fallback + // turn's place as its fallback (continuationFor), so it is inferred + // here like any other. + // + // The fork rule applies here, where the route is decided, to every + // wake's candidates: a fork takes only its home's, and no other agent + // takes a channel a fork owns, as ownership stands now. A wake can + // wait in the queue while a fork claims or gives up a channel. A + // candidate it leaves out still named its conversation, so it still + // keeps the fallback away. That includes an unroutable candidate, + // which never routes but competes: a fork's channel never holds + // another agent's turn. + const turn = inferTurnRoute( + (trigger?.routeCandidates ?? []) + .map((c) => this.resolvedCandidate(c)) + .filter((c) => this.mayTakeCandidate(agent.name, c)), + this.homeRoute(agent.name), + (c) => this.publishTargetOf(c), + // Only a resident's wake that named no conversation, candidate or + // not, takes the fallback: a reaction, a system marker or a channel + // a fork owns names one, and gets no route as before. An ephemeral + // subagent never takes it: its prose is its caller's business, + // never a channel's. (A conversation fork's name is in the same + // set, but a fork's home always wins before any fallback is read.) + // A continued fallback turn keeps that turn's place, under the same + // fork rule as any fallback. + trigger?.namesConversation || this.usedEphemeralAgentNames.has(agent.name) + ? null + : trigger?.fallbackPlace + ? (this.mayTakeCandidate(agent.name, trigger.fallbackPlace) ? trigger.fallbackPlace : null) + : this.fallbackCandidate(agent.name), + ); + // A continued fallback whose kept place was refused has no place, + // and its own continuations take none either: it counts as named. + const named = trigger?.namesConversation || (!!trigger?.fallbackPlace && !turn.route); + this.turnRoutes.set(agent.name, { ...turn, ...(named ? { named: true as const } : {}) }); + this.midTurnInputSignals.delete(agent.name); + if (!trigger?.suppressProse) this.announceRouteIfChanged(agent.name, turn); + } + /** * Body of startAgentStream, split out so the caller's try/finally owns the * turn token unconditionally. Returns true iff the token was handed off to @@ -9084,6 +11423,12 @@ export class AgentFramework { const continuingTurn = trigger?.reason === 'context_budget_restart' || trigger?.reason === 'tool_result_guard_retry'; const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting; + // A true new turn begins once the previous turn's speech has landed. A + // turn that ends without settling its chain (an abort, an explicit-mode + // endTurn) could otherwise publish late into this one: its words + // overtaken by this turn's, its `>>`/`>>>` choices and deliveries + // written into the state cleared for this turn below. + if (attempt === 0 && !continuingTurn) await this.speechSettled(agent.name); // Flush messages deferred during the PREVIOUS turn — before the // checkpoint, the locus announcement, and the compile — so a turn started // by a queued wake actually CONTAINS the message that woke it. (2026-07-31 @@ -9178,39 +11523,12 @@ export class AgentFramework { this.redoStacks.delete(agent.name); // new work invalidates redo } - // Establish this turn's outbound routing locus (item-3 redux). Set it to the - // triggering channel for a channel/DM-triggered turn; clear it otherwise so - // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and - // instead falls back to the global default. A given agent runs one turn at a - // time, so a set here is only read during THIS turn's routeSpeech / - // buildChannelContext; retries re-run with the same trigger, re-setting it. - // A context-budget restart continues the SAME logical turn in a fresh - // stream — its trigger carries no channelId, and deleting the trigger - // channel here mid-logical-turn is exactly the hole that sent Sol's DM - // reply to a stale guild channel (2026-07-22): any live locus resolution - // after the restart fell through to the hours-old defaultPublishChannel. - // Keep the turn's trigger channel across restarts; only real new turns - // reset it. - if (!continuingTurn) { - if (trigger?.channelId) { - this.activeTriggerChannels.set(agent.name, trigger.channelId); - } else { - this.activeTriggerChannels.delete(agent.name); - } - } - - // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved - // once — home channel for forks, else the triggering channel, else the - // global default — and never moved for the rest of the turn: mid-turn - // injections must not redirect the agent's plain prose (ambient chatter / - // reactions hijacked it before, 2026-07-21). A context-budget restart - // continues the same logical turn, so it keeps the existing pin. The - // eager snapshot also protects heartbeat turns from a moving - // defaultPublishChannel mid-turn. If the effective locus differs from the - // last one announced, drop a durable `[routing]` notice into the window - // BEFORE this turn compiles, so the agent always knows where its voice - // goes (announce-on-change only — no per-turn chatter, append-only for - // KV stability). + // This turn's speech route (shelf-355) is decided below, once, at a true + // new turn: a fork's home, else inferred from the wake's candidates. A + // context-budget restart or a framework retry continues the SAME logical + // turn and keeps it — including a channel_open the resident made — so a + // late or repeated stream never re-resolves where its words go (the + // 2026-07-22 Sol DM misroute was exactly such a re-resolution). if (continuingTurn) { const previousLogicalToolState = this.logicalTurnToolCalls.get(agent); this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); @@ -9218,6 +11536,9 @@ export class AgentFramework { if (!continuingTurn) { if (attempt === 0) this.maybePrimeProseMode(agent); + // Name any held drafts no notice reached (a crash, an aborted turn), + // privately, before this turn's compile. + if (attempt === 0) this.noticeUnnoticedDrafts(agent); const previousLogicalToolState = this.logicalTurnToolCalls.get(agent); if (attempt === 0) { // A true new turn gets a fresh generation and count. @@ -9226,47 +11547,79 @@ export class AgentFramework { // A framework retry is a new physical stream in the same logical turn. this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 }); } - // Fresh turn: forget the previous turn's explicit-send engagements and - // prose deliveries — both are strictly turn-scoped (a restart continues - // the same logical turn and keeps them). - this.turnEngagedChannels.delete(agent.name); - this.turnProseDeliveries.delete(agent.name); - this.turnProseSuppressed.delete(agent.name); - this.proseHybridSuppressed.delete(agent.name); - if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); + if (attempt === 0) { + // A true new turn: forget the previous turn's explicit-send + // engagements, prose deliveries, drafts and deliberate prose choices + // (a `>>` or `>>>` target, a hybrid suppression) — all strictly + // turn-scoped. A queued framework wake that continues a turn (a + // rewind retry, an unstick, a prose continuation or bounce) starts a + // true new turn too: a rewind sheds the turn whose choices these + // were, and only where the words go carries over, through the + // conversations the wake names (continuationFor). + // A framework retry (attempt > 0), like a restart, + // continues the same logical turn: its sends were made, its words + // delivered or held, its choices chosen, and none of that rolls back + // with a failed stream, so it keeps them, as it keeps the route. + this.turnEngagement?.delete(agent.name); + this.turnProseDeliveries.delete(agent.name); + this.turnProseSuppressed.delete(agent.name); + this.turnDrafts.delete(agent.name); + this.turnProsePrivate.delete(agent.name); + this.turnDraftFailures.delete(agent.name); + this.proseHybridSuppressed.delete(agent.name); + this.proseTargetPins.delete(agent.name); + (this.logicalTurns ??= new Map()).set(agent.name, this.logicalTurn(agent.name) + 1); + } if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { - // Explicit and disabled prose routing have no inferred locus. + // Explicit and disabled prose routing have no inferred route. // Explicit mode uses a turn-scoped `>>` target; disabled mode has no - // prose target at all. Neither mode freezes or announces a locus. - this.proseTargetPins.delete(agent.name); + // prose target at all. Neither mode infers or announces a route. this.proseContinuations.delete(agent.name); this.midTurnInputSignals.delete(agent.name); - this.turnLocusPins.delete(agent.name); - } else { - const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null; - if (locus !== null) this.turnLocusPins.set(agent.name, locus); - else this.turnLocusPins.delete(agent.name); - this.midTurnInputSignals.delete(agent.name); - if (attempt === 0) this.announceLocusIfChanged(agent.name, locus); + this.turnRoutes.delete(agent.name); + } else if (attempt === 0) { + this.decideTurnRoute(agent, trigger); } } - // A context-budget restart normally preserves the turn locus, but a - // silent control-plane wake must remain locusless even if an explicit tool - // opened/pinned a channel mid-turn. + // A context-budget restart normally preserves the turn route. A silent + // control-plane wake has none: its channel_open sets none (the result + // says so), so this resets only what a restart could carry over. if (trigger?.suppressProse) { - this.turnLocusPins.delete(agent.name); + // The record keeps the wake's silence, so the turn's own continuations + // are silent ticks too (continuationFor). It is written when the turn + // begins; a restart or a guard retry continues the turn and keeps it. + if (!continuingTurn) { + this.turnRoutes.set(agent.name, { + route: null, + ...(trigger.namesConversation ? { named: true as const } : {}), + silent: { + ...(trigger.ephemeralSystemPrompt ? { ephemeralSystemPrompt: trigger.ephemeralSystemPrompt } : {}), + ...(trigger.silentHeartbeat ? { silentHeartbeat: trigger.silentHeartbeat } : {}), + }, + }); + } this.proseTargetPins.delete(agent.name); this.proseContinuations.delete(agent.name); + } else if (continuingTurn && this.turnRoutes.get(agent.name)?.silent) { + // A silent tick's restart batched with an ordinary wake runs as an + // ordinary turn (processInferenceRequests). The tick had no route to + // keep, so its route is decided now from the batch's wakes, as a true + // new turn's is, and its record stops being a silent tick's. In + // explicit or disabled prose routing there is no inferred route. + if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') this.turnRoutes.delete(agent.name); + else this.decideTurnRoute(agent, trigger); } this.touchEphemeralRun(agent.name, true); this.emitTrace({ type: 'inference:started', agentName: agent.name, - // The turn-frozen locus was pinned just above (kept across a - // context-budget restart, which skips the re-pin but re-emits this). - channelId: this.turnLocusPins.get(agent.name), + // The turn's route channel and its server, decided just above (kept + // across a context-budget restart, which skips the decision but + // re-emits this). + channelId: this.routeChannelId(agent.name) ?? undefined, + ...(this.routeChannel(agent.name)?.serverId ? { serverId: this.routeChannel(agent.name)!.serverId } : {}), ...(trigger?.suppressProse ? { silent: true } : {}), }); // A budget restart continues the same logical inference window. The @@ -9288,7 +11641,7 @@ export class AgentFramework { const earlyTypingChannel = turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : this.turnLocusPins.get(agent.name) ?? null; + : this.routeChannel(agent.name); if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel); try { @@ -9505,40 +11858,39 @@ export class AgentFramework { let generationLost = false; // ---- Present-while-acting turn state --------------------------------- - // Output locus for the WHOLE logical turn: frozen in startAgentStream - // (home → addressed trigger → global default) before this stream began, - // and never moved until the next turn. Mid-turn injections do not touch - // it — an ambient message or a reaction arriving at a tool boundary must - // not redirect the agent's task narration (2026-07-21 Cairn misroute). - // The pin lives in `turnLocusPins` so a context-budget restart (same - // logical turn, fresh driveStream) keeps it. - const resolveTurnLocus = (): string | null => - this.turnLocusPins.get(agent.name) ?? null; - - // Ordered delivery chain for live-routed prose. Links are enqueued + // The turn's speech route (turnRoutes) was decided in startAgentStream + // before this stream began and lives there, so a context-budget restart + // (same logical turn, fresh driveStream) keeps it. An arrival can HOLD an + // inferred route mid-turn (the ambiguity hold), and a channel_open can + // set a deliberate one, but nothing that arrives can point it somewhere + // else. Speech is published along the route it was WRITTEN under: each + // round's words take their SpeechMoment when the round ends, so a hold + // or an open that lands while they wait on the speech chain governs only + // words written after it. + const routeLabel = (moment: SpeechMoment): string => AgentFramework.routeText(moment.turn ?? { route: null }); + + // Ordered delivery for live-routed prose: the agent's speech chain + // (chainSpeech), shared by its physical streams. Links are enqueued // WITHOUT awaiting in the stream-event loop — an awaited network post // here would stall consumption of the next round's events by // segments × RTT on every prose-bearing round. The 'complete' case // awaits the chain before routing trailing prose, so in-channel ordering // is preserved end-to-end. Each link catches its own error: one failed // post must not silence the rest of the turn. - let turnSpeechChain: Promise = Promise.resolve(); - const enqueueSpeech = (text: string, locus: string | null): void => { - turnSpeechChain = turnSpeechChain - .then(async () => { - const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); - this.recordProseDelivery(agent.name, outcome); - }) - .catch((err) => console.error('mid-turn speech routing failed:', err)); - }; // Sticky explicit-send suppression: prose after send_message stays quiet // to prevent a redundant "sent it" postscript. Fresh injected input // clears it, because the following prose is a reply to a new message. - let turnSilenced = trigger?.suppressProse === true - || (trigger?.reason === 'tool_result_guard_retry' - && this.guardRetryTurnSilenced.get(agent.name) === true); + // Why it is silenced decides what happens to the held words: an explicit + // send ('send') holds them as resendable drafts; a deliberate silence + // ('private': skip_reply, a silent control-plane turn) keeps them private. + let silenceCause: 'send' | 'private' | null = trigger?.suppressProse === true + ? 'private' + : (trigger?.reason === 'tool_result_guard_retry' ? this.guardRetryTurnSilenced.get(agent.name) : undefined) ?? null; + let turnSilenced = silenceCause !== null; this.guardRetryTurnSilenced.delete(agent.name); + /** Provider rounds of this physical stream, for draft provenance. */ + let draftRound = 0; // Live routing is only trusted when the membrane provides verbatim // round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64). @@ -9565,8 +11917,8 @@ export class AgentFramework { ? null : turnProseRouting === 'explicit' ? trigger?.channelId ?? null - : resolveTurnLocus(); - if (typingChannel) this.channelRegistry!.startTyping(typingChannel); + : this.routeChannel(agent.name); + if (typingChannel) this.channelRegistry?.startTyping(typingChannel); // MCPL Spec 14.3 outgoing streaming: route text deltas to their // destination server AS THEY GENERATE (voice synthesis, live message @@ -9578,13 +11930,13 @@ export class AgentFramework { `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; // NOT const: a membrane refusal retry abandons everything streamed so // far, and the surface must not concatenate two attempts. Re-minting the - // id makes the discarded attempt's chunk buffer orphaned (never - // finalized) instead of being appended to — see the 'retrying' case. + // id makes the discarded attempt a separate inference instead of one + // appended to — see the 'retrying' case. let outgoingInferenceId = newOutgoingInferenceId(); // RFC-007: every inference id THIS stream mints, so the stream's end - // aborts only its own open tool calls — never a successor's. + // aborts only its own open tool calls — never a successor's — and + // completes only its own outgoing streams. const lifecycleInferenceIds = new Set([outgoingInferenceId]); - let outgoingIndex = 0; // §10.5 inference/lifecycle: `started` now, exactly one terminal in the // finally below (the one block every exit path shares). Which terminal @@ -9599,44 +11951,24 @@ export class AgentFramework { turnIndex: 0, phase: 'started', }); - // The wrapper guard needs the complete response before it can distinguish - // an exact invocation-shaped body from ordinary XML/prose. Buffer guarded - // turns until completion: otherwise speculative outgoing chunks could - // expose the wrapper before the fail-closed classifier runs. - const proseStream = this.channelRegistry && - turnProseRouting !== 'disabled' && - !agent.toolWrapperProseGuard - ? new ProseStreamRouter({ - mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus', - initialTarget: typingChannel, - resolve: (spec) => { - const r = this.channelRegistry!.resolveProseTarget(spec); - return 'channelId' in r ? r.channelId : null; - }, - }) - : null; - const emitOutgoing = (deltas: { channelId: string; delta: string }[]): void => { - for (const rd of deltas) { - this.channelRegistry!.sendOutgoingChunk( - rd.channelId, agent.name, outgoingInferenceId, outgoingIndex++, rd.delta, - ); - } - }; + // Outgoing streams (MCPL §14.3) carry only prose already published: the + // speech chain sends each confirmed publication at a channel root to the + // inference that wrote it (streamPublished), and this stream's teardown + // completes them after its last speech (completeOutgoingStreams). Nothing + // is streamed while it is generated, since a later call in the same + // round can still make the words private, held or unsent. // Tool-result-guard publication boundary. While a SUBMITTED batch is // pending (or a guard recovery is running), a refusal can still abandon // this physical round, so its streamed text must not reach any surface - // yet — neither the prose router nor the inference:tokens trace (TTS and - // other trace consumers voice it). Hold both; release in order on a clean - // round (tool-calls / non-guard completion), discard on a guard refusal. - let guardHeld: Array<{ trace: Parameters[0]; text?: string }> = []; + // yet — the inference:tokens trace (TTS and other trace consumers voice + // it). Hold it; release in order on a clean round (tool-calls / non-guard + // completion), discard on a guard refusal. + let guardHeld: Array[0]> = []; const releaseGuardHeld = (): void => { const held = guardHeld; guardHeld = []; - for (const item of held) { - this.emitTrace(item.trace); - if (proseStream && item.text !== undefined) emitOutgoing(proseStream.feed(item.text)); - } + for (const trace of held) this.emitTrace(trace); }; const discardGuardHeld = (): void => { guardHeld = []; }; @@ -9648,6 +11980,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 { @@ -9674,12 +12007,10 @@ export class AgentFramework { // turn's prose is bound for, without re-deriving routing. channelId: typingChannel ?? undefined, }; - const text = event.meta.type === 'text' ? event.content : undefined; if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) { - guardHeld.push({ trace, text }); + guardHeld.push(trace); } else { this.emitTrace(trace); - if (proseStream && text !== undefined) emitOutgoing(proseStream.feed(text)); } break; } @@ -9687,15 +12018,14 @@ export class AgentFramework { case 'retrying': { // Membrane is re-issuing after a content-policy refusal. Per the // RetryingEvent contract we must DISCARD everything this call has - // emitted: the tokens above were already streamed to the surface - // as outgoing chunks, and appending a second attempt to them - // would show the human two half-answers spliced together. + // emitted: the tokens above already reached trace consumers, and + // appending a second attempt to them would show the human two + // half-answers spliced together. // - // Re-mint the outgoing id so the partial buffer is orphaned - // rather than continued (delivery is authoritative via - // deliverProse and has not happened yet — nothing has been - // *sent*, only previewed), and reset the prose router so its - // destination bookkeeping starts clean for the new attempt. + // Re-mint the outgoing id so the attempt that stands is a new + // inference (the empty tokens trace below tells consumers to + // drop the partial one). Its words never reached the speech + // chain, so no outgoing channel stream carries them. console.error( `[refusal-retry] agent=${agent.name} membrane retry ` + `${event.attempt}/${event.maxAttempts}` + @@ -9704,9 +12034,7 @@ export class AgentFramework { ); outgoingInferenceId = newOutgoingInferenceId(); lifecycleInferenceIds.add(outgoingInferenceId); - outgoingIndex = 0; discardGuardHeld(); - proseStream?.reset(); this.emitTrace({ type: 'inference:tokens', agentName: agent.name, @@ -9806,6 +12134,11 @@ export class AgentFramework { agent.enterWaitingForTools(event.calls, stream); + // This round's words were written before any of its calls ran: + // they go where the route stood then, whatever those calls (a + // channel_open) or the arrivals at their results (a hold) change. + const roundMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name), inferenceId: outgoingInferenceId }; + for (const call of event.calls) { // RFC-007: register at the one dispatch point for model-issued // calls, keyed by (agent, call id) with this inference's id; @@ -9828,17 +12161,39 @@ export class AgentFramework { // from the round it occurs (see SILENCING_TOOLS). Only rounds // with verbatim roundContent are live-routed (see liveProseRouting // note above — the fallback preamble is cumulative in XML mode). + draftRound++; + // Whether this round's tool-result batch (and anything queued with + // it) reaches the model before it speaks again: the producer says + // so when it can; otherwise native rounds (verbatim roundContent) + // carry injections and XML rounds don't. + this.roundPresentsInjections.set( + agent.name, + (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages + ?? (roundContent !== undefined && roundContent.length > 0), + ); if (this.channelRegistry) { const roundToolNames = event.calls.map((c) => c.name); const hasSameRoundPrivateThink = 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; + const holdOpts = { round: draftRound, notice: presentsInjections ? 'now' as const : 'later' as const }; + // 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( @@ -9847,13 +12202,15 @@ export class AgentFramework { this.recordProseSuppression(agent.name, roundSegments.length); } else if (turnProseRouting === 'hybrid') { if (turnSilenced) { - this.recordProseSuppression(agent.name, roundSegments.length); + // In order behind earlier rounds' envelopes: the router + // state they leave is where this round's starts. + const cause = silenceCause; + this.chainSpeech(agent.name, 'mid-turn hybrid prose hold', () => + this.holdSilencedProse(agent, roundRuns, cause, hasSameRoundPrivateThink, holdOpts)); } else if (!hasSameRoundPrivateThink) { - const locus = resolveTurnLocus(); for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverHybridProse(agent, seg, locus, true)) - .catch((err) => console.error('mid-turn hybrid prose delivery failed:', err)); + this.chainSpeech(agent.name, 'mid-turn hybrid prose delivery', () => + this.deliverHybridProse(agent, seg, holdOpts, roundMoment)); } } } else if (turnProseRouting === 'explicit') { @@ -9865,29 +12222,26 @@ export class AgentFramework { `[prose] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> ${roundSegments.length} segment(s) via prose gateway`, ); for (const seg of roundSegments) { - turnSpeechChain = turnSpeechChain - .then(() => this.deliverProse(agent, seg)) - .catch((err) => console.error('mid-turn prose delivery failed:', err)); + this.chainSpeech(agent.name, 'mid-turn prose delivery', () => this.deliverProse(agent, seg, holdOpts, roundMoment)); } } } else if (turnSilenced) { console.error( - `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`, + `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced: ${silenceCause})`, ); - // Visible in the turn-end receipt — silencing must never - // be a silent black hole (n=8: the flying-scene reply). - this.recordProseSuppression(agent.name, roundSegments.length); + // Never a silent black hole (n=8: the flying-scene reply): + // held as drafts the resident can resend, and named. + this.holdSilencedProse(agent, 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)`, ); } else { - const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`, + `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${routeLabel(roundMoment)}`, ); for (const seg of roundSegments) { - enqueueSpeech(seg, locus); + this.chainSpeech(agent.name, 'mid-turn speech routing', () => this.speakUnaddressed(agent, seg, holdOpts, roundMoment)); } } } @@ -9898,6 +12252,11 @@ export class AgentFramework { } case 'complete': { + // The final round's words were all written by now: they go where + // the route stood as they were, even though they are published + // after the agent is idle, when a next turn may already have + // decided its own (the 2026-07-22 Sol DM misroute's shape). + const finalMoment: SpeechMoment = { turn: this.turnRoutes.get(agent.name), inferenceId: outgoingInferenceId }; adoptInjectedRound(); const durationMs = Date.now() - startTime; let response = event.response; @@ -9917,7 +12276,6 @@ export class AgentFramework { // Nothing from the abandoned physical attempt is published or // persisted as assistant speech, including a terminal refusal // after the single recovery retry. - proseStream?.reset(); if (withheld) { const usage = response.details?.usage ?? response.usage; const tokenUsage = usage ? { @@ -9951,7 +12309,7 @@ export class AgentFramework { reason: 'tool_result_guard', inputTokens: agent.lastStreamInputTokens, budget: agent.maxStreamTokens, }); - await turnSpeechChain; + await this.speechSettled(agent.name); if (this.agents.get(agent.name) !== agent || agent.streamId !== myStreamId) { generationLost = true; lifecyclePhase = 'aborted'; @@ -9963,7 +12321,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, { @@ -10215,12 +12573,9 @@ export class AgentFramework { const count = this.updateRewindMarker(agent, category); onStep(count); if (forced) forced.removed.push(rec); - this.pendingRequests.push({ - agentName: agent.name, - reason: forced ? 'unstick-retry' : 'refusal-rewind-retry', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push( + this.continuationRequest(agent.name, forced ? 'unstick-retry' : 'refusal-rewind-retry'), + ); handledByRewind = true; }; @@ -10310,54 +12665,63 @@ export class AgentFramework { // they are never routed here — which is precisely how the `think` // tool (and any explicit send tool) yields a silent turn. if (speechContent.length > 0 && this.channelRegistry) { - const speechText = speechContent + const speechRun = speechContent .map((b) => (b as ContentBlock & { type: 'text' }).text) - .join('\n') - .trim(); - if (speechText) { + .join('\n'); + // A text-only turn is one message — unless an all-refused XML + // round (membrane `tool_attempt`, answered in-band with a + // `tool_notice`) separates its prose: nothing was called, but + // the words before the attempt and after its notice are two + // messages, routed as segments like a tool turn's. Routing + // publishes trimmed segments; held drafts keep the runs exactly + // as written. + const speechRuns = response.content.some((b) => (b.type as string) === 'tool_attempt') + ? splitProseRuns(response.content) + : speechRun.trim() ? [speechRun] : []; + const speechSegments = speechRuns.map((run) => run.trim()); + const textOnlyHold = { round: draftRound + 1, notice: 'later' as const }; + if (speechSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); - this.recordProseSuppression(agent.name, 1); + this.recordProseSuppression(agent.name, speechSegments.length); } else if (turnSilenced && turnProseRouting !== 'explicit') { // Only reachable on a guard recovery that carried the // same-turn send suppression, or a suppressProse trigger // (a fresh text-only turn starts unsilenced). Explicit // mode is exempt, as mid-turn. - console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced)`); - this.recordProseSuppression(agent.name, 1); + console.error(`[routing] ${agent.name}: text-only prose NOT routed (turn silenced: ${silenceCause})`); + const cause = silenceCause; + this.chainSpeech(agent.name, 'text-only prose hold', () => + this.holdSilencedProse(agent, speechRuns, cause, false, textOnlyHold)); } else if (turnProseRouting === 'hybrid') { - const locus = resolveTurnLocus(); console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`); - try { - await this.deliverHybridProse(agent, speechText, locus, true); - } catch (err) { - console.error('text-only hybrid prose delivery failed:', err); + for (const segment of speechSegments) { + this.chainSpeech(agent.name, 'text-only hybrid prose delivery', () => + this.deliverHybridProse(agent, segment, textOnlyHold, finalMoment)); } } else if (turnProseRouting === 'explicit') { console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`); - try { - await this.deliverProse(agent, speechText); - } catch (err) { - console.error('text-only prose delivery failed:', err); + for (const segment of speechSegments) { + this.chainSpeech(agent.name, 'text-only prose delivery', () => this.deliverProse(agent, segment, textOnlyHold, finalMoment)); } } else { - // Route to the TURN-FROZEN locus, like every other speech - // path. This dispatch runs AFTER the agent is idle, so a live - // resolution here can read the NEXT turn's trigger state (or - // a post-restart cleared one) and land the reply in a stale - // channel — the 2026-07-22 Sol DM-to-guild misroute. The - // frozen pin is immune to both races. - const locus = resolveTurnLocus(); + // Route along the route the words were written under + // (finalMoment), like every other speech path. This + // dispatch runs AFTER the agent is idle, so a live + // resolution here can read the NEXT turn's route (or a + // post-restart cleared one) and land the reply in a stale + // channel — the 2026-07-22 Sol DM-to-guild misroute. console.error( - `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`, + `[routing] ${agent.name}: text-only turn -> routing speech -> ${routeLabel(finalMoment)}`, ); - try { - const outcome = await this.channelRegistry.routeSpeech(agent.name, speechText, locus); - this.recordProseDelivery(agent.name, outcome); - } catch (err) { - console.error('speech routing failed:', err); + for (const segment of speechSegments) { + this.chainSpeech(agent.name, 'speech routing', () => this.speakUnaddressed(agent, segment, textOnlyHold, finalMoment)); } } + // In order behind anything an earlier physical stream of the + // turn still has on the chain, and settled before the turn's + // receipt is written. + await this.speechSettled(agent.name); } } else if (this.channelRegistry && hadToolCalls && allText.length > 0) { // Tool-call turn that also produced prose. When live routing was @@ -10374,21 +12738,28 @@ export class AgentFramework { // injected message can reset. The legacy/fallback path has no // reliable round boundaries, so retain its historical turn-wide // scan to avoid double-posting. - const toolNames = response.content + const toolCalls = response.content .filter((b) => b.type === 'tool_use') - .map((b) => (b as unknown as { name?: string }).name) - .filter((n): n is string => typeof n === 'string'); + .map((b) => b as unknown as { name?: string; input?: unknown }) + .filter((c): c is { name: string; input?: unknown } => typeof c.name === 'string'); + const toolNames = toolCalls.map((c) => c.name); + const fallbackCause = liveProseRouting ? null : silenceCauseOf(toolCalls); const silenced = liveProseRouting ? turnSilenced - : turnSilenced || toolNames.some(isSilencingTool); - - const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); - - // 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; - + : turnSilenced || fallbackCause !== null; + const trailingCause: 'send' | 'private' | null = + silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); + const trailingHold = { round: draftRound + 1, notice: 'later' as const }; + + // Routing publishes trimmed segments; held drafts keep the runs + // exactly as written. + const runs = splitProseRuns(liveProseRouting ? terminalContent : response.content); + const segments = runs.map((run) => run.trim()); + + // Preserve in-channel ordering: trailing prose is chained behind + // everything enqueued live (by this stream or an earlier one of + // the turn), and the chain is settled even when silenced — it + // may still be flushing earlier rounds' posts. if (turnProseRouting === 'disabled') { if (segments.length > 0) { console.error( @@ -10398,15 +12769,12 @@ export class AgentFramework { } } else if (turnProseRouting === 'hybrid') { if (silenced && segments.length > 0) { - this.recordProseSuppression(agent.name, segments.length); + this.chainSpeech(agent.name, 'trailing hybrid prose hold', () => + 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); - } catch (err) { - console.error('trailing hybrid prose delivery failed:', err); - } + this.chainSpeech(agent.name, 'trailing hybrid prose delivery', () => + this.deliverHybridProse(agent, seg, trailingHold, finalMoment)); } } } else if (turnProseRouting === 'explicit') { @@ -10415,53 +12783,43 @@ export class AgentFramework { `[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`, ); for (const seg of segments) { - try { - await this.deliverProse(agent, seg); - } catch (err) { - console.error('trailing prose delivery failed:', err); - } + this.chainSpeech(agent.name, 'trailing prose delivery', () => this.deliverProse(agent, seg, trailingHold, finalMoment)); } } } else if (silenced || segments.length === 0) { console.error( `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` + - `(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`, + `(${silenced ? `turn silenced: ${trailingCause}` : 'no trailing prose'})`, ); if (silenced && segments.length > 0) { - this.recordProseSuppression(agent.name, segments.length); + this.chainSpeech(agent.name, 'trailing prose hold', () => + this.holdSilencedProse(agent, runs, trailingCause, false, trailingHold)); } } else { - // Reuse the locus pinned at the turn's first live-routed - // segment (or resolve it now for a turn whose only prose is - // trailing). This dispatch runs AFTER the agent is idle (see - // PR #32 note below), so a queued inbound from another channel - // could otherwise overwrite the per-agent triggering channel - // between segments — and a turn that narrated live into one + // Along the route the words were written under (finalMoment). + // This dispatch runs AFTER the agent is idle (see PR #32 note + // below), so a next turn could otherwise have decided its own + // route meanwhile — and a turn that narrated live into one // channel must not land its postscript in another. - const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`, + `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${routeLabel(finalMoment)}`, ); - // Deliver sequentially (await each) so the segments land in order. + // Chained, so the segments land in order. for (const seg of segments) { - try { - const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus); - this.recordProseDelivery(agent.name, outcome); - } catch (err) { - console.error('speech routing failed:', err); - } + this.chainSpeech(agent.name, 'speech routing', () => this.speakUnaddressed(agent, seg, trailingHold, finalMoment)); } } + await this.speechSettled(agent.name); } // NOTE: agent.reset() + onInferenceEnded() already ran above, // BEFORE dispatchSpeech. Locus routing is speech dispatch and // doesn't depend on the status field, so it correctly runs after. // Logical turn end (normal completion): drop the `[delivered]` - // receipt. All deliveries are settled — the live chain was - // awaited before trailing dispatch, and trailing/text-only - // segments were awaited in-loop. Locus mode only; explicit-mode - // envelopes acknowledge themselves through the prose gateway. + // receipt. All deliveries are settled — the speech chain, with + // the trailing and text-only segments on it, was awaited above. + // Locus mode only; explicit-mode envelopes acknowledge themselves + // through the prose gateway. if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent); } @@ -10473,12 +12831,7 @@ export class AgentFramework { // robotics-style loops where an end-of-turn pause is harmful). if (this.proseContinuations.delete(agent.name)) { console.error(`[prose] ${agent.name}: '!' continuation — requesting immediate next turn`); - this.pendingRequests.push({ - agentName: agent.name, - reason: 'prose-continuation', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agent.name, 'prose-continuation')); } break; @@ -10632,7 +12985,7 @@ export class AgentFramework { // start) and the continuation stream's end writes ONE // receipt for the whole turn. if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') { - await turnSpeechChain; + await this.speechSettled(agent.name); this.appendProseDeliveryReceipt(agent); } return; @@ -10899,15 +13252,12 @@ export class AgentFramework { // exhausted, abort) so it never sticks after the turn ends. if (frameReachedTerminal) this.channelRegistry?.stopTyping(); - // Spec 14.3: flush any held line-start text, then close each streamed - // channel with its final moderated content — the consumer's signal to - // finalize (end the TTS utterance, settle the rendered message). - if (frameReachedTerminal && proseStream) { - emitOutgoing(proseStream.finish()); - for (const [channelId, text] of proseStream.byChannel()) { - this.channelRegistry!.sendOutgoingComplete(channelId, agent.name, outgoingInferenceId, text); - } - } + // Spec 14.3: close each channel this stream's published prose went to, + // with exactly what it carried — the consumer's signal to finalize (end + // the TTS utterance). Chained, so it follows the stream's last speech, + // which may still be publishing. + const inferenceIds = [...lifecycleInferenceIds]; + this.chainSpeech(agent.name, 'outgoing stream completion', () => this.completeOutgoingStreams(inferenceIds)); this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`); if (ownsPhysicalStream) { this.activeStreams.delete(agent.name); @@ -10946,6 +13296,16 @@ export class AgentFramework { } this.ackDeferredWrites(); } + + // The logical turn has ended and its notices are written: record the + // drafts they name as noticed (the store is synced first). + if (frameReachedTerminal && ownsPhysicalStream) { + try { + this.settleTurnDrafts(agent); + } catch (err) { + console.error('[drafts] turn-end settlement failed:', err); + } + } } } @@ -12327,7 +14687,9 @@ export class AgentFramework { void this.tuneOutCoordinator .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record) .then((result) => { - this.queue.push({ + // Through pushEvent, like every other tool's result, so one that + // arrives after stop() is dropped rather than rejected unhandled. + this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, agentName, @@ -12392,6 +14754,25 @@ export class AgentFramework { return; } + // drafts: the resident's own held drafts (list/read/resend/dismiss). + // Answered by the framework; resend publishes through the registry's + // publish executor, so the result arrives asynchronously. + if (enrichedCall.name === 'drafts' && this.channelRegistry) { + const startedAt = Date.now(); + this.emitTrace({ type: 'tool:started', module: 'framework', tool: 'drafts', callId: enrichedCall.id, input: enrichedCall.input }); + void this.handleDraftsTool(agentName, enrichedCall.input) + .catch((err): ToolResult => ({ + success: false, + error: `drafts failed: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + })) + .then((result) => { + this.emitTrace({ type: 'tool:completed', module: 'framework', tool: 'drafts', callId: enrichedCall.id, durationMs: Date.now() - startedAt }); + this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, agentName, moduleName: 'framework', result }); + }); + return; + } + // prose_help: on-demand routing reference for explicit-prose agents. // Answered inline — the grammar is a tool RESULT (model-requested), never // ambient context (see PROSE_ROUTING_HELP / teach-by-bounce). @@ -12877,7 +15258,8 @@ export class AgentFramework { kind: 'refusal', category, streak, - locus: this.channelRegistry?.buildChannelContext()?.incoming?.channelId ?? null, + locus: this.routeChannelId(agentName), + locusServer: this.routeChannel(agentName)?.serverId ?? null, tokens: tokens ?? null, }); if (streak >= 2) { @@ -13101,12 +15483,7 @@ export class AgentFramework { ); // Retry immediately on the repaired history (bounded by the cap above). - this.pendingRequests.push({ - agentName: agent.name, - reason: 'inference-failure-rewind-retry', - source: 'framework', - timestamp: Date.now(), - }); + this.pendingRequests.push(this.continuationRequest(agent.name, 'inference-failure-rewind-retry')); } // ========================================================================== @@ -13143,6 +15520,7 @@ export class AgentFramework { // Per-server callback takes precedence; fall back to EventGate; fall back to no filter. const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined); + this.coalescedTriggerFilter = triggerFilter; // Push events handler (Step 6) this.initializePushCoalescer(); @@ -13152,6 +15530,14 @@ 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 at admission; it is accepted + // (the last argument) once its trigger filter returns, before it is + // queued. + (event) => this.pushSource(event), + // A coalesced push: its envelope before it is gated; its coalescer + // observes the acceptance. + (serverId, params, event) => this.coalescedPushSource(serverId, params, event), + (source) => this.noteInboundAccepted(source), ); // Server-initiated inference router (Step 6) @@ -13183,6 +15569,14 @@ 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 at + // admission; accepted once its trigger filter returns, before it is + // queued. + inboundSource: (event) => this.channelEventSource(event, 'channels/incoming'), + inboundAccepted: (source) => this.noteInboundAccepted(source), + // A coalesced message: its envelope before it is gated; its + // coalescer observes the acceptance. + coalescedSource: (serverId, message, event) => this.coalescedIncomingSource(serverId, message, event), sendTypingFn: (serverId, channelId, metadata, op) => { const server = this.mcplServerRegistry!.getServer(serverId); if (server) { @@ -13190,50 +15584,68 @@ export class AgentFramework { } }, shouldTriggerInference: triggerFilter, - // Route a conversation fork's plain-text speech to its HOME channel, not - // the process-global most-recent-inbound locus (item 3). The trunk agent - // has no home entry, so this returns undefined and routeSpeech falls back - // to defaultPublishChannel. `conversationAgentHomes` is the permanent - // spawn-time binding; `channelForAgent` is the router's live binding as a - // belt-and-suspenders fallback. + // A conversation fork's HOME channel: its standing speech route, which + // always wins (item 3). The trunk agent has no home entry; its route + // comes from each turn. `conversationAgentHomes` is the permanent + // spawn-time binding; `channelForAgent` is the router's live binding as + // a belt-and-suspenders fallback. homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName) ?? this.conversationRouter?.channelForAgent(agentName), - // Route a single TRUNK agent's plain-text speech to the channel that - // triggered its CURRENT turn (item-3 redux). connectome-host runs every - // agent as a trunk (it never exposes conversation forks), so without this - // a reply falls back to the process-global most-recent-inbound locus and - // a concurrent message in another channel hijacks it. Empty for - // heartbeat / no-trigger turns → correct global fallback. - activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName), + // The agent's own speech route for the live turn (shelf-355): the + // beforeInference channel context and a channel_publish without a + // channel use it, so what the agent is told and where its words land + // never diverge. The latest-inbound fallback is itself a route, taken + // only by a turn whose wake named no conversation, so it agrees too. + speechRouteResolver: (agentName) => this.speechRouteView(agentName), // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the // agent sees, on her next turn, that her reply never reached the human. // 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: ({ conversationId, serverId, channelId, label, threadId, reason, textLen, outcome, draftId }) => { try { - // Render a human-readable channel name when we can — a bare - // snowflake in the marker is unresolvable for the agent (the - // 2026-07-21 incident read as "a stale artifact", not a live - // failure). The marker is `system: true`, so it is never - // conversational and never influences routing. - const label = channelId - ? this.channelRegistry?.getDescriptor(channelId)?.label - : undefined; + // Name the conversation the words were bound for as every + // conversation is named (describeConversation): its label, its + // server and channel, and its thread, so a check or a resend + // looks in the right place. A bare snowflake in the marker is + // unresolvable for the agent (the 2026-07-21 incident read as "a + // stale artifact", not a live failure); the label is the + // destination's own, or else its server's, never another + // server's by first match. The marker is `system: true`, so it is + // never conversational and never influences routing. + const ownLabel = label ?? (serverId && channelId ? this.channelRegistry?.getChannelLabel(serverId, channelId) : undefined); const where = channelId - ? label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId + ? describeConversation({ + kind: 'channel', + ...(serverId ? { serverId } : {}), + channelId, + ...(threadId ? { threadId } : {}), + ...(ownLabel ? { label: ownLabel } : {}), + }) : 'the channel'; this.addMessage( 'user', [{ type: 'text', - text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). It was saved to your archive but the human did not receive it.`, + text: outcome === 'unknown' + // A request went out and no valid receipt came back: it may + // have been posted (in part or whole). Never claim it wasn't. + ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted; check the ${threadId ? 'thread' : 'channel'} before sending it again. It is saved in your archive.` + : draftId + ? `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). ` + + `Nothing was posted; it is held as draft ${draftId}. drafts(action: "resend", draftIds: ["${draftId}"]` + + (channelId ? `, destination: "${channelId}"${threadId ? `, threadId: "${threadId}"` : ''}` : '') + + ') sends it there once that can work, or name another destination.' + : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). Nothing was posted; it is saved in your archive.`, }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason }, + { + system: true, kind: 'discord-send-failed', ...(serverId ? { serverId } : {}), channelId: channelId ?? '', + ...(threadId ? { threadId } : {}), reason, ...(outcome ? { outcome } : {}), + // The draft it names counts as named (settleTurnDrafts). + ...(draftId ? { draftOwner: conversationId, draftIds: [draftId] } : {}), + } as MessageMetadata, ); } catch (err) { console.error('onRouteFailure: failed to record send-failure marker:', err); @@ -13243,8 +15655,8 @@ export class AgentFramework { // open forced by delivery) must be announced in the window, with the // opt-out named. Routed here so registry-driven opens and framework- // driven opens produce the same durable notice. - onChannelAutoOpened: ({ conversationId, source, channels }) => { - this.recordChannelAutoOpenNotice(conversationId, channels, source); + onChannelAutoOpened: ({ conversationId, serverId, source, channels }) => { + this.recordChannelAutoOpenNotice(conversationId, channels.map((c) => ({ serverId, ...c })), source); }, }, ); @@ -14280,9 +16692,19 @@ export class AgentFramework { params: PushEventParams, responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void }, ) => { - const barrier = this.discordAwarenessBarrier; - if (barrier) await barrier.promise; - await this.pushHandler?.handlePushEvent(connection.id, params, responder as never); + try { + const barrier = this.discordAwarenessBarrier; + if (barrier) await barrier.promise; + // Checked here, after the barrier and before anything records the + // push as accepted: a stopped host refuses it. + if (this.queue?.isClosed) { + this.refuseServerItem('push/event', connection.id, responder, { kind: 'refused' }); + return; + } + await this.pushHandler?.handlePushEvent(connection.id, params, responder as never); + } catch (error) { + this.refuseServerItem('push/event', connection.id, responder, { kind: 'failed', error }); + } }); // Handle server-initiated inference requests (Step 6) @@ -14381,11 +16803,19 @@ export class AgentFramework { // Handle incoming channel messages (Step 7) connection.on('channels-incoming', async ( params: ChannelsIncomingParams, - responder?: { respond: (result: unknown) => void }, + responder?: { respond: (result: unknown) => void; respondError?: (code: number, message: string) => void }, ) => { - const barrier = this.discordAwarenessBarrier; - if (barrier) await barrier.promise; - await this.channelRegistry?.handleIncoming(connection.id, params, responder as never); + try { + const barrier = this.discordAwarenessBarrier; + if (barrier) await barrier.promise; + if (this.queue?.isClosed) { + this.refuseServerItem('channels/incoming', connection.id, responder, { kind: 'refused' }); + return; + } + await this.channelRegistry?.handleIncoming(connection.id, params, responder as never); + } catch (error) { + this.refuseServerItem('channels/incoming', connection.id, responder, { kind: 'failed', error }); + } }); // Handle host-level admin commands from a surface (e.g. Discord /undo) @@ -14896,22 +17326,20 @@ export class AgentFramework { this.channelRegistry .openIfClosedForSend(target, serverId) .then(({ status, channelId, label }) => { - // Turn-scoped engagement record (mid-turn re-pin, 2026-07-31 - // n=7): a human follow-up in a channel the agent explicitly - // sent into this turn counts as conversationally addressed. - // Recorded here — not eagerly — because injection metadata - // carries the CANONICAL channel id (descriptor form), while - // send args often carry the raw provider id; this callback - // has the resolved form. The already-open path resolves on - // the microtask queue, i.e. before the tool-result event - // that evaluates injections. + // Turn-scoped engagement record (2026-07-31 n=7; the + // ambiguity hold): a follow-up where the agent explicitly + // sent this turn competes for its speech. A connector's own + // send tool chooses its place in the channel, so what the + // host knows is the channel: any conversation in it may be + // the one engaged (turnEngagement). Recorded here — not + // eagerly — because injection metadata carries the + // CANONICAL channel id (descriptor form), while send args + // often carry the raw provider id; this callback has the + // resolved form. The already-open path resolves on the + // microtask queue, i.e. before the tool-result event that + // evaluates injections. if (status !== 'unknown-channel' && status !== 'ambiguous') { - let engaged = this.turnEngagedChannels.get(agentName); - if (!engaged) { - engaged = new Set(); - this.turnEngagedChannels.set(agentName, engaged); - } - engaged.add(channelId ?? target); + this.noteChannelEngagement(agentName, serverId, channelId ?? target); } if (status === 'opened') { console.error( @@ -14919,7 +17347,7 @@ export class AgentFramework { ); this.recordChannelAutoOpenNotice( agentName, - [{ channelId: channelId ?? target, label }], + [{ ...(serverId ? { serverId } : {}), channelId: channelId ?? target, label }], 'opened-by-reply', ); } else if (status === 'open-failed') { @@ -14985,6 +17413,28 @@ export class AgentFramework { * Dispatch a synthesized channel tool call. */ private dispatchChannelToolCall(agentName: string, call: ToolCall): void { + const reject = (error: string, who = ''): void => { + // RFC-007: the guard refuses before the tool runs — no lifecycle events. + this.toolLifecycleEmitter?.refuse(agentName, call.id); + this.emitTrace({ + type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, + error: `${who}${error}`, + }); + this.pushEvent({ + type: 'tool-result', + callId: call.id, + agentName, + moduleName: 'channels', + result: { success: false, error, isError: true }, + }); + }; + // Supplied selectors are checked before any default applies, as + // channel_publish's own are: an empty or non-string one is refused, + // never read as omission (a fork's home, the channel root, "any + // server"). null means the field is not in use. + const supplied = (v: unknown): boolean => v !== undefined && v !== null; + const named = (v: unknown): boolean => typeof v === 'string' && v.length > 0; + // Conversation forks act only on their home channel. channel_publish and // channel_close default a missing channelId to home and reject foreign // ones; channel_open is rejected outright — opening channels mutates @@ -14992,79 +17442,185 @@ export class AgentFramework { // are scoped against), which is not a fork's call to make. const home = this.conversationAgentHomes.get(agentName); if (home) { - const reject = (error: string): void => { - // RFC-007: the guard refuses before the tool runs — no lifecycle events. - this.toolLifecycleEmitter?.refuse(agentName, call.id); - this.emitTrace({ - type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, - error: `conversation agent ${agentName}: ${error}`, - }); - this.pushEvent({ - type: 'tool-result', - callId: call.id, - agentName, - moduleName: 'channels', - result: { success: false, error, isError: true }, - }); - }; - + const who = `conversation agent ${agentName}: `; if (call.name === 'channel_open') { - reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`); + reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`, who); return; } if (call.name === 'channel_publish' || call.name === 'channel_close') { - const input = (call.input ?? {}) as { channelId?: string }; - if (!input.channelId) { + const input = (call.input ?? {}) as { channelId?: unknown; serverId?: unknown }; + if (!supplied(input.channelId)) { call = { ...call, input: { ...input, channelId: home } }; + } else if (!named(input.channelId)) { + reject(`channelId must name a channel (leave it out for this conversation's channel, ${home}). Nothing was ${call.name === 'channel_publish' ? 'sent' : 'closed'}.`, who); + return; } else if (input.channelId !== home) { const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing'; - reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`); + reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId as string} is not allowed.`, who); return; } + // The home is a bare channel id, so a server named with it must be + // the one server that registered it: the same id on another server + // is another conversation, and a shared id names no server at all. + if (supplied(input.serverId)) { + const homeServer = this.channelRegistry?.getChannelServerId(home) ?? null; + if (input.serverId !== homeServer) { + const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing'; + reject( + homeServer + ? `This conversation is bound to channel ${home} on server ${homeServer}; ${verb} it on server ${String(input.serverId)} is not allowed.` + : `This conversation is bound to channel ${home}, which isn't registered by exactly one server, so it can't be named with a server. Nothing was ${call.name === 'channel_publish' ? 'sent' : 'closed'}.`, + who, + ); + return; + } + } + } + } + + // channel_open's own choices: where speech goes (a thread, with + // setSpeechTarget) and which server's channel. Checked before anything is + // opened, so an invalid choice can't open a channel or become a route. + if (call.name === 'channel_open') { + const input = (call.input ?? {}) as { serverId?: unknown; threadId?: unknown; setSpeechTarget?: unknown }; + const thread = readThreadId(input.threadId); + const invalid = supplied(input.serverId) && !named(input.serverId) + ? 'serverId must name a server (leave it out to resolve the channel id alone).' + : 'error' in thread + ? thread.error + : supplied(input.setSpeechTarget) && typeof input.setSpeechTarget !== 'boolean' + ? 'setSpeechTarget must be true or false (left out, it is true).' + : undefined; + if (invalid) { + reject(`${invalid} Nothing was opened.`); + return; } } this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); + const turnAtDispatch = this.logicalTurn(agentName); this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) .then((result) => { const durationMs = Date.now() - startTime; this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs }); - // A successful channel_open plants the agent's feet in the opened - // channel. The agent's own deliberate open is the strongest "my next - // words go here" signal in the system — stronger than any injection — - // so it MOVES the current turn's prose pin (turnLocusPins), not just - // the next turn's trigger state. (The original 2026-07-21 Aria fix - // set only activeTriggerChannels, which resolveLocus reads at the - // NEXT turn's freeze — the turn-frozen refactor had silently - // regressed the "reply right after opening" case; found while - // mapping the routing logic 2026-07-31, antra-ratified fix.) The - // announcement rides THIS TOOL RESULT — model-requested content, - // distance zero, the safest role there is — instead of a separate - // window notice. lastAnnouncedLocus tracks it so announce-on-change - // stays coherent. Locus-mode agents only: explicit mode has no pin. + // channel_publish is an explicit send to an exact place: its outcome + // engages the conversation it named, as a resend's does. + if (call.name === 'channel_publish') { + const sent = result?.data as { status?: unknown; serverId?: unknown; channelId?: unknown; threadId?: unknown } | undefined; + if ((sent?.status === 'delivered' || sent?.status === 'unknown') && typeof sent.channelId === 'string') { + this.noteSendEngagement(agentName, sent.status, { + ...(typeof sent.serverId === 'string' ? { serverId: sent.serverId } : {}), + channelId: sent.channelId, + threadId: typeof sent.threadId === 'string' ? sent.threadId : null, + }); + } + } + // channel_open is a deliberate speech choice (shelf-355): unless the + // resident says setSpeechTarget: false, its unaddressed plain speech + // goes to the opened channel for the rest of this logical turn — a + // true new turn decides afresh. It replaces an inferred route or an + // ambiguity hold, and a hybrid `>>>` pin (the latest deliberate choice + // wins). With setSpeechTarget: false the channel is opened for + // reading and the route is unchanged; the result says what it is. + // The announcement rides THIS TOOL RESULT (model-requested content, + // distance zero) instead of a separate window notice, and + // lastAnnouncedRoute tracks it so announce-on-change stays coherent. + // Locus and hybrid modes only: explicit mode has no route. if (call.name === 'channel_open' && result?.success) { - const opened = - (result.data as { channelId?: string } | undefined)?.channelId - ?? (call.input as { channelId?: string } | undefined)?.channelId; - if (opened) { - this.activeTriggerChannels.set(agentName, opened); - const openerAgent = this.agents.get(agentName); - if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { - this.turnLocusPins.set(agentName, opened); - this.lastAnnouncedLocus.set(agentName, opened); - result = { - ...result, - data: { - ...(result.data as Record | undefined), - routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.', - }, + const openInput = call.input as { channelId?: string; serverId?: string; threadId?: string | null; setSpeechTarget?: boolean | null } | undefined; + const opened = (result.data as { channelId?: string } | undefined)?.channelId ?? openInput?.channelId; + const openerAgent = this.agents.get(agentName); + if (opened && openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { + const unchanged = (): string => { + const turn = this.turnRoutes.get(agentName); + return turn?.hold + ? 'Your speech route is unchanged: it is held between ' + + `${turn.hold.conversations.map(describeConversation).join(' and ')}, so unaddressed plain speech stays as drafts.` + : turn?.route + ? `Your plain speech still goes to ${describeConversation(routeConversation(turn.route))}.` + : 'You have no speech route, so unaddressed plain speech is held as drafts.'; + }; + let routing: string; + if (this.logicalTurn(agentName) !== turnAtDispatch) { + // The turn that asked has been superseded by a newer one, whose + // speech is its own to decide: the open stands, the route and + // prose choices don't move. + routing = 'Opened. A newer turn has begun since this call, so its speech route is unchanged.'; + } else if (this.turnRoutes.get(agentName)?.silent) { + // A silent control-plane wake keeps its plain speech private: + // the open stands, and no route is set. The turn's record says + // so, during the turn and after it ends. + routing = 'Opened. This call came from a silent wake, so its plain speech stays private; ' + + 'to post there, use a send tool.'; + } else if (openInput?.setSpeechTarget !== false) { + const resolved = this.channelRegistry!.resolveDestination({ + channelId: opened, + ...(openInput?.serverId ? { serverId: openInput.serverId } : {}), + }); + const destination = resolved && 'destination' in resolved ? resolved.destination : undefined; + const openedServer = destination?.serverId ?? openInput?.serverId; + // A channel named alone means its root — never a thread + // borrowed from an earlier route; a thread is named with it. + const read = readThreadId(openInput?.threadId); + const threadId = 'threadId' in read && read.threadId ? read.threadId : undefined; + const route: SpeechRoute = { + kind: 'channel', + ...(openedServer ? { serverId: openedServer } : {}), + channelId: opened, + ...(threadId ? { threadId } : {}), + ...(destination?.label ? { label: destination.label } : {}), + origin: 'open', }; - console.error( - `[routing] ${agentName}: channel_open -> pin moved to ${opened} (announced in tool result)`, - ); + // Only a place the framework can publish into exactly becomes + // a route (MCPL RFC-011); otherwise the open stands, and the + // route doesn't move. + const refusal = routeRefusal(routeConversation(route), (c) => this.publishTargetOf(c)); + if (!this.mayTakeRoute(agentName, opened)) { + // The fork rule, as for every route: a channel a conversation + // fork owns is that fork's conversation. (A fork's own + // channel_open is refused before this.) + routing = 'Opened, but your plain speech can\'t go there: a conversation fork owns that channel. ' + + unchanged(); + console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (fork rule)`); + } else if (refusal) { + routing = + `Opened, but your plain speech can't go there: ${this.unroutableText({ conversation: routeConversation(route), reason: refusal })} ` + + unchanged(); + console.error(`[routing] ${agentName}: channel_open ${opened} not a speech route (${refusal})`); + } else { + const next: TurnRecord = { route }; + this.turnRoutes.set(agentName, next); + this.lastAnnouncedRoute.set(agentName, AgentFramework.routeKey(next)); + // The open supersedes a hybrid `>>>` choice — a pin, or the + // suppression a skip_reply or failed envelope left — in the + // order the resident chose: on the speech chain, after the + // words written before it (already chained) and before any + // written after (chained only once this result is in). + if (openerAgent.proseRouting === 'hybrid') { + this.chainSpeech(agentName, 'channel_open hybrid reset', () => { + if (this.logicalTurn(agentName) !== turnAtDispatch) return; + this.proseTargetPins.delete(agentName); + this.proseHybridSuppressed.delete(agentName); + }); + } + routing = + `Your unaddressed plain speech now goes to ${describeConversation(routeConversation(route))} for the ` + + 'rest of this turn. Other channels need an explicit send tool.'; + console.error(`[routing] ${agentName}: channel_open -> speech route ${opened}${threadId ? ` thread ${threadId}` : ''} (announced in tool result)`); + } + } else { + const read = readThreadId(openInput?.threadId); + const ignored = 'threadId' in read && read.threadId + ? ` threadId ${read.threadId} chooses where speech goes, so with setSpeechTarget: false it was not used.` + : ''; + routing = `Opened for reading.${ignored} ${unchanged()}`; } + result = { + ...result, + data: { ...(result.data as Record | undefined), routing }, + }; } } this.pushEvent({ @@ -15273,7 +17829,7 @@ export class AgentFramework { } : { success: false, error: r.error, isError: false }; } - this.queue.push({ + this.pushEvent({ type: 'tool-result', callId: call.id, agentName, @@ -15311,9 +17867,10 @@ export class AgentFramework { : seconds >= 60 ? `${Math.round(seconds / 60)}m` : `${Math.round(seconds)}s`; - // Announce in the TURN's frozen locus (best-effort; never blocks the - // result). The sleep tool runs mid-turn, so the pin is live — same - // authority as every other speech path, no live re-resolution. + // Announce as the turn's speech goes (best-effort; never blocks the + // result): an explicit-mode resident's pinned target, a hybrid one's + // `>>>` pin when it set one, else the turn's route, exactly as other + // unaddressed speech, with no live re-resolution. if (announce && this.channelRegistry) { const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`; const agent = this.agents.get(agentName); @@ -15331,10 +17888,23 @@ export class AgentFramework { } else { console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`); } - } else { - const locus = this.turnLocusPins.get(agentName) ?? null; - this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => { - console.error('[sleep] announce failed:', err instanceof Error ? err.message : err); + } else if (agent) { + // Unaddressed speech like any other, after the words written before + // it. A hybrid `>>>` pin set this turn wins, as for an unprefixed + // envelope; else the route as it stands when the tool runs: a held + // turn keeps the words as a draft, a surface shows them, no route + // keeps them with no destination, and only a publish that was tried + // can fail. + const moment: SpeechMoment = { turn: this.turnRoutes.get(agentName) }; + this.chainSpeech(agentName, '[sleep] announcement', async () => { + const pin = agent.proseRouting === 'hybrid' ? this.proseTargetPins.get(agentName) : undefined; + if (pin) { + const outcome = await this.channelRegistry!.routeSpeech(agentName, text, pin, { holdFailed: this.holdUndelivered(agent, text, {}) }); + this.recordProseDelivery(agentName, outcome); + if (outcome?.delivered) this.streamPublished(agentName, moment, outcome, text); + return; + } + await this.speakUnaddressed(agent, text, { notice: 'later' }, moment); }); } } @@ -16203,11 +18773,10 @@ export class AgentFramework { return { inferenceId, // Conversation identity = the agent (a trunk agent IS its own conversation; - // forks get their own agent). The turn's channel LOCUS — the "proper - // conversation tracking" this once flagged as a TODO — is now tracked - // per-agent in `activeTriggerChannels` and surfaced to the agent via + // forks get their own agent). The turn's speech route is tracked + // per-agent in `turnRoutes` and surfaced to the agent via // buildChannelContext (channels.defaultOutgoing) below, so the agent is - // told the same channel its speech will route to (item-3 redux). + // told the same channel its speech will route to. conversationId: agent.name, turnIndex: 0, // Simplified; needs per-conversation counter TODO userMessage: null, // Could extract from trigger context diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index bb8bd3d1a..68f13c91c 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -12,6 +12,8 @@ */ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { describeConversation, isConversational } from '../speech-routes.js'; +import { readInboundSource } from '../mcpl/inbound-source.js'; import { dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; @@ -93,8 +95,38 @@ interface PendingEvent { * whose `channelId` is the adapter's raw id (a bare Discord snowflake for a * DM) — unroutable. Absent when the host could not resolve one. */ routeChannelId?: string; + /** The triggering message's id, when the metadata carries one: the reply + * edge of a speech route inferred from this event. */ + messageId?: string; + /** The thread the event belongs to, when the metadata carries one: part + * of its conversation's identity (a thread is not its channel's root). */ + threadId?: string; + /** Conversational input rather than machinery (a reaction, a system + * marker): only conversational events are route candidates. */ + conversational: boolean; } +/** One conversation a batched gate wake carries, for the framework's + * speech-route inference (src/speech-routes.ts). */ +export type GateRouteCandidate = + | { + kind: 'channel'; + /** The registered channel; for an unroutable candidate, the raw id + * the event named (or `user:` when it named only an author). */ + channelId: string; + serverId?: string; + /** The thread the conversation is, when it is one. */ + threadId?: string; + messageId?: string; + addressed: boolean; + at: number; + /** The host could not resolve the event's registered channel: it still + * competes for the turn (replying to an older conversation instead + * would answer the wrong person), but can never be the route. */ + unroutable?: true; + } + | { kind: 'surface'; surface: string; at: number }; + /** * Where a gate-requested wake came from, handed to the framework alongside the * reason so the turn's InferenceRequest can carry it (host telemetry stamps @@ -111,18 +143,34 @@ export interface WakeProvenance { counterparty?: string; /** True when the chosen event was `chat:addressed`. */ addressed?: boolean; - /** Speech locus for the wake: the routable channel of the chosen event, - * set ONLY when that event addressed the agent (mention / reply / DM) and - * the host resolved its registered channel. A batched wake used to carry - * no locus at all, so its reply fell back to the process-global - * most-recent-inbound channel — which any ambient message elsewhere, - * arriving a second after the DM that caused the wake, could retarget - * (2026-09-30: a DM reply published into a guild channel). Ambient-only - * batches still set nothing and keep the legacy fallback. */ + /** The routable channel of the chosen event, set ONLY when that event + * addressed the agent (mention / reply / DM) and the host resolved its + * registered channel: the framework marks the wake addressed there. + * Ambient-only batches set nothing. It is not the speech route, which the + * framework infers from `routeCandidates` (shelf-355); only a wake whose + * batch names no conversation at all (`namesConversation` unset) falls + * back to the last delivered message's conversation. */ routeChannelId?: string; /** Timestamp (ms) of the chosen event, so a consumer coalescing several * requests can order by event recency, not by flush order. */ at?: number; + /** + * Every conversation in the batch, addressed or not: each event whose + * registered channel the host resolved, and each local-surface message + * (console/API input, addressed by nature). The framework infers the + * turn's speech route from all of them — the addressed ones if any, else + * all; one conversation only, else the turn starts held — instead of + * trusting a single pick. + */ + routeCandidates?: GateRouteCandidate[]; + /** + * Some event in the batch names a conversation — a channel, a thread, an + * author, or a local surface — whether or not it is a route candidate: a + * reaction or a system marker names its channel but answers nothing. Unset + * only when nothing in the batch named one (a heartbeat, a timer): the one + * wake the framework's fallback route serves. + */ + namesConversation?: true; } interface DebounceState { @@ -207,7 +255,7 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin // A resolved route counts as naming a channel only for an ADDRESSED // event: a push whose origin declares only `mcplChannelId` (no raw // channel id, no author) is still a routable addressed message, and - // skipping it would leave its wake on the most-recent-inbound fallback. + // skipping it would hide that the wake was addressed there. // An ambient one stays out: the route is never reported for it, so as // the pick it would carry no telemetry at all and hide an earlier event // that names its channel and author. @@ -215,8 +263,52 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin if (!newest || e.timestamp >= newest.timestamp) newest = e; if (e.addressed && (!newestAddressed || e.timestamp >= newestAddressed.timestamp)) newestAddressed = e; } + const routeCandidates: GateRouteCandidate[] = []; + let namesConversation = false; + for (const e of events) { + if (e.routeChannelId || e.channelId || e.threadId || e.authorId || e.eventType === 'external-message' || e.eventType === 'api:message') { + namesConversation = true; + } + // A reaction or system marker answers nothing: never a route candidate + // (the framework's mid-turn rule, applied at turn start too). + if (!e.conversational) continue; + if (e.routeChannelId) { + routeCandidates.push({ + kind: 'channel', + channelId: e.routeChannelId, + ...(e.serverId ? { serverId: e.serverId } : {}), + ...(e.threadId ? { threadId: e.threadId } : {}), + ...(e.messageId ? { messageId: e.messageId } : {}), + addressed: e.addressed, + at: e.timestamp, + }); + } else if ((e.eventType === 'external-message' || e.eventType === 'api:message') && !e.channelId) { + // A local surface only when it names no channel, as surfaceSourceFor + // decides: one that names a channel no registry holds is that + // channel's conversation, unroutable (below), never answered locally. + routeCandidates.push({ + kind: 'surface', + surface: e.serverId || (e.eventType === 'api:message' ? 'api' : 'external'), + at: e.timestamp, + }); + } else if (e.channelId || e.authorId) { + routeCandidates.push({ + kind: 'channel', + channelId: e.channelId || `user:${e.authorId}`, + ...(e.serverId ? { serverId: e.serverId } : {}), + ...(e.threadId ? { threadId: e.threadId } : {}), + addressed: e.addressed, + at: e.timestamp, + unroutable: true, + }); + } + } const pick = newestAddressed ?? newest; - if (!pick) return undefined; + if (!pick) { + return routeCandidates.length > 0 || namesConversation + ? { ...(routeCandidates.length > 0 ? { routeCandidates } : {}), ...(namesConversation ? { namesConversation: true as const } : {}) } + : undefined; + } // push-event channel ids are the adapter's raw ids (a Discord snowflake): // not a composite channel id, so not reported as one const channelId = pick.channelId && pick.eventType !== 'mcpl:push-event' ? pick.channelId : undefined; @@ -227,6 +319,8 @@ export function wakeProvenance(events: PendingEvent[]): WakeProvenance | undefin ...(pick.addressed ? { addressed: true } : {}), ...(pick.addressed && pick.routeChannelId ? { routeChannelId: pick.routeChannelId } : {}), at: pick.timestamp, + ...(routeCandidates.length > 0 ? { routeCandidates } : {}), + ...(namesConversation ? { namesConversation: true as const } : {}), }; } @@ -567,6 +661,11 @@ export class EventGate { private quiesced = false; /** Lifetime count of buffer-cap evictions (see bufferForInference). */ private droppedBufferedEvents = 0; + /** The route candidates of events the buffer evicted, per conversation: + * its newest event and its newest addressed one. Eviction bounds what + * the buffer holds of each event, never which conversations the flush + * can route to: the flush's candidates are as if nothing was evicted. */ + private evictedRouteEvents = new Map(); // Per-policy stats private stats = new Map(); @@ -1480,6 +1579,15 @@ export class EventGate { private handleDebounce(policy: GatePolicy, info: GateEventInfo): void { const debounceMs = (policy.behavior as { debounce: number }).debounce; + // The item's frozen source envelope, when its lane supplies one, names + // its conversation — server, registered channel, thread, message — ahead + // of anything in free-form metadata (the direct path reads the same + // envelope, so both paths agree on where a reply goes). + const envelope = readInboundSource(info.metadata); + const conversation = envelope?.kind === 'channel' ? envelope : undefined; + const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + const messageId = conversation ? conversation.messageId : str(info.metadata?.messageId); + const threadId = conversation ? conversation.threadId : str(info.metadata?.threadId); const event: PendingEvent = { policyName: policy.name, @@ -1495,9 +1603,18 @@ export class EventGate { ? (info.metadata.channelName as string) : undefined, authorId: this.extractAuthorId(info.metadata) ?? undefined, - serverId: info.serverId || undefined, + serverId: conversation?.serverId ?? (info.serverId || undefined), addressed: Array.isArray(info.tags) && info.tags.includes('chat:addressed'), - routeChannelId: this.routeChannelFor(info), + routeChannelId: conversation?.channelId ?? this.routeChannelFor(info), + ...(messageId ? { messageId } : {}), + ...(threadId ? { threadId } : {}), + // An MCPL item's metadata is its server's (an origin, an incoming + // message's metadata): only the host marks machinery, so its tags + // alone decide. A host module's event metadata is the host's. + conversational: isConversational( + info.tags, + info.eventType === 'mcpl:channel-incoming' || info.eventType === 'mcpl:push-event' ? undefined : info.metadata, + ), }; const existing = this.debounceTimers.get(policy.name); @@ -1544,7 +1661,13 @@ export class EventGate { // Event delivery // ========================================================================= - private deliverEvents(events: PendingEvent[]): void { + /** + * @param evicted The newest event, and newest addressed one, of each + * conversation the inference buffer evicted: they count toward the + * wake's route candidates and provenance, as if they had stayed, but not + * toward the notice, which names what is still held. + */ + private deliverEvents(events: PendingEvent[], evicted: PendingEvent[] = []): void { if (events.length === 0) return; const policyNames = [...new Set(events.map(e => e.policyName))]; @@ -1559,22 +1682,38 @@ export class EventGate { const lines: string[] = quoted.map(e => `- [${e.policyName}] (${e.eventType}): ${e.content}`); if (referenced.length > 0) { - const byChannel = new Map(); + const byConversation = new Map(); for (const e of referenced) { - // The registered id when known, so this line names the channel the - // same way routing notices and send tools do (a push event's own - // channelId is the adapter's raw id). - const key = e.routeChannelId ?? e.channelId ?? '(unknown channel)'; - const entry = byChannel.get(key) ?? { count: 0, oldest: e.timestamp, label: e.channelLabel }; + // The registered id when known (a push event's own channelId is the + // adapter's raw id), counted per server and thread: the same id on + // another server, or a thread and its channel's root, is another + // conversation. + const channelId = e.routeChannelId ?? e.channelId; + const key = JSON.stringify([e.serverId ?? null, channelId ?? null, e.threadId ?? null]); + const entry = byConversation.get(key) ?? { + ...(e.serverId ? { serverId: e.serverId } : {}), + ...(channelId ? { channelId } : {}), + ...(e.threadId ? { threadId: e.threadId } : {}), + count: 0, oldest: e.timestamp, label: e.channelLabel, + }; entry.count += 1; entry.oldest = Math.min(entry.oldest, e.timestamp); entry.label = entry.label ?? e.channelLabel; - byChannel.set(key, entry); + byConversation.set(key, entry); } - for (const [channelId, { count, oldest, label }] of byChannel) { - // Prefer the human-readable name; keep the id parenthesized so the - // agent can still address tools that want the composite id. - const channel = label ? `#${label} (${channelId})` : channelId; + for (const { serverId, channelId, threadId, count, oldest, label } of byConversation.values()) { + // Named the way routing notices and send tools name a conversation + // (describeConversation): its label, then `server / channel-id`, + // then its thread. + const channel = channelId + ? describeConversation({ + kind: 'channel', + ...(serverId ? { serverId } : {}), + channelId, + ...(threadId ? { threadId } : {}), + ...(label ? { label } : {}), + }) + : '(unknown channel)'; const age = Math.round((Date.now() - oldest) / 1000); lines.push( `- ${count} message${count > 1 ? 's' : ''} in ${channel} ` + @@ -1595,10 +1734,11 @@ export class EventGate { // ADDRESSED event first, else the newest event naming a channel or an // author; channel + author + addressed from that ONE event. When that // event addressed the agent and its registered channel is known, - // `routeChannelId` names the turn's speech locus too — the reply goes - // back to whoever addressed the agent, not to whichever channel last saw - // any traffic. Ambient-only batches carry telemetry only. - const provenance = wakeProvenance(events); + // `routeChannelId` marks the wake addressed there. The turn's speech + // route comes from `routeCandidates`, every conversation in the batch + // (shelf-355); the last delivered message's conversation is the route + // only when the batch names none. + const provenance = wakeProvenance([...evicted, ...events]); for (const agentName of this.getAgentNamesFn()) { this.requestInferenceFn(agentName, 'gate:debounce', 'gate', provenance); } @@ -1644,8 +1784,11 @@ export class EventGate { if (this.quiesced) return; if (this.inferring.size === 0 && this.inferenceBuffer.length > 0) { const events = this.inferenceBuffer.splice(0); + const evicted = [...this.evictedRouteEvents.values()] + .flatMap(({ newest, addressed }) => (addressed && addressed !== newest ? [addressed, newest] : [newest])); + this.evictedRouteEvents.clear(); // Defer to avoid re-entrancy inside trace callbacks - queueMicrotask(() => this.deliverEvents(events)); + queueMicrotask(() => this.deliverEvents(events, evicted)); } } @@ -1671,11 +1814,31 @@ export class EventGate { // Inference buffer cap // ========================================================================= + /** Keep an evicted event's route candidate (wakeProvenance's identity of + * it): the conversation's newest, and its newest addressed. */ + private keepEvictedRoute(event: PendingEvent): void { + if (!event.conversational) return; + const surface = !event.routeChannelId && !event.channelId + && (event.eventType === 'external-message' || event.eventType === 'api:message'); + const channel = event.routeChannelId ?? event.channelId ?? (event.authorId ? `user:${event.authorId}` : undefined); + if (!surface && !channel) return; + const key = JSON.stringify(surface ? ['surface', event.serverId ?? null] : [event.serverId ?? null, channel, event.threadId ?? null]); + const kept = { ...event, content: '' }; + const seen = this.evictedRouteEvents.get(key); + this.evictedRouteEvents.set(key, { + newest: !seen || kept.timestamp >= seen.newest.timestamp ? kept : seen.newest, + ...(kept.addressed && (!seen?.addressed || kept.timestamp >= seen.addressed.timestamp) + ? { addressed: kept } + : seen?.addressed ? { addressed: seen.addressed } : {}), + }); + } + /** Called internally when debounce fires during inference. */ private bufferForInference(events: PendingEvent[]): void { for (const event of events) { if (this.inferenceBuffer.length >= MAX_INFERENCE_BUFFER) { - this.inferenceBuffer.shift(); // Drop oldest + // Drop the oldest, keeping its route candidate. + this.keepEvictedRoute(this.inferenceBuffer.shift()!); // Telemetry (issue #122): quiesce stretches buffering from // single-inference minutes to operator-length windows, so silent // eviction is no longer a corner case — count and log it, or the @@ -1983,5 +2146,6 @@ export class EventGate { this.rateLimitDenied.clear(); this.passiveSampleFires.clear(); this.inferenceBuffer = []; + this.evictedRouteEvents.clear(); } } diff --git a/src/gate/index.ts b/src/gate/index.ts index 2722ce340..29e4f56aa 100644 --- a/src/gate/index.ts +++ b/src/gate/index.ts @@ -1,4 +1,4 @@ -export { EventGate, wakeProvenance, type WakeProvenance } from './event-gate.js'; +export { EventGate, wakeProvenance, type WakeProvenance, type GateRouteCandidate } from './event-gate.js'; export type { GateConfig, GateOptions, diff --git a/src/index.ts b/src/index.ts index adfab80b2..30542947b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,17 @@ 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`), and +// the digest its delivered and stored bodies are stamped with +// (`metadata.sourceBodyDigest`, `metadata.storedBodyDigest`) +export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey, sourceBodyDigest } from './mcpl/index.js'; +export type { + InboundSource, + InboundChannelSource, + 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'; @@ -90,7 +101,7 @@ export type { McplServerConfig } from './mcpl/index.js'; // MCPL client connection (exposed so external tooling — server playtests, // health probes, protocol harnesses — can dial an MCPL server exactly the // way the framework host does, over stdio or WebSocket) -export { McplServerConnection } from './mcpl/index.js'; +export { McplServerConnection, McplRequestError } from './mcpl/index.js'; export type { McplHostCapabilities } from './mcpl/index.js'; // MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 64894a18f..80efe5bb8 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -40,6 +40,10 @@ import { expandCoreTags } from './tags.js'; import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js'; import { isVisiblyEmptyContent } from './visible-content.js'; import { CapabilityGrant } from './capability-grant.js'; +import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; +import { McplRequestError } from './server-connection.js'; +import { describeConversation, isConversational, readThreadId } from '../speech-routes.js'; +import type { TriggerInputs } from './push-handler.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -237,6 +241,14 @@ 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; + /** A coalesced message's trigger-filter inputs, until the framework + * decides its trigger when its coalescer first delivers an admitted + * occurrence. Plain data, so a persisted occurrence keeps them. */ + triggerInputs?: TriggerInputs; } // ============================================================================ @@ -311,6 +323,9 @@ function convertBlock(block: McplContentBlock): ContentBlock { // Channel Tool Definitions // ============================================================================ +// Nullable fields (a caller whose provider presents every property as +// required can still say "not in use") are cast, as the drafts tool's are: +// ToolDefinition's schema type admits one type name per property. const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ { name: 'channel_list', @@ -327,7 +342,10 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'object' as const, properties: { channelId: { type: 'string', description: 'ID of the channel to open' }, - serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' }, + serverId: { + type: ['string', 'null'], + description: 'Owning MCPL server; required only when channelId is ambiguous. null, like omitting it, names none.', + }, backscroll: { type: 'number', description: 'Number of earlier messages to return while opening (0-500; default 0).', @@ -336,10 +354,23 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'string', description: 'Anchor message from the closed-channel notice; it is excluded from backscroll.', }, + setSpeechTarget: { + type: ['boolean', 'null'], + description: + 'Whether your unaddressed plain speech should go to this channel for the rest of this turn ' + + '(default true, as for null). false opens it for reading only; the result says where your speech goes.', + }, + threadId: { + type: ['string', 'null'], + description: + 'With setSpeechTarget, a thread of this channel to speak into instead of its root (the thread ' + + 'id from a [source: … · thread ] line). Only where the channel\'s connector can post into ' + + 'a named thread. null, like omitting it, means the channel root.', + }, }, required: ['channelId'], }, - }, + } as unknown as ToolDefinition, { name: 'channel_decline', description: @@ -367,24 +398,45 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ type: 'object' as const, properties: { channelId: { type: 'string', description: 'ID of the channel to close' }, - serverId: { type: 'string', description: 'Owning MCPL server; required only when channelId is ambiguous.' }, + serverId: { + type: ['string', 'null'], + description: 'Owning MCPL server; required only when channelId is ambiguous. null, like omitting it, names none.', + }, }, required: ['channelId'], }, - }, + } as unknown as ToolDefinition, { name: 'channel_publish', - description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.', + description: + 'Publish a message to a channel — an explicit send. Without channelId it goes to your current speech ' + + 'route (the conversation your plain speech is going to this turn, thread included); with no route it is ' + + 'refused, never guessed. With channelId it goes to that channel\'s root, or into threadId. The receipt ' + + 'names where it went, and whether delivery was confirmed, failed (nothing posted) or is unknown (it may ' + + 'have been posted).', inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' }, + channelId: { + type: ['string', 'null'], + description: 'ID of the channel to publish to; null, like omitting it, means your current speech route', + }, + serverId: { + type: ['string', 'null'], + description: 'Owning MCPL server; needed only when channelId is registered by more than one. null, like omitting it, names none.', + }, + threadId: { + type: ['string', 'null'], + description: + 'A thread of channelId to post in (the id from a [source: … · thread ] line); null, like omitting ' + + 'it, means the channel root. Only where the channel\'s connector can post into a named thread.', + }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, required: [], }, - }, + } as unknown as ToolDefinition, { name: 'think', description: @@ -454,7 +506,103 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ // Constructor Options // ============================================================================ +/** An agent's current speech route as the registry needs it. */ +export type SpeechRouteView = + | { kind: 'channel'; serverId?: string; channelId: string; threadId?: string; replyTo?: string } + | { kind: 'surface'; surface: string } + /** Competing conversations hold unaddressed speech (usable addresses). */ + | { kind: 'held'; conversations: string[] } + | { kind: 'none' }; + +/** Where a publish was resolved to go — named by the registry, never by + * the caller's spelling. */ +export interface PublishDestination { + serverId: string; + channelId: string; + /** The channel's registered label when the attempt was made. */ + label?: string; + /** + * The place inside the channel the publish asked for (MCPL RFC-011): a + * thread id, or null for the channel root. Absent only on a destination + * recorded before targeted publishing. + */ + threadId?: string | null; +} + +/** + * A channel's declared publish target (MCPL RFC-011 + * `capabilities.publish.target`): `exact` posts exactly where a publish + * says (a thread, or the root), `root` has no threads. Undefined — no + * declaration, or a malformed one — means no guarantee: the framework does + * not publish there, because the connector may choose a place itself. + */ +export function declaredPublishTarget(descriptor: ChannelDescriptor | undefined): 'exact' | 'root' | undefined { + const target = (descriptor?.capabilities as { publish?: { target?: unknown } } | undefined)?.publish?.target; + return target === 'exact' || target === 'root' ? target : undefined; +} + +/** Where a publish may go inside a channel, given its declaration: why not, or undefined when it may. */ +export function publishPlaceRefusal( + declared: 'exact' | 'root' | undefined, + threadId: string | null, +): 'undeclared' | 'no-threads' | undefined { + if (!declared) return 'undeclared'; + if (threadId !== null && declared !== 'exact') return 'no-threads'; + return undefined; +} + +/** + * What one channels/publish attempt established (ChannelRegistry.publish). + * - `delivered`: the connector returned `delivered: true`. A historical + * receipt (confirmed at `at`, to `destination`, message `messageId` when + * the connector named one), not a claim the message still exists. + * - `failed`: nothing was posted: the request never left the host + * (unresolvable or shared channel id, missing grant, failed open, closed + * connection), or the connector answered `delivered: false`. + * - `unknown`: the request was dispatched and no valid receipt came back (an + * error response, a timeout, a lost connection, or a missing or malformed + * receipt). It may or may not have been posted. + */ +export interface PublishOutcome { + status: 'delivered' | 'failed' | 'unknown'; + /** Absent only when no destination could be resolved. */ + destination?: PublishDestination; + messageId?: string; + /** Why it failed, or why its outcome is unknown — for a connector error, + * its message verbatim (which may say what was already posted). */ + reason?: string; + /** The connector error's structured `data`, verbatim, when it sent one. */ + detail?: unknown; + /** When the outcome was established (epoch ms). */ + at: number; +} + interface ChannelRegistryOptions { + /** + * An ordinary (uncoalesced) `channels/incoming` message has just been + * admitted: return its source envelope, frozen now + * (mcpl/inbound-source.ts), before the trigger filter reads it. Building + * it accepts nothing (inboundAccepted); coalesced work is stamped and + * observed by its own path. + */ + inboundSource?: (event: McplChannelIncomingEvent) => InboundSource | undefined; + /** + * An ordinary message is accepted: once its trigger filter has returned, + * right before it is queued, as a coalesced one is on admission. A filter + * that throws leaves nothing accepted. + */ + inboundAccepted?: (source: InboundSource) => void; + /** + * A `channels/incoming` message carrying `coalesce`: its source envelope, + * built — not accepted — before it is gated. Its coalescer freezes and + * carries this same envelope, and observes the acceptance itself if it + * admits the occurrence. + */ + coalescedSource?: ( + serverId: string, + message: ChannelIncomingMessage, + event: McplChannelIncomingEvent, + ) => InboundSource | undefined; /** * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with * the event the ordinary path would have queued. The handler decides @@ -481,8 +629,20 @@ interface ChannelRegistryOptions { onRouteFailure?: (info: { conversationId: string; channelId: string | null; + /** The server the speech was for, when its route named or resolved one. */ + serverId?: string; + /** That server's label for the channel, as the destination resolved. */ + label?: string; + /** The thread the speech was for, when its route was a thread. */ + threadId?: string; reason: string; 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'; + /** The draft the words were kept as (deliverSpeech's holdFailed), for + * a failure where nothing was posted. */ + draftId?: string; }) => void; /** * Called when channels were opened WITHOUT the agent asking (subscription @@ -504,27 +664,22 @@ interface ChannelRegistryOptions { * Resolve a conversation fork's HOME channel from its agent name. Conversation * forks are spawned bound to a single channel (the framework tracks this in * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their - * plain-text speech must route THERE. The process-global `defaultPublishChannel` - * tracks only the most-recent inbound across ALL channels, so with one fork per - * channel running concurrently it misroutes a fork's reply to whichever channel - * last spoke (item 3). Returns undefined for the trunk/primary agent, which has - * no home and correctly falls back to the global locus (heartbeats, etc.). + * plain-text speech must route THERE, whatever else arrives. Returns + * undefined for an agent without a home: its route comes from its turn. */ homeChannelResolver?: (agentName: string) => string | undefined; /** - * Resolve the channel that triggered an agent's CURRENT inference turn, by - * agent name. This is the fix for single-TRUNK agents (the only mode - * connectome-host runs — it never exposes conversation forks). A trunk agent - * has no fork home, so without this its plain-text speech falls back to the - * process-global `defaultPublishChannel`, which tracks the most-recent inbound - * across ALL channels and misroutes a reply to whichever channel last spoke - * under concurrency (item-3 redux). The framework tracks the triggering - * channel of the live turn per-agent and exposes it here; resolves to - * undefined for a heartbeat / no-trigger turn, which correctly keeps the - * global fallback. Consulted AFTER `homeChannelResolver` (a fork's home always - * wins), BEFORE `defaultPublishChannel`. + * The agent's current speech route (src/speech-routes.ts), as the + * framework holds it for the live turn: where its unaddressed plain speech + * goes, if anywhere. The same route feeds the beforeInference channel + * context (defaultOutgoing, and its reply edge as `incoming`) and is the + * destination of a channel_publish that names no channel — so what the + * agent is told and where it lands never diverge. The registry's last + * delivered message reaches speech only through the framework, as the + * fallback route of a turn whose wake named no conversation: without a + * route there is nothing to default to. */ - activeChannelResolver?: (agentName: string) => string | undefined; + speechRouteResolver?: (agentName: string) => SpeechRouteView; } // ============================================================================ @@ -558,12 +713,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; @@ -571,11 +721,15 @@ export class ChannelRegistry { channels: Array<{ channelId: string; label?: string }>; }) => void; private homeChannelResolver?: (agentName: string) => string | undefined; - private activeChannelResolver?: (agentName: string) => string | undefined; + private speechRouteResolver?: (agentName: string) => SpeechRouteView; private store?: JsStore; /** Registered channels, keyed by `{serverId}:{channelId}`. */ private channels = new Map(); + /** Where the last message any channel delivered came in (noteDelivered): + * a turn whose wake names no conversation speaks there, as before speech + * routes. Cleared when its channel or server goes. */ + private lastInbound: { serverId: string; channelId: string; threadId?: string } | null = null; /** * Most-recently-known label per channelId, replayed from @@ -627,18 +781,13 @@ export class ChannelRegistry { */ private dmClassified = new Map(); - /** Most recent incoming channel ID — used for speech routing / default publish. */ - private defaultPublishChannel: string | null = null; - - /** Most recent incoming message metadata, used for buildChannelContext. */ - private defaultPublishMessageId: string | null = null; - private defaultPublishThreadId: string | undefined = undefined; - - /** Per-channel typing indicator timers. */ - private typingIntervals = new Map>(); + /** Typing indicator timers, per channel on its server (typingKey): the + * same channel id on another server is another channel. */ + private typingIntervals = new Map }>(); - /** Per-channel typing metadata — carried on the 7s refresh so the target - * server keeps getting the same routing hints (e.g. Zulip topic). */ + /** Typing metadata, keyed like the timers — carried on the 7s refresh so + * the target server keeps getting the same routing hints (e.g. Zulip + * topic). */ private typingMetadata = new Map>(); /** Chronicle-projected desired lifecycle state, keyed by server + channel. @@ -658,6 +807,9 @@ export class ChannelRegistry { private legacyPolicies = new Map(); private migratedLegacyPolicies = new Set(); private handleCoalescedIncoming?: ChannelRegistryOptions['handleCoalescedIncoming']; + private inboundSource?: ChannelRegistryOptions['inboundSource']; + private inboundAccepted?: ChannelRegistryOptions['inboundAccepted']; + private coalescedSource?: ChannelRegistryOptions['coalescedSource']; constructor( serverRegistry: McplServerRegistry, @@ -674,6 +826,9 @@ export class ChannelRegistry { }, ) { this.handleCoalescedIncoming = options?.handleCoalescedIncoming; + this.inboundSource = options?.inboundSource; + this.inboundAccepted = options?.inboundAccepted; + this.coalescedSource = options?.coalescedSource; this.serverRegistry = serverRegistry; this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -683,7 +838,7 @@ export class ChannelRegistry { this.onRouteFailure = options?.onRouteFailure; this.onChannelAutoOpened = options?.onChannelAutoOpened; this.homeChannelResolver = options?.homeChannelResolver; - this.activeChannelResolver = options?.activeChannelResolver; + this.speechRouteResolver = options?.speechRouteResolver; this.store = options?.store; this.initializeLifecycleStore(); this.initializeLabelHistoryStore(); @@ -778,7 +933,8 @@ export class ChannelRegistry { for (const channelId of params.removed) { const key = `${serverId}:${channelId}`; const existed = this.channels.delete(key); - this.stopTyping(channelId); + this.stopTyping({ serverId, channelId }); + if (this.lastInbound?.serverId === serverId && this.lastInbound.channelId === channelId) this.lastInbound = null; addedResults.push({ id: channelId, accepted: existed, reason: existed ? undefined : 'not registered' }); } } @@ -896,7 +1052,7 @@ export class ChannelRegistry { // channels/register authorization). A server whose channel genuinely // exists registers it first; discord's DM case goes through // ensureChannelRegistered on push/event, which creates a CLOSED - // routable entry rather than an open self-attested one. + // placeholder that names the channel, not an open self-attested one. const incomingKey = `${serverId}:${message.channelId}`; if (!this.channels.has(incomingKey)) { this.emitTraceFn({ @@ -949,34 +1105,69 @@ export class ChannelRegistry { continue; } - // Track default publish channel (most recent ACCEPTED incoming) — - // deliberately after §14.5 validation: a rejected message from an - // unregistered channel must not retarget outbound speech (the locus is - // exactly the authority a self-attested channel would be stealing). // A coalesced item is "accepted" only once the coalescer admits it, so - // for those this runs after the hook (below). + // for those this runs after the hook, when its channel may be gone. const markAccepted = () => { - this.defaultPublishChannel = message.channelId; - this.defaultPublishMessageId = message.messageId; - this.defaultPublishThreadId = message.threadId; // A server sending channels/incoming is authoritative evidence that // the transport is actually open. This repairs transient status only; // durable desired state still changes exclusively through lifecycle // operations. - this.channels.get(incomingKey)!.open = true; + const entry = this.channels.get(incomingKey); + if (entry) entry.open = true; }; + // An ordinary message, once queued, is where the last message came in + // (noteDelivered). A coalesced one counts only when its coalescer + // admits it afresh, which the host observes itself: its result here + // reads `accepted` for a producer's retry too. if (!coalesced) markAccepted(); - // Determine whether to trigger inference - let triggerInference = true; + // Build the incoming event; whether it triggers inference is decided + // just below. + const event: McplChannelIncomingEvent = { + type: 'mcpl:channel-incoming', + serverId, + channelId: message.channelId, + messageId: message.messageId, + threadId: message.threadId, + author: message.author, + content: convertedContent, + timestamp: message.timestamp, + metadata: message.metadata, + ...(message.tags ? { tags: message.tags } : {}), + triggerInference: true, + acceptedAt: Date.now(), + }; + + // The source envelope is the host's, built here, at admission, before + // the message is gated, queued or acknowledged: a rename or rebind + // while it waits cannot change where it says it came from, and the + // gate reads the same envelope the direct path does. Building it + // accepts nothing: an ordinary message is accepted once its trigger + // filter returns (below), a coalesced one by its coalescer if it + // admits the occurrence. + const inboundSource = coalesced + ? this.coalescedSource?.(serverId, message, event) + : this.inboundSource?.(event); + if (inboundSource) event.inboundSource = inboundSource; + + // Whether to trigger inference: decided now for an ordinary message; + // a coalesced one carries the filter's inputs (triggerInputs), decided + // once its coalescer admits it, so a refused occurrence, a retry or a + // retraction is never gated. if (this.shouldTriggerInference) { const textContent = message.content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map((b) => b.text) .join('\n'); - triggerInference = this.shouldTriggerInference( - textContent, - { + const inputs: TriggerInputs = { + content: textContent, + metadata: { + // The adapter's own metadata first; the protocol's fields after + // it, always present (even undefined), so an adapter metadata key + // can never stand in for them; and the frozen envelope last — + // always present, even undefined, so an adapter key can't pose as + // it either — which the gate's route candidates read first (a + // thread decides where a post lands, MCPL RFC-011). ...message.metadata, eventType: 'mcpl:channel-incoming', serverId, @@ -985,25 +1176,13 @@ export class ChannelRegistry { threadId: message.threadId, author: message.author, ...(message.tags ? { tags: message.tags } : {}), + [INBOUND_SOURCE_KEY]: inboundSource, }, - ); + }; + if (coalesced) event.triggerInputs = inputs; + else event.triggerInference = this.shouldTriggerInference(inputs.content, inputs.metadata); } - // Build the incoming event - const event: McplChannelIncomingEvent = { - type: 'mcpl:channel-incoming', - serverId, - channelId: message.channelId, - messageId: message.messageId, - threadId: message.threadId, - author: message.author, - content: convertedContent, - timestamp: message.timestamp, - metadata: message.metadata, - ...(message.tags ? { tags: message.tags } : {}), - triggerInference, - }; - if (coalesced) { // RFC-006 §14.3: a coalesced message is admitted like any other and // then handed, with the event the ordinary path would have queued, to @@ -1024,10 +1203,17 @@ export class ChannelRegistry { continue; } - // Push to the processing queue + // Push to the processing queue, accepted now that its trigger filter + // has returned. // Cast through unknown because McplChannelIncomingEvent matches the // CustomEvent `${string}:${string}` type pattern but lacks an index signature. + if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(event as unknown as ProcessEvent); + // A withdrawal is never the place, as before speech routes: on a + // registry with no coalescing hook it arrives here as a plain item. + if ((message.coalesce as { retract?: unknown } | null | undefined)?.retract !== true) { + this.noteDelivered(serverId, message.channelId, message.threadId, message.tags); + } // Collect per-message result results.push({ @@ -1048,19 +1234,18 @@ export class ChannelRegistry { } /** - * Ensure a channel is registered so it can serve as an outbound routing - * locus. A push event from a closed channel must never mutate lifecycle - * state: direct-address events are intentionally usable without subscribing. + * Ensure a channel a push names has an entry: a CLOSED placeholder that + * names the channel (its id and label), so the push's envelope and its + * route candidate name a known conversation. A push event from a closed + * channel must never mutate lifecycle state: direct-address events are + * intentionally usable without subscribing. * - * Mirrors the lazy-registration inside handleIncoming(), but for channels - * that only ever arrive as push/events rather than channels/incoming — the - * motivating case is Discord DMs, which discord-mcpl forwards via push/event - * with the channel closed (`channelIsOpen:false`). Such a channel is never - * registered, so routeSpeech() can't resolve it and the agent's reply is - * silently dropped even though the inbound message proves the channel is - * reachable (item-3 redux, DM sub-case). Idempotent: a later authoritative - * channels/register or channels/changed overwrites this descriptor with the - * richer one. + * The placeholder declares no publish target (MCPL RFC-011), so nothing + * is published to it: a reply to it is held as a draft until the server + * registers the channel with a declaration (channels/register or + * channels/changed), which overwrites this descriptor with the richer + * one. The motivating case is Discord DMs, which discord-mcpl forwards via + * push/event with the channel closed (`channelIsOpen:false`). Idempotent. */ ensureChannelRegistered( serverId: string, @@ -1128,93 +1313,90 @@ export class ChannelRegistry { // Typing Indicator Management // ========================================================================== + /** The typing state's key for a channel on its server. */ + private static typingKey(serverId: string, channelId: string): string { + return JSON.stringify([serverId, channelId]); + } + /** * Start sending typing indicators for a channel. * * Sends a typing notification immediately and every 7 seconds thereafter. * Discord typing indicators last ~10s, so 7s keeps them alive. * + * `target` names the channel and, when known, its server: the indicator + * shows only on that server's channel. A bare id resolves only when one + * server registers it, so an id two servers share, named alone, shows no + * indicator rather than one on a guessed server. + * * No-op if already typing on this channel. */ - startTyping(channelId: string, metadata?: Record): void { + startTyping(target: string | { serverId?: string; channelId: string }, metadata?: Record): void { + const { serverId, channelId } = typeof target === 'string' ? { serverId: undefined, channelId: target } : target; + const found = this.findExactEntry({ ...(serverId ? { serverId } : {}), channelId }); + if ('error' in found) return; + const entry = found.entry; + const key = ChannelRegistry.typingKey(entry.serverId, entry.descriptor.id); const metadataChanged = metadata !== undefined && - !shallowEqualRecord(this.typingMetadata.get(channelId), metadata); + !shallowEqualRecord(this.typingMetadata.get(key), metadata); if (metadata) { - this.typingMetadata.set(channelId, metadata); + this.typingMetadata.set(key, metadata); } - if (this.typingIntervals.has(channelId)) { + if (this.typingIntervals.has(key)) { // Already typing. If the caller supplied new routing metadata (e.g. the // relevant Zulip topic just moved because a newer message arrived), // dispatch an immediate refresh so the server sees the new routing // within this request instead of waiting up to TYPING_INTERVAL_MS for // the next tick. - if (metadataChanged) { - const entry = this.findChannelEntry(channelId); - if (entry) { - this.sendTypingNotification(entry.serverId, channelId); - } - } - return; - } - - // Find the channel entry and its server - const entry = this.findChannelEntry(channelId); - if (!entry) { + if (metadataChanged) this.sendTypingNotification(entry.serverId, entry.descriptor.id); return; } // Send typing immediately - this.sendTypingNotification(entry.serverId, channelId); + this.sendTypingNotification(entry.serverId, entry.descriptor.id); // Set up interval — pulls the latest metadata on each tick so mid-stream // updates (e.g. a newer incoming message switching the relevant topic) // take effect on the next refresh. const interval = setInterval(() => { - this.sendTypingNotification(entry.serverId, channelId); + this.sendTypingNotification(entry.serverId, entry.descriptor.id); }, TYPING_INTERVAL_MS); - this.typingIntervals.set(channelId, interval); + this.typingIntervals.set(key, { serverId: entry.serverId, channelId: entry.descriptor.id, interval }); } /** * Stop sending typing indicators. * - * If channelId is specified, stops typing on that channel only. - * If no channelId, stops all typing indicators. + * With a target, stops typing on that channel: on its server when named, + * else on every server where that channel id is typing. With none, stops + * all typing indicators. */ - stopTyping(channelId?: string): void { - if (channelId !== undefined) { - const interval = this.typingIntervals.get(channelId); - if (interval) { - clearInterval(interval); - this.typingIntervals.delete(channelId); - // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip) - // clear the indicator immediately rather than waiting for auto-expire. - // Metadata still carries the routing hint so the stop hits the same - // topic/thread as the start. Guarded by `interval`: matches the - // global-clear branch's semantics, and keeps defensive stopTyping(ch) - // calls from spamming stops at a server that never saw a start. - const entry = this.findChannelEntry(channelId); - if (entry && this.sendTypingFn) { - this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop'); - } + stopTyping(target?: string | { serverId?: string; channelId: string }): void { + const named = target === undefined + ? undefined + : typeof target === 'string' ? { serverId: undefined, channelId: target } : target; + for (const [key, typing] of [...this.typingIntervals]) { + if (named && (typing.channelId !== named.channelId || (named.serverId && typing.serverId !== named.serverId))) continue; + clearInterval(typing.interval); + this.typingIntervals.delete(key); + // Dispatch an explicit 'stop' so servers that support it (e.g. Zulip) + // clear the indicator immediately rather than waiting for auto-expire. + // Metadata still carries the routing hint so the stop hits the same + // topic/thread as the start. Only where typing was started: a + // defensive stopTyping(ch) never sends a stop to a server that never + // saw a start. + this.sendTypingFn?.(typing.serverId, typing.channelId, this.typingMetadata.get(key), 'stop'); + this.typingMetadata.delete(key); + } + if (named) { + for (const key of [...this.typingMetadata.keys()]) { + const [serverId, channelId] = JSON.parse(key) as [string, string]; + if (channelId === named.channelId && (!named.serverId || serverId === named.serverId)) this.typingMetadata.delete(key); } - this.typingMetadata.delete(channelId); } else { - // Clear all typing intervals and dispatch stop for each known channel - const channels = Array.from(this.typingIntervals.keys()); - for (const interval of this.typingIntervals.values()) { - clearInterval(interval); - } - this.typingIntervals.clear(); - if (this.sendTypingFn) { - for (const id of channels) { - const entry = this.findChannelEntry(id); - if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop'); - } - } this.typingMetadata.clear(); } } @@ -1223,11 +1405,45 @@ export class ChannelRegistry { // Accessors // ========================================================================== + /** The channel id of where the last message any channel delivered came in + * (noteDelivered: a conversational message, never a reaction or a + * deletion, nor a withdrawal, which its callers leave out), or null. Kept + * from before speech routes for hosts that read it. The framework + * doesn't: a turn's fallback route reads getLastInbound, which names the + * server and thread too. */ + getDefaultPublishChannel(): string | null { + return this.lastInbound?.channelId ?? null; + } + + /** Where the last message any channel delivered came in: its server, + * channel and thread, for the route of a turn whose wake names no + * conversation. */ + getLastInbound(): { serverId: string; channelId: string; threadId?: string } | null { + return this.lastInbound ? { ...this.lastInbound } : null; + } + /** - * Get the default publish channel ID (most recent incoming channel). + * A message was delivered: it is now where the last message came in, the + * fallback route of a turn whose wake names no conversation. Called for an + * ordinary `channels/incoming` message once it is queued, and by the host + * for a coalesced one when its coalescer admits it afresh (never for a + * producer's retry, which the coalescer acknowledges again without + * delivering anything). Only a message a conversation could continue + * counts, on a channel still registered: never a reaction or a deletion + * (`chat:reaction`, `chat:reaction-remove`, `chat:deleted`). Each caller + * leaves out withdrawals (`coalesce.retract`), which are never the place, + * even when a coalescer shows the resident a withdrawal notice: the + * place is where the last message came in. An MCPL item's tags alone + * decide: `metadata.system` is host-written. */ - getDefaultPublishChannel(): string | null { - return this.defaultPublishChannel; + noteDelivered(serverId: string, channelId: string, threadId?: string, tags?: readonly string[]): void { + if (!this.channels.has(`${serverId}:${channelId}`)) return; + if (!isConversational(tags) || tags?.includes('chat:deleted')) return; + this.lastInbound = { + serverId, + channelId, + ...(typeof threadId === 'string' && threadId ? { threadId } : {}), + }; } /** @@ -1250,6 +1466,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; } @@ -1331,7 +1554,7 @@ export class ChannelRegistry { }); case 'channel_publish': - return this.handleToolPublish(input as { channelId: string; content: string }); + return this.handleToolPublish(input as { channelId?: string; serverId?: string; threadId?: string | null; content?: string; text?: string }, origin); case 'think': return this.handleToolThink(input as { content?: string }); @@ -1351,6 +1574,15 @@ export class ChannelRegistry { // Channel Context for beforeInference // ========================================================================== + /** Whether a route's place is one a publish can still go to now: its + * channel (on its server) still declares a target that allows the + * route's thread or root. */ + private routeStillPublishable(route: { serverId?: string; channelId: string; threadId?: string }): boolean { + const found = this.findExactEntry({ ...(route.serverId ? { serverId: route.serverId } : {}), channelId: route.channelId }); + if ('error' in found) return false; + return !publishPlaceRefusal(declaredPublishTarget(found.entry.descriptor), route.threadId ?? null); + } + /** * Build channel context for inclusion in beforeInference params. * @@ -1359,15 +1591,17 @@ export class ChannelRegistry { buildChannelContext(agentName?: string): ChannelContext | undefined { const openChannels = this.getOpenChannels(); - // Resolve the outbound locus the SAME way routeSpeech does, so the agent is - // told the channel its speech will actually land in: a conversation fork's - // home channel, else this turn's triggering channel (single trunk agent — - // item-3 redux), else the global default (heartbeats). Without this, a fork - // was advertised the global locus but published somewhere else; and a trunk - // agent was told the wrong channel under concurrency. - const home = agentName ? this.homeChannelResolver?.(agentName) : undefined; - const active = agentName ? this.activeChannelResolver?.(agentName) : undefined; - const outgoing = home ?? active ?? this.defaultPublishChannel; + // The agent's own speech route, the one its plain speech will actually + // use: the outgoing channel, and the message it answers as `incoming` + // (a fallback route answers none). Without a channel route there is no + // outgoing channel to advertise — never a latest-inbound guess beyond + // the fallback route itself. + const route = agentName ? this.speechRouteResolver?.(agentName) : undefined; + // The outgoing channel, while its publish can still go there: a route + // lasts its turn, but a declaration withdrawn mid-turn refuses the + // publish, and the context never names a place no post will reach + // (MCPL RFC-011 §5). + const outgoing = route?.kind === 'channel' && this.routeStillPublishable(route) ? route.channelId : undefined; if (openChannels.length === 0 && !outgoing) { return undefined; @@ -1375,21 +1609,22 @@ export class ChannelRegistry { const context: ChannelContext = {}; - // Incoming: the most-recent inbound message (what the agent is replying to). - // Left process-global — per-channel inbound tracking (the right messageId for - // a fork's own channel) is a separate concern from the outbound routing fix. - if (this.defaultPublishChannel && this.defaultPublishMessageId) { + if (route?.kind === 'channel' && route.replyTo) { context.incoming = { - channelId: this.defaultPublishChannel, - messageId: this.defaultPublishMessageId, - threadId: this.defaultPublishThreadId, + channelId: route.channelId, + messageId: route.replyTo, + ...(route.threadId ? { threadId: route.threadId } : {}), }; } - // Default outgoing: the resolved outbound locus (home channel for forks). if (outgoing) { + // Where the speech lands, whether or not the route has a reply edge, + // with its place exactly as the publish will carry it (MCPL RFC-011 + // §5): the thread, or null for the root. A route is only ever a place + // the framework publishes to exactly, so the place is always known. context.defaultOutgoing = { channelId: outgoing, + threadId: route?.kind === 'channel' ? route.threadId ?? null : null, }; } @@ -1414,13 +1649,9 @@ export class ChannelRegistry { for (const [key, entry] of this.channels) { if (entry.serverId !== serverId) continue; this.channels.delete(key); - this.stopTyping(entry.descriptor.id); - if (this.defaultPublishChannel === entry.descriptor.id) { - this.defaultPublishChannel = null; - this.defaultPublishMessageId = null; - this.defaultPublishThreadId = undefined; - } + this.stopTyping({ serverId: entry.serverId, channelId: entry.descriptor.id }); } + if (this.lastInbound?.serverId === serverId) this.lastInbound = null; // Desired state and migration markers deliberately survive disconnects. } @@ -1429,18 +1660,15 @@ export class ChannelRegistry { */ stopAll(): void { // Clear all typing intervals - for (const interval of this.typingIntervals.values()) { - clearInterval(interval); + for (const typing of this.typingIntervals.values()) { + clearInterval(typing.interval); } this.typingIntervals.clear(); + this.typingMetadata.clear(); // Clear channels map this.channels.clear(); - - // Reset default publish tracking - this.defaultPublishChannel = null; - this.defaultPublishMessageId = null; - this.defaultPublishThreadId = undefined; + this.lastInbound = null; } // ========================================================================== @@ -1991,15 +2219,44 @@ export class ChannelRegistry { /** * Publish into a channel on behalf of a named non-resident agent (the * subconscious's speak_in_channel). Same delivery path as the - * channel_publish tool; the agent name rides the speech-routed trace. + * channel_publish tool — the publish executor, with the same receipt — + * attributed to that agent. Traced once the outcome is in, as plain + * speech is: speech-routed for a confirmed delivery, with the server, + * channel and thread it resolved to; speech-route-failed otherwise, with + * its outcome (unknown: it may have been posted; failed: it wasn't). */ async publishForAgent( channelId: string, text: string, agentName: string, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { - this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text }); - return this.handleToolPublish({ channelId, content: text }); + const result = await this.handleToolPublish({ channelId, content: text }, { kind: 'agent', agentName }); + const at = (result.data ?? {}) as { status?: string; serverId?: string; channelId?: string; threadId?: string | null; messageId?: string }; + if (at.status === 'delivered') { + this.emitTraceFn({ + type: 'mcpl:speech-routed', + conversationId: agentName, + serverId: at.serverId, + channelId: at.channelId ?? channelId, + threadId: at.threadId ?? null, + delivered: true, + textLen: text.length, + text, + ...(at.messageId ? { messageId: at.messageId } : {}), + }); + } else { + this.emitTraceFn({ + type: 'mcpl:speech-route-failed', + conversationId: agentName, + ...(at.serverId ? { serverId: at.serverId } : {}), + channelId: at.channelId ?? channelId, + threadId: at.threadId ?? null, + reason: result.error ?? 'refused', + textLen: text.length, + outcome: at.status === 'unknown' ? 'unknown' : 'failed', + }); + } + return result; } /** @@ -2188,7 +2445,7 @@ export class ChannelRegistry { // tick would be noise. if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return; if (this.sendTypingFn) { - this.sendTypingFn(serverId, channelId, this.typingMetadata.get(channelId)); + this.sendTypingFn(serverId, channelId, this.typingMetadata.get(ChannelRegistry.typingKey(serverId, channelId))); } // TODO: When server-connection exposes a public sendNotification or // sendTyping method, wire it here directly instead of using a callback. @@ -2205,9 +2462,12 @@ export class ChannelRegistry { .map((e) => e.descriptor); } - /** Server id owning a registered channel (by descriptor id), or null. */ + /** The server that registered a channel id, or null when none did or more + * than one did. A shared id names no server: resolveDestination refuses it + * too, so a caller never fills one in by registration order. */ getChannelServerId(channelId: string): string | null { - return this.findChannelEntry(channelId)?.serverId ?? null; + const found = this.findExactEntry({ channelId }); + return 'error' in found ? null : found.entry.serverId; } /** @@ -2340,14 +2600,18 @@ export class ChannelRegistry { */ resolveProseTarget( spec: string, - ): { channelId: string; label?: string } | { error: string; candidates?: string[] } { + ): { serverId: string; channelId: string; label?: string } | { error: string; candidates?: string[] } { const entries = [...this.channels.values()]; + // The conversation it resolved to, with its server: a pin keeps that + // server, so the same id registered by another server later changes + // nothing. + const found = (e: ChannelEntry) => ({ serverId: e.serverId, channelId: e.descriptor.id, label: e.descriptor.label }); const trimmed = spec.trim(); if (!trimmed) return { error: 'empty target' }; const exact = entries.filter((e) => e.descriptor.id === trimmed); if (exact.length === 1) { - return { channelId: exact[0]!.descriptor.id, label: exact[0]!.descriptor.label }; + return found(exact[0]!); } if (exact.length > 1) { return { @@ -2360,7 +2624,7 @@ export class ChannelRegistry { (e) => (e.descriptor.address as { channelId?: string } | undefined)?.channelId === trimmed, ); if (raw.length === 1) { - return { channelId: raw[0]!.descriptor.id, label: raw[0]!.descriptor.label }; + return found(raw[0]!); } if (raw.length > 1) { return { @@ -2396,7 +2660,7 @@ export class ChannelRegistry { const id = mention[1]!; const byId = dms.filter((e) => dmMeta(e)?.recipientId === id); if (byId.length === 1) { - return { channelId: byId[0]!.descriptor.id, label: byId[0]!.descriptor.label }; + return found(byId[0]!); } return { error: `no registered DM matches the mention <@${id}>`, @@ -2413,7 +2677,7 @@ export class ChannelRegistry { return n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name)); }); if (pool.length === 1) { - return { channelId: pool[0]!.descriptor.id, label: pool[0]!.descriptor.label }; + return found(pool[0]!); } if (pool.length > 1) { return { @@ -2431,7 +2695,7 @@ export class ChannelRegistry { (e) => norm(e.descriptor.label ?? '') === norm(trimmed), ); if (byLabel.length === 1) { - return { channelId: byLabel[0]!.descriptor.id, label: byLabel[0]!.descriptor.label }; + return found(byLabel[0]!); } if (byLabel.length > 1) { return { @@ -2448,7 +2712,7 @@ export class ChannelRegistry { const nameOf = normalizeChannelLabelName; const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed)); if (byName.length === 1) { - return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label }; + return found(byName[0]!); } if (byName.length > 1) { return { @@ -2702,7 +2966,7 @@ export class ChannelRegistry { } this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource); - this.stopTyping(input.channelId); + this.stopTyping({ serverId: entry.serverId, channelId: input.channelId }); const server = this.serverRegistry.getServer(entry.serverId); if (!server) { @@ -2887,66 +3151,62 @@ export class ChannelRegistry { } /** - * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN). - * Publish the agent's plain-text speech to the current conversational locus - * (the most recent incoming channel, tracked cross-surface here in the host). - * Called by the framework on a text-only turn — replaces the per-surface - * sticky auto-post that used to live in discord-mcpl. Returns null when there - * is no locus / the channel or its server can't be resolved (in which case - * the speech simply stays in chronicle + module surfaces). - */ - /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel → - * process-global default). Public so a multi-segment caller can snapshot it - * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */ - /** - * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the - * server owning the channel, AS THE MODEL GENERATES. Emitted only when that - * server declared `channels.streaming` in its initialize capabilities — - * servers that never opted in (the whole existing fleet) receive nothing. - * Fire-and-forget and never throws: streaming is an observer surface; the - * authoritative delivery is the eventual channels/publish. + * MCPL Spec 14.3 outgoing streaming: forward prose the agent has published + * to the server owning the channel. Emitted only to a server whose + * effective grant includes `channels.streaming` (§5.4; streamingServerFor), + * and only for text a confirmed publish placed at the root of a channel + * that declares where its posts land (never speculatively: §14.3), so + * every chunk names the root (`threadId: null`). `destination` is the + * publish outcome's own server and channel, never re-resolved from a bare + * channel id. Fire-and-forget and never throws: streaming is an observer + * surface; the authoritative delivery is channels/publish. Says whether the + * chunk went out, so a caller completes only what was streamed. */ sendOutgoingChunk( - channelId: string, + destination: { serverId: string; channelId: string }, conversationId: string, inferenceId: string, index: number, delta: string, - ): void { - const server = this.streamingServerFor(channelId); - if (!server) return; + ): boolean { + const server = this.streamingServerFor(destination); + if (!server) return false; try { - server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId, index, delta }); - } catch { /* observer surface — never disturb the turn */ } + server.sendChannelsOutgoingChunk({ inferenceId, conversationId, channelId: destination.channelId, index, delta, threadId: null }); + return true; + } catch { + /* observer surface — never disturb the turn */ + return false; + } } - /** Spec 14.3 companion: final moderated content per channel at stream end. */ + /** Spec 14.3 companion: the final content of one channel's stream, at its end. */ sendOutgoingComplete( - channelId: string, + destination: { serverId: string; channelId: string }, conversationId: string, inferenceId: string, text: string, ): void { - const server = this.streamingServerFor(channelId); + const server = this.streamingServerFor(destination); if (!server) return; try { server.sendChannelsOutgoingComplete({ inferenceId, conversationId, - channelId, + channelId: destination.channelId, content: [{ type: 'text', text }], + threadId: null, }); } catch { /* observer surface — never disturb the turn */ } } - private streamingServerFor(channelId: string) { - // The map is keyed `${serverId}:${channelId}`; stream targets arrive as - // bare descriptor ids (what resolveProseTarget returns). Scan like the - // resolver does, and fail closed on cross-server ambiguity — the same - // never-guess rule that governs delivery. - const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); - if (matches.length !== 1) return null; - const server = this.serverRegistry.getServer(matches[0]!.serverId); + private streamingServerFor(destination: { serverId: string; channelId: string }) { + const found = this.findExactEntry(destination); + if ('error' in found) return null; + // §14.3 fail-closed: nothing streams that delivery would refuse — and + // delivery publishes only where the place is declared (RFC-011 §6). + if (publishPlaceRefusal(declaredPublishTarget(found.entry.descriptor), null)) return null; + const server = this.serverRegistry.getServer(found.entry.serverId); // §5.4: the GRANT gates streaming, not the raw advertisement. The old // `capabilities?.channels?.streaming` check was doubly wrong: undefined // for the boolean `channels: true` shape (masking discord-mcpl's latent @@ -2956,12 +3216,329 @@ export class ChannelRegistry { return server; } + /** + * An agent's standing HOME channel — a conversation fork's — or null. It is + * the only route the registry knows of its own: every other route is the + * framework's, decided per turn (shelf-355), and incoming traffic never + * makes one. + */ resolveLocus(conversationId: string): string | null { - const home = this.homeChannelResolver?.(conversationId); - return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; + return this.homeChannelResolver?.(conversationId) ?? null; } - 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 } : {}), + }, + }; + } + + /** + * The publish target a registered channel declares (MCPL RFC-011), read + * live: a `channels/changed` that adds or withdraws it applies to the next + * decision. Undefined when the channel doesn't resolve to exactly one + * registration or declares nothing. + */ + publishTarget(target: { serverId?: string; channelId: string }): 'exact' | 'root' | undefined { + const found = this.findExactEntry(target); + return 'error' in found ? undefined : declaredPublishTarget(found.entry.descriptor); + } + + private findExactEntry( + target: { serverId?: string; channelId: string }, + ): { entry: ChannelEntry } | { error: string } { + // 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) { + // Name the servers: a resident told only that the id is shared would + // have nothing to put in serverId. + const servers = matches.map((e) => e.serverId).sort(); + return { + error: `channel id "${target.channelId}" is registered by more than one MCPL server (${servers.join(', ')}); ` + + '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), resident resends of held drafts, and + * channel_publish (with publishForAgent, which shares its handler). + * + * Resolution is exact (resolveDestination): `serverId` plus `channelId` + * when the caller has the server, otherwise the channel id must be + * 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. + * + * Every publish names its place inside the channel (MCPL RFC-011): the + * target's `threadId`, or the channel root (null) when it names none. It + * goes only to a channel that declares `capabilities.publish.target`, and + * a thread only to one that declares `exact`; anything else is refused + * before anything is opened or sent, because the connector would choose + * a place itself. + * + * Only the connector's `delivered: true` with an echo of the requested + * place confirms a post. `failed` means nothing was posted: the attempt + * stopped before the request was written, or the connector answered + * `delivered: false` and named no message (no `messageId`, or null) — a + * refusal. Everything else is `unknown`: once the request has been handed + * to the transport, an error response, a timeout, a lost connection, or a + * missing, malformed or contradictory receipt (such as `delivered: false` + * beside a message id, valid or not, or a delivery whose echoed place is + * missing or different) means the post may or may not exist where it was + * asked to go, and the caller must not treat it as safe to retry blindly. + */ + async publish( + conversationId: string, + text: string, + target: { serverId?: string; channelId: string; threadId?: string | null }, + openSource: 'opened-by-delivery' | 'opened-by-reply' = 'opened-by-delivery', + ): Promise { + const at = (): number => Date.now(); + const found = this.findExactEntry(target); + if ('error' in found) return { status: 'failed', reason: found.error, at: at() }; + let entry = found.entry; + const place = target.threadId === undefined ? null : target.threadId; + const destinationOf = (e: ChannelEntry): PublishDestination => ({ + serverId: e.serverId, + channelId: e.descriptor.id, + ...(e.descriptor.label ? { label: e.descriptor.label } : {}), + threadId: place, + }); + let destination = destinationOf(entry); + if (place !== null && (typeof place !== 'string' || place === '')) { + return { status: 'failed', destination, reason: 'the thread to post in must be a non-empty thread id', at: at() }; + } + const refusal = publishPlaceRefusal(declaredPublishTarget(entry.descriptor), place); + if (refusal) { + return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(refusal, ChannelRegistry.entryPlace(entry), place), at: at() }; + } + + // A send that would be refused is refused before anything opens: a + // closed channel opens only for words that can go out. + let 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); + this.emitTraceFn({ + type: 'mcpl:channel-opened-by-send', + serverId: entry.serverId, + channelId: entry.descriptor.id, + }); + if (openSource === 'opened-by-delivery') { + try { + this.onChannelAutoOpened?.({ + conversationId, + serverId: entry.serverId, + source: 'opened-by-delivery', + channels: [{ channelId: entry.descriptor.id, label: entry.descriptor.label }], + }); + } catch (err) { + console.error('onChannelAutoOpened (delivery) failed:', err); + } + } + } catch (err) { + return { + status: 'failed', + destination, + reason: `channel is closed and open failed: ${(err as Error).message}`, + at: at(), + }; + } + // The open waited on the connector, and a `channels/changed` meanwhile + // can have removed the channel or withdrawn its declaration. Recheck + // the entry the destination resolved to (its server and channel, never + // the original selector, which could now resolve to another server) + // immediately before sending. + const current = this.findExactEntry({ serverId: entry.serverId, channelId: entry.descriptor.id }); + if ('error' in current) { + return { status: 'failed', destination, reason: `the channel went away while it was being opened: ${current.error}`, at: at() }; + } + entry = current.entry; + destination = destinationOf(entry); + const withdrawn = publishPlaceRefusal(declaredPublishTarget(entry.descriptor), place); + if (withdrawn) { + return { status: 'failed', destination, reason: ChannelRegistry.placeRefusalText(withdrawn, ChannelRegistry.entryPlace(entry), place), at: at() }; + } + // So can a grant reduced (or a server gone) while the open waited. + const now = this.serverRegistry.getServer(entry.serverId); + if (!now || !CapabilityGrant.of(now).has('channels.publish')) { + return { + status: 'failed', + destination, + reason: now + ? `channels.publish was withdrawn from "${entry.serverId}"'s effective grant while the channel was being opened (§14.1)` + : `server "${entry.serverId}" went away while the channel was being opened`, + at: at(), + }; + } + server = now; + } + + let result: unknown; + try { + result = await server.sendChannelsPublish({ + conversationId, + channelId: entry.descriptor.id, + content: [{ type: 'text', text }], + threadId: place, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Only a request that never left the host proves nothing was posted. + // An error response may follow a partial post (a multi-part send whose + // later part failed), so it is unknown, with the connector's own words + // (and data) kept for the resident to judge. + const notSent = err instanceof McplRequestError && err.outcome === 'not-sent'; + const detail = err instanceof McplRequestError ? err.data : undefined; + return { + status: notSent ? 'failed' : 'unknown', + destination, + reason: notSent ? message : `${message} (delivery uncertain)`, + ...(detail !== undefined ? { detail } : {}), + at: at(), + }; + } + const receipt = (result ?? {}) as { delivered?: unknown; messageId?: unknown; threadId?: unknown; reason?: unknown }; + const messageId = typeof receipt.messageId === 'string' && receipt.messageId ? receipt.messageId : undefined; + if (receipt.delivered === true) { + // RFC-011 §5: the echo must name the place asked for. Missing or + // different, something was posted — but not provably there. + if (!('threadId' in receipt) || receipt.threadId !== place) { + const asked = place === null ? 'the channel root' : `thread ${place}`; + const echoed = !('threadId' in receipt) || receipt.threadId === undefined + ? 'did not say where it posted' + : receipt.threadId === null + ? 'reported posting at the channel root' + : `reported posting in ${typeof receipt.threadId === 'string' ? `thread ${receipt.threadId}` : `an unreadable place ${JSON.stringify(receipt.threadId)}`}`; + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: `server "${entry.serverId}" reported delivery but ${echoed}, not ${asked} (where it landed is unconfirmed)`, + at: at(), + }; + } + return { status: 'delivered', destination, ...(messageId ? { messageId } : {}), at: at() }; + } + if (receipt.delivered === false) { + // `delivered: false` proves nothing went out only when the receipt + // names no message at all — a refusal (RFC-011 §4). A message id + // beside it — valid or malformed — contradicts it: something may have + // been posted. + if (receipt.messageId === undefined || receipt.messageId === null) { + const why = typeof receipt.reason === 'string' && receipt.reason.trim() ? `: ${receipt.reason.trim()}` : ''; + return { + status: 'failed', + destination, + reason: `server "${entry.serverId}" reported delivered:false${why}`, + at: at(), + }; + } + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: messageId + ? `server "${entry.serverId}" reported delivered:false but named posted message ${messageId} (delivery uncertain)` + : `server "${entry.serverId}" reported delivered:false beside a malformed message id ` + + `${JSON.stringify(receipt.messageId)} (contradictory receipt; delivery uncertain)`, + at: at(), + }; + } + // Final publish is a request, so success requires an explicit receipt; a + // missing or malformed one leaves delivery uncertain (anima-research/ + // agent-framework#163's rule). + return { + status: 'unknown', + destination, + ...(messageId ? { messageId } : {}), + reason: `server "${entry.serverId}" returned no valid delivery receipt (delivery uncertain)`, + at: at(), + }; + } + + /** Why a publish's place was refused, in words a resident can act on. The + * channel is named as every conversation is (describeConversation), its + * server included. */ + static placeRefusalText( + refusal: 'undeclared' | 'no-threads', + place: { serverId: string; channelId: string; label?: string }, + threadId: string | null, + ): string { + const where = describeConversation({ + kind: 'channel', serverId: place.serverId, channelId: place.channelId, ...(place.label ? { label: place.label } : {}), + }); + return refusal === 'undeclared' + ? `${where}'s connector doesn't declare where a post lands (MCPL RFC-011), so it may choose a thread itself; ` + + 'the framework doesn\'t publish there' + : `${where} has no threads (its connector declares root), so a post into thread ${threadId} is refused`; + } + + /** A registered channel's place: its own server, id and label. */ + private static entryPlace(entry: ChannelEntry): { serverId: string; channelId: string; label?: string } { + const label = entry.descriptor.label; + return { serverId: entry.serverId, channelId: entry.descriptor.id, ...(typeof label === 'string' && label ? { label } : {}) }; + } + + /** + * Plain speech for one turn segment: publish `text` to the turn's frozen + * locus and report the PublishOutcome. A failure (or an uncertain outcome) + * 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 @@ -2971,122 +3548,129 @@ export class ChannelRegistry { * stale global default and land the reply in the wrong channel (item-3, * PR #32, and the 2026-07-22 Sol DM misroute). Explicit `null` means * "this turn is pinned to no locus": fail loudly rather than guess. */ - locusChannelId: string | null, - ): 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)`); + locusChannelId: string | null | { serverId?: string; channelId: string; threadId?: string | null }, + opts: { + /** Keep words that provably weren't posted (a failed outcome) and + * return the draft's id, which the failure notice then names. Not + * called for an unknown outcome: those words may have been posted, + * and a draft would invite a duplicate. */ + holdFailed?: (reason: string) => string | undefined; + } = {}, + ): Promise { + const fail = ( + where: { serverId?: string; channelId: string | null; label?: string; threadId?: string | null }, + reason: string, + outcome: PublishOutcome, + ): PublishOutcome => { + const status = outcome.status === 'unknown' ? 'unknown' : 'failed'; + const { serverId, channelId, label, threadId } = where; + let draftId: string | undefined; + if (status === 'failed' && opts.holdFailed) { + try { + draftId = opts.holdFailed(reason); + } catch (err) { + console.error(`[routeSpeech] ${conversationId}: could not keep the undelivered words as a draft:`, err); + } + } + console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${status === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, + ...(serverId ? { serverId } : {}), channelId: channelId ?? '', + threadId: threadId ?? null, reason, textLen: text.length, + outcome: status, }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length }); - return null; + this.onRouteFailure?.({ + conversationId, channelId, + ...(serverId ? { serverId } : {}), + ...(label ? { label } : {}), + ...(threadId ? { threadId } : {}), + reason, textLen: text.length, outcome: status, + ...(draftId ? { draftId } : {}), + }); + 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({ channelId: 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}"`); - } - - // INVARIANT: it is not possible to send into a closed channel. Speech - // routed into a closed-but-registered locus (the canonical case: a DM - // that woke the agent — DMs register closed) OPENS the channel first, - // so typing indicators, reaction machinery, and inbound forwarding come - // alive with the reply. If the open fails, the speech does NOT go out: - // a half-alive delivery is worse than a loud marker. - if (!entry.open) { - try { - await this.openChannelNow(entry, 'opened-by-delivery'); - console.error( - `[routeSpeech] ${conversationId}: locus ${channelId} was closed — opened by delivery (speech implies engagement)`, - ); - this.emitTraceFn({ - type: 'mcpl:channel-opened-by-send', - serverId: entry.serverId, - channelId, - }); - try { - this.onChannelAutoOpened?.({ - conversationId, - serverId: entry.serverId, - source: 'opened-by-delivery', - channels: [{ channelId, label: entry.descriptor.label }], - }); - } catch (err) { - console.error('onChannelAutoOpened (delivery) failed:', err); - } - } catch (err) { - return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`); - } - } - - const server = this.serverRegistry.getServer(entry.serverId); - if (!server) { - return fail(channelId, `server "${entry.serverId}" not found`); - } - // §14.1: channels/publish requires channels.publish in the grant. The - // host not sending is the enforcement — a send to an ungranted server - // would have it act on authority it was never told it has. - if (!CapabilityGrant.of(server).has('channels.publish')) { - return fail(channelId, `channels.publish not in "${entry.serverId}"'s effective grant (§14.1)`); + const reason = 'turn has no locus (no home/trigger channel; nothing to deliver into)'; + return fail({ channelId: null }, reason, { status: 'failed', reason, at: Date.now() }); + } + + // A route that knows its server publishes exactly there: the same + // channel id on another server is a different conversation. + const target: { serverId?: string; channelId: string; threadId?: string | null } = + typeof locusChannelId === 'string' ? { channelId: locusChannelId } : locusChannelId; + const outcome = await this.publish(conversationId, text, target); + const channelId = outcome.destination?.channelId ?? target.channelId; + if (outcome.status !== 'delivered') { + // The server and label are the destination's own when it resolved, so + // the marker names the conversation the words were bound for. + const serverId = outcome.destination?.serverId ?? target.serverId; + const label = outcome.destination?.label; + return fail({ + ...(serverId ? { serverId } : {}), + channelId, + ...(label ? { label } : {}), + threadId: outcome.destination?.threadId ?? target.threadId, + }, outcome.reason ?? 'delivery not confirmed', outcome); } - const publishParams: ChannelsPublishParams = { - conversationId, - channelId, - content: [{ type: 'text', text }], - }; - const result = await server.sendChannelsPublish(publishParams); - const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true; - // Surface the posted message's id (ChannelsPublishResult.messageId) so - // trace consumers can act on the just-posted message — e.g. a TTS-relay - // tap editing it down to the words actually voiced on interruption. - // Previously this was silently dropped here. - const messageId = (result as { messageId?: string } | undefined)?.messageId; - - // The server accepted the publish RPC but reported the message was not - // actually delivered (e.g. missing Send Messages permission). Previously - // this returned `{ delivered: true }`, masking the failure. Surface it. - if (delivered === false) { - return fail(channelId, `server "${entry.serverId}" reported delivered:false for "${channelId}"`); - } - - console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${entry.serverId}, delivered=${delivered})`); + console.error(`[routeSpeech] ${conversationId}: routed ${text.length} chars -> ${channelId} (server=${outcome.destination!.serverId}, delivered=true)`); this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId, - serverId: entry.serverId, + serverId: outcome.destination!.serverId, channelId, - delivered, + threadId: outcome.destination!.threadId ?? null, + 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 | { serverId?: string; channelId: string; threadId?: string | null }, + opts: { holdFailed?: (reason: string) => string | undefined } = {}, + ): Promise<{ delivered: boolean; serverId: string; channelId: string; label?: string; threadId?: string; messageId?: string } | null> { + const outcome = await this.deliverSpeech(conversationId, text, locusChannelId, opts); + if (outcome.status !== 'delivered') return null; + // The destination as resolved at delivery — server, channel, the label it + // had then, and the thread — so a receipt names exactly where it went. + return { + delivered: true, + serverId: outcome.destination!.serverId, + channelId: outcome.destination!.channelId, + ...(outcome.destination!.label ? { label: outcome.destination!.label } : {}), + ...(outcome.destination!.threadId ? { threadId: outcome.destination!.threadId } : {}), + ...(outcome.messageId !== undefined ? { messageId: outcome.messageId } : {}), + }; } - private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise { + private async handleToolPublish( + input: { channelId?: string; serverId?: string; threadId?: string | null; content?: string; text?: string }, + origin?: ChannelToolOrigin, + ): Promise { // Resolve content: accept both `content` and `text` (backward compat) const messageText = input.content ?? input.text; if (!messageText) { @@ -3097,59 +3681,104 @@ export class ChannelRegistry { }; } - // Resolve channelId: default to most recent incoming channel - const channelId = input.channelId ?? this.defaultPublishChannel; - if (!channelId) { - return { - success: false, - error: 'No channelId specified and no default channel available', - isError: true, - }; - } - - const entry = this.findChannelEntry(channelId); - if (!entry) { - return { - success: false, - error: `Channel not found: ${channelId}`, - isError: true, - }; - } - - const server = this.serverRegistry.getServer(entry.serverId); - if (!server) { - return { - success: false, - error: `Server not found: ${entry.serverId}`, - isError: true, + // The destination: the one named — its root, or the thread named with + // it — else the caller's current speech route, thread included (for a + // turn whose wake named no conversation, its fallback route); never a + // thread borrowed from an earlier route for a channel named explicitly. + // Supplied selectors are checked before any default applies: an empty + // or non-string one is refused, never read as omission (which would + // widen it to "your route" or "whichever server has the id"). null means + // the field is not in use. + const refuse = (error: string): ToolResult => ({ success: false, error: `${error} Nothing was sent.`, isError: true }); + const supplied = (v: unknown): boolean => v !== undefined && v !== null; + if (supplied(input.channelId) && (typeof input.channelId !== 'string' || !input.channelId)) { + return refuse('channelId must name a channel (leave it out to publish to your speech route).'); + } + if (supplied(input.serverId) && (typeof input.serverId !== 'string' || !input.serverId)) { + return refuse('serverId must name a server (leave it out to resolve the channel id alone).'); + } + const thread = readThreadId(input.threadId); + if ('error' in thread) return refuse(thread.error); + if (supplied(input.serverId) && !supplied(input.channelId)) { + return refuse('serverId needs the channelId it belongs to.'); + } + if (supplied(input.threadId) && !supplied(input.channelId)) { + return refuse('threadId needs the channelId it belongs to.'); + } + let target: { serverId?: string; channelId: string; threadId: string | null }; + if (supplied(input.channelId)) { + target = { + channelId: input.channelId as string, + ...(supplied(input.serverId) ? { serverId: input.serverId as string } : {}), + threadId: thread.threadId, }; - } - - try { - if (!CapabilityGrant.of(server).has('channels.publish')) { + } else { + const route = origin?.kind === 'agent' ? this.speechRouteResolver?.(origin.agentName) : undefined; + if (route?.kind !== 'channel') { + const why = route?.kind === 'held' + ? `your speech route is held between ${route.conversations.join(' and ')}` + : route?.kind === 'surface' + ? `your speech route is the local surface ${route.surface}, not a channel` + : 'you have no current speech route'; return { success: false, - error: `channels.publish not in "${server.id}"'s effective grant (§14.1)`, + error: `No channelId given, and ${why}: name the channel to publish to. Nothing was sent.`, isError: true, }; } - const publishParams: ChannelsPublishParams = { - conversationId: '', // Framework will fill this when wired - channelId, - content: [{ type: 'text', text: messageText }], + // The route's server when it has one; otherwise the id alone, resolved + // exactly as plain speech on the same route resolves it — and its + // thread, when the route is one. + target = { + channelId: route.channelId, + ...(route.serverId ? { serverId: route.serverId } : {}), + threadId: route.threadId ?? null, }; + } - const result = await server.sendChannelsPublish(publishParams); + const outcome = await this.publish( + origin?.kind === 'agent' ? origin.agentName : '', + messageText, + target, + ); + const where = outcome.destination + ? { + serverId: outcome.destination.serverId, + channelId: outcome.destination.channelId, + ...(outcome.destination.label ? { channelLabel: outcome.destination.label } : {}), + threadId: outcome.destination.threadId ?? null, + } + : { channelId: target.channelId, ...(target.serverId ? { serverId: target.serverId } : {}), threadId: target.threadId }; + if (outcome.status === 'delivered') { return { success: true, - data: result ?? { delivered: true }, - }; - } catch (err) { - return { - success: false, - error: `Failed to publish to channel: ${(err as Error).message}`, - isError: true, + data: { + delivered: true, + status: 'delivered', + ...where, + ...(outcome.messageId ? { messageId: outcome.messageId } : {}), + }, }; } + // Not confirmed: say which, and where it was attempted. `unknown` may + // already be posted (a partial multi-part send, a timeout): never invite + // a blind retry. + // Named as every conversation is (describeConversation): label, then + // `server / channel-id`, then thread. + const shown = describeConversation({ + kind: 'channel', + ...(where.serverId ? { serverId: where.serverId } : {}), + channelId: where.channelId, + ...(where.threadId ? { threadId: where.threadId } : {}), + ...('channelLabel' in where && where.channelLabel ? { label: where.channelLabel } : {}), + }); + return { + success: false, + error: outcome.status === 'unknown' + ? `Delivery to ${shown} was not confirmed — it may or may not have been posted: ${outcome.reason ?? 'no valid receipt'}. Check the channel before sending again.` + : `Not sent to ${shown}: ${outcome.reason ?? 'refused'}. Nothing was posted.`, + isError: true, + data: { delivered: false, status: outcome.status, ...where, ...(outcome.messageId ? { messageId: outcome.messageId } : {}) }, + }; } } diff --git a/src/mcpl/inbound-source.ts b/src/mcpl/inbound-source.ts new file mode 100644 index 000000000..e2bc75645 --- /dev/null +++ b/src/mcpl/inbound-source.ts @@ -0,0 +1,286 @@ +/** + * 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. + */ + +import { createHash } from 'node:crypto'; + +/** The MCPL admission lane that accepted an item. Its contract decides what + * an `eventId` is worth: `push/event` deduplicates by eventId, and RFC-006 + * coalesced admission (`coalesced: true`) guarantees stable retries and + * 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; +} + +/** + * JSON with object keys sorted at every level, written as the store's append + * keeps a value (its native UTF-8 write, which the real-store digest table + * checks): an `undefined` object entry is dropped; a string is well-formed + * (each lone surrogate comes back as U+FFFD); and an entry whose key isn't + * well-formed is dropped (the store doesn't keep it). An `undefined` array + * element is written as `null`, as JSON writes it: the store's append + * refuses a body holding one, so no stored copy does. So a value + * hashes alike however its keys were ordered (content read back from the + * store has them in a different order than it was written), and alike + * before and after the store. editMessage writes JSON instead, so an edited + * copy can keep what an append wouldn't; it has changed anyway. + * + * It expects JSON-shaped values, as the MCPL lanes deliver them (parsed from + * JSON). Any other object is written by its own enumerable entries, unlike + * JSON.stringify: a Date becomes `{}`, as the store's own write keeps it, but + * a deferred write recovered from its JSON file brings it back as a string. + * + * sourceBodyDigest's serializer, kept out of the package API: serialization + * and framing both stay inside sourceBodyDigest, so consumers don't + * duplicate either (`canonicalJson(blocks)` alone hashes differently). + */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value && typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([k, v]) => v !== undefined && k.isWellFormed()) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`; + } + if (typeof value === 'string') return JSON.stringify(value.toWellFormed()); + return JSON.stringify(value) ?? 'null'; +} + +/** + * A block as the store hands it back. Context-manager keeps inline media as + * blobs (blob-manager.ts): an image whose source isn't a URL, and every + * document, audio or video block, comes back as `{ type, source: { type: + * 'base64', data, mediaType } }` and nothing else, its data re-encoded from + * the decoded bytes, and an image's media type taken from its bytes' + * signature where they have one. Any other block comes back as it was given, + * except a `blob_ref`, which the store resolves to its inline media on read: + * it is digested as given here, so a copy holding one doesn't match its + * digest (the MCPL lanes never store one). + */ +function storedBlock(block: unknown): unknown { + const media = block as { type?: unknown; source?: { type?: unknown; data?: unknown; mediaType?: unknown } } | null; + const blob = media?.type === 'image' + ? media.source?.type !== 'url' + : media?.type === 'document' || media?.type === 'audio' || media?.type === 'video'; + const source = media?.source; + if (!blob || typeof source?.data !== 'string') return block; + const data = canonicalBase64(source.data); + const mediaType = media!.type === 'image' + ? sniffRasterImageMediaType(Buffer.from(data.slice(0, 32), 'base64')) ?? source.mediaType + : source.mediaType; + return { type: media!.type, source: { type: 'base64', data, mediaType } }; +} + +/** + * Base64 as the store writes it back from the decoded bytes: padded, with no + * whitespace, the standard alphabet and zero unused bits. Data already in + * that form, as every stored copy's is, is returned without decoding it all. + */ +function canonicalBase64(data: string): string { + if (data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(data)) { + const last = data.slice(-4); + if (Buffer.from(last, 'base64').toString('base64') === last) return data; + } + return Buffer.from(data, 'base64').toString('base64'); +} + +/** + * The store's own test for raster image types with an unambiguous byte + * signature, copied from context-manager's blob-manager.ts (it isn't + * exported): the store relabels an image whose bytes match one of these. + */ +function sniffRasterImageMediaType(bytes: Uint8Array): string | null { + if (bytes.length >= 8 && + bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && + bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) { + return 'image/png'; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + if (bytes.length >= 6) { + const signature = Buffer.from(bytes.subarray(0, 6)).toString('ascii'); + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif'; + } + if (bytes.length >= 12 && + Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && + Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') { + return 'image/webp'; + } + return null; +} + +/** + * The version identity of a delivered body (agreed with the receipts lane, + * room-220 #46752–#47210): SHA-256 hex of `canonicalJson([blocks])` over the + * ContentBlock[] the framework stores for the body — after MCPL conversion, + * before any host decoration is added and before storage shards it — with + * each block as the store keeps it (storedBlock, canonicalJson). So a body + * hashes alike as delivered, as handed to storage, and as read back from + * the store. The one-element array keeps the framing an undecorated, + * unsharded stored copy has always hashed to, so a stamped copy and an + * older copy of the same body share a version. + * + * Ingestion stamps it as `metadata.sourceBodyDigest`, and the same function + * over exactly the blocks handed to storage (decorations included) as + * `metadata.storedBodyDigest`, on both MCPL lanes. Both live outside the + * frozen admission envelope: they describe the body actually delivered (a + * materialization, a correction), not the admission. Neither proves later + * presence or completeness; an unsharded stored copy that no longer hashes + * to its storedBodyDigest was changed after delivery (editMessage keeps + * metadata). A body the store shards carries the whole body's two digests + * on every shard, and no shard hashes to them. Check a copy read back with + * its blobs resolved (getAllMessages, getMessage, or getMessageWindow + * without `resolveBlobs: false`): a blob reference never matches. + * + * Exported from the package root, so a consumer checks a copy against either + * field with this same function rather than a copy of it. + */ +export function sourceBodyDigest(blocks: readonly unknown[]): string { + return createHash('sha256').update(canonicalJson([blocks.map(storedBlock)])).digest('hex'); +} diff --git a/src/mcpl/index.ts b/src/mcpl/index.ts index d82790c67..e7a54fb9b 100644 --- a/src/mcpl/index.ts +++ b/src/mcpl/index.ts @@ -106,7 +106,7 @@ export { } from './errors.js'; // Server connection and registry -export { McplServerConnection } from './server-connection.js'; +export { McplServerConnection, McplRequestError } from './server-connection.js'; export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js'; // Feature set management (permission layer) @@ -157,6 +157,16 @@ 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; +// and the digest of a delivered or stored body +export { INBOUND_SOURCE_KEY, readInboundSource, conversationKey, sourceBodyDigest } 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/prose-grammar.ts b/src/mcpl/prose-grammar.ts index 557a5b2ce..863f56fed 100644 --- a/src/mcpl/prose-grammar.ts +++ b/src/mcpl/prose-grammar.ts @@ -1,9 +1,7 @@ /** - * Explicit-prose routing grammar — the SINGLE definition shared by the - * delivery path (framework.deliverProse*) and the outgoing-stream router - * (prose-stream-router.ts). Extracted from framework.ts verbatim so the two - * parsers cannot drift: a streamed chunk and its eventual delivery must agree - * on what is a prefix, what is a modifier, and what is body. + * Explicit-prose routing grammar — the single definition the delivery path + * (framework.deliverProse*, deliverHybridProse) reads. Outgoing streams carry + * only what delivery published, so nothing else parses prose. * * Grammar (docs/explicit-prose-routing.md): * `>>target [!] [body…]` — target = channel spec or `skip_reply` diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts deleted file mode 100644 index e14ad8c3b..000000000 --- a/src/mcpl/prose-stream-router.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Incremental prose router: turns a stream of text deltas into a stream of - * (channelId, delta) pairs AS THE MODEL GENERATES, so downstream surfaces - * (voice synthesis, streamed message rendering, future platforms) can act on - * speech before the turn completes. This is the emission half of MCPL Spec - * 14.3 (`channels/outgoing/chunk`) — the notification has existed in the - * wire types since the spec landed; this class is what finally produces it. - * - * Correctness contract: routing decisions here MUST agree with the delivery - * path (deliverProse/parseProsePrefix). Both use prose-grammar.ts; this - * router additionally holds back exactly as much text as needed to make each - * line's decision — the start of a line is buffered only until it either - * cannot be a `>>` prefix (flush as body) or its prefix token (+ optional - * ` !` modifier) completes (switch target, swallow the prefix). Body text - * after the decision point streams through with zero added latency. - * - * Suppression is fail-closed: text whose destination is unknown (explicit - * mode before the first `>>`, unresolvable/ambiguous targets, `>>skip_reply`) - * emits NOTHING — the delivery path will bounce/retain it; a stream consumer - * must never see text that delivery would refuse to send. - * - * Two modes, mirroring proseRouting: - * - explicit: parse `>>` prefixes; initial target null (suppressed). - * - locus: NO prefix parsing (locus mode's `>>` lines are plain text); - * everything streams to the frozen turn locus. - */ - -export interface RoutedDelta { - channelId: string; - delta: string; -} - -type State = 'line-start' | 'hybrid-indent' | 'maybe-prefix' | 'prefix-gap' | 'prefix-token' | 'after-token' | 'body'; - -export class ProseStreamRouter { - private target: string | null; - private state: State = 'line-start'; - private held = ''; // text held while a line's decision is pending - private token = ''; // the >>spec token being accumulated - private sawBang = false; - private accumulated = new Map(); - - constructor( - private opts: { - /** explicit parses `>>`; hybrid parses `>>>` while retaining locus fallback. */ - mode: 'explicit' | 'hybrid' | 'locus'; - /** Initial destination: the frozen turn locus (locus mode) or null. */ - initialTarget: string | null; - /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */ - resolve: (spec: string) => string | null; - }, - ) { - this.target = opts.initialTarget; - if (opts.mode === 'locus') this.state = 'body'; - } - - /** Per-channel full text emitted so far (for outgoing/complete). */ - byChannel(): ReadonlyMap { - return this.accumulated; - } - - /** - * Drop everything routed so far and return to the constructed state. - * - * For a membrane refusal retry (RetryingEvent): the abandoned attempt's - * prose must not survive into the new one, and `accumulated` feeds - * outgoing/complete — leaving it would finalize text the model never - * actually said in the attempt that stands. - */ - reset(): void { - this.target = this.opts.initialTarget; - this.state = this.opts.mode === 'locus' ? 'body' : 'line-start'; - this.held = ''; - this.token = ''; - this.sawBang = false; - this.accumulated = new Map(); - } - - feed(delta: string): RoutedDelta[] { - const out: RoutedDelta[] = []; - for (const ch of delta) this.step(ch, out); - return coalesce(out); - } - - /** End of stream: flush any held line-start text (it can no longer become a - * complete prefix; an unterminated `>>tok` at EOF is routing metadata with - * no body — dropped, matching delivery which would deliver empty body). */ - finish(): RoutedDelta[] { - const out: RoutedDelta[] = []; - if (this.state === 'hybrid-indent') this.emit(this.held, out); - if (this.state === 'maybe-prefix') { - const full = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; - if (arrows !== full) this.emit(this.held, out); - } - // prefix-token / after-token at EOF: prefix without body — nothing to emit. - this.held = ''; - this.state = 'line-start'; - return coalesce(out); - } - - private step(ch: string, out: RoutedDelta[]): void { - switch (this.state) { - case 'body': - if (ch === '\n' && this.opts.mode !== 'locus') { - this.emit('\n', out); - this.state = 'line-start'; - } else { - this.emit(ch, out); - } - return; - - case 'line-start': - if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) { - this.state = 'hybrid-indent'; - this.held = ch; - return; - } - if (ch === '>') { this.state = 'maybe-prefix'; this.held = '>'; return; } - this.state = 'body'; - this.step(ch, out); - return; - - case 'hybrid-indent': - if (ch === ' ' || ch === '\t') { this.held += ch; return; } - if (ch === '>') { this.held += ch; this.state = 'maybe-prefix'; return; } - this.emit(this.held, out); - this.held = ''; - this.state = 'body'; - this.step(ch, out); - return; - - case 'maybe-prefix': { - const prefix = this.opts.mode === 'hybrid' ? '>>>' : '>>'; - const arrows = this.opts.mode === 'hybrid' ? this.held.trimStart() : this.held; - if (ch === '>' && arrows.length < prefix.length) { - this.held += '>'; - return; - } - if (arrows === prefix) { - if (this.opts.mode === 'hybrid' && (ch === ' ' || ch === '\t')) { - this.held = ''; - this.state = 'prefix-gap'; - return; - } - if (ch === '\n' || ch === ' ' || ch === '\t') { - if (this.opts.mode === 'explicit') this.emit(this.held, out); - this.held = ''; - this.state = ch === '\n' ? 'line-start' : 'body'; - if (ch !== '\n') this.step(ch, out); - return; - } - this.state = 'prefix-token'; - this.token = ch; - this.held = ''; - return; - } - this.emit(this.held, out); - this.held = ''; - this.state = 'body'; - this.step(ch, out); - return; - } - - case 'prefix-gap': - if (ch === ' ' || ch === '\t') return; - if (ch === '\n') { this.target = null; this.state = 'line-start'; return; } - this.state = 'prefix-token'; - this.token = ch; - return; - - case 'prefix-token': - if (ch === ' ' || ch === '\t') { this.applyTarget(); this.state = 'after-token'; this.sawBang = false; return; } - if (ch === '\n') { this.applyTarget(); this.state = 'line-start'; return; } - this.token += ch; - return; - - case 'after-token': - // Grammar: `([ \t]+!)?[ \t]*` — one optional `!` immediately after the - // first whitespace run is the continue-turn modifier, not body. - if (ch === ' ' || ch === '\t') return; - if (ch === '\n') { this.state = 'line-start'; return; } - if (ch === '!' && !this.sawBang) { this.sawBang = true; return; } - this.state = 'body'; - this.step(ch, out); - return; - } - } - - private applyTarget(): void { - const spec = this.token; - this.token = ''; - if (spec === 'skip_reply') { this.target = null; return; } - this.target = this.opts.resolve(spec); - } - - private emit(text: string, out: RoutedDelta[]): void { - if (this.target === null || !text) return; - out.push({ channelId: this.target, delta: text }); - this.accumulated.set(this.target, (this.accumulated.get(this.target) ?? '') + text); - } -} - -function coalesce(deltas: RoutedDelta[]): RoutedDelta[] { - const out: RoutedDelta[] = []; - for (const d of deltas) { - const last = out[out.length - 1]; - if (last && last.channelId === d.channelId) last.delta += d.delta; - else out.push({ ...d }); - } - return out; -} diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 871d7a3c9..2e9557a05 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -192,6 +192,10 @@ export interface CoalescerHost { /** Persist one receipt now, before the acceptance is acknowledged. Throws * on failure, which fails the acceptance (the producer retries). */ recordReceipt?(record: CoalescingReceiptRecord): void; + /** One call per ADMITTED occurrence (never for a producer retry the receipt + * deduplicates), after its receipt is recorded. Observation only: a throw + * is contained here and never fails the acceptance. */ + accepted?(occurrence: CoalescedOccurrence): void; /** Make every write so far DURABLE (fsync, group-committed by the host). * Awaited before an acceptance is acknowledged and before push/render is * issued: a kill after either boundary must find the receipt, the @@ -431,6 +435,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 294e18959..36a885158 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -19,6 +19,7 @@ import type { FeatureSetManager } from './feature-set-manager.js'; import { McplFeatureSetError } from './feature-set-manager.js'; import { expandCoreTags } from './tags.js'; import { EmptyContentError, validateCoalescedContent } from './push-coalescer.js'; +import { INBOUND_SOURCE_KEY, type InboundSource } from './inbound-source.js'; import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './visible-content.js'; // ============================================================================ @@ -48,6 +49,21 @@ 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; + /** A coalesced push's trigger-filter inputs, until the framework decides + * its trigger when its coalescer first delivers or wakes for an admitted + * occurrence. Plain data, so a persisted occurrence keeps them. */ + triggerInputs?: TriggerInputs; +} + +/** What a trigger filter (shouldTriggerInference) is called with. */ +export interface TriggerInputs { + content: string; + metadata: Record; } // ============================================================================ @@ -177,7 +193,25 @@ export class PushHandler { pushEventFn: (event: McplPushEvent) => void, emitTraceFn: (event: { type: string; [key: string]: unknown }) => void, shouldTriggerInference?: (content: string, metadata: Record) => boolean, + /** RFC-006: hand a coalesced push to its coalescer. Its trigger isn't + * decided here: the event carries the filter's inputs + * (triggerInputs), and the framework decides only once the coalescer + * admits the occurrence for delivery, so a refused one, a retry or a + * retraction is never gated (never a route candidate). */ private readonly handleCoalesced?: (serverId: string, params: PushEventParams, event: McplPushEvent) => Promise, + /** An ordinary (uncoalesced) push was just admitted: return its source + * envelope, frozen now (mcpl/inbound-source.ts), before the trigger + * filter reads it. Building it accepts nothing (inboundAccepted). + * Coalesced work is stamped by its own path. */ + private readonly inboundSource?: (event: McplPushEvent) => InboundSource | undefined, + /** A coalesced push's source envelope, built — not accepted — before it + * is gated: its coalescer freezes and carries this same envelope, and + * observes the acceptance itself if it admits the occurrence. */ + private readonly coalescedSource?: (serverId: string, params: PushEventParams, event: McplPushEvent) => InboundSource | undefined, + /** An ordinary push is accepted: once its trigger filter has returned, + * right before it is queued, as a coalesced one is on admission. A + * filter that throws leaves nothing accepted. */ + private readonly inboundAccepted?: (source: InboundSource) => void, ) { this.featureSetManager = featureSetManager; this.pushEventFn = pushEventFn; @@ -191,10 +225,14 @@ export class PushHandler { * 1. Validate feature set * 2. Convert content blocks; reject visibly-empty content * 3. Deduplicate by eventId - * 4. Optionally check shouldTriggerInference callback - * 5. Push event to queue - * 6. Emit trace - * 7. Respond with accepted + inferenceId + * 4. Generate inferenceId + * 5. Build the event and its host source envelope (accepted now for an + * ordinary push; only built for a coalesced one, whose coalescer + * freezes it) + * 6. Optionally check shouldTriggerInference, with that envelope; then + * hand a coalesced push to its coalescer, or queue an ordinary one + * 7. Emit trace + * 8. Respond with accepted + inferenceId */ async handlePushEvent( serverId: string, @@ -270,40 +308,60 @@ export class PushHandler { return; } - // 4. Check shouldTriggerInference callback - let triggerInference = true; + // 4. Generate inferenceId + const inferenceId = `${serverId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + // 5. The event; whether it triggers inference is decided below. + const pushEvent: McplPushEvent = { + type: 'mcpl:push-event', + serverId, + featureSet: params.featureSet, + eventId: params.eventId, + content, + origin: params.origin, + tags: params.tags, + timestamp: params.timestamp, + inferenceId, + triggerInference: true, + acceptedAt: Date.now(), + }; + // The source envelope is the host's, built at admission, before the item + // is gated, queued or acknowledged: nothing that changes while it waits + // can rewrite where it came from, and the gate reads the same envelope + // the direct path does. Building it accepts nothing: an ordinary push is + // accepted once its trigger filter returns (below), a coalesced one by + // its coalescer if it admits the occurrence. + const inboundSource = coalesced + ? this.coalescedSource?.(serverId, params, pushEvent) + : this.inboundSource?.(pushEvent); + if (inboundSource) pushEvent.inboundSource = inboundSource; + + // 6. The shouldTriggerInference callback: decided now for an ordinary + // push; a coalesced one carries the inputs, decided once its coalescer + // admits it. if (this.shouldTriggerInference) { const textContent = content .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') .map((b) => b.text) .join('\n'); + // The server's origin first; the host's own fields after it, so an + // origin key can never stand in for which server sent this or what + // kind of event it is; the frozen envelope last — always present, even + // undefined, so an origin key can't pose as it either — which the + // gate's route candidates read first. const metadata: Record = { + ...(params.origin ?? {}), serverId, featureSet: params.featureSet, eventId: params.eventId, eventType: 'mcpl:push-event', - ...(params.origin ?? {}), ...(params.tags ? { tags: params.tags } : {}), + [INBOUND_SOURCE_KEY]: inboundSource, }; - triggerInference = this.shouldTriggerInference(textContent, metadata); + if (coalesced) pushEvent.triggerInputs = { content: textContent, metadata }; + else pushEvent.triggerInference = this.shouldTriggerInference(textContent, metadata); } - // 5. Generate inferenceId - const inferenceId = `${serverId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - - // 6. Push event to queue - const pushEvent: McplPushEvent = { - type: 'mcpl:push-event', - serverId, - featureSet: params.featureSet, - eventId: params.eventId, - content, - origin: params.origin, - tags: params.tags, - timestamp: params.timestamp, - inferenceId, - triggerInference, - }; if (coalesced) { // RFC-006: the coalescer decides whether this occurrence replaces an // unread one, appends, or withdraws; it delivers through the same event @@ -320,6 +378,7 @@ export class PushHandler { } return; } + if (inboundSource) this.inboundAccepted?.(inboundSource); this.pushEventFn(pushEvent); // 7. Emit trace diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index f29fea22d..eb8c76e40 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -62,6 +62,34 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; /** MCP protocol version used in the initialize handshake. */ const MCP_PROTOCOL_VERSION = '2024-11-05'; +/** + * Why a request to an MCPL server produced no result, as far as the host can + * know it. Messages are unchanged from plain errors; the `outcome` makes the + * difference between "never left the host" and "left, and no answer came + * back" a fact a caller can act on instead of a string to match: + * - `not-sent`: the request was refused before anything was written (the + * connection was already closed); + * - `error-response`: the server answered with a JSON-RPC error (it may have + * acted before failing; the error alone does not say); + * - `no-response`: the request was handed to the transport and no answer + * came back (timeout, or the connection closed while awaiting). It does + * not prove the request reached the server — the transport can decline or + * drop a write — only that the host can no longer say it didn't. + */ +export class McplRequestError extends Error { + constructor( + message: string, + readonly outcome: 'not-sent' | 'error-response' | 'no-response', + readonly code?: number, + /** The JSON-RPC error's `data`, verbatim, when the server supplied one + * (e.g. which parts of a multi-part send were posted). */ + readonly data?: unknown, + ) { + super(message); + this.name = 'McplRequestError'; + } +} + /** * Represents a pending JSON-RPC request awaiting a response. */ @@ -212,6 +240,9 @@ export class McplServerConnection extends EventEmitter { /** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS; + /** The deadline a channels/publish request keeps when requestTimeoutMs is 0 + * (see sendChannelsPublish). */ + private publishDeadlineMs = DEFAULT_REQUEST_TIMEOUT_MS; // Event buffering: control-plane traffic can be released independently from // inference-bearing data-plane traffic. This lets a server finish its own @@ -747,13 +778,22 @@ 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) { + // A targeted publish (MCPL RFC-011) is always a Request: a Notification + // has no result, so where it landed could never be confirmed. + if (params.stream && params.threadId === undefined) { this.sendNotification(McplMethod.ChannelsPublish, params as unknown as Record); return Promise.resolve(); } - 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. */ @@ -846,7 +886,7 @@ export class McplServerConnection extends EventEmitter { // Reject all pending requests for (const [id, pending] of this.pendingRequests) { if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`)); + pending.reject(new McplRequestError(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`, 'no-response')); } this.pendingRequests.clear(); this.orphanedRequests.clear(); @@ -973,7 +1013,7 @@ export class McplServerConnection extends EventEmitter { options: RequestOptions = {}, ): Promise { if (this.closed) { - return Promise.reject(new Error(`Cannot send request: connection to "${this.id}" is closed`)); + return Promise.reject(new McplRequestError(`Cannot send request: connection to "${this.id}" is closed`, 'not-sent')); } const id = this.nextRequestId++; @@ -994,13 +1034,14 @@ export class McplServerConnection extends EventEmitter { this.orphanedRequests.set(id, { method, ...(method === 'push/render' ? { renderParams: { featureSet: params.featureSet, key: params.key, eventId: params.eventId } } : {}) }); if (this.orphanedRequests.size > 4096) this.orphanedRequests.delete(this.orphanedRequests.keys().next().value!); } - reject(new Error( + reject(new McplRequestError( `MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` + `within ${timeoutMs}ms — the server may be hung. The response ` + `outcome is unknown: the request was abandoned locally but was not ` + `cancelled, so the tool may still have completed server-side; verify ` + `state before retrying; a blind retry of a stateful/side-effecting ` + `tool may duplicate it.`, + 'no-response', )); }, timeoutMs); // Don't hold the event loop open for the watchdog alone. @@ -1215,7 +1256,12 @@ export class McplServerConnection extends EventEmitter { if (response.error) { pending.reject( - new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`), + new McplRequestError( + `MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`, + 'error-response', + typeof response.error.code === 'number' ? response.error.code : undefined, + (response.error as { data?: unknown }).data, + ), ); } else { pending.resolve(response.result); @@ -1267,8 +1313,9 @@ export class McplServerConnection extends EventEmitter { for (const [id, pending] of this.pendingRequests) { if (pending.timer) clearTimeout(pending.timer); pending.reject( - new Error( + new McplRequestError( `MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`, + 'no-response', ), ); } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 0f913ea5a..90f9f88cb 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -374,7 +374,9 @@ export interface McplServerConfig { * accepts a request and never responds would otherwise freeze the awaiting * agent turn forever; with the timeout the pending request rejects with a * descriptive error, which the framework surfaces as a normal isError - * tool_result. Set 0 to disable. Default: 60000 (60 seconds). + * tool_result. Set 0 to disable, except for a channels/publish request, + * which keeps the 60 s default: its outcome is reported to the resident and + * recorded for its draft, so it must end. Default: 60000 (60 seconds). */ requestTimeoutMs?: number; @@ -986,6 +988,17 @@ export interface ChannelCapabilities { kind?: string; supportsValue?: boolean; }; + /** + * MCPL RFC-011: where a `channels/publish` to this channel lands — a + * per-channel behavioral declaration, not a capability path. `exact`: a + * publish carrying `threadId` lands exactly there (that thread, or the root + * for null) or is refused with nothing posted. `root`: the channel has no + * threads; a string `threadId` is refused. Absent: no guarantee, and the + * framework does not publish there. + */ + publish?: { + target: 'exact' | 'root'; + }; } export interface ChannelHistoryRequest { @@ -1006,9 +1019,13 @@ export interface ChannelContext { threadId?: string; }; - /** Default channel for outgoing messages */ + /** Default channel for outgoing messages, and the place in it the agent's + * plain speech is published to, exactly as that publish carries it (MCPL + * RFC-011 §5, amending §14.4): a thread, or null for the root. A server + * that doesn't read it loses nothing. */ defaultOutgoing?: { channelId: string; + threadId?: string | null; }; /** All candidate channels for this inference */ @@ -1116,6 +1133,8 @@ export interface ChannelsOutgoingChunkParams { channelId: string; index: number; delta: string; + /** MCPL RFC-011 §6: the place the stream's final publish targets. */ + threadId?: string | null; } /** @@ -1128,6 +1147,8 @@ export interface ChannelsOutgoingCompleteParams { conversationId: string; channelId: string; content: McplContentBlock[]; + /** MCPL RFC-011 §6: the place the stream's final publish targets. */ + threadId?: string | null; } /** @@ -1140,6 +1161,13 @@ export interface ChannelsPublishParams { channelId: string; stream?: boolean; content: McplContentBlock[]; + /** + * MCPL RFC-011: where in the channel to post — a non-empty string for that + * thread, null for the channel root; absent is legacy (the server + * chooses). Sent only to a channel that declares + * `capabilities.publish.target`, and only in the Request form. + */ + threadId?: string | null; } /** @@ -1148,6 +1176,10 @@ export interface ChannelsPublishParams { export interface ChannelsPublishResult { delivered: boolean; messageId?: string; + /** MCPL RFC-011: where the post landed (null: the channel root). */ + threadId?: string | null; + /** MCPL RFC-011: why a publish was refused, or a note on the outcome. */ + reason?: string; } /** diff --git a/src/prose-drafts.ts b/src/prose-drafts.ts new file mode 100644 index 000000000..c290a749b --- /dev/null +++ b/src/prose-drafts.ts @@ -0,0 +1,479 @@ +/** + * Held prose drafts: a resident's plain speech that was not sent. + * + * Plain speech is held back for reasons that are right in themselves: an + * explicit send in the same round suppresses the round's prose (so a "sent + * it" postscript is not posted twice), a turn may have no destination, two + * conversations may compete for an unaddressed reply, and a malformed or + * unresolvable routing prefix bounces. Before drafts, the held words were + * kept only as a count (or, in explicit mode, as one in-memory latest-wins + * clipboard): the law was right, and the penalty was amnesia. A draft keeps + * the exact words until the resident resends or dismisses them. + * + * The boundary is publication and agency, not visibility: a draft is never + * published except by its resident's explicit resend (or `{{unsent}}`), and + * only that resident can list or act on it. Its notices go into the + * resident's own history — which residents that share one message slot + * (#197) share, as they already share the assistant turn the words came + * from. + * + * Durability is outside the conversation. Drafts live in a RecordJournal of + * typed Chronicle records, which do not follow branch switches: undo, + * rollback, suppression and branch moves never touch them. A draft written + * in a turn that is later undone stays usable (the words were genuinely + * written and held back), and a delivered draft stays delivered. Its + * originating branch is recorded as provenance only. The notices that name + * a draft are conversation, so they do follow the branch: a draft counts as + * named while a message naming it is on the current branch, and one a + * rollback or branch move has left unnamed is named again. + * + * Drafts are kept, not hoarded. Each resident's KEEP_NEWEST newest drafts + * are always kept; an older one is let go once nothing has happened to it + * (held, named, attempted, answered, dismissed) for KEEP_IDLE_MS. The words + * stay in the resident's history either way. The journal checkpoints what + * it keeps every CHECKPOINT_EVERY entries, so opening it replays only what + * came after. + * + * A delivery attempt is journaled durably BEFORE its request leaves the + * host, and its outcome after: an attempt with no recorded outcome (a crash + * mid-send) reads as unknown, never as unsent. A delivered draft carries a + * historical receipt — confirmed at a time, to a destination, with the + * connector's message id — not a claim that the message still exists. + * Nothing here publishes anything: every send is the resident's explicit + * resend, and nothing replays at boot or on a branch move. + */ + +import { randomInt } from 'node:crypto'; +import type { JsStore } from '@animalabs/chronicle'; +import { RecordJournal } from './record-journal.js'; +import type { PublishDestination, PublishOutcome } from './mcpl/channel-registry.js'; + +/** Chronicle record type of the drafts journal. */ +export const PROSE_DRAFT_RECORD_TYPE = 'framework/prose-draft'; + +/** Why words were held instead of sent. */ +export type DraftReason = + /** An explicit send in the same round suppressed the round's plain speech. */ + | 'explicit-send' + /** The speech had no destination (no route this turn, or a failed envelope). */ + | 'no-destination' + /** Two conversations competed for an unaddressed reply. */ + | 'ambiguous' + /** An explicit/hybrid routing prefix was missing, malformed or unresolvable. */ + | 'bounced'; + +/** Where a draft's words were written. Provenance only: nothing routes by it. */ +export interface DraftSource { + /** Chronicle branch current when the words were held. */ + branch: string; + /** The resident's logical turn (process-local counter) and the round in it. */ + turn: number; + round: number; + /** Position of the segment within its round's speech. */ + segment: number; +} + +export interface DraftAttemptOutcome { + status: PublishOutcome['status']; + at: number; + messageId?: string; + reason?: string; + detail?: unknown; +} + +export interface DraftAttempt { + attemptId: string; + at: number; + destination: PublishDestination; + /** How the words went out: the drafts tool, or explicit mode's `{{unsent}}`. */ + via: 'resend' | 'unsent-token'; + /** The resident resent knowing an earlier attempt may have been posted. */ + confirmedDuplicate?: true; + /** Absent until recorded; an attempt without one reads as unknown. */ + outcome?: DraftAttemptOutcome; +} + +/** + * 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; + source: DraftSource; + 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`: never attempted, or every attempt definitely failed — resend freely; + * - `unconfirmed`: some attempt's outcome is unknown (or was never + * recorded), and none was confirmed: it may have been posted, so a resend + * needs explicit confirmation. A later attempt that fails does not resolve + * an earlier one that may have landed — the uncertainty stays; + * - `delivered`: an attempt was confirmed — its receipt is historical; + * - `dismissed`: the resident set it aside. + */ +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'; + +/** 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 reaching the store + * leaves the journal unreconciled: rebuild from it before rethrowing, so + * the projection never disagrees with what the journal holds. + */ + private write(entry: DraftEntry, durable: boolean, 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 let go can be given again: the notices that named it are + * by then at least KEEP_IDLE_MS old and KEEP_NEWEST drafts back. */ + private newId(agent: string): string { + const existing = this.agentDrafts(agent); + const latestBounce = this.latestBounces.get(agent); + for (;;) { + let id = 'd-'; + for (let i = 0; i < 5; i++) id += ID_ALPHABET[randomInt(ID_ALPHABET.length)]; + if (!existing.has(id) && 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. + */ + hold( + agent: string, + segments: Array<{ text: string; source: DraftSource; 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(), + source: segment.source, + }; + 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 6d8f3f079..d10ce357d 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,5 +52,5 @@ export function splitProseSegments(content: readonly ContentBlock[]): string[] { } flush(); - return segments; + return runs; } diff --git a/src/record-journal.ts b/src/record-journal.ts new file mode 100644 index 000000000..0565e5422 --- /dev/null +++ b/src/record-journal.ts @@ -0,0 +1,209 @@ +/** + * An append-only journal of typed Chronicle records that does not follow + * branch switches. + * + * Chronicle's branch state — snapshot, tree and log states — rewinds with a + * historical rollback (createBranchAt + switchBranch). Typed records do not: + * `getRecordIdsByType` enumerates them from every branch, in global order, and + * they survive `deleteBranch`, reopen and a hard kill. A journal is for state + * that must not rewind with the conversation but should live and die with its + * store: what a workspace's disk last agreed with, what reached a recipient, + * what became of a suppressed draft. + * + * Durability. A record append reaches the OS before `append` returns, so it + * survives a killed process, while branch-state commits are buffered until + * `store.sync()`: a record appended after a state commit can outlive that + * commit. Two barriers, each named where it's needed and neither implied by + * the `{id, sequence}` an append returns: + * - `afterCommittedState: true` syncs BEFORE appending — for an entry that + * asserts state committed just before it, so the entry can never survive + * without that state; + * - `durable: true` syncs AFTER appending — for an entry that must be on + * stable storage before the caller acts on it (an attempt before its + * outbound request, an intent before its disk effect). + * + * Checkpoints bound the replay at open: `checkpoint(snapshot)` records the + * caller's reduced state together with the last entry it covers, and `load()` + * returns the latest checkpoint's snapshot plus only the entries appended + * after it. A journal that never checkpoints replays everything. What entries + * mean, how they reduce, and when to checkpoint stay with the caller. + * + * One writer per record type: a checkpoint covers every entry up to the last + * one THIS journal loaded or appended, so entries another instance appended + * to the same type in between would be covered without being reduced. + * + * An ambiguous write must be reconciled before the journal writes again. Once + * `append` or `checkpoint` has handed a record to the store, any failure from + * there on (the append itself, or a barrier after it) leaves it unknown + * whether that record exists, and the caller didn't reduce it. A later + * checkpoint would then cover it without its ever entering the reducer. So + * such a failure marks the journal unreconciled: every further `append` and + * `checkpoint` throws until `load()` has replayed the tail — the possibly + * written record included — for the caller to reduce. A failure BEFORE the + * record reaches the store (the `afterCommittedState` sync) wrote nothing and + * leaves the journal usable. + */ + +import type { JsStore } from '@animalabs/chronicle'; + +/** Where an appended record landed. Not a durability receipt — see the barriers. */ +export interface JournalRecordRef { + /** Chronicle record id: global, increasing in append order. */ + id: string; + /** The record's sequence on the branch current when it was appended. */ + sequence: number; +} + +export interface JournalEntry extends JournalRecordRef { + entry: Entry; +} + +export interface JournalLoad { + /** The latest checkpoint's snapshot, or null when none was written. */ + snapshot: Snapshot | null; + /** Every entry appended after that checkpoint, in append order. */ + entries: Array>; +} + +export interface AppendOptions { + /** `store.sync()` before appending: the entry asserts state committed just before it. */ + afterCommittedState?: boolean; + /** `store.sync()` after appending: the entry must be on stable storage before the caller acts. */ + durable?: boolean; +} + +interface CheckpointPayload { + /** Id of the last entry the snapshot covers ("0" when it covers none). */ + through: string; + snapshot: Snapshot; +} + +/** Record ids are decimal strings from one store-wide counter. */ +function idValue(id: string): bigint { + if (!/^\d+$/.test(id)) throw new Error(`RecordJournal: unexpected chronicle record id "${id}"`); + return BigInt(id); +} + +function decode(store: JsStore, id: string): { value: T; sequence: number } { + const record = store.getRecord(id); + if (!record) throw new Error(`RecordJournal: record ${id} is listed but cannot be read`); + return { value: JSON.parse(Buffer.from(record.payload).toString('utf-8')) as T, sequence: record.sequence }; +} + +export class RecordJournal { + private readonly store: JsStore; + /** Record type of the entries. */ + readonly type: string; + /** Record type of the checkpoints. */ + readonly checkpointType: string; + /** Highest entry id this journal has loaded or appended (0 when none). */ + private lastEntryId = 0n; + private sinceCheckpoint = 0; + /** Set by a failure after a record reached the store; cleared by load(). */ + private unreconciled: Error | null = null; + + constructor(store: JsStore, opts: { type: string; checkpointType?: string }) { + if (!opts.type) throw new Error('RecordJournal: a record type is required'); + this.store = store; + this.type = opts.type; + this.checkpointType = opts.checkpointType ?? `${opts.type}/checkpoint`; + if (this.checkpointType === this.type) { + throw new Error('RecordJournal: entries and checkpoints need distinct record types'); + } + } + + /** Entries appended since the last checkpoint (or since load, when there was none). */ + get entriesSinceCheckpoint(): number { + return this.sinceCheckpoint; + } + + /** + * The latest checkpoint and every entry after it, whichever branch each was + * appended on. The caller rebuilds its reduced state from exactly these — + * at open, and to reconcile after an ambiguous write. Also positions the + * journal, so later checkpoints cover what was loaded. + */ + load(): JournalLoad { + let snapshot: Snapshot | null = null; + let through = 0n; + let latestCheckpoint: bigint | null = null; + let latestCheckpointId: string | null = null; + for (const id of this.store.getRecordIdsByType(this.checkpointType)) { + const value = idValue(id); + if (latestCheckpoint === null || value > latestCheckpoint) { + latestCheckpoint = value; + latestCheckpointId = id; + } + } + if (latestCheckpointId !== null) { + const payload = decode>(this.store, latestCheckpointId).value; + snapshot = payload.snapshot; + through = idValue(payload.through); + } + + const tail = this.store.getRecordIdsByType(this.type) + .map((id) => ({ id, value: idValue(id) })) + .filter(({ value }) => value > through) + .sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0)); + const entries = tail.map(({ id }) => { + const { value, sequence } = decode(this.store, id); + return { id, sequence, entry: value }; + }); + + this.lastEntryId = tail.length > 0 ? tail[tail.length - 1]!.value : through; + this.sinceCheckpoint = entries.length; + this.unreconciled = null; + return { snapshot, entries }; + } + + /** True after an ambiguous write, until load() has replayed it. */ + get needsReconcile(): boolean { + return this.unreconciled !== null; + } + + /** Append one entry. The returned ref says where it landed, not that it's durable. */ + append(entry: Entry, opts: AppendOptions = {}): JournalRecordRef { + this.assertReconciled(); + if (opts.afterCommittedState) this.store.sync(); + return this.write(this.type, entry, opts.durable === true, (ref) => { + const value = idValue(ref.id); + if (value > this.lastEntryId) this.lastEntryId = value; + this.sinceCheckpoint++; + }); + } + + /** + * Record the caller's reduced state as covering every entry this journal has + * loaded or appended. Like an entry, it's durable only with `durable`. + */ + checkpoint(snapshot: Snapshot, opts: { durable?: boolean } = {}): JournalRecordRef { + this.assertReconciled(); + const payload: CheckpointPayload = { through: this.lastEntryId.toString(), snapshot }; + return this.write(this.checkpointType, payload, opts.durable === true, () => { + this.sinceCheckpoint = 0; + }); + } + + /** Hand one record to the store; any failure from here on is ambiguous. */ + private write(type: string, payload: unknown, durable: boolean, onWritten: (ref: JournalRecordRef) => void): JournalRecordRef { + try { + const record = this.store.appendJson(type, payload); + const ref = { id: record.id, sequence: record.sequence }; + onWritten(ref); + if (durable) this.store.sync(); + return ref; + } catch (err) { + this.unreconciled = err instanceof Error ? err : new Error(String(err)); + throw err; + } + } + + private assertReconciled(): void { + if (this.unreconciled) { + throw new Error( + `RecordJournal(${this.type}): an earlier write failed after reaching the store ` + + `(${this.unreconciled.message}); load() and reduce its tail before writing again`, + ); + } + } +} diff --git a/src/speech-routes.ts b/src/speech-routes.ts new file mode 100644 index 000000000..6e68828dc --- /dev/null +++ b/src/speech-routes.ts @@ -0,0 +1,450 @@ +/** + * Speech routes: where a resident's unaddressed plain speech goes (shelf-355). + * + * A route is decided deliberately or inferred from what triggered the turn, + * and comes from where the last message came in only when the wake named no + * conversation at all: + * - deliberate: a conversation fork's home channel, or a channel the resident + * opened with `channel_open` (setSpeechTarget, the default); a hybrid + * `>>>target` prefix is deliberate too and lives in its own pin; + * - inferred, at a true new turn, from the wake's candidates: the addressed + * ones if any, else the conversational ones. Only candidates that name ONE + * conversation infer a route; several conversations start the turn held; + * - fallback, at a true new turn whose wake named no conversation at all (a + * heartbeat, a timer, a self-wake): where the last message any channel + * delivered came in, as before speech routes, checked like any route. The + * framework decides which wakes those are (InferenceRequest + * .namesConversation): a reaction or a channel a fork owns names one + * without being a candidate, and gets no fallback. A framework wake that + * continues a turn (a rewind retry, an unstick, a prose bounce) names the + * conversations that turn answered (continuationCandidates), so its words + * go where that turn's would have, by the rules every wake follows. + * + * A held turn (competing conversations at turn start, or a competing + * addressed or engaged arrival mid-turn) keeps unaddressed speech as drafts + * rather than guessing. Ambient traffic never suspends a route and never + * selects one over an addressed candidate. Explicit sends name their own + * destination and don't retarget narration. + * + * A route is only ever a place the framework can publish to exactly (MCPL + * RFC-011): a channel whose connector declares `capabilities.publish.target`, + * and a thread only on one that declares `exact`. Anything else is + * unroutable, and speech for it is held rather than handed to a connector + * that would choose the place itself. + * + * Pure: the framework supplies the candidates and the fork home, and owns + * the per-turn state. + */ + +/** A conversation a reply could belong to. */ +export type ConversationRef = + | { + kind: 'channel'; + /** The MCPL server, when known. Absent — never a placeholder — when the + * channel id couldn't be tied to one server; delivery then resolves + * the id alone, the same way on every path. */ + serverId?: string; + channelId: string; + threadId?: string; + /** The channel's registered label when it was seen. */ + label?: string; + } + | { + kind: 'surface'; + /** The local surface (console, TUI, API) — never a channel. */ + surface: string; + }; + +/** One conversation the turn's wake carried, as a route candidate. */ +export interface RouteCandidate { + conversation: ConversationRef; + /** Spoken TO the resident (mention, reply, DM, or a local surface's message). */ + addressed: boolean; + /** The triggering message within the conversation: the reply edge. */ + messageId?: string; + /** When its event happened (ms). */ + at: number; + /** Its registered channel could not be resolved: it competes for the + * turn like any conversation, but is never itself the route. */ + unroutable?: true; +} + +export type RouteOrigin = + /** Inferred from the turn's wake. */ + | 'trigger' + /** A conversation fork's home channel. */ + | 'home' + /** The resident's own channel_open (setSpeechTarget). */ + | 'open' + /** A wake that named no conversation: the conversation of the last + * message any channel delivered (its thread, but no reply edge), as + * before speech routes. */ + | 'fallback'; + +export type SpeechRoute = + | { + kind: 'channel'; + /** As in ConversationRef: absent when unknown. */ + serverId?: string; + channelId: string; + /** The thread the route speaks into — only on a channel whose + * connector posts into a named thread (RFC-011 `exact`). Absent: the + * channel root. */ + threadId?: string; + /** The message being answered, kept across same-conversation arrivals. */ + replyTo?: string; + label?: string; + origin: RouteOrigin; + } + | { + kind: 'surface'; + surface: string; + origin: 'trigger'; + }; + +/** The turn's routing state. */ +export interface TurnRoute { + /** Where unaddressed speech goes when the turn isn't held; null when it + * has no destination. A mid-turn hold keeps the route it suspended here, + * alongside `hold`, which wins while it is set. */ + route: SpeechRoute | null; + /** Unaddressed speech is held as drafts: these conversations compete. + * `neverRoutes` keys (conversationKey) the ones that came from a + * candidate marked unroutable, which a continuation keeps marked. */ + hold?: { + conversations: ConversationRef[]; + since: 'turn-start' | 'mid-turn'; + neverRoutes?: string[]; + }; + /** No route because the one conversation the turn answers can't take + * plain speech, said in the routing notice and the drafts' note: + * - `untargetable`: its channel's connector doesn't declare where a post + * lands (RFC-011), so the framework doesn't publish there; + * - `thread`: a thread on a channel whose connector can't post into a + * named thread (it declares `root`: a contradiction the connector + * shouldn't produce); + * - `unresolved`: a channel the host couldn't resolve. + * `neverRoutes` says the conversation came from a candidate marked + * unroutable (a raw id the host couldn't resolve to a registered + * channel, or a channel with no server), so a continuation keeps it + * marked. A channel with no server becomes a route only once the + * framework finds its server; one named with its server never does, + * whatever the registry says. */ + unroutable?: { conversation: ConversationRef; reason: UnroutableReason; neverRoutes?: true }; + /** The turn's wake named a conversation (a channel, a thread, an author + * or a local surface), candidate or not. A continuation of the turn then + * names one too (continuationOf). */ + named?: true; + /** When the message the route's reply edge names (`replyTo`) arrived. A + * continuation's candidate for the route carries it, so only a newer + * message batched in the same conversation takes the reply edge. */ + replyAt?: number; +} + +export type UnroutableReason = 'untargetable' | 'thread' | 'unresolved'; + +/** A channel's declared publish target (RFC-011), as the framework reads it; + * `unresolved` when the channel doesn't resolve to exactly one registered + * channel (none, or an id more than one server registers, named without + * its server). */ +export type PublishTargetOf = (channel: { serverId?: string; channelId: string }) => 'exact' | 'root' | 'unresolved' | undefined; + +/** Why a channel conversation can't be a route, or undefined when it can. */ +export function routeRefusal(c: ConversationRef, targetOf: PublishTargetOf): UnroutableReason | undefined { + if (c.kind !== 'channel') return undefined; + const declared = targetOf(c); + if (declared === 'unresolved') return 'unresolved'; + if (!declared) return 'untargetable'; + if (c.threadId && declared !== 'exact') return 'thread'; + return undefined; +} + +/** Identity of a conversation: server, channel and thread, or the surface. + * The parts are server-chosen strings, so they are encoded as a JSON array, + * never joined with a separator one of them could contain: two keys are + * equal only for the same parts (an unknown server is not an empty one). */ +export function conversationKey(c: ConversationRef): string { + return c.kind === 'surface' + ? JSON.stringify(['surface', c.surface]) + : JSON.stringify(['channel', c.serverId ?? null, c.channelId, c.threadId ?? null]); +} + +/** + * An agent-supplied thread id, read one way by every tool that takes one + * (channel_publish, channel_open, drafts resend): absent or null is the + * channel root; a string is trimmed of surrounding whitespace; a blank one, + * or anything else, is refused, so a blank thread never becomes a place. + */ +export function readThreadId(value: unknown): { threadId: string | null } | { error: string } { + if (value === undefined || value === null) return { threadId: null }; + if (typeof value !== 'string') return { error: 'threadId must be a thread id (a string), or null for the channel root.' }; + const threadId = value.trim(); + return threadId ? { threadId } : { error: 'threadId must be a thread id, not blank: leave it out, or null, for the channel root.' }; +} + +/** The conversation a route speaks into. */ +export function routeConversation(route: SpeechRoute): ConversationRef { + return route.kind === 'surface' + ? { kind: 'surface', surface: route.surface } + : { + kind: 'channel', + ...(route.serverId ? { serverId: route.serverId } : {}), + channelId: route.channelId, + ...(route.threadId ? { threadId: route.threadId } : {}), + ...(route.label ? { label: route.label } : {}), + }; +} + +/** + * A conversation as the framework names it to a resident, self-contained: its + * label as its connector gave it (nothing added or withheld for how it is + * spelled: a DM or a group may be named after a person or a title), then the + * server and channel id, written `server / channel-id` as the source headers + * write them, and its thread. The server is named whenever it is known, + * never left to what else is on screen: the same channel id (and label) can + * exist on another server, and a bare id is then no address a resident can + * send to. Notices, holds and the [delivered] receipt all name conversations + * this one way. + */ +export function describeConversation(c: ConversationRef): string { + if (c.kind === 'surface') return `${c.surface} (the local surface that messaged you)`; + const id = c.serverId ? `${c.serverId} / ${c.channelId}` : c.channelId; + const where = c.threadId ? `${id}, thread ${c.threadId}` : id; + if (c.label && c.label !== c.channelId) return `${c.label} (${where})`; + return c.serverId || c.threadId ? `(${where})` : c.channelId; +} + +/** + * The turn's route from its wake candidates (a true new turn only). + * A fork's home always wins; the message it answers is the newest of the + * candidates in the home conversation itself (addressed ones first), as for + * an inferred route, and none when no candidate is there. Otherwise the + * addressed candidates are + * considered if there are any, else every (conversational) candidate; they + * infer a route only when they name one conversation, from the newest + * candidate in it (its message is the reply edge). Several conversations + * start the turn held, naming each. One that can't take plain speech — a + * channel the framework can't publish into exactly (`targetOf`, RFC-011), + * or one that couldn't be resolved — leaves the turn without a route, which + * it records as `unroutable`. + * + * `fallback` is for a wake that named no conversation at all (a heartbeat, + * a timer, a self-wake), and only the caller can tell that from a wake whose + * named conversations all fell out as candidates (a reaction, a channel a + * fork owns): it passes one only for the former. With no candidates, the + * route is then where the last message any channel delivered came in, when + * plain speech can go there exactly; otherwise there is none. + */ +export function inferTurnRoute( + candidates: readonly RouteCandidate[], + home: SpeechRoute | null | undefined, + targetOf: PublishTargetOf, + fallback?: RouteCandidate | null, +): TurnRoute { + if (home) { + const conversation = routeConversation(home); + const refusal = routeRefusal(conversation, targetOf); + if (refusal) return { route: null, unroutable: { conversation, reason: refusal } }; + const key = conversationKey(conversation); + const newest = newestOf(considered(candidates.filter((c) => conversationKey(c.conversation) === key))); + return newest?.messageId && home.kind === 'channel' + ? { route: { ...home, replyTo: newest.messageId }, replyAt: newest.at } + : { route: home }; + } + const pool = considered(candidates); + if (pool.length === 0) return { route: fallbackRoute(fallback, targetOf) }; + const newestByConversation = new Map(); + for (const candidate of pool) { + const key = conversationKey(candidate.conversation); + const seen = newestByConversation.get(key); + if (!seen || candidate.at >= seen.at) newestByConversation.set(key, candidate); + } + if (newestByConversation.size > 1) { + const competing = [...newestByConversation.values()].sort((a, b) => b.at - a.at); + const neverRoutes = competing.filter((c) => c.unroutable).map((c) => conversationKey(c.conversation)); + return { + route: null, + hold: { + conversations: competing.map((c) => c.conversation), + since: 'turn-start', + ...(neverRoutes.length > 0 ? { neverRoutes } : {}), + }, + }; + } + const [chosen] = newestByConversation.values(); + const c = chosen!.conversation; + // A conversation whose channel isn't resolvable can't be spoken into, and + // neither can one the framework can't publish into exactly: its connector + // would choose the place, which may be another conversation (RFC-011). + // Both still compete for the turn (above). + if (chosen!.unroutable) return { route: null, unroutable: { conversation: c, reason: 'unresolved', neverRoutes: true } }; + const refusal = routeRefusal(c, targetOf); + if (refusal) return { route: null, unroutable: { conversation: c, reason: refusal } }; + return c.kind === 'surface' + ? { route: { kind: 'surface', surface: c.surface, origin: 'trigger' } } + : { + route: { + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + ...(chosen!.messageId ? { replyTo: chosen!.messageId } : {}), + ...(c.label ? { label: c.label } : {}), + origin: 'trigger', + }, + ...(chosen!.messageId ? { replyAt: chosen!.at } : {}), + }; +} + +/** The fallback conversation as a route, or null when there is none or plain + * speech can't go there exactly. Nothing in the wake named it, so it is no + * conversation in front of the resident: an unroutable fallback is simply + * no route, not an `unroutable` one. */ +function fallbackRoute(fallback: RouteCandidate | null | undefined, targetOf: PublishTargetOf): SpeechRoute | null { + if (!fallback || fallback.unroutable) return null; + const c = fallback.conversation; + if (c.kind !== 'channel' || routeRefusal(c, targetOf)) return null; + // No reply edge: the turn answers no message, and the last delivered one + // didn't trigger it (MCPL's `incoming` names only a triggering message). + return { + kind: 'channel', + ...(c.serverId ? { serverId: c.serverId } : {}), + channelId: c.channelId, + ...(c.threadId ? { threadId: c.threadId } : {}), + ...(c.label ? { label: c.label } : {}), + origin: 'fallback', + }; +} + +/** + * The conversations a framework wake names when it continues a turn (a + * rewind retry, an unstick, a prose continuation or bounce), as candidates: + * the ones that turn answered, so the continuation's words go where the + * turn's would have. A held turn names each conversation it held between; a + * turn with a route names its route's conversation, reply edge kept, at the + * time its reply message arrived (`replyAt`), so only a newer message in + * that conversation takes the reply edge; a turn whose one conversation + * couldn't take plain speech names that one. A conversation refused for its + * publish target is checked again as any candidate is; one from a candidate + * marked unroutable stays marked (`neverRoutes`). Each counts as addressed, + * since the resident was answering it: an ambient arrival batched beside it + * doesn't take the turn, and an addressed one from elsewhere holds it. + * + * A turn that answered no conversation gives none: a fallback turn, or a + * turn whose words had no place. What its continuation names then is + * continuationOf's to say. + */ +export function continuationCandidates(turn: TurnRoute | undefined, at: number): RouteCandidate[] { + if (!turn) return []; + if (turn.hold) { + const neverRoutes = new Set(turn.hold.neverRoutes ?? []); + return turn.hold.conversations.map((conversation) => ({ + conversation, + addressed: true, + at, + ...(neverRoutes.has(conversationKey(conversation)) ? { unroutable: true as const } : {}), + })); + } + if (turn.route) { + const { route } = turn; + if (route.origin === 'fallback') return []; + const replyTo = route.kind === 'channel' ? route.replyTo : undefined; + return [{ + conversation: routeConversation(route), + addressed: true, + at: replyTo && turn.replyAt !== undefined ? turn.replyAt : at, + ...(replyTo ? { messageId: replyTo } : {}), + }]; + } + if (turn.unroutable) { + // A conversation from a candidate marked unroutable stays marked, so it + // never routes unless the framework finds its channel's server when the + // continuation starts. One refused for its publish target is checked + // again as it is. + const { conversation, neverRoutes } = turn.unroutable; + return [{ conversation, addressed: true, at, ...(neverRoutes ? { unroutable: true as const } : {}) }]; + } + return []; +} + +/** + * What a framework wake that continues a turn carries into its own turn, + * read from that turn's record when the continuation's batch starts: + * - `candidates`: the conversations the turn answered + * (continuationCandidates); + * - `namesConversation`: whether it names a conversation. It does when it + * has candidates, or when the turn's own wake named one. So a turn whose + * words had no place although its wake named a conversation (a reaction, + * a channel a conversation fork owns) gives its continuation no place + * either. A turn whose wake named none gives a continuation that names + * none, and that continuation takes the fallback; + * - `fallback`: for a fallback turn that wasn't held, the place it spoke + * into. Its continuation keeps that place as its own fallback, rather + * than the last delivered message's conversation as it is by then, so a + * message arriving between them doesn't move the rest of the reply. Like + * any fallback, it applies only when nothing the batch carries names a + * conversation. + * No record (no turn yet, or an explicit-prose turn) gives nothing. + */ +export function continuationOf( + turn: TurnRoute | undefined, + at: number, +): { candidates: RouteCandidate[]; namesConversation: boolean; fallback?: RouteCandidate } { + const candidates = continuationCandidates(turn, at); + const namesConversation = candidates.length > 0 || turn?.named === true; + const route = turn?.route; + const fallback = !namesConversation && route?.origin === 'fallback' + ? { conversation: routeConversation(route), addressed: false, at } + : undefined; + return { candidates, namesConversation, ...(fallback ? { fallback } : {}) }; +} + +/** The candidates a route is inferred from: the addressed ones if any, else all. */ +function considered(candidates: readonly RouteCandidate[]): readonly RouteCandidate[] { + const addressed = candidates.filter((c) => c.addressed); + return addressed.length > 0 ? addressed : candidates; +} + +/** The newest candidate (the latest of equals). */ +function newestOf(candidates: readonly RouteCandidate[]): RouteCandidate | undefined { + let newest: RouteCandidate | undefined; + for (const candidate of candidates) if (!newest || candidate.at >= newest.at) newest = candidate; + return newest; +} + +/** + * Whether an item is conversational input — something the resident might be + * answering — rather than machinery: a host system marker (send-failure + * notices, routing notices) or a reaction or its removal (`chat:reaction`, + * `chat:reaction-remove`, MCPL RFC-001) is not. One predicate for every place + * that asks: turn-start route candidates, mid-turn holds, and clearing send + * suppression. `metadata.system` is host-written only: a caller passes an + * MCPL item's tags alone, and ingestion drops a `system` an adapter's + * metadata or origin carries, so no server can mark its item as machinery. + */ +export function isConversational(tags: readonly string[] | undefined, metadata?: Record): boolean { + if (metadata?.system === true) return false; + // Both reaction tags (MCPL RFC-001 / SPEC §16.2 keep them distinct). + if (tags?.includes('chat:reaction') || tags?.includes('chat:reaction-remove')) return false; + return true; +} + +/** + * Whether a mid-turn arrival suspends the turn's route: only an inferred + * route (origin 'trigger', or the 'fallback' a wake that named no + * conversation took) not already held, and only for an arrival from a + * DIFFERENT conversation that addressed the resident, or that continues a + * conversation the resident explicitly sent into this turn. Ambient chatter + * never does; deliberate routes never are. It sees no tags: the caller passes + * only conversational arrivals (isConversational), so a reaction or a system + * marker never reaches it. + */ +export function suspendsRoute( + turn: TurnRoute, + arrival: { conversation: ConversationRef; addressed: boolean }, + engaged: (c: ConversationRef) => boolean, +): boolean { + if (turn.hold || !turn.route || (turn.route.origin !== 'trigger' && turn.route.origin !== 'fallback')) return false; + if (conversationKey(arrival.conversation) === conversationKey(routeConversation(turn.route))) return false; + return arrival.addressed || engaged(arrival.conversation); +} diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index dbfee7013..df911612a 100644 --- a/src/tune-out/coordinator.ts +++ b/src/tune-out/coordinator.ts @@ -42,7 +42,9 @@ export interface TuneOutFrameworkHooks { forAgent?: string, ) => string; /** Queue an inference request for an agent. */ - requestInference: (agentName: string, reason: string, source: string) => void; + /** Wake an agent. `channelId`, when the wake is about a channel, says it + * names that conversation, so the turn never takes the fallback route. */ + requestInference: (agentName: string, reason: string, source: string, channelId?: string) => void; /** The subconscious's registry/participant name. */ subconsciousName: () => string | null; /** The primary resident's registry name. */ @@ -245,7 +247,7 @@ export class TuneOutCoordinator { } const primary = this.hooks.primaryName(); if (primary) { - this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out'); + this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out', channelId); } // The subconscious gets one cancel turn of its own (unless it initiated // the cancel — its note already carries its words): the issue's cancel @@ -261,7 +263,7 @@ export class TuneOutCoordinator { { system: true, kind: 'tune-out-cancel-report', channelId }, subName, ); - this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out'); + this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out', channelId); } this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount }); return { ok: true }; @@ -414,7 +416,7 @@ export class TuneOutCoordinator { { system: true, kind: `tune-out-${trigger}`, channelId }, subName, ); - this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out'); + this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out', channelId); } // ========================================================================== diff --git a/src/types/agent.ts b/src/types/agent.ts index bbf4971ca..d7f8f7896 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -321,20 +321,20 @@ export interface InferenceRequest { timestamp: number; /** * The MCPL channel whose message triggered this inference, if any (composite - * id, e.g. `discord:guild:channel` / `discord:dm:id`). The framework routes - * the turn's auto-published plain-text speech here so a single TRUNK agent - * replies in the channel it is answering — not the process-global most-recent- - * inbound locus, which a concurrent message elsewhere can hijack (item-3 - * redux). Undefined for non-channel wakes (heartbeat, timers, module events), - * which correctly fall back to the global default channel. + * id, e.g. `discord:guild:channel` / `discord:dm:id`). It is not where the + * turn's plain speech goes: that is the turn's speech route, inferred from + * `routeCandidates` (src/speech-routes.ts). Undefined for wakes from no + * channel (a heartbeat, a timer, most module events). Whether a wake names + * a conversation at all is `namesConversation`. */ channelId?: string; /** * True when the triggering message explicitly addressed the agent (mention, * reply-to-bot, DM — `chat:addressed` in MCPL RFC-001 terms). When a wake * batch spans several channels, an addressed channel outranks ambient - * chatter for the turn's frozen speech locus: overheard conversation must - * not capture the agent's voice just by being newest. + * chatter for the turn's trigger channel, as addressed candidates do for its + * speech route: overheard conversation must not capture the agent's voice + * just by being newest. */ addressed?: boolean; /** @@ -349,14 +349,48 @@ export interface InferenceRequest { * Where the wake came from, for telemetry ONLY — never a speech locus. * Set by the EventGate for batched wakes (composite channel id of the * chosen event when the event carried one; push-event raw ids are not - * used). Routing keeps reading `channelId`, set from registered ids: by - * the direct channel paths, and by a gate wake only when its chosen event + * used). `channelId`, by contrast, is set from registered ids: by the + * direct channel paths, and by a gate wake only when its chosen event * addressed the agent and the host resolved that event's registered * channel (ambient-only batches leave it unset). */ wakeChannelId?: string; /** Timestamp of the event the wake provenance was taken from (ms). */ wakeAt?: number; + /** + * The conversations this wake carries, as speech-route candidates + * (src/speech-routes.ts): the triggering message's channel or local + * surface, whether it addressed the agent, and its message id (the reply + * edge). A batched gate wake carries its whole batch. At a true new turn + * the framework infers the turn's route from every candidate of the + * batch: the addressed ones if any, else all; one conversation only. + */ + routeCandidates?: import('../speech-routes.js').RouteCandidate[]; + /** + * The wake names a conversation — a channel, a thread or a local surface — + * whether or not any of it became a route candidate: a reaction, a system + * marker, or a channel a conversation fork owns names one but answers + * nothing here. A turn none of whose requests names one (a heartbeat, a + * timer, a self-wake, a script or subagent wake, a nudge) takes the + * fallback route, the last delivered message's conversation; every other + * turn's route comes from its candidates alone. + */ + namesConversation?: true; + /** + * A framework wake that continues its agent's turn (a rewind retry, an + * unstick, a tool round's results, a prose continuation or bounce, a + * provider-cooldown retry). It carries nothing else of that turn: when + * its batch starts, the framework reads the agent's turn record then and + * fills in the conversations, whether it names one, the place a fallback + * turn spoke into, and a silent wake's silence (continuationOf). + */ + continuesTurn?: true; + /** + * The place a continued fallback turn spoke into: the turn's fallback, + * kept in place of the last delivered message's conversation. Like any + * fallback, it applies only to a turn whose batch names no conversation. + */ + fallbackPlace?: import('../speech-routes.js').RouteCandidate; /** Suppress every automatic plain-prose delivery for this logical turn. * Explicit tool calls remain available. Used by authenticated silent wakes. */ suppressProse?: boolean; diff --git a/src/types/events.ts b/src/types/events.ts index b5e8afb8e..1c91b9592 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -231,6 +231,8 @@ export interface McplPushEvent { inferenceId: string; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the push is admitted. */ + acceptedAt?: number; } /** @@ -249,4 +251,6 @@ export interface McplChannelIncomingEvent { metadata?: Record; triggerInference?: boolean; targetAgents?: string[]; + /** Host acceptance time (epoch ms), stamped where the message is admitted. */ + acceptedAt?: number; } diff --git a/src/types/trace.ts b/src/types/trace.ts index de8f41ea8..45f65591d 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -41,13 +41,17 @@ export type TraceEvent = type: 'inference:started'; agentName: string; /** - * The turn-frozen outbound locus (see turnLocusPins) — the channel this - * turn's plain prose will be routed to. Omitted for turns with no locus - * (heartbeats with no default channel). Observability only, like every + * The channel of the turn's speech route (src/speech-routes.ts) — where + * this turn's plain prose will be routed. Omitted when the turn has no + * channel route: no route at all (such as a heartbeat's turn), a held + * turn, or a local surface. Observability only, like every * trace field; lets external taps (e.g. a TTS relay) tag the whole * activation with its channel without re-deriving routing. */ channelId?: string; + /** The server of that channel route, when known: the same channel id + * on another server is another channel. */ + serverId?: string; /** True for a control-plane wake that must not surface typing/prose. */ silent?: boolean; }) @@ -381,6 +385,8 @@ export type TraceEvent = conversationId: string; serverId: string; channelId: string; + /** The thread it was posted in; null for the channel root. */ + threadId: string | null; delivered: boolean; textLen: number; /** The routed prose segment, verbatim. */ @@ -394,8 +400,13 @@ export type TraceEvent = conversationId: string; /** Empty string when the failure was "no locus at all". */ channelId: string; + /** The thread it was bound for; null for the channel root (or no locus). */ + threadId: string | null; reason: string; textLen: number; + /** `failed`: nothing was posted; `unknown`: the request was dispatched + * 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 @@ -555,6 +566,42 @@ 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: 'explicit-send' | 'no-destination' | 'ambiguous' | 'bounced'; + draftIds: string[]; + textLen: number; + }) + | (TraceEventBase & { + type: 'prose:draft-resent'; + agentName: string; + draftId: string; + status: 'delivered' | 'failed' | 'unknown'; + serverId: string; + channelId: string; + /** The thread it was sent to; null for the channel root. */ + threadId: string | null; + messageId?: string; + }) + | (TraceEventBase & { + type: 'prose:drafts-dismissed'; + agentName: string; + draftIds: string[]; }); /** diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index ab2df9035..161d4fdf0 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -8,8 +8,10 @@ * that; a DM push event never does. Either order misrouted the DM reply: * - ambient AFTER the DM: the ambient channel became the fallback; * - ambient BEFORE the DM: the DM never displaced it. - * Now a batch containing an addressed message routes to that message's - * registered channel; an ambient-only batch keeps the legacy fallback. + * Now the turn's speech route is inferred from the whole batch + * (shelf-355): its addressed messages if any, else all of it; one + * conversation routes there, several hold the reply as a draft. Nothing + * falls back to the most recent inbound channel. */ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; @@ -118,15 +120,68 @@ describe('batched wake routing', () => { incoming(GENERAL, 'ambient', 'unrelated chatter'); await waitFor(() => publishes().length >= 1, 'reply published'); assert.deepEqual(publishes(), [ROOM]); + // What the agent is told is where its speech went: the channel context + // advertises the same route, answering the mention. + const registry = (framework as unknown as { + channelRegistry: { buildChannelContext(agent: string): { defaultOutgoing?: { channelId: string }; incoming?: { channelId: string; messageId: string } } | undefined }; + }).channelRegistry; + const context = registry.buildChannelContext('scout'); + assert.equal(context?.defaultOutgoing?.channelId, ROOM); + assert.equal(context?.incoming?.channelId, ROOM); + assert.match(context?.incoming?.messageId ?? '', /^m-\d+$/); }); - it('an ambient-only batch keeps the legacy fallback (most recent inbound)', async () => { + const drafts = (): Array<{ id: string; text: string; reason: string; note?: string }> => + (framework as unknown as { proseDrafts: { open(agent: string): Array<{ id: string; text: string; reason: string; note?: string }> } }) + .proseDrafts.open('scout'); + const turnDone = async (): Promise => { + await waitFor(() => membrane.calls.length >= 1, 'the turn ran'); + await waitFor(() => framework.getAgent('scout')!.state.status === 'idle', 'the turn ended'); + await framework.runUntilIdle(); + }; + + it('an ambient-only batch in ONE conversation takes that conversation as its route', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'joining in' }])); incoming(ROOM, 'ambient', 'first'); await waitFor(() => sent('incoming-sent') === 1, 'first sent'); - incoming(GENERAL, 'ambient', 'second'); + incoming(ROOM, 'ambient', 'second'); await waitFor(() => publishes().length >= 1, 'reply published'); - assert.deepEqual(publishes(), [GENERAL]); + assert.deepEqual(publishes(), [ROOM]); + }); + + it('an ambient-only batch across two conversations holds the reply as a draft, never the newest channel', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'joining in' }])); + incoming(ROOM, 'ambient', 'first'); + await waitFor(() => sent('incoming-sent') === 1, 'first sent'); + incoming(GENERAL, 'ambient', 'second'); + await turnDone(); + assert.deepEqual(publishes(), [], 'nothing is guessed'); + const [held] = drafts(); + assert.equal(held?.text, 'joining in'); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', /discord:g1:room/); + assert.match(held?.note ?? '', /discord:g1:general/); + }); + + it('two conversations that addressed the agent in one batch: held, never guessed', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'which of you?' }])); + incoming(ROOM, 'addressed', '@scout hello'); + await waitFor(() => sent('incoming-sent') === 1, 'mention sent'); + dm('are you there?'); + await waitFor(() => sent('dm-sent') === 1, 'dm sent'); + await turnDone(); + assert.deepEqual(publishes(), []); + const [held] = drafts(); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', new RegExp(`${DM}|${RAW_DM}`)); + assert.match(held?.note ?? '', /discord:g1:room/); + // The resident decides: the held words go to whichever conversation it + // names, verbatim. + const result = await (framework as unknown as { + handleDraftsTool(agent: string, input: Record): Promise<{ success?: boolean; content?: unknown; data?: unknown }>; + }).handleDraftsTool('scout', { action: 'resend', draftIds: [held!.id], destination: DM }); + assert.match(JSON.stringify(result), /delivered to/); + assert.deepEqual(publishes(), [DM]); }); }); @@ -146,7 +201,7 @@ describe('batched wake routing with conversation forks', () => { ], gate: { config: { - policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 50 } }], + policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, behavior: { debounce: 50 } }], default: 'skip', }, }, @@ -159,6 +214,30 @@ describe('batched wake routing with conversation forks', () => { rmSync(tempDir, { recursive: true, force: true }); }); + it("a batch candidate the host could not resolve reaches every agent's wake, for the fork rule to judge when each turn starts", async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Array<{ agentName: string; routeCandidates?: Array<{ conversation: { channelId?: string }; unroutable?: true }> }>; + eventGate: { evaluate(info: unknown): unknown }; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + // A push naming the fork's channel by a raw id the host can't resolve: + // an unroutable candidate, which competes but never routes. + internals.eventGate.evaluate({ + content: '@agent hi', eventType: 'mcpl:push-event', serverId: 'discord', channelId: ROOM, + tags: ['chat:addressed'], metadata: { authorId: '5' }, + }); + await waitFor(() => internals.pendingRequests.some((r) => r.agentName === 'trunk'), 'gate wake broadcast'); + const byAgent = new Map(internals.pendingRequests.map((r) => [r.agentName, r])); + // Only the fork homed there may take it, as ownership stands when its + // turn starts (trunk-channel-routing pins that a fork's channel holds no + // other agent's turn). + for (const agent of ['fork-room', 'trunk', 'fork-other']) { + assert.deepEqual(byAgent.get(agent)?.routeCandidates?.map((c) => [c.conversation.channelId, c.unroutable]), [[ROOM, true]], + `${agent}'s wake names it`); + } + }); + it('the addressed route applies only to the fork homed there; other forks and the trunk get none', async () => { const internals = framework as unknown as { conversationAgentHomes: Map; @@ -180,4 +259,110 @@ describe('batched wake routing with conversation forks', () => { // Telemetry provenance still reaches every agent. assert.equal(byAgent.get('fork-other')?.counterparty, 'discord:user:5'); }); + + type Wake = { agentName: string; channelId?: string; routeCandidates?: Array<{ conversation: { channelId?: string } }> }; + const pushInto = (channelId: string, eventId: string) => ({ + type: 'mcpl:push-event', serverId: 'discord', featureSet: 'chat', eventId, + content: [{ type: 'text', text: 'are you there?' }], timestamp: new Date().toISOString(), + triggerInference: true, tags: ['chat:addressed'], + origin: { mcplChannelId: channelId, messageId: `${eventId}-m`, authorId: '5' }, + }); + + it('a push event follows the fork rule for typing, and names its conversation for the turn to judge', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Wake[]; + handleMcplPushEvent(event: unknown): unknown; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + internals.handleMcplPushEvent(pushInto(ROOM, 'e1')); + const trunk = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.ok(trunk, 'the trunk is still woken'); + assert.equal(trunk!.channelId, undefined, 'no typing in a fork-owned channel'); + assert.deepEqual(trunk!.routeCandidates?.map((c) => c.conversation.channelId), [ROOM], 'its route is judged at turn start'); + // Control: the same push into a channel no fork owns. + internals.pendingRequests.length = 0; + internals.handleMcplPushEvent(pushInto(GENERAL, 'e2')); + const free = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.equal(free?.channelId, GENERAL); + assert.deepEqual(free?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL]); + }); + + it('a coalesced push batch follows the fork rule for its typing channel, and names its conversation for the turn to judge', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Wake[]; + wakeForCoalescedBatch(occ: unknown): Promise; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + const batch = (channelId: string, eventId: string) => ({ + serverId: 'discord', binding: 'discord', scope: { kind: 'channel', id: channelId }, key: 'k', eventId, + timestamp: new Date().toISOString(), retract: false, deferred: false, initial: false, tags: ['chat:addressed'], + event: { + lane: 'push', + event: { + ...pushInto(channelId, eventId), + // The envelope the host froze at acceptance: the batch's conversation. + inboundSource: { kind: 'channel', serverId: 'discord', channelId, messageId: `${eventId}-m`, acceptedAt: Date.now() }, + }, + }, + }); + await internals.wakeForCoalescedBatch(batch(ROOM, 'c1')); + const trunk = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.ok(trunk, 'the trunk is still woken'); + assert.equal(trunk!.channelId, undefined, 'no typing in a fork-owned channel'); + assert.deepEqual(trunk!.routeCandidates?.map((c) => c.conversation.channelId), [ROOM], 'its route is judged at turn start'); + internals.pendingRequests.length = 0; + await internals.wakeForCoalescedBatch(batch(GENERAL, 'c2')); + const free = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.equal(free?.channelId, GENERAL, 'a free channel is taken'); + assert.deepEqual(free?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'as a route candidate too'); + }); + + it('a channel-incoming broadcast follows the fork rule for every agent it wakes: typing here, the route at turn start', async () => { + const internals = framework as unknown as { + conversationAgentHomes: Map; + pendingRequests: Wake[]; + handleMcplChannelIncoming(event: unknown): Promise; + }; + internals.conversationAgentHomes.set('fork-room', ROOM); + internals.conversationAgentHomes.set('fork-other', GENERAL); + await internals.handleMcplChannelIncoming({ + type: 'mcpl:channel-incoming', serverId: 'discord', channelId: ROOM, messageId: 'm-1', + author: { id: '5', name: 'Five' }, content: [{ type: 'text', text: '@agent hi' }], + timestamp: new Date().toISOString(), triggerInference: true, tags: ['chat:addressed'], + }); + const byAgent = new Map(internals.pendingRequests.map((r) => [r.agentName, r])); + assert.equal(byAgent.get('fork-room')?.channelId, ROOM, 'the fork that owns the channel takes it'); + assert.deepEqual(byAgent.get('fork-room')?.routeCandidates?.map((c) => c.conversation.channelId), [ROOM]); + for (const other of ['fork-other', 'trunk']) { + assert.equal(byAgent.get(other)?.channelId, undefined, `${other} gets no typing there`); + assert.deepEqual(byAgent.get(other)?.routeCandidates?.map((c) => c.conversation.channelId), [ROOM], + `${other}'s wake names it; its turn may not take it (trunk-channel-routing)`); + } + }); + + it("a server can't mark its item as host machinery: system: true in an origin or in metadata is dropped, and the item is a route candidate", async () => { + const internals = framework as unknown as { + pendingRequests: Wake[]; + handleMcplPushEvent(event: unknown): unknown; + handleMcplChannelIncoming(event: unknown): Promise; + }; + const stored = (text: string) => framework.getAgent('trunk')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes(text)); + const push = pushInto(GENERAL, 'sys-push'); + internals.handleMcplPushEvent({ ...push, content: [{ type: 'text', text: 'pushed as system' }], origin: { ...push.origin, system: true } }); + const pushed = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.deepEqual(pushed?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'the push is a route candidate'); + assert.equal((stored('pushed as system')?.metadata as Record | undefined)?.system, undefined, "the origin's system is not stored"); + internals.pendingRequests.length = 0; + await internals.handleMcplChannelIncoming({ + type: 'mcpl:channel-incoming', serverId: 'discord', channelId: GENERAL, messageId: 'm-sys', + author: { id: '5', name: 'Five' }, content: [{ type: 'text', text: 'incoming as system' }], + timestamp: new Date().toISOString(), triggerInference: true, tags: ['chat:addressed'], metadata: { system: true }, + }); + const incoming = internals.pendingRequests.find((r) => r.agentName === 'trunk'); + assert.deepEqual(incoming?.routeCandidates?.map((c) => c.conversation.channelId), [GENERAL], 'the message is a route candidate'); + assert.equal((stored('incoming as system')?.metadata as Record | undefined)?.system, undefined, "the adapter's system is not stored"); + }); }); diff --git a/test/channel-label-history.test.ts b/test/channel-label-history.test.ts index b5c869593..2382fcecf 100644 --- a/test/channel-label-history.test.ts +++ b/test/channel-label-history.test.ts @@ -163,7 +163,7 @@ test('resolveProseTargetDurable prefers the live path and only falls back to his // Live path resolves uniquely against the live channel — must win over // the (stale) history entry for the ghost channel. const live = registry.resolveProseTarget('shared-label'); - assert.deepEqual(live, { channelId: 'discord:g1:live', label: 'shared-label' }); + assert.deepEqual(live, { serverId: 'discord', channelId: 'discord:g1:live', label: 'shared-label' }); assert.deepEqual(registry.resolveProseTargetDurable('shared-label'), live); }); @@ -285,6 +285,7 @@ test('a label-less descriptor from handleRegister does not crash and does not po // (this is what made it crash all four HistoryModule tools process-wide). await registry.handleRegister('discord', { channels: [descriptor('discord:g1:c2', '#general')] }); assert.deepEqual(registry.resolveProseTargetDurable('#general'), { + serverId: 'discord', channelId: 'discord:g1:c2', label: '#general', }); @@ -300,7 +301,7 @@ test('a channel renamed A -> B -> A is still resolvable by a name from BEFORE th await registry.handleChanged('discord', { updated: [descriptor('discord:g1:c1', '#alpha')] }); // Live-path check: '#alpha' is current again, so it resolves live. - assert.deepEqual(registry.resolveProseTarget('#alpha'), { channelId: 'discord:g1:c1', label: '#alpha' }); + assert.deepEqual(registry.resolveProseTarget('#alpha'), { serverId: 'discord', channelId: 'discord:g1:c1', label: '#alpha' }); // The interesting case: '#beta' is a name from BEFORE the most recent // rename (A -> B -> A), not the current OR immediately-previous label — @@ -377,6 +378,7 @@ test('a DM registered live resolves after a restart via @name, not just the lite // Live check: '@antra' already resolves live, before any restart — // establishes this is real DM-shaped addressing, not a coincidence. assert.deepEqual(first.resolveProseTarget('@antra'), { + serverId: 'discord', channelId: 'discord:dm:antra', label: 'DM: antra', }); @@ -444,6 +446,7 @@ test('a DM whose live username differs from its display label resolves by USERNA // label for DM matching — establishes this is real behavior to mirror, // not an invented requirement. assert.deepEqual(first.resolveProseTarget('@antra_tessera'), { + serverId: 'discord', channelId: 'discord:dm:42', label: 'DM: Tess', }); @@ -480,6 +483,7 @@ test('a DM classified only via metadata.channelType (non-conventional id/label) // Live check: classified as a DM live via metadata alone. assert.deepEqual(first.resolveProseTarget('<@42>'), { + serverId: 'discord', channelId: 'private-room-42', label: 'Tess', }); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 80a825658..078b232e8 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -1,7 +1,7 @@ import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { ChannelRegistry, type SpeechRouteView } from '../src/mcpl/channel-registry.js'; import { parseProsePrefix } from '../src/mcpl/prose-grammar.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -15,7 +15,7 @@ type RouteFailure = { conversationId: string; channelId: string | null; reason: function makeRegistry( publishResult: { delivered?: boolean } | undefined, homeChannelResolver?: (agentName: string) => string | undefined, - activeChannelResolver?: (agentName: string) => string | undefined, + speechRouteResolver?: (agentName: string) => SpeechRouteView, onChannelAutoOpened?: (info: { conversationId?: string; serverId: string; @@ -25,7 +25,7 @@ function makeRegistry( ) { const failures: RouteFailure[] = []; const traces: Array<{ type: string; [k: string]: unknown }> = []; - const publishCalls: Array<{ channelId?: string; conversationId?: string }> = []; + const publishCalls: Array<{ channelId?: string; conversationId?: string; threadId?: string | null }> = []; const openCalls: Array<{ channelId?: string }> = []; const closeCalls: Array<{ channelId?: string }> = []; @@ -34,9 +34,10 @@ function makeRegistry( const mockServer = { // Post-policy state: full grant, so tests exercise delivery, not §5.3 denial. grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), - sendChannelsPublish: async (params: { channelId?: string; conversationId?: string }) => { + sendChannelsPublish: async (params: { channelId?: string; conversationId?: string; threadId?: string | null }) => { publishCalls.push(params); - return publishResult; + // A conforming MCPL RFC-011 server: a delivery echoes the place asked for. + return publishResult?.delivered === true && 'threadId' in params ? { ...publishResult, threadId: params.threadId } : publishResult; }, sendChannelsOpen: async (params: { channelId?: string }) => { if (failOpens) throw new Error('open refused by server'); @@ -60,7 +61,7 @@ function makeRegistry( { onRouteFailure: (info) => { failures.push(info); }, homeChannelResolver, - activeChannelResolver, + speechRouteResolver, onChannelAutoOpened, }, ); @@ -94,14 +95,19 @@ function incoming(channelId: string, text: string, channelName?: string) { /** §14.5: channels/incoming no longer mints unknown channels — seed the * registered state a conforming server would have created via - * channels/register before feeding incoming traffic. */ + * channels/register before feeding incoming traffic. Each channel declares + * an MCPL RFC-011 publish target (`root`), as a conforming server's do. */ function seedRegistered(registry: ChannelRegistry, serverId: string, ...ids: string[]): void { const map = (registry as unknown as { - channels: Map; + channels: Map; open: boolean }>; }).channels; for (const id of ids) { if (!map.has(`${serverId}:${id}`)) { - map.set(`${serverId}:${id}`, { serverId, descriptor: { id, type: serverId, label: id }, open: false }); + map.set(`${serverId}:${id}`, { + serverId, + descriptor: { id, type: serverId, label: id, capabilities: { publish: { target: 'root' } } }, + open: false, + }); } } } @@ -122,12 +128,350 @@ test('handleIncoming REJECTS an unknown channel instead of minting it (§14.5)', assert.equal(registry.resolveLocus('cairn'), null, 'a rejected message must not establish a locus'); }); +test('the registry remembers the last delivered message for the fallback, and forgets it when its channel or server goes', async () => { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:C2'); + assert.equal(registry.getLastInbound(), null); + assert.equal(registry.getDefaultPublishChannel(), null); + const deliver = (channelId: string, messageId: string, threadId?: string) => registry.handleIncoming('slack', { + messages: [{ + channelId, messageId, author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], ...(threadId ? { threadId } : {}), + }], + }); + await deliver('slack:C1', 'm-1', '1700.0001'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C1', threadId: '1700.0001' }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); + await deliver('slack:C2', 'm-2'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2' }, 'the newest wins, a root message naming no thread'); + + // A removal of another channel leaves it; its own channel's removal forgets it. + await registry.handleChanged('slack', { removed: ['slack:C1'] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C2'); + await registry.handleChanged('slack', { removed: ['slack:C2'] }); + assert.equal(registry.getLastInbound(), null); + + seedRegistered(registry, 'slack', 'slack:C1'); + await deliver('slack:C1', 'm-3'); + registry.removeServer('slack'); + assert.equal(registry.getLastInbound(), null, 'its server going forgets it'); +}); + +test('only a delivered message becomes the fallback\'s place: not a reaction, a refused or unqueued message, or a withdrawal', async () => { + let throwFilter = false; + let admit = true; + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { + shouldTriggerInference: () => { if (throwFilter) throw new Error('filter broke'); return true; }, + handleCoalescedIncoming: async (_serverId, message) => ({ messageId: message.messageId, accepted: admit }), + }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:C2', 'slack:C3'); + const msg = (channelId: string, messageId: string, extra: Record = {}) => ({ + channelId, messageId, author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], ...extra, + }); + await registry.handleIncoming('slack', { messages: [msg('slack:C1', 'm-1')] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); + + // A reaction answers nothing: it isn't a message a conversation continues. + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'r-1', { tags: ['chat:reaction'] })] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a reaction never becomes the place'); + + // An unregistered channel's message is refused before anything counts. + await registry.handleIncoming('slack', { messages: [msg('slack:NOPE', 'm-x')] }); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a refused message never becomes the place'); + + // A message whose trigger filter throws is never queued, so never counts. + throwFilter = true; + await assert.rejects(registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-2')] }), /filter broke/); + throwFilter = false; + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'an unqueued message never becomes the place'); + + // A coalesced message is the host's to count: its coalescer's acceptance + // reads `accepted` for a producer's retry too, so the registry's own path + // never counts it, refused or admitted. + admit = false; + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-3', { eventId: 'e-3', coalesce: { key: 'k' } })] } as never); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a refused coalesced occurrence never counts'); + admit = true; + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-4', { eventId: 'e-4', coalesce: { key: 'k' }, threadId: 't-4' })] } as never); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'an accepted coalesced result is the host\'s to count'); + // The host counts a fresh admission through noteDelivered. + registry.noteDelivered('slack', 'slack:C2', 't-4', []); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a noted delivery counts'); + registry.noteDelivered('slack', 'slack:C3', undefined, ['chat:deleted']); + registry.noteDelivered('slack', 'slack:C3', undefined, ['chat:reaction-remove']); + registry.noteDelivered('slack', 'slack:GONE', undefined, []); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, + 'a deletion, a reaction\'s removal or an unregistered channel never counts'); + // An ordinary deletion notice is delivered, and never counts either. + let deletion: { results: Array<{ accepted: boolean }> } | undefined; + await registry.handleIncoming('slack', { messages: [msg('slack:C3', 'd-1', { tags: ['chat:deleted'] })] }, + { respond: (r: unknown) => { deletion = r as typeof deletion; } } as never); + assert.equal(deletion?.results[0]?.accepted, true, 'the deletion notice was delivered'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2', threadId: 't-4' }, 'a deletion never counts'); + + // Stopping forgets it. + registry.stopAll(); + assert.equal(registry.getLastInbound(), null); + assert.equal(registry.getDefaultPublishChannel(), null); +}); + +test('on a registry with no coalescing hook, a withdrawal arrives as a plain item and is still never the place', async () => { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { shouldTriggerInference: () => true }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:C2'); + const msg = (channelId: string, messageId: string, extra: Record = {}) => ({ + channelId, messageId, author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], ...extra, + }); + await registry.handleIncoming('slack', { messages: [msg('slack:C1', 'm-1')] }); + let withdrawal: { results: Array<{ accepted: boolean }> } | undefined; + await registry.handleIncoming('slack', + { messages: [msg('slack:C2', 'm-2', { eventId: 'e-2', coalesce: { key: 'k', retract: true } })] } as never, + { respond: (r: unknown) => { withdrawal = r as typeof withdrawal; } } as never); + assert.equal(withdrawal?.results[0]?.accepted, true, 'delivered as a plain item'); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C1' }, 'a withdrawal is never the place'); + // A plain message there still is. + await registry.handleIncoming('slack', { messages: [msg('slack:C2', 'm-3')] }); + assert.deepEqual(registry.getLastInbound(), { serverId: 'slack', channelId: 'slack:C2' }); +}); + +test('a coalesced message whose channel goes while its coalescer decides breaks nothing', async () => { + let registry: ChannelRegistry; + registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { + handleCoalescedIncoming: async (serverId, message) => { + // The channel is withdrawn while the occurrence is being admitted. + await registry.handleChanged(serverId, { removed: [message.channelId] }); + return { messageId: message.messageId, accepted: true }; + }, + }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:GONE'); + await registry.handleIncoming('slack', { messages: [{ + channelId: 'slack:C1', messageId: 'm-1', author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'hi' }], + }] }); + let answered: { results: Array<{ accepted: boolean; reason?: string }> } | undefined; + await registry.handleIncoming('slack', { messages: [{ + channelId: 'slack:GONE', messageId: 'm-2', author: { id: 'u1', name: 'Ada' }, timestamp: '2026-10-07T00:00:00.000Z', + content: [{ type: 'text' as const, text: 'bye' }], eventId: 'e-2', coalesce: { key: 'k' }, + }] } as never, { respond: (r: unknown) => { answered = r as typeof answered; } } as never); + assert.deepEqual(answered?.results.map((r) => [r.accepted, r.reason]), [[true, undefined]], 'the admission itself stands'); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1'); + // A host noting it afterwards finds the channel gone: it never becomes the place. + registry.noteDelivered('slack', 'slack:GONE', undefined, []); + assert.equal(registry.getDefaultPublishChannel(), 'slack:C1', 'a channel removed meanwhile never becomes the place'); +}); + +test('the gate reads a message\'s thread from the protocol field, never from adapter metadata', async () => { + const seen: Array> = []; + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { shouldTriggerInference: (_content, metadata) => { seen.push(metadata); return true; } }, + ); + seedRegistered(registry, 'slack', 'slack:C1'); + const message = (extra: Record) => ({ + messages: [{ + channelId: 'slack:C1', messageId: `m-${seen.length}`, author: { id: 'u1', name: 'Ada' }, + timestamp: '2026-10-07T00:00:00.000Z', content: [{ type: 'text' as const, text: 'hi' }], ...extra, + }], + }); + // A root message whose adapter metadata happens to carry a threadId key. + await registry.handleIncoming('slack', message({ metadata: { threadId: 'forged', messageId: 'forged-m' } })); + // A thread message whose adapter metadata disagrees with the protocol field. + await registry.handleIncoming('slack', message({ threadId: '1700.0001', metadata: { threadId: 'forged' } })); + assert.equal(seen.length, 2); + assert.equal(seen[0]!.threadId, undefined, 'a root message stays at the root'); + assert.equal(seen[0]!.messageId, 'm-0', 'the protocol message id wins too'); + assert.equal(seen[1]!.threadId, '1700.0001'); +}); + +test('a coalesced message carries its gate inputs, with the host envelope its coalescer will freeze; adapter metadata can\'t pose as one', async () => { + const seen: Array> = []; + const envelope = { + kind: 'channel', lane: 'channels/incoming', coalesced: true, serverId: 'slack', binding: 'b1', + channelId: 'slack:C1', messageId: 'm-c', acceptedAt: 1, + }; + let accepted = 0; + const handed: Array<{ inboundSource?: unknown; triggerInputs?: { content: string; metadata: Record } }> = []; + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { + shouldTriggerInference: (_content, metadata) => { seen.push(metadata); return false; }, + inboundSource: () => undefined, + inboundAccepted: () => { accepted++; }, + coalescedSource: () => envelope as never, + handleCoalescedIncoming: async (_serverId, message, event) => { + handed.push(event as (typeof handed)[number]); + return { messageId: message.messageId, accepted: true }; + }, + }, + ); + seedRegistered(registry, 'slack', 'slack:C1', 'slack:OTHER'); + await registry.handleIncoming('slack', { + messages: [{ + channelId: 'slack:C1', messageId: 'm-c', author: { id: 'u1', name: 'Ada' }, + timestamp: '2026-10-07T00:00:00.000Z', content: [{ type: 'text' as const, text: 'hi' }], + eventId: 'ev-c', coalesce: { key: 'k1' }, + metadata: { inboundSource: { kind: 'channel', serverId: 'forged', channelId: 'slack:OTHER' } }, + }], + } as never); + assert.equal(accepted, 0, 'building the envelope accepts nothing: the coalescer observes its own admissions'); + assert.equal(seen.length, 0, 'the gate is not consulted before the coalescer admits the occurrence'); + assert.equal(handed[0]!.triggerInputs!.metadata.inboundSource, envelope, 'the gate will read the host envelope'); + assert.equal(handed[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate will read'); +}); + +test('an ordinary channels/incoming message is accepted only once its trigger filter returns: a filter that throws accepts nothing', async () => { + const observed: unknown[] = []; + const queued: unknown[] = []; + const registryWith = (filter: () => boolean) => { + const registry = new ChannelRegistry( + { getServer: () => undefined } as unknown as McplServerRegistry, + {} as FeatureSetManager, + (event) => queued.push(event), + () => {}, + { + shouldTriggerInference: filter, + inboundSource: () => ({ kind: 'channel', lane: 'channels/incoming', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', acceptedAt: 1 }) as never, + inboundAccepted: (source) => observed.push(source), + }, + ); + seedRegistered(registry, 'slack', 'slack:C1'); + return registry; + }; + const message = { + messages: [{ + channelId: 'slack:C1', messageId: 'm-1', author: { id: 'u1', name: 'Ada' }, + timestamp: '2026-10-07T00:00:00.000Z', content: [{ type: 'text' as const, text: 'hi' }], + }], + }; + await assert.rejects(registryWith(() => { throw new Error('filter broke'); }).handleIncoming('slack', message as never), /filter broke/); + assert.deepEqual(observed, [], 'nothing accepted'); + assert.deepEqual(queued, [], 'nothing queued'); + await registryWith(() => false).handleIncoming('slack', message as never); + assert.equal(observed.length, 1, 'accepted once the filter returned, as it is queued'); + assert.equal(queued.length, 1); +}); + +test('publish rechecks its destination after opening it: a declaration withdrawn meanwhile refuses the send', async () => { + // A closed channel declares where posts land; while the open waits on the + // connector, a channels/changed withdraws that (or removes the channel, or + // moves the id to another server). The host refuses, rather than relying + // on the connector to. + const published: unknown[] = []; + let duringOpen: () => void = () => {}; + const server = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsOpen: async () => { duringOpen(); return {}; }, + sendChannelsPublish: async (params: { threadId?: string | null }) => { + published.push(params); + return { delivered: true, threadId: params.threadId }; + }, + }; + const failures: Array<{ draftId?: string }> = []; + const registry = new ChannelRegistry( + { getServer: () => server } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { onRouteFailure: (info) => { failures.push(info); } }, + ); + const channels = (registry as unknown as { + channels: Map; open: boolean }>; + }).channels; + const seed = (): void => { + channels.clear(); + seedRegistered(registry, 'discord', 'dm-alice'); + }; + + seed(); + duringOpen = () => { channels.get('discord:dm-alice')!.descriptor = { id: 'dm-alice', type: 'discord', label: 'dm-alice' }; }; + const withdrawn = await registry.publish('sol', 'reply', { serverId: 'discord', channelId: 'dm-alice', threadId: null }); + assert.equal(withdrawn.status, 'failed'); + assert.match(withdrawn.reason ?? '', /declare/i); + + seed(); + duringOpen = () => { channels.delete('discord:dm-alice'); }; + const removed = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(removed.status, 'failed'); + assert.match(removed.reason ?? '', /went away while it was being opened/); + + // Removed from its server while another server registers the same id: the + // destination it resolved to is gone, and the send never moves to the other. + seed(); + duringOpen = () => { + channels.delete('discord:dm-alice'); + seedRegistered(registry, 'slack', 'dm-alice'); + }; + const moved = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(moved.status, 'failed'); + assert.equal(moved.destination?.serverId, 'discord'); + + // The grant loses channels.publish while the open waits. + seed(); + const fullGrant = server.grant; + duringOpen = () => { server.grant = new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS.filter((p) => p !== 'channels.publish')), []); }; + const ungranted = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(ungranted.status, 'failed'); + assert.match(ungranted.reason ?? '', /channels\.publish was withdrawn from "discord"'s effective grant while the channel was being opened/); + server.grant = fullGrant; + + // As plain speech: the refusal is reported, and the words kept as the + // caller's draft, which the failure names. + seed(); + duringOpen = () => { channels.get('discord:dm-alice')!.descriptor = { id: 'dm-alice', type: 'discord', label: 'dm-alice' }; }; + const kept: string[] = []; + const speech = await registry.deliverSpeech('sol', 'reply', { serverId: 'discord', channelId: 'dm-alice', threadId: null }, { + holdFailed: (reason) => { kept.push(reason); return 'd-kept'; }, + }); + assert.equal(speech.status, 'failed'); + assert.equal(kept.length, 1, 'the words were kept'); + assert.equal(failures.at(-1)?.draftId, 'd-kept', 'and the failure names their draft'); + + assert.deepEqual(published, [], 'nothing was sent in any case'); + + // Unchanged across the open: it sends. + seed(); + duringOpen = () => {}; + const sent = await registry.publish('sol', 'reply', { channelId: 'dm-alice', threadId: null }); + assert.equal(sent.status, 'delivered'); + assert.equal(published.length, 1); +}); + test('routeSpeech surfaces a failure when the server reports delivered:false', async () => { const { registry, failures, traces } = makeRegistry({ delivered: false }); seedRegistered(registry, 'discord', 'ch-x'); registry.handleIncoming('discord', incoming('ch-x', 'hi')); - const res = await registry.routeSpeech('cairn', 'undeliverable reply', registry.resolveLocus('cairn')); + const res = await registry.routeSpeech('cairn', 'undeliverable reply', 'ch-x'); assert.equal(res, null, 'a non-delivered send must not report success'); assert.equal(failures.length, 1, 'onRouteFailure should fire'); @@ -136,10 +480,9 @@ test('routeSpeech surfaces a failure when the server reports delivered:false', a assert.ok(traces.some(t => t.type === 'mcpl:speech-route-failed')); }); -test('routeSpeech routes a conversation fork to its HOME channel, not the global last-inbound (item 3)', async () => { - // Two channels are live. chanA registered first; then a message arrives on - // chanB, flipping the process-global defaultPublishChannel to chanB. A fork - // bound to chanA must still publish to chanA. +test('routeSpeech routes a conversation fork to its HOME channel, whatever arrives elsewhere (item 3)', async () => { + // Two channels are live and chanB spoke last. A fork bound to chanA must + // still publish to chanA. const homes: Record = { 'conversation-chanA-g1': 'chanA' }; const { registry, publishCalls } = makeRegistry( { delivered: true }, @@ -150,19 +493,18 @@ test('routeSpeech routes a conversation fork to its HOME channel, not the global registry.handleIncoming('discord', incoming('chanA', 'hi from A')); seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); - // Global locus is now chanB. - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); const res = await registry.routeSpeech('conversation-chanA-g1', 'reply for A', registry.resolveLocus('conversation-chanA-g1')); - assert.deepEqual(res, { delivered: true, channelId: 'chanA' }, - 'fork must route to its home channel, not the global last-inbound'); + assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'chanA', label: 'chanA' }, + 'fork must route to its home channel, not the last inbound'); assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); }); -test('routeSpeech falls back to the global locus for the trunk agent (no home)', async () => { - // The trunk/primary agent has no home entry; it correctly uses the global - // most-recent-inbound channel. - const { registry, publishCalls } = makeRegistry( +test('incoming traffic never selects a destination: without a home there is no locus (shelf-355)', async () => { + // The old registry tracked the process-global most-recent inbound channel + // and routed homeless speech there. Now a route comes only from a turn's + // own wake or a deliberate choice, which the framework holds. + const { registry, publishCalls, failures } = makeRegistry( { delivered: true }, () => undefined, // no agent has a home ); @@ -172,35 +514,140 @@ test('routeSpeech falls back to the global locus for the trunk agent (no home)', seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); + assert.equal(registry.resolveLocus('trunk'), null); + assert.equal(registry.buildChannelContext('trunk')?.defaultOutgoing, undefined, + 'nothing is advertised as where speech goes'); const res = await registry.routeSpeech('trunk', 'heartbeat reply', registry.resolveLocus('trunk')); - assert.deepEqual(res, { delivered: true, channelId: 'chanB' }); - assert.equal(publishCalls.at(-1)?.channelId, 'chanB'); + assert.equal(res, null); + assert.equal(publishCalls.length, 0); + assert.match(failures.at(-1)!.reason, /no locus/); }); -test('buildChannelContext advertises the fork home as defaultOutgoing (item 3)', () => { - const homes: Record = { 'conversation-chanA-g1': 'chanA' }; - const { registry } = makeRegistry( - { delivered: true }, - (agentName) => homes[agentName], - ); - - seedRegistered(registry, 'discord', 'chanA'); +test('buildChannelContext advertises the agent\'s own route and its reply edge, and nothing for a held or absent route', () => { + const routes: Record = { + 'conversation-chanA-g1': { kind: 'channel', serverId: 'discord', channelId: 'chanA' }, + scout: { kind: 'channel', serverId: 'discord', channelId: 'chanB', replyTo: 'm-7', threadId: 't-1' }, + // A thread route with no reply edge (a channel_open of a thread, say). + opener: { kind: 'channel', serverId: 'discord', channelId: 'chanB', threadId: 't-2' }, + held: { kind: 'held', conversations: ['#chanA (chanA)', '#chanB (chanB)'] }, + tui: { kind: 'surface', surface: 'tui' }, + }; + const { registry } = makeRegistry({ delivered: true }, () => undefined, (n) => routes[n] ?? { kind: 'none' }); + seedRegistered(registry, 'discord', 'chanA', 'chanB'); + // chanB holds threads: a thread route is a place a publish goes to there. + const map = (registry as unknown as { channels: Map }> }).channels; + map.get('discord:chanB')!.descriptor.capabilities = { publish: { target: 'exact' } }; registry.handleIncoming('discord', incoming('chanA', 'hi from A')); - seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanB', 'hi from B')); - // The fork is told chanA (where its speech actually lands)... - const forkCtx = registry.buildChannelContext('conversation-chanA-g1'); - assert.equal(forkCtx?.defaultOutgoing?.channelId, 'chanA'); + assert.deepEqual(registry.buildChannelContext('conversation-chanA-g1')?.defaultOutgoing, { channelId: 'chanA', threadId: null }, + "a root route names the root, as its publish's null does"); + const scout = registry.buildChannelContext('scout'); + assert.equal(scout?.defaultOutgoing?.channelId, 'chanB'); + assert.deepEqual(scout?.incoming, { channelId: 'chanB', messageId: 'm-7', threadId: 't-1' }, + 'the reply edge is the route\'s own triggering message'); + assert.deepEqual(scout?.defaultOutgoing, { channelId: 'chanB', threadId: 't-1' }); + const opener = registry.buildChannelContext('opener'); + assert.deepEqual(opener?.defaultOutgoing, { channelId: 'chanB', threadId: 't-2' }, 'the thread is named without a reply edge too'); + // A declaration withdrawn while the route stands: the publish would be + // refused, so the context names no place. + delete map.get('discord:chanB')!.descriptor.capabilities; + assert.equal(registry.buildChannelContext('scout')?.defaultOutgoing, undefined, 'no place a post would not reach'); + assert.equal(opener?.incoming, undefined); + for (const agent of ['held', 'tui', 'nobody']) { + const ctx = registry.buildChannelContext(agent); + assert.equal(ctx?.defaultOutgoing, undefined, `${agent}: no outgoing channel`); + assert.equal(ctx?.incoming, undefined, `${agent}: no reply edge`); + } +}); - // ...while the trunk agent (no home) is told the global default. - const trunkCtx = registry.buildChannelContext('trunk'); - assert.equal(trunkCtx?.defaultOutgoing?.channelId, 'chanB'); +test('a route that names its server publishes there, even when another server registers the same channel id', async () => { + const published: Array<{ server: string; channelId?: string }> = []; + const server = (id: string) => ({ + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (params: { channelId?: string; threadId?: string | null }) => { + published.push({ server: id, channelId: params.channelId }); + return { delivered: true, messageId: `${id}-1`, threadId: params.threadId }; + }, + sendChannelsOpen: async () => ({}), + }); + const servers: Record = { a: server('a'), b: server('b') }; + const registry = new ChannelRegistry( + { getServer: (id: string) => servers[id] } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + {}, + ); + seedRegistered(registry, 'a', 'shared'); + seedRegistered(registry, 'b', 'shared'); + + const res = await registry.routeSpeech('scout', 'to b', { serverId: 'b', channelId: 'shared' }); + assert.deepEqual(res, { delivered: true, serverId: 'b', channelId: 'shared', label: 'shared', messageId: 'b-1' }); + assert.deepEqual(published, [{ server: 'b', channelId: 'shared' }]); + // Without the server, the shared id is refused rather than guessed. + assert.equal(await registry.routeSpeech('scout', 'which one?', 'shared'), null); + assert.equal(published.length, 1); +}); + +test('typing shows on the route\'s own server when another server registers the same channel id, and nowhere for the id alone', () => { + const typed: Array<[string, string, string]> = []; + const servers: Record = { + a: { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []) }, + b: { grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []) }, + }; + const registry = new ChannelRegistry( + { getServer: (id: string) => servers[id] } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { sendTypingFn: (serverId, channelId, _metadata, op) => { typed.push([serverId, channelId, op ?? 'start']); } }, + ); + seedRegistered(registry, 'a', 'shared'); + seedRegistered(registry, 'b', 'shared'); + try { + registry.startTyping('shared'); + assert.deepEqual(typed, [], 'an id two servers share, named alone, shows no indicator'); + registry.startTyping({ serverId: 'b', channelId: 'shared' }); + registry.startTyping({ serverId: 'b', channelId: 'shared' }); + assert.deepEqual(typed, [['b', 'shared', 'start']], "only the route's server, once"); + registry.stopTyping({ serverId: 'a', channelId: 'shared' }); + assert.equal(typed.length, 1, 'no stop to a server that never saw a start'); + registry.stopTyping({ serverId: 'b', channelId: 'shared' }); + assert.deepEqual(typed.at(-1), ['b', 'shared', 'stop']); + } finally { + registry.stopTyping(); + } +}); + +test('speech traces name the thread, and publishForAgent traces its outcome once it is in', async () => { + const { registry, traces } = makeRegistry({ delivered: true }); + seedRegistered(registry, 'discord', 'forum'); + const channels = (registry as unknown as { channels: Map }> }).channels; + channels.get('discord:forum')!.descriptor.capabilities = { publish: { target: 'exact' } }; + await registry.deliverSpeech('scout', 'in the thread', { serverId: 'discord', channelId: 'forum', threadId: 't-1' }); + const routed = traces.filter((t) => t.type === 'mcpl:speech-routed'); + assert.deepEqual(routed.map((t) => [t.serverId, t.channelId, t.threadId]), [['discord', 'forum', 't-1']]); + + // A refused thread publish names the thread it was bound for. + seedRegistered(registry, 'discord', 'flat'); + await registry.deliverSpeech('scout', 'nowhere', { serverId: 'discord', channelId: 'flat', threadId: 't-2' }); + const failed = traces.filter((t) => t.type === 'mcpl:speech-route-failed'); + assert.deepEqual(failed.map((t) => [t.channelId, t.threadId, t.outcome]), [['flat', 't-2', 'failed']]); + + // publishForAgent: routed only once delivered, with where it went; a + // refused one is a failure, never routed. + traces.length = 0; + await registry.publishForAgent('forum', 'from the subconscious', 'subconscious'); + await registry.publishForAgent('nowhere-registered', 'refused', 'subconscious'); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:speech-routed').map((t) => [t.conversationId, t.serverId, t.channelId, t.threadId]), + [['subconscious', 'discord', 'forum', null]]); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:speech-route-failed').map((t) => [t.channelId, t.outcome]), + [['nowhere-registered', 'failed']]); }); test('routeSpeech surfaces a failure when there is no locus at all', async () => { const { registry, failures } = makeRegistry({ delivered: true }); - // No handleIncoming → defaultPublishChannel is null. const res = await registry.routeSpeech('cairn', 'into the void', registry.resolveLocus('cairn')); assert.equal(res, null); assert.equal(failures.length, 1); @@ -208,85 +655,118 @@ test('routeSpeech surfaces a failure when there is no locus at all', async () => assert.match(failures[0].reason, /no locus/); }); -// ── item-3 redux: single TRUNK agents route to the turn's triggering channel ── +// ── channel_publish: an explicit send through the publish executor ── -test('routeSpeech routes a TRUNK agent to its ACTIVE triggering channel, not the global last-inbound (item-3 redux)', async () => { - // The exact live repro: scout (a single trunk agent) is answering channel A - // when a message arrives on channel B, flipping the process-global - // defaultPublishChannel to B. The reply must still land in A. Forks (home) - // don't exist here — connectome-host never spawns them — so the ONLY thing - // keeping A's answer in A is the active-triggering-channel resolver. - const active: Record = { scout: 'chanA' }; +test('channel_publish without a channel goes to the caller\'s route, and its receipt names the destination', async () => { const { registry, publishCalls } = makeRegistry( - { delivered: true }, - () => undefined, // no fork homes (trunk-only, connectome-host mode) - (agentName) => active[agentName], + { delivered: true, messageId: 'p-1' } as { delivered?: boolean }, + () => undefined, + (n) => (n === 'scout' ? { kind: 'channel', serverId: 'discord', channelId: 'chanA' } : { kind: 'none' }), ); + seedRegistered(registry, 'discord', 'chanA', 'chanB'); + registry.handleIncoming('discord', incoming('chanB', 'chanB spoke last')); - seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'A: sleep && date')); - seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'B: unrelated')); // flips global to chanB - assert.equal(registry.getDefaultPublishChannel(), 'chanB'); - - const res = await registry.routeSpeech('scout', 'the date is ...', registry.resolveLocus('scout')); - assert.deepEqual(res, { delivered: true, channelId: 'chanA' }, - 'trunk reply must go to the channel that triggered the turn, not the global last-inbound'); - assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); + const res = await registry.handleChannelToolCall('channel_publish', { content: 'hello' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(res.success, true); + assert.deepEqual(res.data, { delivered: true, status: 'delivered', serverId: 'discord', channelId: 'chanA', channelLabel: 'chanA', threadId: null, messageId: 'p-1' }); + assert.equal(publishCalls.at(-1)?.channelId, 'chanA', 'the route, not the last inbound channel'); }); -test('routeSpeech precedence: fork HOME wins over the active triggering channel', async () => { - // A fork bound to chanA must route home even if its live turn was (somehow) - // triggered from chanB — home is the strongest signal and must not regress. +test('a route whose server is unknown resolves the same way for channel_publish as for plain speech', async () => { const { registry, publishCalls } = makeRegistry( - { delivered: true }, - (n) => (n === 'conversation-chanA-g1' ? 'chanA' : undefined), - () => 'chanB', + { delivered: true, messageId: 'p-2' } as { delivered?: boolean }, + () => undefined, + () => ({ kind: 'channel', channelId: 'chanA' }), ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi')); - seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi')); + const published = await registry.handleChannelToolCall('channel_publish', { content: 'explicit' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(published.success, true, JSON.stringify(published)); + const spoken = await registry.routeSpeech('scout', 'plain speech', 'chanA'); + assert.deepEqual(spoken, { delivered: true, serverId: 'discord', channelId: 'chanA', label: 'chanA', messageId: 'p-2' }); + assert.deepEqual(publishCalls.map((c) => c.channelId), ['chanA', 'chanA']); +}); - const res = await registry.routeSpeech('conversation-chanA-g1', 'reply', registry.resolveLocus('conversation-chanA-g1')); - assert.deepEqual(res, { delivered: true, channelId: 'chanA' }); - assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); +test('channel_publish without a channel or a route is refused, never guessed', async () => { + const views: Record = { + held: { kind: 'held', conversations: ['#chanA (chanA)', '#chanB (chanB)'] }, + tui: { kind: 'surface', surface: 'tui' }, + }; + const { registry, publishCalls } = makeRegistry({ delivered: true }, () => undefined, (n) => views[n] ?? { kind: 'none' }); + seedRegistered(registry, 'discord', 'chanA', 'chanB'); + registry.handleIncoming('discord', incoming('chanB', 'chanB spoke last')); + + const none = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'agent', agentName: 'nobody' }); + assert.equal(none.success, false); + assert.match(none.error!, /No channelId given, and you have no current speech route: name the channel to publish to\. Nothing was sent\./); + const held = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'agent', agentName: 'held' }); + assert.match(held.error!, /your speech route is held between #chanA \(chanA\) and #chanB \(chanB\)/); + const tui = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'agent', agentName: 'tui' }); + assert.match(tui.error!, /your speech route is the local surface tui, not a channel/); + const module = await registry.handleChannelToolCall('channel_publish', { content: 'x' }, { kind: 'module' }); + assert.match(module.error!, /you have no current speech route/); + assert.equal(publishCalls.length, 0); }); -test('buildChannelContext advertises the active triggering channel as defaultOutgoing (item-3 redux)', () => { - const active: Record = { scout: 'chanA' }; - const { registry } = makeRegistry( - { delivered: true }, +test('channel_publish checks supplied selectors before any default: an empty one is refused, never read as omitted', async () => { + const { registry, publishCalls } = makeRegistry( + { delivered: true, messageId: 'p-9' } as { delivered?: boolean }, () => undefined, - (n) => active[n], + () => ({ kind: 'channel', serverId: 'discord', channelId: 'routed' }), + ); + seedRegistered(registry, 'discord', 'only', 'routed'); + const caller = { kind: 'agent' as const, agentName: 'scout' }; + for (const [input, why] of [ + [{ channelId: 'only', serverId: '', content: 'x' }, /serverId must name a server/], + [{ channelId: 'only', serverId: 7, content: 'x' }, /serverId must name a server/], + [{ channelId: '', content: 'x' }, /channelId must name a channel/], + [{ serverId: 'discord', content: 'x' }, /serverId needs the channelId it belongs to/], + [{ channelId: 'only', threadId: '', content: 'x' }, /threadId must be a thread id/], + ] as const) { + const res = await registry.handleChannelToolCall('channel_publish', input, caller); + assert.equal(res.success, false, JSON.stringify(input)); + assert.match(res.error!, why); + assert.match(res.error!, /Nothing was sent\.$/); + } + assert.equal(publishCalls.length, 0, 'not the sole server, and not the route'); + // null means "not in use": the field is omitted, not invalid. + const ok = await registry.handleChannelToolCall('channel_publish', { channelId: 'only', serverId: null, threadId: null, content: 'y' }, caller); + assert.equal(ok.success, true, JSON.stringify(ok)); + assert.deepEqual(publishCalls.map((c) => [c.channelId, c.threadId]), [['only', null]]); +}); + +test('channel_publish reports failed and unknown outcomes with the attempted destination', async () => { + const refused = makeRegistry({ delivered: false }); + seedRegistered(refused.registry, 'discord', 'chanA'); + const failed = await refused.registry.handleChannelToolCall('channel_publish', { channelId: 'chanA', content: 'x' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(failed.success, false); + assert.match(failed.error!, /^Not sent to \(discord \/ chanA\): .*Nothing was posted\.$/); + assert.deepEqual( + { status: (failed.data as { status: string }).status, serverId: (failed.data as { serverId: string }).serverId, channelId: (failed.data as { channelId: string }).channelId }, + { status: 'failed', serverId: 'discord', channelId: 'chanA' }, ); - seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); - seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi from B')); // global → chanB - // The trunk is TOLD chanA (where its speech will actually land), matching - // what routeSpeech resolves — not the global chanB. - const ctx = registry.buildChannelContext('scout'); - assert.equal(ctx?.defaultOutgoing?.channelId, 'chanA'); + const silent = makeRegistry(undefined); + seedRegistered(silent.registry, 'discord', 'chanA'); + const unknown = await silent.registry.handleChannelToolCall('channel_publish', { channelId: 'chanA', content: 'x' }, { kind: 'agent', agentName: 'scout' }); + assert.equal(unknown.success, false); + assert.match(unknown.error!, /^Delivery to \(discord \/ chanA\) was not confirmed — it may or may not have been posted: .*Check the channel before sending again\.$/); + assert.equal((unknown.data as { status: string }).status, 'unknown'); }); -test('ensureChannelRegistered keeps a DM closed while making its one-shot reply routable', async () => { - // A Discord DM arrives via push/event (channel closed), so it is never - // registered and never updates defaultPublishChannel — routeSpeech would drop - // the reply. Registering it on inbound makes it a publishable locus; the - // active resolver (the woken turn's DM) then targets it. +test('ensureChannelRegistered keeps a DM closed; the reply goes out once its connector declares the channel', async () => { + // A Discord DM arrives via push/event (channel closed). The host registers + // it lazily so it resolves — but a host-minted channel carries no MCPL + // RFC-011 declaration, so the framework doesn't publish there: the + // connector might choose the place itself. A connector that registers the + // DM with a declaration (as discord-mcpl does) makes the reply routable. const dm = 'discord:dm:42'; - const active: Record = { scout: dm }; const { registry, publishCalls, lookup, traces } = makeRegistry( { delivered: true }, () => undefined, - (n) => active[n], ); // No prior handleIncoming for the DM — it only ever came as a push event. assert.equal(lookup(dm), undefined); - assert.equal(registry.getDefaultPublishChannel(), null); registry.ensureChannelRegistered('discord', dm, 'DM with Antra'); @@ -296,10 +776,18 @@ test('ensureChannelRegistered keeps a DM closed while making its one-shot reply assert.equal(entry!.open, false, 'one-shot reachability is not a subscription'); assert.ok(traces.some((t) => t.type === 'mcpl:channel-lazy-registered')); - const res = await registry.routeSpeech('scout', 'replying in the DM', registry.resolveLocus('scout')); - assert.deepEqual(res, { delivered: true, channelId: dm }, - 'the DM reply must route back to the DM channel, not the global locus'); + const undeclared = await registry.routeSpeech('scout', 'replying in the DM', { serverId: 'discord', channelId: dm }); + assert.equal(undeclared, null, 'no declaration, no publication'); + assert.equal(publishCalls.length, 0); + + await registry.handleChanged('discord', { + updated: [{ id: dm, type: 'discord', label: 'DM with Antra', direction: 'bidirectional', capabilities: { publish: { target: 'root' } } }], + }); + const res = await registry.routeSpeech('scout', 'replying in the DM', { serverId: 'discord', channelId: dm }); + assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: dm, label: 'DM with Antra' }, + 'the DM reply routes back to the DM channel once it is declared'); assert.equal(publishCalls.at(-1)?.channelId, dm); + assert.equal(publishCalls.at(-1)?.threadId, null, 'at the channel itself'); }); test('ensureChannelRegistered is idempotent and does not reopen a closed channel', () => { @@ -330,7 +818,7 @@ test('routeSpeech into a closed locus opens the channel first, then delivers', a const res = await registry.routeSpeech('sol', 'a reply meant for the DM', 'dm-alice'); - assert.deepEqual(res, { delivered: true, channelId: 'dm-alice' }); + assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'dm-alice', label: 'dm-alice' }); assert.equal(openCalls.length, 1, 'delivery into a closed channel must open it'); assert.equal(openCalls[0]!.channelId, 'dm-alice'); assert.equal(lookup('dm-alice')!.open, true, 'live state flips open'); diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index cd55d3c4d..36145545d 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -458,6 +458,7 @@ describe('Conversation routing', () => { channelRegistry: { handleChannelToolCall(name: string, input: unknown, origin?: unknown): Promise<{ success: boolean }>; stopAll(): void; + getChannelServerId?(id: string): string | null; } | null; }; @@ -466,7 +467,12 @@ describe('Conversation routing', () => { // channel_* tools). Stub the downstream boundary — the unit under test is // the fence ABOVE it, and a home-defaulted publish must get past the fence // to be observable at tool:started. - fw.channelRegistry = { handleChannelToolCall: async () => ({ success: true }), stopAll: () => {} }; + fw.channelRegistry = { + handleChannelToolCall: async () => ({ success: true }), + stopAll: () => {}, + // The home's sole registrant (a fork's home is a bare channel id). + getChannelServerId: (id: string) => (id === 'slack:C7' ? 'slack-a' : null), + } as typeof fw.channelRegistry; // (a) omitted channelId → rewritten to the home channel, passes the fence fw.dispatchChannelToolCall(forkName, { id: 'p1', name: 'channel_publish', input: { content: 'hello home' } }); @@ -488,6 +494,18 @@ describe('Conversation routing', () => { 'foreign publish never reaches tool:started', ); + // (c) the home's id on another server → rejected: the same id there is + // another conversation + fw.dispatchChannelToolCall(forkName, { id: 'p3', name: 'channel_publish', input: { channelId: 'slack:C7', serverId: 'slack-b', content: 'elsewhere' } }); + const otherServer = failures.find((f) => f.callId === 'p3'); + assert.ok(otherServer, 'the home id named on another server is rejected'); + assert.ok(otherServer!.error.includes('on server slack-a; publishing to it on server slack-b is not allowed'), otherServer!.error); + + // (d) the home with its own server → passes the fence + fw.dispatchChannelToolCall(forkName, { id: 'p4', name: 'channel_publish', input: { channelId: 'slack:C7', serverId: 'slack-a', content: 'here' } }); + assert.ok(!failures.some((f) => f.callId === 'p4'), "naming the home's own server passes"); + assert.equal(started.filter((t) => t.tool === 'channel_publish').length, 2); + await framework.stop(); }); }); diff --git a/test/deferred-wake-integrity.test.ts b/test/deferred-wake-integrity.test.ts index 6aa5cdc5d..0b5994e34 100644 --- a/test/deferred-wake-integrity.test.ts +++ b/test/deferred-wake-integrity.test.ts @@ -119,10 +119,11 @@ function stubChannelRegistry(framework: AgentFramework) { routeSpeech: async (_agent: string, text: string, locus?: string | null) => { routed.push({ text, locus: locus ?? null }); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // As the registry answers for a locus no server registered. + resolveDestination: () => ({ error: 'no registered channel' }), }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { get: (target, prop: string) => (prop in target ? target[prop] : () => undefined), diff --git a/test/event-gate.test.ts b/test/event-gate.test.ts index 637cf1029..119127701 100644 --- a/test/event-gate.test.ts +++ b/test/event-gate.test.ts @@ -33,7 +33,7 @@ function makeGate(configPath: string, opts?: { }) { const traces: TraceEntry[] = []; const messages: Array<{ participant: string; content: unknown; metadata?: unknown }> = []; - const inferenceRequests: Array<{ agentName: string; reason: string; source: string; channelId?: string; counterparty?: string; addressed?: boolean; at?: number; routeChannelId?: string }> = []; + const inferenceRequests: Array<{ agentName: string; reason: string; source: string; channelId?: string; counterparty?: string; addressed?: boolean; at?: number; routeChannelId?: string; routeCandidates?: Array> }> = []; const gate = new EventGate({ configPath, @@ -48,6 +48,24 @@ function makeGate(configPath: string, opts?: { return { gate, traces, messages, inferenceRequests }; } +/** Until the gate has added `n` messages, bounded. */ +async function waitForMessages(messages: unknown[], n: number, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (messages.length < n) { + if (Date.now() > deadline) throw new Error(`expected ${n} message(s), saw ${messages.length}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + +/** Until the gate has requested `n` wakes, bounded: what a debounced test waits on. */ +async function waitForRequests(requests: unknown[], n: number, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (requests.length < n) { + if (Date.now() > deadline) throw new Error(`expected ${n} wake request(s), saw ${requests.length}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + function event(overrides?: Partial): GateEventInfo { return { content: 'test message', @@ -347,6 +365,45 @@ describe('debounce', () => { assert.deepStrictEqual(d.behavior, { debounce: 100 }); }); + it('a batched wake counts each server\'s channel apart and names its server', async () => { + const path = writeConfig('debounce-servers.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, messages } = makeGate(path); + // The same channel id and name on two servers: two conversations. + gate.evaluate(event({ serverId: 'alpha', channelId: 'C1', metadata: { channelName: 'general' } })); + gate.evaluate(event({ serverId: 'beta', channelId: 'C1', metadata: { channelName: 'general' } })); + gate.evaluate(event({ serverId: 'beta', channelId: 'C1', metadata: { channelName: 'general' } })); + await new Promise(r => setTimeout(r, 160)); + const text = (messages[0].content as Array<{ text: string }>)[0].text; + // The label as the connector gave it: nothing added for its spelling. + assert.ok(text.includes('- 1 message in general (alpha / C1) '), text); + assert.ok(text.includes('- 2 messages in general (beta / C1) '), text); + }); + + it('a batched wake counts a thread apart from its channel root and from another thread, naming each', async () => { + const path = writeConfig('debounce-threads.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, messages } = makeGate(path); + const said = (threadId?: string) => gate.evaluate(event({ + eventType: 'mcpl:channel-incoming', serverId: 'slack', channelId: 'slack:C1', + metadata: { channelName: '#eng', ...(threadId ? { threadId } : {}) }, + })); + said('1700.1'); said('1700.1'); said('1700.2'); said(); + await waitForMessages(messages, 1); + const text = (messages[0].content as Array<{ text: string }>)[0].text; + assert.ok(text.includes('- 2 messages in #eng (slack / slack:C1, thread 1700.1) '), text); + assert.ok(text.includes('- 1 message in #eng (slack / slack:C1, thread 1700.2) '), text); + assert.ok(text.includes('- 1 message in #eng (slack / slack:C1) '), text); + }); + it('batches events and fires after delay', async () => { const path = writeConfig('debounce-fire.json', { policies: [ @@ -584,6 +641,199 @@ describe('debounce', () => { assert.strictEqual(inferenceRequests[0].routeChannelId, undefined); }); + it('a batched wake reports every conversation in its batch as a route candidate (shelf-355)', async () => { + const path = writeConfig('debounce-route-candidates.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const resolveRouteChannel = (info: GateEventInfo) => + info.eventType === 'mcpl:channel-incoming' ? info.channelId : undefined; + const { gate, inferenceRequests } = makeGate(path, { resolveRouteChannel }); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: '@agent hi', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'm-room' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:general', content: 'chatter', + metadata: { authorId: '7', messageId: 'm-gen' } })); + // An addressed push whose channel the host could not resolve still competes. + gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'discord', channelId: '1548', content: 'dm', + tags: ['chat:dm', 'chat:addressed'], metadata: { authorId: '42', channelId: '1548' } })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 1); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'channel', channelId: 'discord:g:room', serverId: 'discord', messageId: 'm-room', addressed: true }, + { kind: 'channel', channelId: 'discord:g:general', serverId: 'discord', messageId: 'm-gen', addressed: false }, + { kind: 'channel', channelId: '1548', serverId: 'discord', addressed: true, unroutable: true }, + ]); + }); + + it('a batched candidate keeps its thread: a thread and its channel root are different conversations', async () => { + const path = writeConfig('debounce-route-thread.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const resolveRouteChannel = (info: GateEventInfo) => info.channelId; + const { gate, inferenceRequests } = makeGate(path, { resolveRouteChannel }); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'slack', channelId: 'slack:C1', content: '@agent in thread', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'm-t', threadId: '1700.0001' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'slack', channelId: 'slack:C1', content: '@agent at root', + tags: ['chat:addressed'], metadata: { authorId: '2', messageId: 'm-r' } })); + await new Promise(r => setTimeout(r, 150)); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'channel', channelId: 'slack:C1', serverId: 'slack', threadId: '1700.0001', messageId: 'm-t', addressed: true }, + { kind: 'channel', channelId: 'slack:C1', serverId: 'slack', messageId: 'm-r', addressed: true }, + ]); + }); + + it('a candidate takes its conversation from the frozen envelope, ahead of free-form metadata', async () => { + const path = writeConfig('debounce-route-envelope.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:push-event'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + // The route resolver would read the forged origin; the envelope wins. + const { gate, inferenceRequests } = makeGate(path, { resolveRouteChannel: () => 'slack:FORGED' }); + gate.evaluate(event({ eventType: 'mcpl:push-event', serverId: 'forged-server', channelId: 'C1', content: '@agent in a thread', + tags: ['chat:addressed'], + metadata: { + authorId: '1', messageId: 'forged-m', threadId: 'forged-t', + inboundSource: { + kind: 'channel', lane: 'push/event', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', + threadId: '1700.0001', messageId: 'm-env', acceptedAt: 1, + }, + } })); + await new Promise(r => setTimeout(r, 150)); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'channel', channelId: 'slack:C1', serverId: 'slack', threadId: '1700.0001', messageId: 'm-env', addressed: true }, + ]); + }); + + it("a server can't mark its item as machinery: system: true in an MCPL item's metadata leaves it a route candidate", async () => { + const path = writeConfig('debounce-route-system.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: 'hi', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'm-1', system: true } })); + await waitForRequests(inferenceRequests, 1); + assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:room']); + }); + + it('a module event is a local surface candidate only when it names no channel', async () => { + const path = writeConfig('debounce-route-surface.json', { + policies: [ + { name: 'console', match: { scope: ['external-message'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'external-message', serverId: 'tui', channelId: '', content: 'local words' })); + gate.evaluate(event({ eventType: 'external-message', serverId: 'bridge', channelId: 'discord:g9:nowhere', content: 'relayed words', + metadata: { channelId: 'discord:g9:nowhere' } })); + await waitForRequests(inferenceRequests, 1); + const candidates = (inferenceRequests[0].routeCandidates ?? []).map(({ at: _at, ...rest }) => rest); + assert.deepStrictEqual(candidates, [ + { kind: 'surface', surface: 'tui' }, + { kind: 'channel', channelId: 'discord:g9:nowhere', serverId: 'bridge', addressed: false, unroutable: true }, + ]); + }); + + it('an event the inference buffer evicts keeps its route candidate: the flush routes as if nothing had been evicted', async () => { + const path = writeConfig('buffer-evict.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.onInferenceStarted('agent'); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:dm:1', content: 'are you there?', + tags: ['chat:addressed'], metadata: { authorId: '1', messageId: 'dm-1' } })); + for (let n = 0; n < 100; n++) { + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:general', content: `chatter ${n}`, + metadata: { authorId: '2', messageId: `g-${n}` } })); + } + const deadline = Date.now() + 5000; + while (gate.inferenceDiagnostics().bufferedEvents < 100) { + if (Date.now() > deadline) throw new Error('the batch never reached the buffer'); + await new Promise((r) => setTimeout(r, 10)); + } + assert.equal(gate.inferenceDiagnostics().droppedBufferedEvents, 1, 'the DM, oldest, was evicted'); + gate.onInferenceEnded('agent'); + await waitForRequests(inferenceRequests, 1); + const addressed = (inferenceRequests[0].routeCandidates ?? []).filter((c) => c.addressed).map((c) => c.channelId); + assert.deepEqual(addressed, ['discord:dm:1'], 'the addressed conversation still competes, and wins'); + assert.equal(inferenceRequests[0].routeChannelId, 'discord:dm:1', 'and the wake is addressed there'); + }); + + it('a reaction in the batch is never a route candidate (the same predicate as mid-turn)', async () => { + const path = writeConfig('debounce-route-reaction.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: '👍', + tags: ['chat:reaction', 'chat:addressed'], metadata: { authorId: '1', messageId: 'r-1' } })); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:general', content: 'hi', + metadata: { authorId: '2', messageId: 'm-2' } })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 1); + assert.deepStrictEqual((inferenceRequests[0].routeCandidates ?? []).map((c) => c.channelId), ['discord:g:general']); + }); + + it('a batch naming only an author, a thread or a local surface names a conversation; one naming none does not', async () => { + const path = writeConfig('debounce-route-author-surface.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:push-event', 'external-message'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + // Each event is its own batch: the next is evaluated only once the + // previous batch's wake has arrived. + const batches = [ + event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'from someone', metadata: { authorId: '7' } }), + event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'in a thread', metadata: { threadId: 't-3' } }), + event({ eventType: 'external-message', serverId: 'tui', content: 'status?' }), + event({ eventType: 'mcpl:push-event', serverId: 'discord', content: 'a nudge' }), + ]; + for (const [index, e] of batches.entries()) { + gate.evaluate(e); + await waitForRequests(inferenceRequests, index + 1); + } + assert.strictEqual(inferenceRequests.length, 4); + const names = inferenceRequests.map((r) => (r as { namesConversation?: true }).namesConversation); + assert.deepStrictEqual(names, [true, true, true, undefined], + 'an author, a thread and a local surface each name a conversation; a bare push names none'); + }); + + it('a reaction-only batch names its conversation though it has no candidate, so it never takes the fallback route', async () => { + const path = writeConfig('debounce-route-reaction-only.json', { + policies: [ + { name: 'chat', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }, + ], + default: 'skip', + }); + const { gate, inferenceRequests } = makeGate(path); + gate.evaluate(event({ eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'discord:g:room', content: '👍', + tags: ['chat:reaction', 'chat:addressed'], metadata: { authorId: '1', messageId: 'r-1' } })); + await new Promise(r => setTimeout(r, 150)); + assert.strictEqual(inferenceRequests.length, 1); + assert.strictEqual(inferenceRequests[0].routeCandidates, undefined, 'a reaction is no candidate'); + assert.strictEqual((inferenceRequests[0] as { namesConversation?: true }).namesConversation, true, 'but it names its conversation'); + }); + it('a batched wake with no channel-bearing event carries no provenance', async () => { const path = writeConfig('debounce-noprov.json', { policies: [ @@ -597,6 +847,8 @@ describe('debounce', () => { assert.strictEqual(inferenceRequests.length, 1); assert.strictEqual(inferenceRequests[0].channelId, undefined); assert.strictEqual(inferenceRequests[0].counterparty, undefined); + assert.strictEqual((inferenceRequests[0] as { namesConversation?: true }).namesConversation, undefined, + 'a batch that names no conversation is the one wake the fallback route serves'); }); it('quotes content for non-MCPL events (no context entry of their own)', async () => { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 98a000067..0e8d219d2 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -64,9 +64,13 @@ class ToolboxModule implements Module { } } +/** A `>>`/`>>>` target as the framework publishes it: on the server its + * target resolved to (the stub's). */ +const pin = (channelId: string) => ({ serverId: 'stub', channelId }); + /** Registry stub: two known channels (#alpha guild, laria's DM) + capture. */ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'false' | 'throw'> = []) { - const routed: Array<{ text: string; locus: string | null }> = []; + const routed: Array<{ text: string; locus: unknown }> = []; const known: Record = { '#alpha': { channelId: 'chan-alpha', label: '#alpha' }, '#cafe': { channelId: 'discord:g:cafe', label: '#cafe' }, @@ -77,18 +81,27 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals 'discord:dm:99': { channelId: 'discord:dm:99', label: 'DM: laria' }, }; const explicit: Record = { + // A target resolves on its server, as the registry's does. resolveProseTarget: (spec: string) => - known[spec] ?? { error: `no channel matches "${spec}"` }, - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { + known[spec] ? { serverId: 'stub', ...known[spec] } : { error: `no channel matches "${spec}"` }, + routeSpeech: async (_agent: string, text: string, locus?: string | null | { channelId: string }) => { routed.push({ text, locus: locus ?? null }); const behavior = plan.shift() ?? 'delivered'; if (behavior === 'throw') throw new Error('synthetic publication failure'); - return { delivered: behavior === 'delivered', channelId: locus ?? '' }; + const channelId = locus && typeof locus === 'object' ? locus.channelId : locus ?? ''; + return { delivered: behavior === 'delivered', channelId }; + }, + // The registry's outcome-returning form of routeSpeech, over the same plan. + deliverSpeech: async (agent: string, text: string, locus?: string | null | { channelId: string }) => { + const result = await (explicit.routeSpeech as (a: string, t: string, l?: unknown) => Promise<{ delivered: boolean; channelId: string }>)(agent, text, locus); + return result.delivered + ? { status: 'delivered', destination: { serverId: 'stub', channelId: result.channelId }, at: Date.now() } + : { status: 'failed', destination: { serverId: 'stub', channelId: result.channelId }, reason: 'stub reported delivered:false', at: Date.now() }; }, + resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'stub', channelId } }), resolveLocus: () => 'world:commons', - getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, + getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { @@ -173,7 +186,7 @@ describe('explicit prose routing', () => { assert.equal(bounceNotices.length, 1, 'exactly one bounce notice'); assert.equal(routed.length, 1, 'only the prefixed resend delivered'); - assert.equal(routed[0]!.locus, 'discord:dm:99', 'delivered to the DM the model named'); + assert.deepEqual(routed[0]!.locus, pin('discord:dm:99'), 'delivered to the DM the model named, on its server'); assert.equal(routed[0]!.text, original, 'clipboard substitution is verbatim'); await framework.stop(); @@ -195,8 +208,8 @@ describe('explicit prose routing', () => { await framework.runUntilIdle(); assert.deepEqual(routed, [ - { text: 'first segment', locus: 'chan-alpha' }, - { text: 'second segment, no prefix', locus: 'chan-alpha' }, + { text: 'first segment', locus: pin('chan-alpha') }, + { text: 'second segment, no prefix', locus: pin('chan-alpha') }, ], 'prefix stripped on wire; sticky target carries the second segment'); await framework.stop(); @@ -240,7 +253,7 @@ describe('explicit prose routing', () => { assert.equal(membrane.calls.length, 2, 'a second inference ran without any external event'); assert.deepEqual(routed.map((r) => r.text), ['step one done, continuing', 'step two done']); - assert.deepEqual(routed.map((r) => r.locus), ['chan-alpha', 'chan-alpha']); + assert.deepEqual(routed.map((r) => r.locus), [pin('chan-alpha'), pin('chan-alpha')]); await framework.stop(); }); @@ -291,7 +304,7 @@ describe('explicit prose routing', () => { assert.equal(stream.receivedToolResults.length, 1); const result = JSON.stringify(stream.receivedToolResults[0]); assert.ok(result.includes('destination'), 'help text served as tool result'); - assert.deepEqual(routed, [{ text: 'got it', locus: 'chan-alpha' }]); + assert.deepEqual(routed, [{ text: 'got it', locus: pin('chan-alpha') }]); await framework.stop(); }); @@ -308,8 +321,8 @@ describe('explicit prose routing', () => { await framework.runUntilIdle(); assert.deepEqual(routed, [ - { text: 'part one for the channel', locus: 'chan-alpha' }, - { text: 'part two, just for you\nwith a second line\n>> quoted arrow stays in body', locus: 'discord:dm:99' }, + { text: 'part one for the channel', locus: pin('chan-alpha') }, + { text: 'part two, just for you\nwith a second line\n>> quoted arrow stays in body', locus: pin('discord:dm:99') }, ], 'two envelopes, two destinations; quoted arrow not split'); await framework.stop(); @@ -323,12 +336,12 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'A message meant for the café.', locus: 'discord:g:cafe' }]); + assert.deepEqual(routed, [{ text: 'A message meant for the café.', locus: pin('discord:g:cafe') }]); const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content) .filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.includes(authored), 'source/author view retains exact envelope'); - assert.ok(texts.includes('[delivered] plain speech → discord:g:cafe'), 'canonical destination receipt enters context'); + assert.ok(texts.includes('[delivered] plain speech → (stub / discord:g:cafe)'), 'canonical destination receipt enters context, server named'); await framework.stop(); }); @@ -338,7 +351,8 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'Ordinary field speech.', locus: 'world:commons' }]); + // The route's exact place: its channel, at the root (MCPL RFC-011). + assert.deepEqual(routed, [{ text: 'Ordinary field speech.', locus: { channelId: 'world:commons', threadId: null } }]); await framework.stop(); }); @@ -371,8 +385,8 @@ describe('explicit prose routing', () => { trigger(framework); await framework.runUntilIdle(); assert.deepEqual(routed, [ - { text: 'first segment', locus: 'discord:g:cafe' }, - { text: 'second segment, no repeated envelope', locus: 'discord:g:cafe' }, + { text: 'first segment', locus: pin('discord:g:cafe') }, + { text: 'second segment, no repeated envelope', locus: pin('discord:g:cafe') }, ]); await framework.stop(); }); @@ -388,7 +402,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'body follows', locus: 'discord:g:cafe' }]); + assert.deepEqual(routed, [{ text: 'body follows', locus: pin('discord:g:cafe') }]); await framework.stop(); }); @@ -402,7 +416,7 @@ describe('explicit prose routing', () => { const routed = stubRegistry(framework); trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'first', locus: 'discord:g:cafe' }, { text: 'recovered', locus: 'world:commons' }]); + assert.deepEqual(routed, [{ text: 'first', locus: pin('discord:g:cafe') }, { text: 'recovered', locus: pin('world:commons') }]); await framework.stop(); }); @@ -418,7 +432,7 @@ describe('explicit prose routing', () => { trigger(framework); await framework.runUntilIdle(); assert.equal(routed.some(r => r.text.includes('plain text must remain suppressed')), false); - assert.deepEqual(routed.filter(r => r.text !== 'fails'), [{ text: 'first', locus: 'discord:g:cafe' }, { text: 'recovered', locus: 'world:commons' }]); + assert.deepEqual(routed.filter(r => r.text !== 'fails'), [{ text: 'first', locus: pin('discord:g:cafe') }, { text: 'recovered', locus: pin('world:commons') }]); const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.some(t => t.includes('[prose-routing]') && t.includes('>>>#channel {{unsent}}'))); @@ -443,7 +457,15 @@ describe('explicit prose routing', () => { const cm = framework.getAgent('assistant')!.getContextManager(); const texts = cm.getAllMessages().flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.includes(authored)); - assert.ok(texts.some(t => t.startsWith('[delivered] nothing') && t.includes('suppressed'))); + assert.ok(texts.some(t => t.startsWith('[delivered] nothing') && /2 plain-speech segment\(s\) held as drafts d-[a-z2-9]{5}, d-[a-z2-9]{5}/.test(t))); + // The held words are the envelope's body, not its routing syntax; the + // destination the resident wrote stays with the draft as a note. + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ text: string; note?: string; reason: string }> } }) + .proseDrafts.open('assistant').reverse(); + assert.deepEqual(drafts.map(d => [d.text, d.note, d.reason]), [ + ['wrong-locus duplicate', 'written for >>>#cafe', 'explicit-send'], + ['tool completed', undefined, 'explicit-send'], + ]); await framework.stop(); }); diff --git a/test/fixtures/record-journal-kill.mjs b/test/fixtures/record-journal-kill.mjs new file mode 100644 index 000000000..e0c04d802 --- /dev/null +++ b/test/fixtures/record-journal-kill.mjs @@ -0,0 +1,18 @@ +// Child process for record-journal.test.ts: commit branch state, append a +// journal entry that asserts it, then die without sync or close. +// node record-journal-kill.mjs +// mode "asserting": append with afterCommittedState (the barrier under test). +import { JsStore } from '@animalabs/chronicle'; +import { RecordJournal } from '../../src/record-journal.js'; + +const [storePath, mode] = process.argv.slice(2); +const store = JsStore.openOrCreate({ path: storePath }); +try { + store.registerState({ id: 'journal-test/tree', strategy: 'tree' }); +} catch { + // registered by the parent's setup +} +const journal = new RecordJournal(store, { type: 'journal-test/entry' }); +store.treeSet('journal-test/tree', 'committed.txt', { blobHash: 'c'.repeat(64), size: 1, mode: 0o644 }); +journal.append({ asserts: 'committed.txt' }, { afterCommittedState: mode === 'asserting' }); +process.kill(process.pid, 'SIGKILL'); diff --git a/test/fixtures/speech-route-mcpl-server.mjs b/test/fixtures/speech-route-mcpl-server.mjs new file mode 100644 index 000000000..9e3976eab --- /dev/null +++ b/test/fixtures/speech-route-mcpl-server.mjs @@ -0,0 +1,304 @@ +// MCPL fixture for speech routing, drafts and provenance tests. +// +// Registers the channels in CHANNELS (JSON array of {id,label,publishTarget?}; +// two Discord guild channels by default) and, on command, sends inbound +// traffic over BOTH MCPL lanes. Each channel declares its MCPL RFC-011 +// publish target: its own publishTarget ('exact' | 'root' | null for none), +// else PUBLISH_TARGET (default 'root'). channels/publish follows RFC-011 as a +// server that implements it: a threadId is honored or refused, never ignored, +// and a delivery echoes where it posted. A DM's channel (discord:dm:) is +// registered with its declaration (DM_TARGET, default 'root') just before +// the DM is pushed, as an RFC-011 connector registers its DM channels; +// DM_TARGET=none leaves it to the host's lazy registration, undeclared. +// The tool names in TOOLS (JSON array) +// are listed, so a test can see which connector tools a notice names. +// The TEST appends one JSON object per line to COMMAND_PATH: +// {"op":"incoming","channelId":…,"messageId":…,"mode":"ambient"|"addressed", +// "text":…, "threadId"?, "metadata"?, "eventId"?, "coalesce"?} +// {"op":"dm","eventId":…,"authorId":…,"authorName"?,"rawChannelId":…,"text":…, +// "content"? (content blocks, instead of text; [] included), "origin"?} +// {"op":"push","eventId":…,"origin"?,"tags"?,"text":…,"featureSet"?, +// "content"? (content blocks, instead of text; [] included)} +// {"op":"rename","channelId":…,"label":…} (channels/changed) +// {"op":"publish-mode","mode":"delivered"|"not-delivered"|"no-receipt"|"error"|"hang"|"no-echo"|"wrong-echo"} +// {"op":"declare","channelId":…,"target":"exact"|"root"|null} (channels/changed) +// {"op":"history","channelId":…,"history":[ChannelIncomingMessage…]} +// Host-side channels/publish and channels/open calls are recorded to +// STATUS_PATH as JSONL ({event:'publish', channelId, text, messageId, +// threadId}; threadId absent when the request carried none). +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 DEFAULT_TARGET = process.env.PUBLISH_TARGET === 'none' ? null : (process.env.PUBLISH_TARGET ?? 'root'); +for (const c of CHANNELS) if (c.publishTarget === undefined) c.publishTarget = DEFAULT_TARGET; +const TOOLS = process.env.TOOLS ? JSON.parse(process.env.TOOLS) : []; +const DM_TARGET = process.env.DM_TARGET === 'none' ? null : (process.env.DM_TARGET ?? 'root'); +const registeredDms = new Set(); +const descriptor = (c) => ({ + id: c.id, type: 'discord', label: c.label, direction: 'bidirectional', + ...(c.publishTarget ? { capabilities: { publish: { target: c.publishTarget } } } : {}), +}); + +const log = (event, extra = {}) => { + if (!statusPath) return; + appendFileSync(statusPath, JSON.stringify({ event, ...extra }) + '\n'); +}; +const send = (message) => process.stdout.write(JSON.stringify(message) + '\n'); +const reply = (id, result) => send({ jsonrpc: '2.0', id, result }); +const replyError = (id, message) => send({ jsonrpc: '2.0', id, error: { code: -32000, message } }); + +let nextId = 500; +let processedCommands = 0; +let publishMode = 'delivered'; +let posted = 0; +const histories = new Map(); + +const textBlock = (text) => [{ type: 'text', text }]; + +function pollCommands() { + if (!commandPath || !existsSync(commandPath)) return; + const lines = readFileSync(commandPath, 'utf8').split('\n').filter(Boolean); + for (const line of lines.slice(processedCommands)) { + processedCommands++; + const c = JSON.parse(line); + if (c.op === 'incoming') { + log('incoming-sent', { channelId: c.channelId, messageId: c.messageId }); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/incoming', + params: { + messages: [{ + channelId: c.channelId, + messageId: c.messageId, + ...(c.threadId ? { threadId: c.threadId } : {}), + ...(c.eventId ? { eventId: c.eventId } : {}), + ...(c.coalesce ? { coalesce: c.coalesce } : {}), + author: { id: c.authorId ?? 'U-other', name: c.authorName ?? 'someone' }, + timestamp: c.timestamp ?? new Date().toISOString(), + content: textBlock(c.text ?? ''), + ...(c.metadata ? { metadata: c.metadata } : {}), + tags: c.mode === 'addressed' + ? ['chat:mention', 'chat:addressed', 'chat:from-human'] + : ['chat:ambient', 'chat:from-human'], + }], + }, + }); + } else if (c.op === 'dm') { + const dmChannel = `discord:dm:${c.rawChannelId}`; + if (DM_TARGET && !registeredDms.has(dmChannel)) { + registeredDms.add(dmChannel); + const dm = { id: dmChannel, label: `DM: ${c.authorName ?? 'antra'}`, publishTarget: DM_TARGET }; + CHANNELS.push(dm); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { + added: [{ + ...descriptor(dm), + metadata: { channelType: 'dm', recipientName: c.authorName ?? 'antra', ...(c.authorId ? { recipientId: c.authorId } : {}) }, + }], + }, + }); + } + log('dm-sent', { eventId: c.eventId }); + send({ + jsonrpc: '2.0', + 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: c.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: c.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: [descriptor(channel ?? { id: c.channelId, label: c.label, publishTarget: DEFAULT_TARGET })], + }, + }); + log('renamed', { channelId: c.channelId, label: c.label }); + } else if (c.op === 'declare') { + const channel = CHANNELS.find((x) => x.id === c.channelId); + if (channel) channel.publishTarget = c.target; + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { updated: [descriptor(channel ?? { id: c.channelId, label: c.channelId, publishTarget: c.target })] }, + }); + log('declared', { channelId: c.channelId, target: c.target }); + } else if (c.op === 'publish-mode') { + publishMode = c.mode; + log('publish-mode', { mode: c.mode }); + } 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) => ({ ...descriptor(c), initiallyOpen: true })), + }, + }); + return; + } + if (msg.method === 'channels/open') { + const channelId = msg.params?.channelId; + const channel = CHANNELS.find((x) => x.id === channelId) ?? { id: channelId, label: channelId, publishTarget: DEFAULT_TARGET }; + const history = msg.params?.history ? histories.get(channelId) : undefined; + log('channel-opened', { channelId, history: !!history }); + reply(msg.id, { + channel: descriptor(channel), + ...(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; + const targeted = msg.params && 'threadId' in msg.params; + const threadId = msg.params?.threadId; + const placed = targeted ? { threadId } : {}; + // RFC-011, as a server that implements it: never ignore a threadId. + if (targeted) { + const declared = CHANNELS.find((x) => x.id === channelId)?.publishTarget ?? null; + const why = threadId !== null && (typeof threadId !== 'string' || threadId === '') + ? 'invalid threadId' + : !declared + ? 'this channel declares no publish target' + : declared === 'root' && threadId !== null + ? 'this channel has no threads' + : undefined; + if (why) { + log('publish-refused', { channelId, text, ...placed, reason: why }); + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: false, reason: why }); + return; + } + } + if (publishMode === 'hang') { + log('publish-hung', { channelId, text }); + return; // never answered: the host's request times out + } + if (publishMode === 'error') { + log('publish-error', { channelId, text }); + if (msg.id !== undefined && msg.id !== null) replyError(msg.id, 'connector exploded'); + return; + } + if (publishMode === 'not-delivered') { + log('publish-refused', { channelId, text, ...placed }); + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: false }); + return; + } + const messageId = `posted-${++posted}`; + log('publish', { channelId, text, messageId, ...placed }); + if (msg.id !== undefined && msg.id !== null) { + const echo = publishMode === 'no-echo' + ? {} + : publishMode === 'wrong-echo' + ? { threadId: threadId === null ? 'some-other-thread' : null } + : placed; + reply(msg.id, publishMode === 'no-receipt' ? {} : { delivered: true, messageId, ...echo }); + } + return; + } + if (msg.method === 'tools/list') { + reply(msg.id, { + tools: TOOLS.map((name) => ({ name, description: `fixture tool ${name}`, inputSchema: { type: 'object', properties: {} } })), + }); + 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/fixtures/tune-out-mcpl-server.mjs b/test/fixtures/tune-out-mcpl-server.mjs index bdb8b6b95..57fc2b74d 100644 --- a/test/fixtures/tune-out-mcpl-server.mjs +++ b/test/fixtures/tune-out-mcpl-server.mjs @@ -109,6 +109,8 @@ rl.on('line', (line) => { label: 'noisy', direction: 'bidirectional', initiallyOpen: true, + // MCPL RFC-011: the channel posts exactly where it is asked. + capabilities: { publish: { target: 'root' } }, }], }, }); @@ -134,7 +136,9 @@ rl.on('line', (line) => { } if (msg.method === 'channels/publish') { log('publish', { channelId: msg.params?.channelId }); - if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true }); + // RFC-011: a delivery echoes the place it was asked for. + const placed = msg.params && 'threadId' in msg.params ? { threadId: msg.params.threadId } : {}; + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true, ...placed }); return; } if (msg.method === 'tools/list') { diff --git a/test/fixtures/wake-locus-mcpl-server.mjs b/test/fixtures/wake-locus-mcpl-server.mjs index 203b4e0e5..d109e8ab9 100644 --- a/test/fixtures/wake-locus-mcpl-server.mjs +++ b/test/fixtures/wake-locus-mcpl-server.mjs @@ -6,6 +6,9 @@ // TEST appends lines to COMMAND_PATH: // incoming // dm +// Every channel declares an MCPL RFC-011 publish target (root), and a DM's +// channel (discord:dm:) is registered with one just before the DM is +// pushed, as discord-mcpl registers its DM channels. // Host-side channels/publish and channels/open calls are recorded to // STATUS_PATH as JSONL. import { appendFileSync, existsSync, readFileSync } from 'node:fs'; @@ -27,6 +30,7 @@ const CHANNELS = [ ]; let nextId = 500; let processedCommands = 0; +const registeredDms = new Set(); function pollCommands() { if (!commandPath || !existsSync(commandPath)) return; @@ -56,6 +60,22 @@ function pollCommands() { }); } else if (kind === 'dm') { const [eventId, authorId, rawChannelId, ...words] = rest; + const dmChannel = `discord:dm:${rawChannelId}`; + if (!registeredDms.has(dmChannel)) { + registeredDms.add(dmChannel); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/changed', + params: { + added: [{ + id: dmChannel, type: 'discord', label: 'DM: antra', direction: 'bidirectional', + metadata: { channelType: 'dm', recipientName: 'antra', recipientId: authorId }, + capabilities: { publish: { target: 'root' } }, + }], + }, + }); + } log('dm-sent', { eventId }); send({ jsonrpc: '2.0', @@ -126,6 +146,8 @@ rl.on('line', (line) => { params: { channels: CHANNELS.map((c) => ({ id: c.id, type: 'discord', label: c.label, direction: 'bidirectional', initiallyOpen: true, + // MCPL RFC-011: every channel posts exactly where it is asked. + capabilities: { publish: { target: 'root' } }, })), }, }); @@ -142,7 +164,9 @@ rl.on('line', (line) => { } if (msg.method === 'channels/publish') { log('publish', { channelId: msg.params?.channelId }); - if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true }); + // RFC-011: a delivery echoes the place it was asked for. + const placed = msg.params && 'threadId' in msg.params ? { threadId: msg.params.threadId } : {}; + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true, ...placed }); return; } if (msg.method === 'tools/list') { diff --git a/test/helpers/coalescing-fixture.ts b/test/helpers/coalescing-fixture.ts index 2a2405683..14f46ab5b 100644 --- a/test/helpers/coalescing-fixture.ts +++ b/test/helpers/coalescing-fixture.ts @@ -44,7 +44,11 @@ export async function fixture(options: { server?: Record; frame } } }, serverInfo: { name: 'editor', version: '1' } }); } else if (m.method === 'featureSets/update') reply({ accepted: true }); else if (m.method === 'tools/list') reply({ tools: [] }); - else if (m.method === 'channels/publish') { published.push(m.params); if (m.id !== undefined) reply({ delivered: true }); } + else if (m.method === 'channels/publish') { + published.push(m.params); + // RFC-011: a delivery echoes the place it was asked for. + if (m.id !== undefined) reply({ delivered: true, ...('threadId' in (m.params ?? {}) ? { threadId: m.params.threadId } : {}) }); + } else if (m.method === 'channels/close') reply({ closed: true }); else if (m.method === 'channels/open') reply({ channel: { id: m.params.channelId, type: 'discord', label: m.params.channelId } }); else if (m.method === 'push/render') { renders.push(m.params); reply(await renderer(m.params)); } @@ -77,7 +81,14 @@ export async function fixture(options: { server?: Record; frame port: (wss.address() as AddressInfo).port, disconnect: () => socket.terminate(), online: (value: boolean) => { online = value; }, - register: (id = 'chat') => send('channels/register', { channels: [{ id, type: 'discord', label: id, metadata: { channelType: 'guild_text' } }] }), + /** Registers a channel; it declares an RFC-011 publish target (default + * `exact`: a thread posts into that thread), or none with `null`. */ + register: (id = 'chat', target: 'exact' | 'root' | null = 'exact') => send('channels/register', { + channels: [{ + id, type: 'discord', label: id, metadata: { channelType: 'guild_text' }, + ...(target ? { capabilities: { publish: { target } } } : {}), + }], + }), /** A coalesced channels/incoming message for platform message `m`. */ channel: (eventId: string, text: string, flags: Record = {}, channelId = 'chat', messageId = 'm') => ({ channelId, messageId, eventId, timestamp: TS, author: { id: 'u', name: 'User' }, tags: ['chat:mention'], diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts new file mode 100644 index 000000000..b68e0e495 --- /dev/null +++ b/test/inbound-source.test.ts @@ -0,0 +1,763 @@ +/** + * 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, + // The package's public digest: consumers import it from the root. + sourceBodyDigest, + PassthroughStrategy, + type InboundSource, + type Module, + type ModuleContext, + type ProcessEvent, + type ProcessState, + type EventResponse, + type ToolDefinition, + type ToolCall, + type ToolResult, +} from '../src/index.js'; +import { ContextManager } from '@animalabs/context-manager'; +import type { ContentBlock } from '@animalabs/membrane'; +import { MockMembrane } from './helpers/mock-membrane.js'; +import { canonicalJson } from '../src/mcpl/inbound-source.js'; +import { createHash } from 'node:crypto'; +import { fixture, eventually, TS } from './helpers/coalescing-fixture.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); +const ROOM = 'discord:g1:room'; +const RAW_DM = '1548000000000000001'; +/** Bytes that open with PNG's signature, which is all the store reads of an + * image. 14 bytes, so their base64 is padded and has a '/' in it. */ +const PNG = Buffer.from('89504e470d0a1a0afbefbeffffff', 'hex'); + +async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + 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('stamps the delivered body\'s digest and the stored copy\'s digest at ingestion', async () => { + command({ op: 'incoming', channelId: ROOM, messageId: 'm-d1', mode: 'ambient', text: 'digest me' }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-d1'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-d1')!; + const meta = message.metadata as Record; + // The unsharded framing receipts already hash: canonicalJson([blocks]). + const expected = createHash('sha256').update(canonicalJson([[{ type: 'text', text: 'digest me' }]])).digest('hex'); + assert.equal(meta.sourceBodyDigest, expected); + assert.equal(sourceBodyDigest([{ text: 'digest me', type: 'text' }]), expected, 'key order does not matter'); + // This lane stores the body as delivered: the stored copy hashes the same. + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content)); + assert.equal(meta.storedBodyDigest, meta.sourceBodyDigest); + // Outside the frozen admission envelope. + assert.equal((readInboundSource(message.metadata) as unknown as Record).sourceBodyDigest, undefined); + + // An edit through the supported path keeps the metadata, so only the + // stored digest can reveal that the copy no longer holds the delivery. + const cm = framework.getAgent('scout')!.getContextManager(); + cm.editMessage(message.id, [{ type: 'text', text: 'edited later' }]); + const edited = cm.getAllMessages().find((m) => m.id === message.id)!; + assert.equal((edited.metadata as Record).storedBodyDigest, meta.storedBodyDigest); + assert.notEqual(sourceBodyDigest(edited.content), meta.storedBodyDigest); + }); + + it('a message accepted while its channel was open but stored after it closed: the stored digest covers the invitation', async () => { + // Tessa-974's discriminator (room-220 #48282): the invitation is + // appended after ingestion stamped the delivered digest, at storage + // time, when the channel has closed in between. + const registry = (framework as unknown as { + channelRegistry: { handleChannelToolCall(name: string, input: unknown, caller: unknown): Promise; isChannelOpen(id: string): boolean }; + }).channelRegistry; + const fw = framework as unknown as { pushEvent(e: { type: string }): void }; + const held: Array<{ type: string }> = []; + const push = fw.pushEvent.bind(framework); + fw.pushEvent = (e) => { if (e.type === 'mcpl:channel-incoming') held.push(e); else push(e); }; + command({ op: 'incoming', channelId: ROOM, messageId: 'm-closed', mode: 'addressed', text: 'hello while open' }); + await waitFor(() => held.length > 0, 'incoming accepted and held'); + await registry.handleChannelToolCall('channel_close', { channelId: ROOM }, { kind: 'agent', agentName: 'scout' }); + assert.equal(registry.isChannelOpen(ROOM), false); + fw.pushEvent = push; + for (const e of held) push(e); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-closed'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-closed')!; + const meta = message.metadata as Record; + assert.equal(meta.channelInvitation, true, 'stored with the closed-channel invitation'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'the untouched copy matches its own witness'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'hello while open' }]), 'the delivered body alone'); + }); + + it('on the push lane, the delivered digest excludes host decoration and the stored digest covers it', async () => { + command({ op: 'dm', eventId: 'ev-dig', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst' }); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-dig'), 'dm stored'); + const message = storedWith((m) => m.eventId === 'ev-dig')!; + const meta = message.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'psst' }]), 'the adapter body alone'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored'); + // The DM's channel is closed, so the host appends its invitation. + assert.equal(message.content.length, 2); + assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the closed-channel invitation decorates the stored copy'); + }); + + it('a visibly-empty ordinary push is refused and stores nothing; an accepted push carries the host\'s digests, never the adapter\'s', async () => { + // Admission (main #236): content with nothing visible is refused with + // -32602 unless it is the exact silent-heartbeat marker or an RFC-006 + // retraction, so it never reaches storage and has no witnesses. Every + // push that is accepted is stored with the host's own digests, written + // over whatever its origin claimed. + const forged = { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }; + command({ op: 'push', eventId: 'empty-1', content: [], origin: { source: 'timer', ...forged } }); + command({ op: 'push', eventId: 'full-1', text: 'tick', origin: { source: 'timer', ...forged } }); + await waitFor(() => !!storedWith((m) => m.eventId === 'full-1'), 'accepted push stored'); + assert.equal(storedWith((m) => m.eventId === 'empty-1'), undefined, 'the empty push was refused at admission, before the next was handled'); + const message = storedWith((m) => m.eventId === 'full-1')!; + const meta = message.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'tick' }]), 'the delivered body, not the adapter\'s value'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored, not the adapter\'s value'); + }); + + it('a DM pushed into a closed channel carries the host\'s digests, never the adapter\'s: the delivered body, and the stored copy with its invitation', async () => { + command({ + op: 'dm', eventId: 'ev-forged', authorId: '134', authorName: 'antra', rawChannelId: RAW_DM, text: 'psst again', + origin: { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }, + }); + await waitFor(() => !!storedWith((m) => m.eventId === 'ev-forged'), 'dm stored'); + const message = storedWith((m) => m.eventId === 'ev-forged')!; + const meta = message.metadata as Record; + assert.equal(meta.channelInvitation, true, 'stored with the closed-channel invitation'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'psst again' }]), 'the delivered body, not the adapter\'s value'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored, not the adapter\'s value'); + assert.notEqual(meta.storedBodyDigest, meta.sourceBodyDigest, 'the invitation decorates the stored copy'); + }); + + it('a channels/incoming item carries the host\'s digests, never the adapter\'s', async () => { + command({ + op: 'incoming', channelId: ROOM, messageId: 'm-forged', mode: 'ambient', text: 'forged digests', + metadata: { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }, + }); + await waitFor(() => !!storedWith((m) => m.messageId === 'm-forged'), 'message stored'); + const message = storedWith((m) => m.messageId === 'm-forged')!; + const meta = message.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'forged digests' }]), 'the delivered body, not the adapter\'s value'); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content), 'exactly what was stored, not the adapter\'s value'); + }); + + it('an image the store relabels or re-encodes still matches both its digests once read back', async () => { + // slimepriestess's review of #252: the store takes an image's media type + // from its bytes and re-encodes its base64, so a digest of the blocks as + // handed to storage failed against the untouched copy read back. + const png = PNG.toString('base64'); + const pushes: Record = { + 'img-labeled': { type: 'image', data: png, mimeType: 'image/png' }, + 'img-mislabeled': { type: 'image', data: png, mimeType: 'image/jpeg' }, + 'img-wrapped': { type: 'image', data: `${png.slice(0, 11)}\n${png.slice(11)}`, mimeType: 'image/png' }, + }; + for (const [eventId, block] of Object.entries(pushes)) command({ op: 'push', eventId, content: [block] }); + await waitFor(() => Object.keys(pushes).every((id) => !!storedWith((m) => m.eventId === id)), 'images stored'); + for (const eventId of Object.keys(pushes)) { + const message = storedWith((m) => m.eventId === eventId)!; + const meta = message.metadata as Record; + const image = message.content.find((b) => b.type === 'image'); + assert.deepEqual(image, { type: 'image', source: { type: 'base64', data: png, mediaType: 'image/png' } }, + `${eventId}: stored as the bytes' own type, in canonical base64`); + assert.equal(sourceBodyDigest(message.content), meta.storedBodyDigest, `${eventId}: the untouched copy matches its stored digest`); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([image]), `${eventId}: the delivered body hashes as the image stored for it`); + } + }); + + it("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('stored digests when the store shards a body', () => { + /** A strategy whose store splits a body of more than one block into one + * shard per block, as adaptive ingress chunking does. */ + class ShardingStrategy extends PassthroughStrategy { + chunkIngressMessage(_participant: string, content: ContentBlock[]) { + if (content.length < 2) return null; + return { bodyGroupId: `group-${content.length}-${Date.now()}`, shards: content.map((block, shardIndex) => ({ content: [block], shardIndex })) }; + } + } + + it("every shard carries the whole body's digests, and no shard hashes to them; an unsharded copy matches its own", async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'inbound-shards-')); + const commandPath = join(tempDir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', strategy: new ShardingStrategy() }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [FIXTURE], + env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + try { + await framework.start(); + const command = (c: Record): void => appendFileSync(commandPath, JSON.stringify(c) + '\n'); + const blocks = [{ type: 'text', text: 'first part' }, { type: 'text', text: 'second part' }]; + command({ op: 'push', eventId: 'sharded', content: blocks }); + command({ op: 'push', eventId: 'whole', text: 'one block' }); + const stored = () => framework.getAgent('scout')!.getContextManager().getAllMessages(); + await waitFor(() => stored().some((m) => (m.metadata as { eventId?: string } | undefined)?.eventId === 'whole'), 'both pushes stored'); + const shards = stored().filter((m) => (m.metadata as { eventId?: string } | undefined)?.eventId === 'sharded'); + assert.equal(shards.length, 2, 'stored as two shards'); + assert.ok(shards[0]!.bodyGroupId && shards.every((m) => m.bodyGroupId === shards[0]!.bodyGroupId)); + assert.deepEqual(shards.map((m) => m.shardIndex), [0, 1]); + const whole = sourceBodyDigest(blocks); + for (const shard of shards) { + const meta = shard.metadata as Record; + assert.equal(meta.sourceBodyDigest, whole, 'the whole delivered body'); + assert.equal(meta.storedBodyDigest, whole, 'the whole body handed to storage'); + assert.notEqual(sourceBodyDigest(shard.content), whole, 'a shard alone is not the body'); + } + const one = stored().find((m) => (m.metadata as { eventId?: string } | undefined)?.eventId === 'whole')!; + assert.equal(one.bodyGroupId, undefined, 'a one-block body is not sharded'); + assert.equal(sourceBodyDigest(one.content), (one.metadata as Record).storedBodyDigest, 'an unsharded copy matches'); + } finally { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe('inbound source envelope under RFC-006 coalescing', () => { + const install = (framework: AgentFramework): InboundSource[] => { + const observed: InboundSource[] = []; + (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 }); + // The materialized copy is its own storage site: both digests are the + // host's, over the rendered body, not the deferred placeholder. + const copy = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('document_diff'))!; + const meta = copy.metadata as Record; + assert.equal(meta.storedBodyDigest, sourceBodyDigest(copy.content), 'exactly what was stored'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest(copy.content), 'the rendered body, undecorated'); + assert.notEqual(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'fallback_unavailable' }]), 'not the placeholder'); + }); + + it('a channel-scoped coalesced message belongs to its channel, frozen at acceptance', async (t) => { + 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 message deferred mid-turn and stored at the boundary still hashes to its stored digest', async (t) => { + const f = await fixture(); t.after(f.close); + await f.register('chat'); + const internals = f.framework as unknown as { activeTurnTokens: Map; flushDeferredWrites(label: string): Promise }; + internals.activeTurnTokens.set('agent', 100); + await f.send('channels/incoming', { messages: [f.channel('e1', 'deferred_words', { initial: true })] }); + assert.ok(!f.context().includes('deferred_words'), 'held until the boundary'); + internals.activeTurnTokens.delete('agent'); + await internals.flushDeferredWrites('test'); + const copy = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('deferred_words'))!; + const meta = copy.metadata as Record; + assert.equal(meta.storedBodyDigest, sourceBodyDigest(copy.content), 'the copy stored at the boundary matches'); + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: 'deferred_words' }]), 'the delivered body, unchanged'); + }); + + it("the conversation router's and a fork correction's storage sites carry the host's digests, never the adapter's", async (t) => { + const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } as never }); t.after(f.close); + await f.register(); + const forged = { sourceBodyDigest: 'adapter-value', storedBodyDigest: 'adapter-value' }; + const router = (f.framework as unknown as { conversationRouter: { getBinding(id: string): { agentName: string } | undefined; unbind(id: string): void } }).conversationRouter; + const check = (text: string, fork: string) => { + const copy = f.framework.getAgent(fork)!.getContextManager().getAllMessages().find((m) => JSON.stringify(m.content).includes(text)); + assert.ok(copy, `${text} stored in ${fork}`); + const meta = copy.metadata as Record; + assert.equal(meta.sourceBodyDigest, sourceBodyDigest([{ type: 'text', text }]), `${text}: the delivered body, not the adapter's value`); + assert.equal(meta.storedBodyDigest, sourceBodyDigest(copy.content), `${text}: exactly what was stored, not the adapter's value`); + }; + // Routed into a fresh fork by the conversation router. + await f.send('channels/incoming', { messages: [{ ...f.channel('c1', 'routed_original', { initial: true }), metadata: forged }] }); + const fork = router.getBinding('chat')!.agentName; + check('routed_original', fork); + // A correction follows the prior delivery into that fork, though the + // channel is no longer bound to it. + router.unbind('chat'); + await f.send('channels/incoming', { messages: [{ ...f.channel('c2', 'fork_correction'), metadata: forged }] }); + assert.equal(router.getBinding('chat'), undefined, 'no fresh fork was spawned for the correction'); + check('fork_correction', fork); + }); +}); + +describe('sourceBodyDigest hashes a body as the store keeps it', () => { + // Every agent stores through context-manager over chronicle. Whatever the + // store changes on the way in (an image's label, base64's spelling, fields + // it doesn't keep, a lone surrogate), the body read back must hash as it + // did when it was handed over, live and after the store is reopened. + const png = PNG.toString('base64'); + const image = (data: string, mediaType = 'image/png', extra: Record = {}) => + ({ type: 'image', source: { type: 'base64', data, mediaType }, ...extra }); + const media = (type: string, data: string, mediaType: string, extra: Record = {}) => + ({ type, source: { type: 'base64', data, mediaType }, ...extra }); + /** Each body, and whether the store hands it back changed. */ + const bodies: Record = { + 'a labeled image': { blocks: [image(png)], changed: false }, + 'PNG bytes labeled image/jpeg': { blocks: [image(png, 'image/jpeg')], changed: true }, + 'JPEG bytes labeled image/png': { blocks: [image(Buffer.from('ffd8ffe000104a464946', 'hex').toString('base64'))], changed: true }, + // One fixture per branch of the copied sniffer, each labeled as something + // else, so a context-manager that stops sniffing one fails here. + 'GIF87a bytes labeled image/png': { blocks: [image(Buffer.from('GIF87a\x01\x00\x01\x00\x00\x00\x00', 'latin1').toString('base64'))], changed: true }, + 'GIF89a bytes labeled image/jpeg': { blocks: [image(Buffer.from('GIF89a\x01\x00\x01\x00\x00\x00\x00', 'latin1').toString('base64'), 'image/jpeg')], changed: true }, + 'WebP bytes labeled image/png': { blocks: [image(Buffer.from('RIFF\x1a\x00\x00\x00WEBPVP8L\x0d\x00\x00\x00', 'latin1').toString('base64'))], changed: true }, + 'base64 with a newline in it': { blocks: [image(`${png.slice(0, 11)}\n${png.slice(11)}`)], changed: true }, + 'base64 with whitespace around it': { blocks: [image(` ${png}\n`)], changed: true }, + 'base64 without its padding': { blocks: [image(png.replace(/=+$/, ''))], changed: true }, + 'base64 with non-zero unused bits': { blocks: [image(png.replace(/8=$/, '9='))], changed: true }, + 'base64 in the URL-safe alphabet': { blocks: [image(png.replace(/\//g, '_'))], changed: true }, + // Bytes the store has no signature for keep their label. Labeled + // image/png, so a context-manager that learns to sniff one fails here. + 'SVG bytes labeled image/png': { blocks: [image(Buffer.from('').toString('base64'))], changed: false }, + 'BMP bytes labeled image/png': { blocks: [image(Buffer.from('424d3a0000000000000036000000', 'hex').toString('base64'))], changed: false }, + 'AVIF bytes labeled image/png': { blocks: [image(Buffer.from('0000001c6674797061766966000000006d696631', 'hex').toString('base64'))], changed: false }, + 'an image whose source is neither base64 nor a URL': { blocks: [{ type: 'image', source: { type: 'file', data: png, mediaType: 'image/png' } }], changed: true }, + 'an image with fields the store drops': { blocks: [image(png, 'image/png', { sourceUrl: 'https://cdn.example/a.png', tokenEstimate: 85 })], changed: true }, + 'an image by URL, its fields kept': { blocks: [{ type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png' }, sourceUrl: 'https://cdn.example/a.png' }], changed: false }, + 'audio, wrapped, with a duration': { blocks: [media('audio', 'SUQz\nAwAAAAAA', 'audio/mpeg', { duration: 3 })], changed: true }, + 'a document with a filename': { blocks: [media('document', 'JVBERi0xLjQ=', 'application/pdf', { filename: 'a.pdf' })], changed: true }, + 'text with lone surrogates': { blocks: [{ type: 'text', text: 'cut \ud83d here, \ude00 there, \ud83d\ud83d twice' }], changed: true }, + 'a key that is not well-formed': { blocks: [{ type: 'text', text: 'x', rawItem: { 'k\ud800': 1, kept: 2 } }], changed: true }, + 'a relabeled image with a text decoration': { blocks: [image(png, 'image/jpeg'), { type: 'text', text: '[invitation]' }], changed: true }, + 'a video with a duration': { blocks: [media('video', 'AAAAGGZ0eXBtcDQy', 'video/mp4', { duration: 2 })], changed: true }, + // No media type: an image takes its bytes' own (an image without a + // signature, or other media, is refused at write: see below). + 'an image without a media type': { blocks: [{ type: 'image', source: { type: 'base64', data: png } }], changed: true }, + 'an image whose media type is undefined': { blocks: [{ type: 'image', source: { type: 'base64', data: png, mediaType: undefined } }], changed: true }, + 'a text block with an undefined field': { blocks: [{ type: 'text', text: 'x', note: undefined }], changed: true }, + // Only a non-URL image is blobbed: a URL image keeps even a stray data + // string as given, and storedBlock must not re-encode it. + 'an image by URL with a stray data string': { blocks: [{ type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png', data: 'not base64 at all' } }], changed: false }, + }; + + it('every body hashes alike as handed to the store and as read back, live and after reopening', async () => { + const dir = mkdtempSync(join(tmpdir(), 'stored-digest-')); + const path = join(dir, 'store.chronicle'); + try { + const strategy = () => new PassthroughStrategy(); + const ids = new Map(); + let cm = await ContextManager.open({ path, strategy: strategy() }); + for (const [name, { blocks }] of Object.entries(bodies)) { + ids.set(name, cm.addMessage('user', blocks as never, { storedBodyDigest: sourceBodyDigest(blocks) })); + } + const check = (when: string) => { + for (const [name, { blocks, changed }] of Object.entries(bodies)) { + const back = cm.getMessage(ids.get(name)!)!; + if (changed) assert.notDeepEqual(back.content, blocks, `${when}, ${name}: the store hands it back changed`); + else assert.deepEqual(back.content, blocks, `${when}, ${name}: the store hands it back as it was`); + assert.equal(sourceBodyDigest(back.content), (back.metadata as Record).storedBodyDigest, `${when}, ${name}: it still hashes as handed over`); + } + }; + check('live'); + cm.close(); + cm = await ContextManager.open({ path, strategy: strategy() }); + check('reopened'); + cm.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a block the store refuses is refused at write, and a blob_ref is digested as given, which its copy read back does not match', async () => { + const dir = mkdtempSync(join(tmpdir(), 'stored-digest-refused-')); + try { + const cm = await ContextManager.open({ path: join(dir, 'store.chronicle'), strategy: new PassthroughStrategy() }); + try { + const svg = Buffer.from('').toString('base64'); + for (const [what, block] of [ + ['a document without a media type', { type: 'document', source: { type: 'base64', data: 'JVBERi0xLjQ=' } }], + ['an image without a media type whose bytes have no signature', { type: 'image', source: { type: 'base64', data: svg } }], + // The store's append can't represent an undefined array element. + ['an array with an undefined element', { type: 'text', text: 'x', parts: ['a', undefined] }], + ] as const) { + assert.throws(() => cm.addMessage('user', [block] as never), Error, `${what}: refused at write`); + } + // A blob_ref names media the store already holds; it is resolved to + // inline media on read, so the copy read back hashes differently. + const held = cm.addMessage('user', [image(png)] as never); + const ref = cm.getMessageWindow(0, cm.getMessageCount(), { resolveBlobs: false }).messages + .find((m) => m.id === held)!.content[0] as { type: string }; + assert.equal(ref.type, 'blob_ref'); + const given = [ref]; + const id = cm.addMessage('user', given as never, { storedBodyDigest: sourceBodyDigest(given) }); + const back = cm.getMessage(id)!; + assert.deepEqual(back.content, [image(png)], 'resolved on read'); + assert.notEqual(sourceBodyDigest(back.content), (back.metadata as Record).storedBodyDigest, 'the documented mismatch'); + } finally { + cm.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a block the store keeps as given is digested as given', () => { + const plain = (blocks: unknown[]) => createHash('sha256').update(canonicalJson([blocks])).digest('hex'); + // Only a non-URL image is blobbed, so a URL image's stray data string is + // neither decoded nor re-encoded. + const urlImage = { type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png', data: 'not base64 at all' } }; + assert.equal(sourceBodyDigest([urlImage]), plain([urlImage])); + assert.equal(sourceBodyDigest([{ type: 'text', text: 'x' }]), plain([{ type: 'text', text: 'x' }])); + // An undefined array element is written as null, as JSON writes it (the + // store refuses such a body, above, so no stored copy holds one). + assert.equal( + sourceBodyDigest([{ type: 'text', text: 'x', parts: ['a', undefined] }]), + sourceBodyDigest([{ type: 'text', text: 'x', parts: ['a', null] }]), + ); + }); + + it('what the store keeps still changes the digest', () => { + const digest = (blocks: unknown[]) => sourceBodyDigest(blocks); + assert.notEqual(digest([image(png)]), digest([image(Buffer.from([...PNG, 0]).toString('base64'))]), 'other bytes'); + const svg = Buffer.from('').toString('base64'); + assert.notEqual(digest([image(svg, 'image/svg+xml')]), digest([image(svg, 'image/png')]), 'a label the store keeps'); + assert.notEqual(digest([media('audio', 'SUQzAwAAAAAA', 'audio/mpeg')]), digest([media('audio', 'SUQzAwAAAAAA', 'audio/wav')]), 'an audio label'); + assert.notEqual(digest([{ type: 'text', text: 'a' }]), digest([{ type: 'text', text: 'b' }]), 'other text'); + assert.equal(digest([image(png, 'image/jpeg')]), digest([image(png)]), 'a label the store replaces is not part of the body'); + }); +}); diff --git a/test/inference-exhaustion-rewind.test.ts b/test/inference-exhaustion-rewind.test.ts index 4462ab2d7..5c0e35fc8 100644 --- a/test/inference-exhaustion-rewind.test.ts +++ b/test/inference-exhaustion-rewind.test.ts @@ -48,6 +48,8 @@ function makeHarness(opts?: { maxRewinds?: number; messages?: any[] }) { fw.rewindEpisode = new Map(); fw.lastInferenceAt = new Map(); // upstream last-inference tracking; noteInferenceExhausted writes it fw.pendingRequests = []; + // The turn the retry continues answered a channel (its speech route). + fw.turnRoutes = new Map([['cairn', { route: { kind: 'channel', serverId: 'discord', channelId: 'discord:g:c1', replyTo: 'm-1', origin: 'trigger' } }]]); fw.traceListeners = []; fw.inferenceFailureEscalationThreshold = 3; fw.agents = new Map([['cairn', { @@ -89,6 +91,13 @@ test('at the threshold, invalid_request failures trigger an automatic rewind + r // A retry was queued so the rewound history is verified immediately. assert.equal(fw.pendingRequests.length, 1); assert.equal(fw.pendingRequests[0].reason, 'inference-failure-rewind-retry'); + // It continues the failed turn: when its batch starts, it names the + // conversation that turn answered, so its words go there. + assert.equal(fw.pendingRequests[0].continuesTurn, true); + const started = fw.continuationFor(fw.pendingRequests[0]); + assert.equal(started.namesConversation, true); + assert.deepEqual(started.routeCandidates.map((c: any) => [c.conversation.channelId, c.addressed, c.messageId]), + [['discord:g:c1', true, 'm-1']]); assert.ok(errs.some((e) => e.includes('[inference-rewind]') && e.includes('auto-quarantined'))); }); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 4091cf35a..62d9cfbd4 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -175,7 +175,17 @@ test('vector 43: a mixed-lane edit keeps the stable reply target', async (t) => await f.framework.runUntilIdle(); await f.send('push/event', { ...f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' }), origin: { messageId: 'm', authorId: 'u', authorName: 'User', threadId: 'thread' } }); await f.turn(); + // Both lanes name one conversation — the same channel, thread and message — + // so the turn has a single reply target, never a hold between "two"; and + // the channel posts into a named thread (RFC-011 exact), so the reply goes + // into that thread, not the channel root. + const sources = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .map((m) => m.metadata?.inboundSource as { channelId?: string; threadId?: string; messageId?: string } | undefined) + .filter((s) => s !== undefined) + .map((s) => [s!.channelId, s!.threadId, s!.messageId]); + assert.deepEqual(sources, [['chat', 'thread', 'm'], ['chat', 'thread', 'm']]); assert.equal(f.published.at(-1)?.channelId, 'chat'); + assert.equal((f.published.at(-1) as { threadId?: unknown } | undefined)?.threadId, 'thread'); }); test('vectors 13/15: a channel-scoped push needs current channel authority and a declared channel', async (t) => { @@ -556,6 +566,24 @@ test('G1: malformed content on a coalesced channel item fails that item only', a assert.equal(badPush.error.code, -32602); }); +test('the trigger (the gate) is decided only for an occurrence the coalescer admits: never for a retry, a retraction or a refused one', async (t) => { + const decided: string[] = []; + const f = await fixture({ server: { shouldTriggerInference: (_content: string, metadata: Record) => { + decided.push(String(metadata.eventId ?? metadata.messageId)); + return false; + } } }); t.after(f.close); + await f.register(); + await f.send('push/event', f.params('p1', 'first_words', { initial: true })); + await f.send('push/event', f.params('p1', 'first_words', { initial: true })); + await f.send('push/event', f.params('p2', '', { retract: true })); + const refused = await f.send('push/event', { ...f.params('p3', 'x'), coalesce: { key: 'document', channelId: 'nowhere' } }); + assert.ok(refused.error, 'a channel-scoped push for an unknown channel is refused'); + await f.send('channels/incoming', { messages: [f.channel('c1', 'channel_words', { initial: true })] }); + await f.send('channels/incoming', { messages: [f.channel('c2', '', { retract: true })] }); + // The channel message is named by its platform message id (m). + assert.deepEqual(decided, ['p1', 'm'], 'once for each admitted occurrence, at delivery'); +}); + test('G3/G5: a deferred channel-scoped push renders with its feature set and wakes with its channel', async (t) => { const f = await fixture({ server: { shouldTriggerInference: () => true } }); t.after(f.close); await f.register(); await f.send('push/event', { ...f.params('1', 'fallback', { deferred: true, channelId: 'chat', key: 'activity' }), origin: { authorId: 'u', authorName: 'User' } }); @@ -1025,3 +1053,92 @@ for (const reader of ['other', 'agent'] as const) test(`R18 (${reader} read it): assert.equal(del.result.coalesce.outcome, 'noted'); assert(f.context().includes('read_by_one') && f.context().includes('deletion_notice') && !f.context().includes('later_edit')); }); + +test('a producer retry of a coalesced message is no new delivery: the fallback stays where the last message came in', async (t) => { + // Clement-2260's sequence (#256 review, room-448 #74615): coalesced A, then + // ordinary B, then A retried with the same event id. + const f = await fixture(); t.after(f.close); + f.alwaysRespond(); + await f.register('A'); + await f.register('B'); + const registry = (f.framework as unknown as { + channelRegistry: { getLastInbound(): { channelId: string; threadId?: string } | null }; + }).channelRegistry; + const original = { messages: [{ ...f.channel('A-1', 'first channel', { initial: true }, 'A', 'message-A'), threadId: 't-9' }] }; + const accepted = await f.send('channels/incoming', original); + assert.equal(accepted.result.results[0].accepted, true); + await f.framework.runUntilIdle(); + assert.deepEqual([registry.getLastInbound()?.channelId, registry.getLastInbound()?.threadId], ['A', 't-9'], + 'a fresh admission counts, with its thread'); + + const b = f.channel('B-1', 'latest real message', {}, 'B', 'message-B') as Record; + delete b.coalesce; + delete b.eventId; + await f.send('channels/incoming', { messages: [b] }); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B'); + const calls = f.membrane.calls.length; + + const retry = await f.send('channels/incoming', original); + assert.deepEqual(retry.result, accepted.result, 'the producer retry gets its cached acknowledgement'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'the retry starts no turn'); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a retry is no delivery: the place stays B'); + + // A withdrawal admitted afresh is never the place either. + await f.register('C'); + const withdrawal = await f.send('channels/incoming', { messages: [{ ...f.channel('C-1', '', { retract: true }, 'C', 'message-C'), content: [] }] }); + assert.equal(withdrawal.result.results[0].accepted, true, 'the withdrawal was admitted'); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a withdrawal is never the place: the place stays B'); + + // Nor is a coalesced reaction or deletion, nor a push admitted in a + // channel's scope: only a channel message is where the last message came in. + await f.register('D'); + for (const [eventId, tags] of [['D-1', ['chat:reaction']], ['D-2', ['chat:deleted']]] as const) { + const item = await f.send('channels/incoming', { + messages: [{ ...f.channel(eventId, 'not a message', { initial: true }, 'D', `message-${eventId}`), tags: [...tags] }], + }); + assert.equal(item.result.results[0].accepted, true, `${tags[0]} was admitted`); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B', `a coalesced ${tags[0]} is never the place`); + } + const push = await f.send('push/event', f.params('P-1', 'a push about D', { channelId: 'D', key: 'message:p-1', initial: true })); + assert.equal(push.result.coalesce.outcome, 'first', 'the channel-scoped push was admitted afresh'); + await f.framework.runUntilIdle(); + assert.equal(registry.getLastInbound()?.channelId, 'B', 'a push is never the place'); + + const publishes = f.published.length; + await (f.framework as unknown as { startAgentStream(a: unknown, tr?: unknown): Promise }) + .startAgentStream(f.framework.getAgent('agent')!, { agentName: 'agent', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await f.framework.runUntilIdle(); + await eventually(() => f.published.length > publishes, 'the scheduled note is published'); + assert.equal(f.published.at(-1)!.channelId, 'B', 'the scheduled note lands where the last message came in'); +}); + +test('a deferred push batch whose origin names only a thread names a conversation, so its turn takes no fallback', async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => true } }); t.after(f.close); + f.alwaysRespond(); + await f.register('A'); + const a = f.channel('A-1', 'a real message', {}, 'A', 'message-A') as Record; + delete a.coalesce; + delete a.eventId; + await f.send('channels/incoming', { messages: [a] }); + await f.framework.runUntilIdle(); + const turnRoutes = (f.framework as unknown as { turnRoutes: Map }).turnRoutes; + assert.equal(turnRoutes.get('agent')?.route?.channelId, 'A'); + const calls = f.membrane.calls.length; + const publishes = f.published.length; + // A deferred batch wakes through the coalescer's batch wake, which names + // its conversation from the batch (not the ordinary push path). + await f.send('push/event', { ...f.params('T-1', 'in a thread', { deferred: true, initial: true }), origin: { threadId: 't-1' } }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'the push woke a turn'); + assert.deepEqual(turnRoutes.get('agent'), { route: null, named: true }, 'its thread names a conversation: no fallback'); + assert.equal(f.published.length, publishes, 'nothing went where the last message came in'); + // A deferred batch naming nothing takes the fallback. + await f.send('push/event', f.params('T-2', 'a nudge', { key: 'nudge', deferred: true, initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 2); + assert.deepEqual([turnRoutes.get('agent')?.route?.channelId, turnRoutes.get('agent')?.route?.origin], ['A', 'fallback']); +}); diff --git a/test/mcpl-request-outcome.test.ts b/test/mcpl-request-outcome.test.ts new file mode 100644 index 000000000..1b0f45647 --- /dev/null +++ b/test/mcpl-request-outcome.test.ts @@ -0,0 +1,92 @@ +/** + * McplRequestError: a request that produced no result says which of three + * things happened, as a fact rather than a message to parse. + * not-sent refused before anything was written (connection closed) + * error-response the server answered with a JSON-RPC error (code, data kept) + * no-response the request was handed to the transport and no answer + * came back (timeout, or the connection closed while + * awaiting): it may or may not have reached the server + * Callers that act in the world (publishing speech, placing reactions) need + * the difference: only `not-sent` proves the server never saw the request. + */ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { McplServerConnection, McplRequestError } from '../src/mcpl/server-connection.js'; +import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; + +// Stdio server: answers initialize; tools/call 'fail' gets a JSON-RPC error +// with data, 'stuck' gets nothing, 'exit' kills the process mid-request. +const SERVER = ` +let buf = ''; +process.stdin.on('data', (c) => { + buf += c.toString('utf8'); + let i; + while ((i = buf.indexOf('\\n')) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + const out = (o) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, ...o }) + '\\n'); + if (m.method === 'initialize') out({ result: { capabilities: {} } }); + else if (m.method === 'tools/call' && m.params.name === 'fail') { + out({ error: { code: -32001, message: 'POSTED: part 1 of 2', data: { posted: ['111'] } } }); + } else if (m.method === 'tools/call' && m.params.name === 'exit') { + process.exit(3); + } + // 'stuck': deliberately no response. + } +}); +setInterval(() => {}, 1 << 30); +`; + +const HOST_CAPS: McplHostCapabilities = { version: '0.4', pushEvents: true, contextHooks: { beforeInference: true }, featureSets: true }; +const config = (overrides?: Partial): McplServerConfig => + ({ id: 'outcome', command: process.execPath, args: ['-e', SERVER], ...overrides }); + +let connection: McplServerConnection | null = null; +afterEach(async () => { + await connection?.close(); + connection = null; +}); + +const rejectsWith = async (promise: Promise, outcome: McplRequestError['outcome']): Promise => { + let caught: unknown; + await assert.rejects(promise, (err: unknown) => { caught = err; return true; }); + assert.ok(caught instanceof McplRequestError, `expected McplRequestError, got ${String(caught)}`); + assert.equal((caught as McplRequestError).outcome, outcome); + return caught as McplRequestError; +}; + +test('an error response is error-response, with its code and data kept', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'error-response'); + assert.equal(err.code, -32001); + assert.deepEqual(err.data, { posted: ['111'] }); + assert.match(err.message, /returned error for tools\/call: \[-32001\] POSTED: part 1 of 2/); +}); + +test('a request nobody answers is no-response, and the message is unchanged', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 200 }), HOST_CAPS); + const err = await rejectsWith(connection.sendToolsCall('stuck', {}), 'no-response'); + assert.match(err.message, /did not respond to tools\/call/); +}); + +test('a connection lost while awaiting is no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 10_000 }), HOST_CAPS); + await rejectsWith(connection.sendToolsCall('exit', {}), 'no-response'); +}); + +test('a request on a closed connection is not-sent', async () => { + connection = await McplServerConnection.connect(config(), HOST_CAPS); + await connection.close(); + const err = await rejectsWith(connection.sendToolsCall('fail', {}), 'not-sent'); + assert.match(err.message, /Cannot send request: connection to "outcome" is closed/); + connection = null; +}); + +test('McplRequestError is an Error that keeps its message and name', () => { + const err = new McplRequestError('boom', 'not-sent'); + assert.ok(err instanceof Error); + assert.equal(err.name, 'McplRequestError'); + assert.equal(err.message, 'boom'); +}); diff --git a/test/mcpl-request-timeout.test.ts b/test/mcpl-request-timeout.test.ts index 2778469e3..8ebf050a6 100644 --- a/test/mcpl-request-timeout.test.ts +++ b/test/mcpl-request-timeout.test.ts @@ -8,7 +8,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { McplServerConnection } from '../src/mcpl/server-connection.js'; +import { McplRequestError, McplServerConnection } from '../src/mcpl/server-connection.js'; import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; // Stdio server: answers initialize and tools/list, but swallows tools/call — @@ -100,6 +100,24 @@ test('requestTimeoutMs: 0 disables the per-request watchdog', async () => { assert.equal(settled, true); }); +test('requestTimeoutMs: 0 leaves channels/publish its own deadline, whose expiry is a no-response', async () => { + connection = await McplServerConnection.connect(config({ requestTimeoutMs: 0 }), HOST_CAPS); + connection.ready(); + // The default is 60 s; the test shortens it rather than wait. + (connection as unknown as { publishDeadlineMs: number }).publishDeadlineMs = 150; + const started = Date.now(); + await assert.rejects( + connection.sendChannelsPublish({ conversationId: 'c', channelId: 'x', content: [{ type: 'text', text: 'hi' }] }), + (err: Error) => { + assert.ok(err instanceof McplRequestError); + assert.equal(err.outcome, 'no-response'); + assert.match(err.message, /did not respond to channels\/publish .*within 150ms/); + return true; + }, + ); + assert.ok(Date.now() - started < 5000); +}); + test('the timeout error warns the model the tool may have completed server-side', async () => { connection = await McplServerConnection.connect(config({ requestTimeoutMs: 150 }), HOST_CAPS); connection.ready(); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157bc..f0e41e51a 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -31,14 +31,24 @@ import type { ToolCall, ToolResult, } from '../src/index.js'; -import { AgentFramework } from '../src/index.js'; -import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; -import type { ContentBlock } from '@animalabs/membrane'; +import { AgentFramework, readInboundSource } from '../src/index.js'; +import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +import type { ContentBlock, StreamEvent } from '@animalabs/membrane'; // --------------------------------------------------------------------------- // Test module: tools that optionally emit a mid-turn external message // --------------------------------------------------------------------------- +/** Until `cond` holds, bounded: wait for the event a check stands for, + * never a fixed pause a loaded machine can outrun. */ +async function until(cond: () => boolean, what: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + class RobotModule implements Module { readonly name = 'robot'; framework: AgentFramework | null = null; @@ -51,6 +61,8 @@ class RobotModule implements Module { /** Full metadata override for the interjected message (wins over * interjectionChannelId when set) — e.g. reaction tags. */ interjectionMetadata: Record | null = null; + /** More arrivals pushed in the same round, after the interjection. */ + moreInterjections: Array<{ text: string; metadata: Record }> = []; /** Delay tool completion so live routing deterministically precedes it. */ toolDelayMs = 0; @@ -89,17 +101,16 @@ class RobotModule implements Module { await new Promise((r) => setTimeout(r, this.toolDelayMs)); } if (this.interjection) { - const text = this.interjection; + const arrivals = [ + { + text: this.interjection, + metadata: this.interjectionMetadata ?? (this.interjectionChannelId ? { channelId: this.interjectionChannelId } : {}), + }, + ...this.moreInterjections, + ]; this.interjection = null; - this.framework!.pushEvent({ - type: 'external-message', - source: 'test', - content: text, - metadata: this.interjectionMetadata - ?? (this.interjectionChannelId - ? { channelId: this.interjectionChannelId } - : {}), - } as unknown as ProcessEvent); + this.moreInterjections = []; + for (const arrival of arrivals) this.pushArrival(arrival.text, arrival.metadata); // Give the run loop a beat to process the queued message while this // tool round is still pending (pendingAssistantBlocks non-empty), so // it lands in deferredMessages before the tool-result event. @@ -108,6 +119,31 @@ class RobotModule implements Module { return { success: true, data: { ok: true, tool: call.name } }; } + /** One arrival: a channel's message on the MCPL lane, which carries its + * envelope (only the framework writes one, so a module's metadata + * can't), else an external message from the local surface. */ + private pushArrival(text: string, metadata: Record): void { + const { inboundSource, ...rest } = metadata as { inboundSource?: Record } & Record; + if (inboundSource?.kind === 'channel') { + this.framework!.pushEvent({ + type: 'mcpl:channel-incoming', + serverId: inboundSource.serverId, + channelId: inboundSource.channelId, + messageId: inboundSource.messageId ?? 'm-arrival', + ...(inboundSource.threadId ? { threadId: inboundSource.threadId } : {}), + author: { id: 'antra', name: 'Antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: rest, + ...(Array.isArray(rest.tags) ? { tags: rest.tags } : {}), + triggerInference: false, + inboundSource, + } as unknown as ProcessEvent); + return; + } + this.framework!.pushEvent({ type: 'external-message', source: 'test', content: text, metadata } as unknown as ProcessEvent); + } + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { if (event.type === 'external-message') { const text = String((event as { content?: unknown }).content); @@ -129,24 +165,40 @@ class RobotModule implements Module { } } -/** Minimal ChannelRegistry stub covering everything driveStream touches. */ -function stubChannelRegistry(framework: AgentFramework) { +/** Minimal ChannelRegistry stub covering everything driveStream touches. + * By default every agent has a HOME route (a fork-style deliberate route, + * `chan-live-N`); `{ home: false }` leaves routes to the turn's wake. */ +function stubChannelRegistry(framework: AgentFramework, opts: { home?: boolean } = {}) { const routed: Array<{ text: string; locus: string | null }> = []; let locusCalls = 0; const explicit: Record = { resolveLocus: () => { + if (opts.home === false) return null; locusCalls++; return `chan-live-${locusCalls}`; }, - routeSpeech: async (_agent: string, text: string, locus?: string | null) => { - routed.push({ text, locus: locus ?? null }); - // Mirror the real registry's outcome shape (delivery receipts read it). - return locus ? { delivered: true, channelId: locus } : null; + routeSpeech: async (_agent: string, text: string, target?: string | null | { channelId: string; threadId?: string | null }) => { + const locus = target && typeof target === 'object' ? target.channelId : target ?? null; + const threadId = target && typeof target === 'object' ? target.threadId : undefined; + routed.push({ text, locus }); + // Mirror the real registry's outcome shape (delivery receipts read it), + // the thread included when the publish was placed in one. + return locus ? { delivered: true, channelId: locus, ...(threadId ? { threadId } : {}) } : null; + }, + // Hybrid `>>>#name` targets resolve to `chan-name` and deliver through + // the registry's outcome-returning form, recorded like routeSpeech. + resolveProseTarget: (spec: string) => ({ channelId: spec.startsWith('#') ? `chan-${spec.slice(1)}` : spec, label: spec }), + deliverSpeech: async (_agent: string, text: string, target?: string | null | { channelId: string }) => { + const locus = target && typeof target === 'object' ? target.channelId : target ?? null; + routed.push({ text, locus }); + return { status: 'delivered', destination: { serverId: 'stub', channelId: locus ?? '' }, at: Date.now() }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // Every channel declares an MCPL RFC-011 publish target, so it can be a + // route (a route is only ever a place the framework publishes to exactly). + publishTarget: () => 'root', }; // Everything else driveStream/stop touches (startTyping, stopTyping, // stopAll, ensureChannelRegistered, ...) becomes a no-op via Proxy so the @@ -157,6 +209,40 @@ function stubChannelRegistry(framework: AgentFramework) { return routed; } +/** A channel item's source envelope, as admission stamps it. */ +function envelope(channelId: string, messageId = 'm-arrival'): Record { + return { + kind: 'channel', lane: 'channels/incoming', serverId: 'srv', binding: 'srv', + channelId, messageId, acceptedAt: Date.now(), + }; +} + +/** Wake the agent from one conversation: the turn infers its route there. */ +function triggerFromChannel(framework: AgentFramework, channelId = 'chan-A'): void { + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'srv', timestamp: Date.now(), + channelId, addressed: true, + routeCandidates: [{ + conversation: { kind: 'channel', serverId: 'srv', channelId }, + addressed: true, messageId: 'm-trigger', at: Date.now(), + }], + }); +} + +/** The framework's engagement record (turnEngagement), as its send paths write and the hold reads it. */ +function engage(framework: AgentFramework) { + return framework as unknown as { + noteSendEngagement(agent: string, status: 'delivered' | 'unknown' | 'failed', place: { serverId?: string; channelId: string; threadId?: string | null }): void; + noteChannelEngagement(agent: string, serverId: string | undefined, channelId: string): void; + engagementOf(agent: string, c: { kind: 'channel'; serverId?: string; channelId: string; threadId?: string }): string | undefined; + }; +} + +function heldDrafts(framework: AgentFramework): Array<{ text: string; reason: string; note?: string }> { + return (framework as unknown as { proseDrafts: { open(agent: string): Array<{ text: string; reason: string; note?: string }> } }) + .proseDrafts.open('assistant'); +} + // --------------------------------------------------------------------------- describe('present while acting', () => { @@ -335,15 +421,14 @@ describe('present while acting', () => { await framework.stop(); }); - it('an ADDRESSED mid-turn injection re-pins the locus for subsequent prose', async () => { - // 2026-07-31 Mythos misroutes (n=6): someone explicitly addresses the - // agent from another channel mid-turn (mention / reply / DM); the - // agent's trailing prose answers THEM but the frozen pin delivered it - // to the stale locus (the hospital scene into antra's DM, and its - // mirror). Addressed conversational injections — the chat:addressed - // signal turn-START batching already prefers — now move the pin, with a - // one-line [routing] notice riding the same injection batch so window - // and wire agree. Ambient injections still cannot (previous test). + it('an ADDRESSED mid-turn arrival from another conversation holds an inferred route: later prose becomes a draft', async () => { + // 2026-07-31 misroutes (n=6): someone addresses the agent from another + // channel mid-turn; the agent's next unaddressed words could answer + // either conversation. The old re-pin guessed the newcomer and sent + // words still meant for the first conversation into the second + // (shelf-355). Now the inferred route is HELD: unaddressed speech becomes + // drafts for the rest of the turn, with a one-line [routing] notice + // riding the same injection batch so window and wire agree. membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Station four, proceeding.' }, { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, @@ -353,10 +438,12 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'hey, quick question over here?'; - module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; const notices: string[] = []; framework.onTrace((event) => { @@ -364,38 +451,165 @@ describe('present while acting', () => { if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); }); - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - // Round-1 prose predates the injection and belongs to the old pin; - // trailing prose follows the addressed speaker. - assert.deepEqual(routed, [ - { text: 'Station four, proceeding.', locus: 'chan-live-1' }, - { text: 'Answering the person who addressed me.', locus: 'discord:dm:antra' }, - ]); - // Boot-baseline announcement + the mid-turn re-pin notice. - assert.equal(notices.length, 2, 'the re-pin produced exactly one routing notice'); - // The notice rode the injection batch to the live stream (window == wire). - const injectedBatches = membrane.lastStream!.receivedToolResultOptions - .map((o) => o?.injectedMessages ?? []); - const wired = injectedBatches.flat().map((m) => JSON.stringify(m.content)); + // Round-1 prose predates the arrival and goes to the turn's route; the + // trailing prose is held, never guessed into either conversation. + assert.deepEqual(routed, [{ text: 'Station four, proceeding.', locus: 'chan-A' }]); + const [held] = heldDrafts(framework); + assert.equal(held?.text, 'Answering the person who addressed me.'); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', /\(srv \/ chan-A\); \(srv \/ discord:dm:antra\)/); + // Turn-start announcement + the mid-turn hold notice. + assert.equal(notices.length, 2, 'the hold produced exactly one routing notice'); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('quick question')), 'the addressed message itself was injected'); assert.ok( - wired.some((s) => s.includes('quick question')), - 'the addressed message itself was injected', + wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn') && t.includes('instead of going to (srv / chan-A)')), + 'the hold notice was injected alongside it', ); + + await framework.stop(); + }); + + it("an addressed mid-turn arrival in a channel a conversation fork owns doesn't hold another agent's route", async () => { + // The fork rule, as at turn start: a fork's channel is that fork's + // conversation, so it never competes for another agent's words. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still answering chan-A.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('fork-antra', 'discord:dm:antra'); + module.toolDelayMs = 25; + module.interjection = 'hey, quick question over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Station four, proceeding.', locus: 'chan-A' }, + { text: 'Still answering chan-A.', locus: 'chan-A' }, + ], 'the route was never held'); + assert.deepEqual(heldDrafts(framework), []); + const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); + assert.equal(turn?.hold, undefined); + // The addressed arrival did reach the turn: it was injected mid-turn. + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('quick question')), 'the addressed message was injected'); + await framework.stop(); + }); + + it('a fallback route is held by an addressed mid-turn arrival, and the notice says it was only where the last message came in', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Checking the logs.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Answering the person who addressed me.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + // Where the last message came in: the fallback of a wake that names none. + (framework as unknown as { channelRegistry: Record }).channelRegistry.getLastInbound = + () => ({ serverId: 'srv', channelId: 'chan-A' }); + module.toolDelayMs = 25; + module.interjection = 'hey, quick question over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + + // A heartbeat-like wake: it names no conversation. + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'timer', source: 'timer', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Checking the logs.', locus: 'chan-A' }], 'the fallback carried the first round'); + const [held] = heldDrafts(framework); + assert.equal(held?.text, 'Answering the person who addressed me.'); + assert.equal(held?.reason, 'ambiguous'); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); assert.ok( - wired.some((s) => s.includes('[routing] The conversation moved to discord:dm:antra')), - 'the re-pin notice was injected alongside it', + wired.some((t) => t.includes('[routing] (srv / discord:dm:antra) addressed you mid-turn. Your unaddressed plain speech was going to (srv / chan-A), where the last message had come in when your reply began (a continuation of it keeps that place), because nothing in the wake that began it named a conversation.')), + 'the hold notice says what the fallback was, not that it could be answering it', ); + await framework.stop(); + }); + + it('a mid-turn hold names every conversation that suspends the route in that round', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Answering someone.' }] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra') }; + module.moreInterjections = [{ + text: 'and here too?', + metadata: { channelId: 'discord:dm:ada', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:ada', 'm-ada') }, + }]; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + const [held] = heldDrafts(framework); + assert.equal(held?.reason, 'ambiguous'); + assert.match(held?.note ?? '', /\(srv \/ chan-A\); \(srv \/ discord:dm:antra\); \(srv \/ discord:dm:ada\)/, 'all three conversations'); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('(srv / discord:dm:antra) addressed you mid-turn; and (srv / discord:dm:ada) addressed you mid-turn') + && t.includes('any of these conversations')), wired.join('\n')); + + await framework.stop(); + }); + + it('a mid-turn arrival from the local surface reads as addressing the resident, tagged or not', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Answering the console.' }] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'a word from the console'; + module.interjectionMetadata = {}; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('[routing] test (the local surface that messaged you) addressed you mid-turn')), wired.join('\n')); + const [held] = heldDrafts(framework); + assert.equal(held?.text, 'Answering the console.'); await framework.stop(); }); - it('a follow-up in a channel the agent explicitly engaged this turn re-pins', async () => { - // 2026-07-31 n=7 (q's #portables reply): the agent explicitly sent into - // a channel this turn; someone replies there WITHOUT a mention (ambient - // by tag, addressed by context). The engaged-channel leg moves the pin; - // trailing prose answering them follows. + it('a follow-up in a channel the agent explicitly engaged this turn holds the route', async () => { + // 2026-07-31 n=7 (q's #portables reply): the agent explicitly sent into a + // channel this turn; someone replies there WITHOUT a mention. Their + // follow-up competes for the agent's unaddressed words, so it holds them. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -404,41 +618,41 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'I need to swap out this eye screen'; module.interjectionMetadata = { channelId: 'discord:guild:portables', tags: ['chat:ambient', 'chat:from-human'], + inboundSource: envelope('discord:guild:portables'), }; - // Simulate an explicit send into #portables earlier in THIS turn (the - // production record happens on the MCPL send path; the harness module's - // tools are not MCPL, so seed the turn-scoped set mid-turn — after the + // Simulate an explicit send into #portables earlier in THIS turn, as a + // delivered resend or channel_publish records it (the harness module's + // tools are not MCPL, so seed the turn's engagement mid-turn — after the // turn-start clear, before the injection boundary). const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { - (framework as unknown as { - turnEngagedChannels: Map>; - }).turnEngagedChannels.set('assistant', new Set(['discord:guild:portables'])); + engage(framework).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'discord:guild:portables', threadId: null }); return origHandle(call); }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - assert.deepEqual(routed, [ - { text: 'Good plan on both counts.', locus: 'discord:guild:portables' }, - ], 'trailing prose followed the human reply into the engaged channel'); + assert.deepEqual(routed, [], 'neither conversation is guessed'); + assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Good plan on both counts.', 'ambiguous']]); + // The notice names the actual cause: a continued conversation, not an address. + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('[routing] (srv / discord:guild:portables), where you sent a message this turn, continued mid-turn'))); + assert.ok(!wired.some((t) => t.includes('addressed you mid-turn'))); await framework.stop(); }); - it('an agent-resident follow-up in an engaged channel re-pins too (no author-kind filter)', async () => { + it('an agent-resident follow-up in an engaged channel holds it too (no author-kind filter)', async () => { // Fleet participants include agent-residents — "bot" by Discord flag, - // full conversational participants in fact. Their in-flow replies in a - // channel the agent just engaged move the pin exactly like anyone - // else's (antra, 2026-07-31: the author-kind distinction is fragile and - // wrong for this fleet). + // full conversational participants in fact (antra, 2026-07-31). membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -447,34 +661,33 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = '*from the table* one receipt for the case, since I have a dated instance'; module.interjectionMetadata = { channelId: 'discord:guild:hospital', tags: ['chat:ambient', 'chat:from-bot'], + inboundSource: envelope('discord:guild:hospital'), }; const origHandle = module.handleToolCall.bind(module); module.handleToolCall = async (call) => { - (framework as unknown as { - turnEngagedChannels: Map>; - }).turnEngagedChannels.set('assistant', new Set(['discord:guild:hospital'])); + // As a connector's own send tool records it: the channel. + engage(framework).noteChannelEngagement('assistant', 'srv', 'discord:guild:hospital'); return origHandle(call); }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - assert.deepEqual(routed, [ - { text: 'Continuing with the colleague.', locus: 'discord:guild:hospital' }, - ], 'the agent-resident follow-up moved the pin like any participant'); + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework).map((d) => d.reason), ['ambiguous']); await framework.stop(); }); - it('ambient chatter in a channel the agent did NOT engage still cannot move the pin', async () => { - // The Cairn-protection boundary, restated for the engaged-channel era: - // no mention, no engagement this turn → the pin holds. + it('ambient chatter in a channel the agent did NOT engage leaves the route alone', async () => { + // The Cairn-protection boundary: no mention, no engagement this turn → + // the route holds steady and speech keeps going where it was going. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -483,28 +696,39 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'unrelated lounge chatter'; module.interjectionMetadata = { channelId: 'discord:guild:lounge', tags: ['chat:ambient', 'chat:from-human'], + inboundSource: envelope('discord:guild:lounge'), }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); - assert.deepEqual(routed, [ - { text: 'Continuing my report.', locus: 'chan-live-1' }, - ], 'un-engaged ambient left the pin alone'); + assert.deepEqual(routed, [{ text: 'Continuing my report.', locus: 'chan-A' }], 'un-engaged ambient changed nothing'); + assert.deepEqual(heldDrafts(framework), []); + // It did reach the suspension check: injected into the turn, as a + // conversational item with its envelope (a route candidate), and the + // check left the route unheld. + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('unrelated lounge chatter')), 'the chatter was injected into the turn'); + const chatter = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('unrelated lounge chatter'))!; + assert.equal(readInboundSource(chatter.metadata)?.kind, 'channel', 'with its envelope: a candidate the check weighs'); + const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); + assert.equal(turn?.route?.channelId, 'chan-A'); + assert.equal(turn?.hold, undefined, 'the check found nothing to suspend'); await framework.stop(); }); - it('an addressed injection from the CURRENT locus does not chatter a notice', async () => { - // Same-channel addressed input is the ordinary case (the person the - // agent is already talking to sends another message mid-turn): the pin - // is already right, so no re-pin and — critically for KV — no notice. + it('an addressed arrival in the SAME conversation keeps the route and adds no notice', async () => { + // The person the agent is already talking to sends another message + // mid-turn: nothing competes, so no hold and — for KV — no notice. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); @@ -513,10 +737,10 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'and one more thing'; - module.interjectionMetadata = { channelId: 'chan-live-1', tags: ['chat:addressed'] }; + module.interjectionMetadata = { channelId: 'chan-A', tags: ['chat:addressed'], inboundSource: envelope('chan-A') }; const notices: string[] = []; framework.onTrace((event) => { @@ -524,11 +748,75 @@ describe('present while acting', () => { if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); }); + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Continuing right here.', locus: 'chan-A' }]); + assert.equal(notices.length, 1, 'only the turn-start announcement'); + // Someone else speaking in the same conversation doesn't move the reply + // edge: the route still answers the message that woke the turn. + const view = (framework as unknown as { speechRouteView(agent: string): Record }).speechRouteView('assistant'); + assert.deepEqual(view, { kind: 'channel', serverId: 'srv', channelId: 'chan-A', replyTo: 'm-trigger' }); + + await framework.stop(); + }); + + it('hybrid: a >>> target set this turn survives a hold — deliberate pins are never held', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Opening note.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'And a follow-up for alpha.' }, + ] as ContentBlock[])); + + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'hybrid.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', proseRouting: 'hybrid' }], + modules: [module], + }); + module.framework = framework; + const routed = stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Opening note.', locus: 'chan-alpha' }, + { text: 'And a follow-up for alpha.', locus: 'chan-alpha' }, + ], 'the sticky >>> target keeps carrying unprefixed prose through the hold'); + assert.deepEqual(heldDrafts(framework), []); + + await framework.stop(); + }); + + it('a deliberate route (a fork home) is never held by an arrival', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still answering at home.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); // home route chan-live-1 + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + trigger(framework); await framework.runUntilIdle(); - assert.deepEqual(routed, [{ text: 'Continuing right here.', locus: 'chan-live-1' }]); - assert.equal(notices.length, 1, 'only the boot-baseline announcement — no re-pin chatter'); + assert.deepEqual(routed, [{ text: 'Still answering at home.', locus: 'chan-live-1' }]); + assert.deepEqual(heldDrafts(framework), []); await framework.stop(); }); @@ -563,7 +851,7 @@ describe('present while acting', () => { await framework.stop(); }); - it('a re-pinned turn\'s receipt names both channels in delivery order', async () => { + it('a held turn\'s receipt names where its speech landed and the draft it held', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Station four, proceeding.' }, { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, @@ -573,12 +861,14 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - stubChannelRegistry(framework); + stubChannelRegistry(framework, { home: false }); module.toolDelayMs = 25; module.interjection = 'quick question over here?'; - module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); const cm = (framework as unknown as { @@ -589,7 +879,9 @@ describe('present while acting', () => { .filter((b) => b.type === 'text') .map((b) => b.text ?? '') .filter((t) => t.startsWith('[delivered]')); - assert.deepEqual(receipts, ['[delivered] plain speech → chan-live-1 · discord:dm:antra']); + assert.equal(receipts.length, 1); + assert.match(receipts[0]!, /^\[delivered\] plain speech → chan-A/); + assert.match(receipts[0]!, /held as draft d-[a-z2-9]{5}/); await framework.stop(); }); @@ -624,8 +916,13 @@ describe('present while acting', () => { .filter((b) => b.type === 'text') .map((b) => b.text ?? '') .filter((t) => t.startsWith('[delivered]')); + // The suppressed words are held as drafts, named in the receipt. + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ id: string; text: string }> } }) + .proseDrafts.open('assistant').reverse(); + assert.deepEqual(drafts.map((d) => d.text), ['the reply that silencing will eat', 'trailing prose, also suppressed (sticky)']); assert.deepEqual(receipts, [ - '[delivered] nothing — 2 plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)', + `[delivered] nothing — 2 plain-speech segment(s) held as drafts ${drafts.map((d) => d.id).join(', ')} ` + + '(not sent — drafts can resend them unchanged, or dismiss them)', ]); await framework.stop(); @@ -665,8 +962,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(); @@ -695,13 +996,11 @@ describe('present while acting', () => { await framework.stop(); }); - it('channel_open moves the pin mid-turn and announces in its own tool result', async () => { + it('channel_open sets the speech route mid-turn and announces it in its own tool result', async () => { // The agent's own deliberate open is the strongest "my next words go - // here" signal — stronger than any injection. The original 2026-07-21 - // Aria fix only set next-turn trigger state; the turn-frozen refactor - // silently regressed the reply-right-after-opening case. Now the pin - // moves immediately and the announcement rides the tool result itself - // (model-requested content: distance zero, safest role). + // here" signal: by default (setSpeechTarget) its unaddressed speech goes + // to the opened channel for the rest of the turn — replacing an inferred + // route, or a hold — and the announcement rides the tool result itself. membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, ] as ContentBlock[], 'tool_use')); @@ -710,27 +1009,78 @@ describe('present while acting', () => { ] as ContentBlock[])); const framework = await createFramework(); - const routed = stubChannelRegistry(framework); + const routed = stubChannelRegistry(framework, { home: false }); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); - trigger(framework); + triggerFromChannel(framework, 'chan-A'); await framework.runUntilIdle(); assert.deepEqual(routed, [ { text: 'Hello, observatory.', locus: 'discord:guild:observatory' }, ], 'prose right after channel_open lands in the opened channel'); - // The announcement rode the tool result the model saw. const results = membrane.lastStream!.receivedToolResults.flat() as Array<{ content?: unknown }>; assert.ok( - JSON.stringify(results).includes('plain speech now lands in this channel'), + JSON.stringify(results).includes('Your unaddressed plain speech now goes to discord:guild:observatory for the rest of this turn'), 'routing note present in the channel_open tool result', ); await framework.stop(); }); + it('channel_open with setSpeechTarget: false opens for reading and leaves the route where it was', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory', setSpeechTarget: false } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still talking to chan-A.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = + async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Still talking to chan-A.', locus: 'chan-A' }]); + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assert.ok(results.includes('Opened for reading. Your plain speech still goes to (srv / chan-A).'), results); + + await framework.stop(); + }); + + it('channel_open with setSpeechTarget: false and no route says speech is held as drafts', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory', setSpeechTarget: false } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Thinking out loud.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = + async () => ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); + + // A heartbeat-style wake: no conversation, so no route. + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'heartbeat', source: 'timer', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Thinking out loud.', 'no-destination']]); + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assert.ok(results.includes('You have no speech route, so unaddressed plain speech is held as drafts.')); + + await framework.stop(); + }); + it('reactions and system markers injected mid-turn do not clear send suppression', async () => { // A reaction (`chat:reaction` tag) or a `system: true` marker is not // conversational input: prose following an explicit send stays suppressed, @@ -781,11 +1131,12 @@ describe('present while acting', () => { await framework.stop(); }); - it('announces the outbound locus in the window only when it changes', async () => { - // Turn 1 (locus chan-live-1, e2e): one durable [routing] notice — the - // boot baseline. Then the announce-on-change logic directly (MockMembrane - // supports one turn per test): same locus → silence (steady state must - // not chatter — KV); new locus → exactly one more notice. + it('announces the speech route in the window only when it changes', async () => { + // Turn 1 (home route chan-live-1, e2e): one durable [routing] notice — + // the boot baseline. Then the announce-on-change logic directly + // (MockMembrane supports one turn per test): same route → silence + // (steady state must not chatter — KV); a new route, a hold, or no + // route → exactly one notice each. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }] as ContentBlock[])); const framework = await createFramework(); @@ -801,25 +1152,29 @@ describe('present while acting', () => { trigger(framework); await framework.runUntilIdle(); - assert.equal(notices.length, 1, 'first turn announced the boot-baseline locus'); + assert.equal(notices.length, 1, 'first turn announced the boot-baseline route'); + type Route = { route: unknown; hold?: unknown }; const announce = ( - framework as unknown as { - announceLocusIfChanged(agentName: string, locus: string | null): void; - } - ).announceLocusIfChanged.bind(framework); - - announce('assistant', 'chan-live-1'); - assert.equal(notices.length, 1, 'unchanged locus announced nothing'); - - announce('assistant', 'chan-B'); - assert.equal(notices.length, 2, 'changed locus announced exactly once'); - - announce('assistant', 'chan-B'); - assert.equal(notices.length, 2, 'steady state on the new locus stays silent'); - - announce('assistant', null); - assert.equal(notices.length, 3, 'losing the locus is announced too'); + framework as unknown as { announceRouteIfChanged(agentName: string, turn: Route): void } + ).announceRouteIfChanged.bind(framework); + const channel = (channelId: string, origin = 'trigger'): Route => + ({ route: { kind: 'channel', serverId: '', channelId, origin } }); + + announce('assistant', channel('chan-live-1', 'home')); + assert.equal(notices.length, 1, 'unchanged route announced nothing'); + announce('assistant', channel('chan-B')); + assert.equal(notices.length, 2, 'changed route announced exactly once'); + announce('assistant', channel('chan-B')); + assert.equal(notices.length, 2, 'steady state on the new route stays silent'); + const held: Route = { + route: null, + hold: { since: 'turn-start', conversations: [{ kind: 'channel', serverId: '', channelId: 'chan-B' }, { kind: 'surface', surface: 'tui' }] }, + }; + announce('assistant', held); + assert.equal(notices.length, 3, 'a held turn is announced'); + announce('assistant', { route: null }); + assert.equal(notices.length, 4, 'losing the route is announced too'); await framework.stop(); }); @@ -879,3 +1234,998 @@ describe('present while acting', () => { await framework.stop(); }); }); + +// --------------------------------------------------------------------------- +// Speech goes where the route stood when its words were written (#256 review) +// --------------------------------------------------------------------------- + +describe('speech follows the moment its words were written', () => { + let tempDir: string; + let membrane: MockMembrane; + let module: RobotModule; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + membrane = new MockMembrane(); + module = new RobotModule(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + async function createFramework(proseRouting?: 'hybrid'): Promise { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', ...(proseRouting ? { proseRouting } : {}) }], + modules: [module], + }); + module.framework = framework; + return framework; + } + + type Registry = Record; + const registryOf = (framework: AgentFramework): Registry => + (framework as unknown as { channelRegistry: Registry }).channelRegistry; + + /** channel_open succeeds for whatever channel it names. */ + function opensChannels(framework: AgentFramework): void { + registryOf(framework).handleChannelToolCall = async (name: string, input: { channelId?: string }) => + name === 'channel_open' ? { success: true, data: { channelId: input.channelId, opened: true } } : { success: true }; + } + + /** + * The registry method's first call waits until the result of tool call + * `callId` (a channel_open, a hold) is in, so the words it publishes are + * certainly still pending when that change lands: the race always + * happens. `pendingWhenItLanded()` says it did. + */ + function holdFirstUntil(framework: AgentFramework, method: 'routeSpeech' | 'deliverSpeech', callId: string): { pendingWhenItLanded: () => boolean | undefined } { + const registry = registryOf(framework); + const real = registry[method] as (...args: unknown[]) => Promise; + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + let pending = false; + let first = true; + let pendingWhenItLanded: boolean | undefined; + registry[method] = async (...args: unknown[]) => { + if (first) { + first = false; + pending = true; + await gate; + pending = false; + } + return real(...args); + }; + const f = framework as unknown as { pushEvent(e: unknown): void }; + const push = f.pushEvent.bind(framework); + f.pushEvent = (e: unknown) => { + push(e); + const ev = e as { type?: string; callId?: string }; + if (ev.type === 'tool-result' && ev.callId === callId) { + pendingWhenItLanded = pending; + release(); + } + }; + return { pendingWhenItLanded: () => pendingWhenItLanded }; + } + + /** The registry method's first call waits `ms` first, so later speech queues behind it. */ + function slowFirst(framework: AgentFramework, method: 'routeSpeech' | 'deliverSpeech', ms = 60): void { + const registry = registryOf(framework); + const fast = registry[method] as (...args: unknown[]) => Promise; + let first = true; + registry[method] = async (...args: unknown[]) => { + if (first) { + first = false; + await new Promise((r) => setTimeout(r, ms)); + } + return fast(...args); + }; + } + + /** Record the outgoing stream (MCPL §14.3): what each chunk and completion carried. */ + function recordStreams(framework: AgentFramework, refuse: (channelId: string, delta: string) => boolean = () => false) { + const chunks: Array<{ channelId: string; delta: string; threadId: unknown }> = []; + const completes: Array<{ channelId: string; text: string; threadId: unknown }> = []; + const registry = registryOf(framework); + registry.sendOutgoingChunk = (destination: { channelId: string }, _c: string, _i: string, _n: number, delta: string) => { + if (refuse(destination.channelId, delta)) return false; + chunks.push({ channelId: destination.channelId, delta, threadId: null }); + return true; + }; + registry.sendOutgoingComplete = (destination: { channelId: string }, _c: string, _i: string, text: string) => { + completes.push({ channelId: destination.channelId, text, threadId: null }); + }; + const streamedTo = (channelId: string): string => chunks.filter((c) => c.channelId === channelId).map((c) => c.delta).join(''); + return { chunks, completes, streamedTo }; + } + + /** An addressed arrival from another conversation, at the next tool call: it holds an inferred route. */ + function addressedArrivalElsewhere(): void { + module.toolDelayMs = 25; + module.interjection = 'hey, over here?'; + module.interjectionMetadata = { + channelId: 'discord:dm:antra', tags: ['chat:addressed'], inboundSource: envelope('discord:dm:antra'), + }; + } + + /** Wake the agent from a thread of `channelId`. */ + function triggerFromThread(framework: AgentFramework, channelId: string, threadId: string): void { + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'assistant', reason: 'mcpl:channel-incoming', source: 'srv', timestamp: Date.now(), + channelId, addressed: true, + routeCandidates: [{ + conversation: { kind: 'channel', serverId: 'srv', channelId, threadId }, + addressed: true, messageId: 'm-trigger', at: Date.now(), + }], + }); + } + + // ---- queued speech (review findings 2 and 4) ---------------------------- + + it('a channel_open landing while earlier words wait on the speech chain does not move them', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'First words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Second words, still for chan-A.' }, + { type: 'tool_use', id: 'c2', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Third words, for the observatory.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + const race = holdFirstUntil(framework, 'routeSpeech', 'c2'); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(race.pendingWhenItLanded(), true, 'the first words were still waiting when the open landed'); + assert.deepEqual(routed, [ + { text: 'First words.', locus: 'chan-A' }, + { text: 'Second words, still for chan-A.', locus: 'chan-A' }, + { text: 'Third words, for the observatory.', locus: 'discord:guild:observatory' }, + ]); + + await framework.stop(); + }); + + it('hybrid: words written under a `>>>` choice keep it though a channel_open lands before they are published', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Pinned words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'More for alpha.' }, + { type: 'tool_use', id: 'c2', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'After the open.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + const race = holdFirstUntil(framework, 'deliverSpeech', 'c2'); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(race.pendingWhenItLanded(), true, 'the pinned words were still waiting when the open landed'); + assert.deepEqual(routed, [ + { text: 'Pinned words.', locus: 'chan-alpha' }, + { text: 'More for alpha.', locus: 'chan-alpha' }, + { text: 'After the open.', locus: 'discord:guild:observatory' }, + ], 'the open supersedes the pin for words written after it, and only those'); + + await framework.stop(); + }); + + it('hybrid: a `>>>` pin keeps the server its target resolved to, and later speech publishes there by server', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Pinned words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'More for alpha.' }] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + stubChannelRegistry(framework, { home: false }); + const registry = registryOf(framework); + // The target resolves on one server; a pin that kept only the id would + // re-resolve it against any server that registers the same id later. + registry.resolveProseTarget = (spec: string) => ({ serverId: 'alpha-server', channelId: `chan-${spec.slice(1)}`, label: spec }); + const targets: unknown[] = []; + for (const method of ['deliverSpeech', 'routeSpeech'] as const) { + const real = registry[method] as (...args: unknown[]) => Promise; + registry[method] = async (agent: string, text: string, target: unknown) => { + targets.push(target); + return real(agent, text, target); + }; + } + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(targets, [ + { serverId: 'alpha-server', channelId: 'chan-alpha' }, + { serverId: 'alpha-server', channelId: 'chan-alpha' }, + ], 'the envelope and the speech after it both name the pinned server'); + + await framework.stop(); + }); + + it('a route chosen with channel_open is never held by a mid-turn arrival, addressed or not', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still for the observatory.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + // An addressed arrival from another conversation, during the move. + addressedArrivalElsewhere(); + const notices: string[] = []; + framework.onTrace((event) => { + const e = event as { type: string; source?: string }; + if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); + }); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('hey, over here?')), 'the addressed arrival was injected'); + assert.deepEqual(routed, [{ text: 'Still for the observatory.', locus: 'discord:guild:observatory' }]); + assert.deepEqual(heldDrafts(framework), [], 'nothing held'); + assert.equal(notices.length, 1, 'only the turn-start announcement: no hold notice'); + const turn = (framework as unknown as { turnRoutes: Map }).turnRoutes.get('assistant'); + assert.equal(turn?.route?.origin, 'open'); + assert.equal(turn?.hold, undefined); + + await framework.stop(); + }); + + it('hybrid: a channel_open that sets the speech route ends a `>>>skip_reply` suppression', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>skip_reply An aside to myself.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Hello, observatory.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Hello, observatory.', locus: 'discord:guild:observatory' }]); + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assert.ok(results.includes('Your unaddressed plain speech now goes to discord:guild:observatory')); + + await framework.stop(); + }); + + it('hybrid: a channel_open with setSpeechTarget: false leaves a `>>>skip_reply` suppression in place', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>skip_reply An aside to myself.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory', setSpeechTarget: false } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still private.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework), [], 'kept private, not drafted'); + + await framework.stop(); + }); + + it('hybrid: a framework retry continues the turn, keeping its `>>>` choice and engagement', async () => { + const pinnedRound = [ + { type: 'text', text: '>>>#alpha Pinned words.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[]; + class ToolThenErrorStream { + private events: StreamEvent[] = [{ + type: 'tokens', content: '>>>#alpha Pinned words.', meta: { type: 'text', visible: true, blockIndex: 0 }, + } as StreamEvent, { + type: 'tool-calls', + calls: [{ id: 'c1', name: 'robot--move', input: { dir: 'north' } }], + context: { rawText: '', preamble: '', depth: 0, previousResults: [], accumulated: '', roundContent: pinnedRound }, + } as StreamEvent]; + private wake: (() => void) | null = null; + private done = false; + get isWaitingForTools() { return !this.done; } + get pendingToolCallIds() { return this.done ? [] : ['c1']; } + get toolDepth() { return 0; } + provideToolResults(): void { this.done = true; this.events.push({ type: 'error', error: new Error('connection reset') } as StreamEvent); this.wake?.(); this.wake = null; } + cancel(): void { this.done = true; this.wake?.(); this.wake = null; } + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { while (this.events.length) yield this.events.shift()!; if (this.done) return; await new Promise((r) => { this.wake = r; }); } + } + } + let streams = 0; + const retrying = { + complete: async () => createMockResponse([{ type: 'text', text: 'unused' }] as ContentBlock[]), + streamYielding: () => streams++ === 0 + ? new ToolThenErrorStream() + : new MockYieldingStream([createMockResponse([{ type: 'text', text: 'Unprefixed, after the retry.' }] as ContentBlock[])]), + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'retry.chronicle'), + membrane: retrying as unknown as import('@animalabs/membrane').Membrane, + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', proseRouting: 'hybrid' }], + modules: [module], + errorPolicy: { maxRetries: 1, onInferenceError: (_e: Error, _a: string, attempt: number) => ({ retry: attempt < 1, delayMs: 0 }) }, + }); + module.framework = framework; + const routed = stubChannelRegistry(framework, { home: false }); + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + engage(framework).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }); + return origHandle(call); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(streams, 2, 'the failed stream was retried once'); + assert.deepEqual(routed, [ + { text: 'Pinned words.', locus: 'chan-alpha' }, + { text: 'Unprefixed, after the retry.', locus: 'chan-alpha' }, + ], 'the pin the failed attempt chose still carries unprefixed speech'); + assert.equal(engage(framework).engagementOf('assistant', { kind: 'channel', serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), 'delivered', + 'the send the failed attempt made still counts'); + + await framework.stop(); + }); + + // ---- engagement by conversation (review finding 6) ---------------------- + + it('a send whose place the host knows engages that thread only: ambient chatter in a sibling thread holds nothing', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still answering chan-A.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'unrelated chatter in another thread'; + module.interjectionMetadata = { + channelId: 'chan-T', tags: ['chat:ambient', 'chat:from-human'], inboundSource: { ...envelope('chan-T'), threadId: 't2' }, + }; + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + engage(framework).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }); + return origHandle(call); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Still answering chan-A.', locus: 'chan-A' }]); + assert.deepEqual(heldDrafts(framework), []); + + await framework.stop(); + }); + + it('engagement holds as certainly as the host knows it: there, maybe there, or somewhere in the channel', async () => { + const cases: Array<{ + name: string; + record: (f: AgentFramework) => void; + arrivalThread: string; + notice: string; + }> = [ + { + name: 'a delivered send, followed up in its own thread', + record: (f) => engage(f).noteSendEngagement('assistant', 'delivered', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), + arrivalThread: 't1', + notice: '(srv / chan-T, thread t1), where you sent a message this turn, continued mid-turn', + }, + { + name: 'an unconfirmed send, followed up in its own thread', + record: (f) => engage(f).noteSendEngagement('assistant', 'unknown', { serverId: 'srv', channelId: 'chan-T', threadId: 't1' }), + arrivalThread: 't1', + notice: '(srv / chan-T, thread t1), where you tried to send a message this turn (its delivery wasn\'t confirmed), continued mid-turn', + }, + { + name: 'a connector send tool, whose place in the channel the host can\'t know', + record: (f) => engage(f).noteChannelEngagement('assistant', 'srv', 'chan-T'), + arrivalThread: 't2', + notice: '(srv / chan-T, thread t2) continued mid-turn, in a channel you sent something into this turn (where in the channel isn\'t known)', + }, + ]; + for (const c of cases) { + membrane = new MockMembrane(); + module = new RobotModule(); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Which conversation is this for?' }, + ] as ContentBlock[])); + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + module.toolDelayMs = 25; + module.interjection = 'a follow-up'; + module.interjectionMetadata = { + channelId: 'chan-T', tags: ['chat:ambient', 'chat:from-human'], inboundSource: { ...envelope('chan-T'), threadId: c.arrivalThread }, + }; + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + c.record(framework); + return origHandle(call); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [], c.name); + assert.deepEqual(heldDrafts(framework).map((d) => d.reason), ['ambiguous'], c.name); + const wired = membrane.lastStream!.receivedToolResultOptions + .map((o) => o?.injectedMessages ?? []).flat().map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes(`[routing] ${c.notice}`)), `${c.name}: ${wired.join(' | ')}`); + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('channel_publish engages the conversation its outcome names when delivered or unknown, and nothing when it fails', async () => { + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + const dispatch = (framework as unknown as { dispatchChannelToolCall(agent: string, call: unknown): void; pushEvent(e: unknown): void }); + const answered = new Set(); + const push = dispatch.pushEvent.bind(framework); + dispatch.pushEvent = (e: unknown) => { + const ev = e as { type?: string; callId?: string }; + if (ev.type === 'tool-result') answered.add(ev.callId!); + else push(e); + }; + const outcomes: Array<[string, string, string | undefined]> = [ + ['delivered', 't1', 'delivered'], + ['unknown', 't2', 'unknown'], + ['failed', 't3', undefined], + ]; + for (const [status, threadId, expected] of outcomes) { + registryOf(framework).handleChannelToolCall = async () => ({ + success: status === 'delivered', + data: { delivered: status === 'delivered', status, serverId: 'srv', channelId: 'chan-T', threadId }, + }); + dispatch.dispatchChannelToolCall('assistant', { id: `p-${status}`, name: 'channel_publish', input: { channelId: 'chan-T', threadId, content: 'hi' } }); + await until(() => answered.has(`p-${status}`), `the ${status} publish's result`); + assert.equal(engage(framework).engagementOf('assistant', { kind: 'channel', serverId: 'srv', channelId: 'chan-T', threadId }), expected, status); + } + await framework.stop(); + }); + + // ---- supplied selectors (review finding 5) ------------------------------ + + it('channel_open refuses an invalid thread, server or setSpeechTarget before opening anything', async () => { + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + let opened = 0; + registryOf(framework).handleChannelToolCall = async () => { opened++; return { success: true, data: { channelId: 'chan-B' } }; }; + const results: Array<{ callId: string; result: { success: boolean; error?: string } }> = []; + const f = framework as unknown as { + dispatchChannelToolCall(agent: string, call: unknown): void; + pushEvent(e: unknown): void; + }; + const push = f.pushEvent.bind(framework); + f.pushEvent = (e: unknown) => { + const ev = e as { type?: string; callId?: string; result?: { success: boolean; error?: string } }; + if (ev.type === 'tool-result') results.push({ callId: ev.callId!, result: ev.result! }); + else push(e); + }; + const invalid: Array<[Record, RegExp]> = [ + [{ channelId: 'chan-B', threadId: '' }, /threadId must be a thread id/], + [{ channelId: 'chan-B', threadId: 7 }, /threadId must be a thread id/], + [{ channelId: 'chan-B', serverId: '' }, /serverId must name a server/], + [{ channelId: 'chan-B', setSpeechTarget: 'false' }, /setSpeechTarget must be true or false/], + ]; + for (const [i, [input, why]] of invalid.entries()) { + f.dispatchChannelToolCall('assistant', { id: `o${i}`, name: 'channel_open', input }); + } + await until(() => results.length === invalid.length, 'every refusal'); + assert.equal(opened, 0, 'nothing was opened'); + assert.equal(results.length, invalid.length); + for (const [i, [, why]] of invalid.entries()) { + const r = results.find((x) => x.callId === `o${i}`)!; + assert.equal(r.result.success, false); + assert.match(r.result.error ?? '', why); + assert.match(r.result.error ?? '', /Nothing was opened\./); + } + // null means "not in use", as for channel_publish's selectors. + f.dispatchChannelToolCall('assistant', { id: 'ok', name: 'channel_open', input: { channelId: 'chan-B', threadId: null, serverId: null, setSpeechTarget: null } }); + await until(() => results.some((r) => r.callId === 'ok'), 'the valid open'); + assert.equal(opened, 1); + await framework.stop(); + }); + + it('a conversation fork refuses a supplied but empty channelId instead of reading it as its home', async () => { + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('assistant', 'chan-home'); + const handled: Array<{ name: string; input: Record }> = []; + registryOf(framework).handleChannelToolCall = async (name: string, input: Record) => { + handled.push({ name, input }); + return { success: true, data: {} }; + }; + const results: Array<{ callId: string; result: { success: boolean; error?: string } }> = []; + const f = framework as unknown as { dispatchChannelToolCall(agent: string, call: unknown): void; pushEvent(e: unknown): void }; + const push = f.pushEvent.bind(framework); + f.pushEvent = (e: unknown) => { + const ev = e as { type?: string; callId?: string; result?: { success: boolean; error?: string } }; + if (ev.type === 'tool-result') results.push({ callId: ev.callId!, result: ev.result! }); + else push(e); + }; + f.dispatchChannelToolCall('assistant', { id: 'empty', name: 'channel_publish', input: { channelId: '', content: 'hi' } }); + f.dispatchChannelToolCall('assistant', { id: 'number', name: 'channel_close', input: { channelId: 5 } }); + f.dispatchChannelToolCall('assistant', { id: 'null', name: 'channel_publish', input: { channelId: null, content: 'hi' } }); + f.dispatchChannelToolCall('assistant', { id: 'absent', name: 'channel_publish', input: { content: 'hi' } }); + await until(() => results.length === 4, 'all four results'); + assert.match(results.find((r) => r.callId === 'empty')!.result.error ?? '', /channelId must name a channel .*Nothing was sent\./); + assert.match(results.find((r) => r.callId === 'number')!.result.error ?? '', /channelId must name a channel .*Nothing was closed\./); + assert.deepEqual(handled.map((h) => [h.name, h.input.channelId]), [ + ['channel_publish', 'chan-home'], + ['channel_publish', 'chan-home'], + ], 'only an omitted (or null) channelId means the home'); + await framework.stop(); + }); + + // ---- outgoing streams (review finding 1) -------------------------------- + + it('a mid-turn hold stops the stream, and completion carries only what streamed', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Before the hold.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Held words.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + addressedArrivalElsewhere(); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(streams.streamedTo('chan-A'), 'Before the hold.'); + assert.ok(streams.chunks.every((c) => !c.delta.includes('Held')), JSON.stringify(streams.chunks)); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Before the hold.', threadId: null }]); + assert.deepEqual(heldDrafts(framework).map((d) => [d.text, d.reason]), [['Held words.', 'ambiguous']]); + + await framework.stop(); + }); + + it('a thread placement is never streamed, held or not; root publication in the same channel streams alone', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Into the thread.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Held words.' }, + ] as ContentBlock[])); + let framework = await createFramework(); + let routed = stubChannelRegistry(framework, { home: false }); + registryOf(framework).publishTarget = () => 'exact'; + let streams = recordStreams(framework); + addressedArrivalElsewhere(); + triggerFromThread(framework, 'chan-A', 't1'); + await framework.runUntilIdle(); + assert.deepEqual(routed, [{ text: 'Into the thread.', locus: 'chan-A' }], 'published into the thread'); + assert.deepEqual(streams.chunks, [], 'the thread placement and the held words: nothing streams'); + assert.deepEqual(streams.completes, []); + await framework.stop(); + + // Hybrid: after the route moves on, a `>>>` envelope to that channel's + // root is a root publication, and its stream names the root alone. + rmSync(tempDir, { recursive: true, force: true }); + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + membrane = new MockMembrane(); + module = new RobotModule(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Into the thread.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>chan-A Root words for chan-A.' }, + ] as ContentBlock[])); + framework = await createFramework('hybrid'); + routed = stubChannelRegistry(framework, { home: false }); + registryOf(framework).publishTarget = () => 'exact'; + opensChannels(framework); + streams = recordStreams(framework); + triggerFromThread(framework, 'chan-A', 't1'); + await framework.runUntilIdle(); + assert.equal(streams.streamedTo('chan-A'), 'Root words for chan-A.'); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Root words for chan-A.', threadId: null }]); + await framework.stop(); + }); + + it('a stream follows the route when channel_open moves it, and each channel completes with its own words', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'To A.' }, + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'chan-B' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'To B.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'To A.', locus: 'chan-A' }, { text: 'To B.', locus: 'chan-B' }]); + assert.equal(streams.streamedTo('chan-A'), 'To A.'); + assert.equal(streams.streamedTo('chan-B'), 'To B.'); + assert.deepEqual(streams.completes.map((c) => [c.channelId, c.text]).sort(), [['chan-A', 'To A.'], ['chan-B', 'To B.']]); + + await framework.stop(); + }); + + it('held words never resurface in a stream: a hold, then a channel_open, then speech', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Before the hold.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Held words.' }, + { type: 'tool_use', id: 'c2', name: 'channel_open', input: { channelId: 'chan-B' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'After the open.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + opensChannels(framework); + const streams = recordStreams(framework); + addressedArrivalElsewhere(); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Before the hold.', locus: 'chan-A' }, { text: 'After the open.', locus: 'chan-B' }]); + assert.equal(streams.streamedTo('chan-A'), 'Before the hold.'); + assert.equal(streams.streamedTo('chan-B'), 'After the open.'); + assert.deepEqual(streams.completes.map((c) => [c.channelId, c.text]).sort(), [['chan-A', 'Before the hold.'], ['chan-B', 'After the open.']]); + + await framework.stop(); + }); + + it('each round is its own segment: a later round\'s prefix names its own destination, and a later skip_reply streams nothing', async () => { + // Mira-1782's probe: the router stayed in `body` across provider rounds, + // so round 2's `>>>#beta` streamed into #alpha as text. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha First.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#beta Second.' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>skip_reply Private words.' }, + ] as ContentBlock[])); + + const framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'First.', locus: 'chan-alpha' }, { text: 'Second.', locus: 'chan-beta' }]); + assert.equal(streams.streamedTo('chan-alpha'), 'First.'); + assert.equal(streams.streamedTo('chan-beta'), 'Second.'); + assert.ok(streams.chunks.every((c) => !c.delta.includes('>>>') && !c.delta.includes('Private')), JSON.stringify(streams.chunks)); + + await framework.stop(); + }); + + it('an envelope delivery couldn\'t make streams nothing, nor do the words held after it; a newer prefix\'s publication does', async () => { + // Mira-1782's probe: delivery held the words after a failed `>>>#alpha` + // as drafts, while the stream kept carrying them to #alpha. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha First.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Later words.' }, + ] as ContentBlock[])); + let framework = await createFramework('hybrid'); + stubChannelRegistry(framework, { home: false }); + // One bounce, and no bounce-wake turn after it. + (framework as unknown as { proseBounceStreaks: Map }).proseBounceStreaks.set('assistant', 100); + registryOf(framework).deliverSpeech = async () => ({ status: 'failed', reason: 'refused', at: Date.now() }); + module.toolDelayMs = 50; // the failure is known before round 2 is written + let streams = recordStreams(framework); + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + assert.ok(heldDrafts(framework).some((d) => d.text === 'Later words.' && d.reason === 'no-destination')); + assert.equal(streams.streamedTo('chan-alpha'), '', 'only confirmed publication streams'); + await framework.stop(); + + // A newer prefix, parsed before the older envelope's failure is known, stands. + rmSync(tempDir, { recursive: true, force: true }); + tempDir = mkdtempSync(join(tmpdir(), 'pwa-moment-')); + membrane = new MockMembrane(); + module = new RobotModule(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha First.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#beta Second.' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'More for beta.' }, + ] as ContentBlock[])); + framework = await createFramework('hybrid'); + const routed = stubChannelRegistry(framework, { home: false }); + (framework as unknown as { proseBounceStreaks: Map }).proseBounceStreaks.set('assistant', 100); + const registry = registryOf(framework); + // routeSpeech's outcome names the resolved server, as the real registry's + // does, so the envelope's publish (deliverSpeech) and the route's + // (routeSpeech) are one stream entry on chan-beta. + const route = registry.routeSpeech as (...args: unknown[]) => Promise | null>; + registry.routeSpeech = async (...args: unknown[]) => { + const outcome = await route(...args); + return outcome ? { ...outcome, serverId: 'stub' } : outcome; + }; + const deliver = registry.deliverSpeech as (...args: unknown[]) => Promise; + // The first envelope's delivery fails only once round 2 (and its newer + // prefix) has been written: the race always happens. + let calls = 0; + let releaseFirst!: () => void; + const firstGate = new Promise((r) => { releaseFirst = r; }); + registry.deliverSpeech = async (...args: unknown[]) => { + if (calls++ === 0) { + await firstGate; + return { status: 'failed', reason: 'refused', at: Date.now() }; + } + return deliver(...args); + }; + let pendingWhenRound2Landed: boolean | undefined; + const fw = framework as unknown as { pushEvent(e: unknown): void }; + const push = fw.pushEvent.bind(framework); + fw.pushEvent = (e: unknown) => { + push(e); + const ev = e as { type?: string; callId?: string }; + if (ev.type === 'tool-result' && ev.callId === 'c2') { + pendingWhenRound2Landed = calls === 1; + releaseFirst(); + } + }; + streams = recordStreams(framework); + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + assert.equal(pendingWhenRound2Landed, true, "the older envelope's delivery was still out when the newer prefix was written"); + assert.deepEqual(routed, [{ text: 'Second.', locus: 'chan-beta' }, { text: 'More for beta.', locus: 'chan-beta' }]); + assert.equal(streams.streamedTo('chan-beta'), 'Second.\n\nMore for beta.'); + assert.deepEqual(streams.completes.filter((c) => c.channelId === 'chan-beta'), + [{ channelId: 'chan-beta', text: 'Second.\n\nMore for beta.', threadId: null }], + 'one stream entry, one completion, for the envelope and the route alike'); + await framework.stop(); + }); + + it('a send-silenced turn streams nothing of the later rounds it holds back', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Sending it now.' }, + { type: 'tool_use', id: 'c1', name: 'robot--send_message', input: { text: 'the note' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Postscript: sent it.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, []); + assert.deepEqual(heldDrafts(framework).map((d) => d.reason), ['explicit-send', 'explicit-send']); + // Neither round was published, so neither streams — not even the words + // written before the round's own send made them held. + assert.deepEqual(streams.chunks, []); + + await framework.stop(); + }); + + it('a private-think round neither streams nor pins later speech', async () => { + // Mira-1782's probe: a later call in the same round (a private `think`) + // made words private that a speculative stream had already carried. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha Private words.' }, + { type: 'tool_use', id: 'think-1', name: 'think', input: { content: 'private reasoning' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Public words.' }, + ] as ContentBlock[])); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'private-think.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test', proseRouting: 'hybrid', sameRoundThinkTextPolicy: 'private' }], + modules: [module], + }); + module.framework = framework; + const routed = stubChannelRegistry(framework, { home: false }); + registryOf(framework).getChannelTools = () => [{ name: 'think', description: 'private reasoning', inputSchema: { type: 'object', properties: {} } }]; + registryOf(framework).handleChannelToolCall = async () => ({ success: true, data: { noted: true } }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Public words.', locus: 'chan-A' }]); + assert.equal(streams.streamedTo('chan-alpha'), ''); + assert.equal(streams.streamedTo('chan-A'), 'Public words.'); + + await framework.stop(); + }); + + it('completion follows the stream\'s last speech, under the inference that wrote it', async () => { + // A trailing reply is published after the agent is idle: its chunk and + // the completion still come in that order, with one inference id. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Narrating.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Done.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + slowFirst(framework, 'routeSpeech', 40); + const events: Array<[string, string, string]> = []; + const registry = registryOf(framework); + registry.sendOutgoingChunk = (d: { channelId: string }, _c: string, inferenceId: string, _n: number, delta: string) => { + events.push(['chunk', inferenceId, delta]); + return true; + }; + registry.sendOutgoingComplete = (d: { channelId: string }, _c: string, inferenceId: string, text: string) => { + events.push(['complete', inferenceId, text]); + }; + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + await until(() => events.some(([kind]) => kind === 'complete'), 'the completion, after the trailing reply is published'); + + assert.deepEqual(events.map(([kind, , text]) => [kind, text]), [ + ['chunk', 'Narrating.'], + ['chunk', '\n\nDone.'], + ['complete', 'Narrating.\n\nDone.'], + ]); + assert.equal(new Set(events.map(([, id]) => id)).size, 1, 'one physical stream, one inference'); + + await framework.stop(); + }); + + it('each later message on a channel opens a paragraph in its stream, and the completion is the deltas joined', async () => { + // Two rounds publish two messages to chan-A. A voice consumer speaks the + // deltas as they come (discord-mcpl's voice output appends them into one + // utterance), so they must not run "Let me look.Found it." together. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Let me look.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Found it.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Let me look.', locus: 'chan-A' }, { text: 'Found it.', locus: 'chan-A' }], + 'each message is published as written, with no separator'); + assert.deepEqual(streams.chunks.map((c) => c.delta), ['Let me look.', '\n\nFound it.']); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Let me look.\n\nFound it.', threadId: null }]); + assert.equal(streams.completes[0]!.text, streams.streamedTo('chan-A'), 'the completion is exactly the deltas, concatenated'); + + await framework.stop(); + }); + + it('an aborted stream still completes what its published speech streamed', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Narrating.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Never written.' }, + ] as ContentBlock[])); + module.toolDelayMs = 300; + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + const streams = recordStreams(framework); + // The abort doesn't stop robot--move: the test owns that call, and waits + // for it after stop() (its late result meets the stopped framework). + const handle = module.handleToolCall.bind(module); + let toolSettled: Promise = Promise.resolve(); + module.handleToolCall = (call) => { + const running = handle(call); + toolSettled = running.catch(() => undefined); + return running; + }; + + triggerFromChannel(framework, 'chan-A'); + const idle = framework.runUntilIdle(); + const deadline = Date.now() + 2000; + while (streams.chunks.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 5)); + assert.ok(streams.chunks.length > 0, 'the narration was published and streamed'); + assert.ok(framework.abortInference('assistant', 'test abort'), 'the turn was still running'); + await idle; + await until(() => streams.completes.length > 0, 'the completion the stream sends'); + + assert.equal(streams.streamedTo('chan-A'), 'Narrating.'); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Narrating.', threadId: null }]); + + await framework.stop(); + await toolSettled; + await new Promise((r) => setImmediate(r)); + }); + + it('completion never replays a chunk the registry refused to stream', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Refused.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Streamed.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework, { home: false }); + // As when the channel's declaration or streaming grant is briefly absent. + const streams = recordStreams(framework, (_channelId, delta) => delta.startsWith('Refused')); + + triggerFromChannel(framework, 'chan-A'); + await framework.runUntilIdle(); + + assert.equal(streams.streamedTo('chan-A'), 'Streamed.'); + assert.deepEqual(streams.completes, [{ channelId: 'chan-A', text: 'Streamed.', threadId: null }]); + + await framework.stop(); + }); +}); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts new file mode 100644 index 000000000..6f2e34e7b --- /dev/null +++ b/test/prose-drafts.test.ts @@ -0,0 +1,1667 @@ +/** + * Held prose drafts (shelf-351): plain speech that is not sent is kept as a + * private, explicitly resendable draft instead of vanishing. + * + * Unit tests drive ProseDraftStore over a real Chronicle store; end-to-end + * tests run real turns (MockMembrane) against a real MCPL child (the + * speech-route fixture), which records every channels/publish it receives. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import type { ContentBlock, NormalizedRequest, StreamEvent, YieldingStream } from '@animalabs/membrane'; +import { AgentFramework } from '../src/index.js'; +import { ProseDraftStore, draftState, PROSE_DRAFT_RECORD_TYPE } from '../src/prose-drafts.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const ROOM = 'discord:g1:room'; +const GENERAL = 'discord:g1:general'; +const source = { branch: 'main', turn: 1, round: 1, segment: 0 }; +const dest = (channelId = ROOM) => ({ serverId: 'discord', channelId, label: '#room (Guild One)' }); + +describe('ProseDraftStore', () => { + const open = () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-unit-')); + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + return { dir, store, drafts: new ProseDraftStore(store) }; + }; + + it('holds exact words in order, with stable ids, per resident', () => { + const { dir, store, drafts } = open(); + try { + const { held } = drafts.hold('a', [{ text: ' one\n', source }, { text: 'two ', source: { ...source, segment: 1 } }], 'explicit-send'); + assert.equal(held.length, 2); + assert.match(held[0]!.id, /^d-[a-z2-9]{5}$/); + assert.notEqual(held[0]!.id, held[1]!.id); + assert.deepEqual(drafts.open('a').map((d) => d.text), ['two ', ' one\n'], 'newest first, bytes exact'); + assert.deepEqual(drafts.open('b'), [], 'another resident sees none of them'); + assert.equal(drafts.get('b', held[0]!.id), undefined); + assert.equal(draftState(held[0]!), 'held'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('replays the journal at reopen: an attempt with no recorded outcome reads as unconfirmed', () => { + const { dir, store, drafts } = open(); + const path = join(dir, 'store'); + const { held } = drafts.hold('a', [{ text: 'words', source }, { text: 'more', source }], 'explicit-send'); + drafts.beginAttempt('a', held[0]!.id, dest(), 'resend', false); // the process "dies" here + const second = drafts.beginAttempt('a', held[1]!.id, dest(), 'resend', false); + drafts.recordOutcome('a', held[1]!.id, second, { status: 'delivered', messageId: 'm-9', at: 5, destination: dest() }); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + const again = new ProseDraftStore(reopened); + assert.equal(draftState(again.get('a', held[0]!.id)!), 'unconfirmed'); + assert.equal(draftState(again.get('a', held[1]!.id)!), 'delivered'); + assert.equal(again.get('a', held[1]!.id)!.attempts[0]!.outcome!.messageId, 'm-9'); + } finally { + reopened.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('is not moved by branch switches: undo, rollback and checkout leave every draft as it was', () => { + const { dir, store, drafts } = open(); + try { + const main = store.currentBranch().name; + const { held } = drafts.hold('a', [{ text: 'kept', source }, { text: 'set aside', source }], 'explicit-send'); + const attempt = drafts.beginAttempt('a', held[0]!.id, dest(), 'resend', false); + drafts.recordOutcome('a', held[0]!.id, attempt, { status: 'delivered', at: 7, destination: dest() }); + drafts.dismiss('a', held[1]!.id); + store.createBranch('elsewhere', main); + store.switchBranch('elsewhere'); + drafts.reload(); + assert.equal(draftState(drafts.get('a', held[0]!.id)!), 'delivered', 'a delivered draft stays delivered'); + assert.equal(draftState(drafts.get('a', held[1]!.id)!), 'dismissed', 'a dismissed draft stays dismissed'); + const later = drafts.hold('a', [{ text: 'held on another branch', source }], 'explicit-send').held[0]!; + store.switchBranch(main); + drafts.reload(); + assert.equal(draftState(drafts.get('a', later.id)!), 'held', 'a draft is usable from any branch'); + assert.equal(store.getRecordIdsByType(PROSE_DRAFT_RECORD_TYPE).length, 6); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports words it could not hold, and stays consistent with its journal', (t) => { + const { dir, store, drafts } = open(); + try { + const real = store.sync.bind(store); + let fail = true; + (store as unknown as { sync: () => void }).sync = () => { if (fail) throw new Error('EIO'); real(); }; + t.mock.method(console, 'error', () => {}); + const result = drafts.hold('a', [{ text: 'x', source }, { text: 'y', source }], 'explicit-send'); + // 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', source }], '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', source }, { text: 'two', source }, { text: 'three', source }], 'explicit-send'); + const [one, two, three] = held; + drafts.markNoticed('a', [one!.id, two!.id], 'm-1'); + drafts.markNoticed('a', [two!.id], 'm-2'); + const unnamed = (present: string[]) => drafts.unnoticed('a', (id) => present.includes(id)).map((d) => d.text); + assert.deepEqual(unnamed(['m-1', 'm-2']), ['three'], 'only the draft no notice named'); + assert.deepEqual(unnamed(['m-2']), ['three', 'one'], 'm-1 rolled away: one is unnamed, two is still named by m-2; newest first'); + assert.deepEqual(unnamed([]), ['three', 'two', 'one']); + assert.deepEqual(drafts.get('a', three!.id)!.noticedIn, undefined); + // The record outlives a reopen; one written without a message id (an + // older build's) names nothing, so its draft is named once more. + store.appendJson(PROSE_DRAFT_RECORD_TYPE, { op: 'noticed', agent: 'a', ids: [three!.id], at: 1 }); + const again = new ProseDraftStore(store); + assert.deepEqual(again.unnoticed('a', (id) => id === 'm-2').map((d) => d.text), ['three', 'one']); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('checkpoints what it keeps: the newest drafts, recent ones, and the latest bounce as {{unsent}} means it', (t) => { + const { dir, store } = open(); + const path = join(dir, 'store'); + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const hour = 60 * 60 * 1000; + const retention = { keepNewest: 2, keepIdleMs: hour, checkpointEvery: 9 }; + let drafts = new ProseDraftStore(store, retention); + try { + const [older] = drafts.hold('a', [{ text: 'an older bounce', source }], 'bounced').held; + const [latest] = drafts.hold('a', [{ text: 'the latest bounce', source }], 'bounced').held; + const [settled] = drafts.hold('a', [{ text: 'resent long ago', source }], 'explicit-send').held; + const attempt = drafts.beginAttempt('a', settled!.id, dest(), 'resend', false); + drafts.recordOutcome('a', settled!.id, attempt, { status: 'delivered', messageId: 'm-1', at: Date.now(), destination: dest() }); + drafts.hold('b', [{ text: "another resident's", source }], 'explicit-send'); + t.mock.timers.tick(2 * hour); + // Something happens to the older bounce now: it isn't idle. + drafts.beginAttempt('a', older!.id, dest(), 'resend', false); + const newest = drafts.hold('a', [{ text: 'new one', source }, { text: 'new two', source }], 'explicit-send').held; + // That was the ninth entry: the checkpoint let go of 'a''s idle + // drafts outside its two newest, the latest bounce among them. + const kept = () => drafts.open('a').map((d) => d.text); + assert.deepEqual(kept(), ['new two', 'new one', 'an older bounce']); + assert.equal(drafts.get('a', settled!.id), undefined, 'a settled, idle draft is let go'); + assert.equal(drafts.get('a', latest!.id), undefined); + assert.equal(drafts.latestBounce('a'), undefined, 'the latest bounce was let go: {{unsent}} has nothing, though an older bounce is kept'); + assert.deepEqual(drafts.open('b').map((d) => d.text), ["another resident's"], 'within its resident\'s newest'); + assert.equal(store.getRecordIdsByType(`${PROSE_DRAFT_RECORD_TYPE}/checkpoint`).length, 1); + // A reopen reads the checkpoint and the entries after it, and gets the same. + drafts.markNoticed('a', [newest[0]!.id], 'm-9'); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + drafts = new ProseDraftStore(reopened, retention); + assert.deepEqual(kept(), ['new two', 'new one', 'an older bounce']); + assert.equal(drafts.latestBounce('a'), undefined); + assert.deepEqual(drafts.get('a', newest[0]!.id)!.noticedIn, ['m-9']); + assert.equal(draftState(drafts.get('a', older!.id)!), 'unconfirmed', 'its attempt survives the checkpoint'); + } finally { + reopened.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('records a notice only after the history holding it is synced (afterCommittedState)', () => { + const { dir, store, drafts } = open(); + try { + const { held } = drafts.hold('a', [{ text: 'x', source }], 'explicit-send'); + const calls: string[] = []; + const real = { sync: store.sync.bind(store), appendJson: store.appendJson.bind(store) }; + (store as unknown as { sync: () => void }).sync = () => { calls.push('sync'); real.sync(); }; + (store as unknown as { appendJson: (t: string, p: unknown) => unknown }).appendJson = (t, p) => { calls.push(`append:${t}`); return real.appendJson(t, p); }; + drafts.markNoticed('a', [held[0]!.id], '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', source, inheritedRisk: risky }], 'bounced').held; + assert.equal(draftState(copy!), 'unconfirmed'); + const failed = drafts.beginAttempt('a', copy!.id, dest(), 'resend', true); + drafts.recordOutcome('a', copy!.id, failed, { status: 'failed', at: 2, destination: dest() }); + assert.equal(draftState(drafts.get('a', copy!.id)!), 'unconfirmed', 'a failed attempt does not clear it'); + const ok = drafts.beginAttempt('a', copy!.id, dest(), 'resend', true); + drafts.recordOutcome('a', copy!.id, ok, { status: 'delivered', at: 3, destination: dest() }); + assert.equal(draftState(drafts.get('a', copy!.id)!), 'delivered'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('{{unsent}} is the latest bounce while it is open, latest-wins', () => { + const { dir, store, drafts } = open(); + try { + const first = drafts.hold('a', [{ text: 'first', source }], 'bounced').held[0]!; + const second = drafts.hold('a', [{ text: 'second', source }], 'bounced').held[0]!; + drafts.hold('a', [{ text: 'not a bounce', source }], 'explicit-send'); + assert.equal(drafts.latestBounce('a')?.id, second.id); + drafts.dismiss('a', second.id); + assert.equal(drafts.latestBounce('a'), undefined, 'an older bounce is not substituted'); + assert.equal(draftState(drafts.get('a', first.id)!), 'held', 'it stays in the collection by id'); + } finally { + store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +// --------------------------------------------------------------------------- +// End to end: real turns against a real MCPL child +// --------------------------------------------------------------------------- + +type Publish = { event: string; channelId: string; text: string; messageId?: string }; + +/** A stream in XML tool mode: tool-calls carry no verbatim roundContent. */ +class XmlModeStream implements YieldingStream { + constructor(private readonly inner: YieldingStream) {} + provideToolResults(...args: Parameters): void { + this.inner.provideToolResults(...args); + } + cancel(): void { this.inner.cancel(); } + get isWaitingForTools(): boolean { return this.inner.isWaitingForTools; } + get pendingToolCallIds(): string[] { return this.inner.pendingToolCallIds; } + get toolDepth(): number { return this.inner.toolDepth; } + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const event of this.inner) { + if (event.type !== 'tool-calls') { + yield event; + continue; + } + const context = { ...(event as unknown as { context: Record }).context }; + delete context.roundContent; + yield { ...event, context } as unknown as StreamEvent; + } + } +} + +/** A membrane in XML tool mode (the inner mock's streams, wrapped). */ +function xmlMode(inner: MockMembrane): MockMembrane { + const streamYielding = inner.streamYielding.bind(inner); + (inner as unknown as { streamYielding: (r: NormalizedRequest, o?: unknown) => YieldingStream }).streamYielding = + (request, options) => new XmlModeStream(streamYielding(request, options)); + return inner; +} + +async function harness(opts: { + agents?: Array<{ name: string; proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; sameRoundThinkTextPolicy?: 'public' | 'private' }>; + xml?: boolean; + /** 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\\) \\(discord / ${ROOM}\\), message posted-2`)); + const sent = h.publishes().slice(1); + assert.deepEqual(sent.map((p) => [p.channelId, p.text]), segments.map((s) => [ROOM, s]), 'verbatim, in order'); + assert.ok(h.store().open('scout').length === 0, 'nothing left open'); + } finally { + await h.close(); + } + }); + + it('XML tool mode: the whole turn\'s prose is held at turn end and named in the receipt, never mid-turn', async () => { + const h = await harness({ xml: true }); + try { + // XML mode completes with the turn's cumulative content (its tool + // calls already ran: the completion carries none of its own). + await h.turn([ + createMockResponse([text('Narration before the send.'), explicitSend('s1')], 'tool_use'), + { ...createMockResponse([text('Narration before the send.'), explicitSend('s1'), text('And after it.')]), toolCalls: [] }, + ]); + const held = h.drafts().reverse(); + assert.deepEqual(held.map((d) => d.text), ['Narration before the send.', 'And after it.']); + assert.equal(h.messages().filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'prose-drafts').length, 0, + 'no mid-turn notice: XML rounds present no injections'); + assert.ok(h.texts().some((t) => t.startsWith('[delivered] nothing — 2 plain-speech segment(s) held as drafts'))); + } finally { + await h.close(); + } + }); + + it('deliberate privacy is never drafted: skip_reply and a same-round private think', async () => { + const h = await harness({ agents: [{ name: 'scout', sameRoundThinkTextPolicy: 'private' }] }); + try { + await h.turn([ + createMockResponse([text('Thinking aloud, privately.'), call('k1', 'skip_reply', {})], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.drafts(), []); + assert.ok(h.texts().some((t) => t === '[delivered] nothing — 1 plain-speech segment(s) kept private (skip_reply)')); + await h.turn([ + createMockResponse([text('Prose beside a private think.'), call('t1', 'think', { thought: 'hmm' }), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + assert.deepEqual(h.drafts(), [], 'a same-round private think keeps its prose private, even beside a send'); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); + } finally { + await h.close(); + } + }); + + it('disabled mode stays draft-free: its narration is private by configuration', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'disabled' }] }); + try { + await h.turn([ + createMockResponse([text('Narration that never publishes.'), explicitSend('s1')], 'tool_use'), + createMockResponse([text('More of it.')]), + ]); + assert.deepEqual(h.drafts(), []); + assert.ok(h.texts().some((t) => t.startsWith('[delivered] nothing — 2 plain-speech segment(s) suppressed (proseRouting=disabled'))); + } finally { + await h.close(); + } + }); + + it('drafts belong to their resident: another resident can neither list nor resend them', async () => { + const h = await harness({ agents: [{ name: 'scout' }, { name: 'other' }] }); + try { + // Direct requests: a channel message would wake both residents. + await h.turn([ + createMockResponse([text('scout words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ], 'scout', 'request'); + const [mine] = h.drafts('scout'); + assert.ok(mine); + await h.turn([ + createMockResponse([drafts('o1', { action: 'list' })], 'tool_use'), + createMockResponse([drafts('o2', { action: 'resend', draftIds: [mine.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ], 'other', 'request'); + const [list, resend] = h.toolResults(); + assert.match(list!, /You have no open drafts/); + assert.match(resend!, new RegExp(`No draft of yours with id ${mine.id}`)); + assert.equal(h.publishes().filter((p) => p.text === 'scout words').length, 0); + // Residents of one framework share a message slot (#197), so the + // notice is visible there too; it names whose drafts it means. + assert.ok(h.texts('scout').some((t) => t.startsWith(`[drafts] scout: not sent — a plain-speech segment held as draft ${mine.id}`))); + } finally { + await h.close(); + } + }); + + it('failed and uncertain resends: a refusal stays resendable; an unknown outcome needs confirmDuplicate', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('fragile words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + const resend = (id: string, confirm?: boolean) => + drafts(id, { action: 'resend', draftIds: [draft!.id], destination: ROOM, ...(confirm ? { confirmDuplicate: true } : {}) }); + + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([resend('r1')], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, /not sent to .* Nothing was posted 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.*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; check the channel/); + // A resend of a held draft records the same outcome. + await h.turn([ + createMockResponse([text('held beside a send'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + await h.turn([ + createMockResponse([drafts('r1', { action: 'resend', draftIds: [draft!.id], destination: ROOM })], 'tool_use'), + createMockResponse([]), + ]); + const [attempt] = h.store().get('scout', draft!.id)!.attempts; + assert.equal(attempt!.outcome?.status, 'unknown'); + assert.match(attempt!.outcome!.reason!, /connector exploded/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + } finally { + await h.close(); + } + }); + + it('through the real connection, a server that never answers is unconfirmed once the request times out', async () => { + const h = await harness({ requestTimeoutMs: 1_000 }); + try { + h.command({ op: 'publish-mode', mode: 'hang' }); + await h.turn([createMockResponse([text('words into silence')])]); + await h.until(() => h.texts().some((t) => t.startsWith('[discord-send-failed]')), 'the marker'); + assert.equal(h.publishes().length, 0); + const marker = h.texts().find((t) => t.startsWith('[discord-send-failed]'))!; + assert.match(marker, /was not confirmed \(MCPL server "discord" did not respond to channels\/publish .*within 1000ms.*\(delivery uncertain\)\)\. It may or may not have been posted/); + } finally { + await h.close(); + } + }); + + it("a silent turn's prose stays private beside a send: counted, never held or named", async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('private narration'), explicitSend('s1')], 'tool_use'), + createMockResponse([text('more private words')]), + ], 'scout', 'silent'); + assert.deepEqual(h.drafts(), [], 'nothing was held as a draft'); + assert.ok(!h.texts().some((t) => t.includes('held as draft')), 'and no notice names one'); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere'], 'only the explicit send went out'); + } finally { + await h.close(); + } + }); + + it('unaddressed speech that provably was not posted is kept as a draft, named by its one failure notice; an unconfirmed one is not', async () => { + const h = await harness(); + try { + h.command({ op: 'publish-mode', mode: 'not-delivered' }); + await h.turn([createMockResponse([text('words that could not go')])]); + const [kept] = h.drafts(); + assert.equal(kept?.text, 'words that could not go'); + assert.equal(kept?.reason, 'bounced'); + assert.match(kept?.note ?? '', /^delivery failed: /); + const markers = () => h.texts().filter((t) => t.startsWith('[discord-send-failed]')); + assert.equal(markers().length, 1); + assert.ok(markers()[0]!.includes(`Nothing was posted; it is held as draft ${kept!.id}. drafts(action: "resend", draftIds: ["${kept!.id}"], destination: "${ROOM}") sends it there once that can work, or name another destination.`), markers()[0]); + + h.command({ op: 'publish-mode', mode: 'no-receipt' }); + await h.turn([createMockResponse([text('words that may have gone')])]); + assert.deepEqual(h.drafts().map((d) => d.id), [kept!.id], 'an unconfirmed delivery keeps no draft, so as not to invite a duplicate'); + assert.match(markers().at(-1)!, /was not confirmed .* It may or may not have been posted/); + } finally { + await h.close(); + } + }); + + it('an unknown outcome is not cleared by a later failure: the draft stays unconfirmed', async () => { + const h = await harness(); + try { + 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]{5}'s attempt to .* may already have been posted: .*delivery uncertain/); + assert.equal(draftState(h.store().get('scout', draft!.id)!), 'unconfirmed'); + h.command({ op: 'publish-mode', mode: 'delivered' }); + const before = h.publishes().length; + 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\\) \\(discord / ${ROOM}\\), message posted-\\d+ .*not sent again`)); + assert.match(refused!, new RegExp(`${dismiss!.id} was dismissed`)); + assert.equal(h.publishes().length, published, 'no new publish'); + } finally { + 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', source }], 'explicit-send'); + const midSend = h.store().hold('scout', [{ text: 'half-sent words', source }], 'explicit-send').held[0]!; + h.store().beginAttempt('scout', midSend.id, dest(), 'resend', false); + await h.turn([createMockResponse([])]); + const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; + assert.match(notice, /^\[drafts\] scout: 2 held drafts of yours are unnamed on this branch of your history: /); + assert.match(notice, /d-[a-z2-9]{5} \(held \(not sent\); held .*"orphaned words"\)/); + assert.match(notice, new RegExp(`${midSend.id} \\(UNCONFIRMED — an attempt to #room \\(Guild One\\) \\(discord / ${ROOM}\\) at .* may have been posted; .*"half-sent words"`)); + assert.doesNotMatch(notice, /They were not sent/); + await h.turn([createMockResponse([])]); + assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'named once'); + } finally { + await h.close(); + } + }); + + it('the catch-up names every draft it marks: the newest eight with previews, the rest by id', async () => { + const h = await harness(); + try { + const held = Array.from({ length: 10 }, (_, i) => h.store().hold('scout', [{ text: `orphan ${i}`, source }], 'explicit-send').held[0]!); + await h.turn([createMockResponse([])]); + const notice = h.texts().find((t) => t.startsWith('[drafts]'))!; + assert.match(notice, /^\[drafts\] scout: 10 held drafts of yours are unnamed on this branch of your history: /); + // Newest first: orphan 9 down to orphan 2 with their previews, then the + // two oldest by id alone. + const previews = [...notice.matchAll(/"(orphan \d)"/g)].map((m) => m[1]); + assert.deepEqual(previews, [9, 8, 7, 6, 5, 4, 3, 2].map((i) => `orphan ${i}`)); + assert.ok(notice.includes(` · and 2 more: ${held[1]!.id}, ${held[0]!.id}.`), notice); + await h.turn([createMockResponse([])]); + assert.equal(h.texts().filter((t) => t.startsWith('[drafts]')).length, 1, 'all ten named, so none is named again'); + } finally { + await h.close(); + } + }); + + it('a rollback past the notice that named a draft names it again at the next turn, once', async () => { + const h = await harness(); + try { + await h.turn([createMockResponse([text('hello')])]); + const anchor = h.messages().at(-1)!.id; + await h.turn([ + createMockResponse([text('words a send held back'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + assert.ok(h.texts().some((t) => t.includes(`held as draft ${draft!.id}`)), 'the turn-end receipt names it'); + const catchUps = () => h.texts().filter((t) => t.includes('unnamed on this branch of your history')); + assert.deepEqual(catchUps(), []); + // The rollback takes the turn, and its notices, off the branch; the + // draft stays, as drafts do. + await h.framework.rollbackToMessage('scout', { messageId: anchor }); + assert.ok(!h.texts().some((t) => t.includes(draft!.id)), 'nothing on the branch names it now'); + await h.turn([createMockResponse([])]); + assert.equal(catchUps().length, 1); + assert.match(catchUps()[0]!, new RegExp(`a held draft of yours is unnamed on this branch of your history: ${draft!.id} \\(held \\(not sent\\);.*"words a send held back"`)); + await h.turn([createMockResponse([])]); + assert.equal(catchUps().length, 1, 'named once on this branch'); + } finally { + await h.close(); + } + }); + + /** 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}`))); + 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('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 .* Check the channel before sending it again \(that needs confirmDuplicate: true\)/, + 'the resend\'s advice holds: the draft is still open'); + assert.equal(draftState(h.store().get('scout', unsure.id)!), 'unconfirmed'); + + // With the result in, the resident can set it aside knowingly. + await h.turn([createMockResponse([drafts('x2', { action: 'dismiss', draftIds: [unsure.id] })], 'tool_use'), createMockResponse([])]); + assert.match(h.toolResults()[0]!, new RegExp(`${unsure.id}: dismissed`)); + assert.equal(draftState(h.store().get('scout', unsure.id)!), 'dismissed'); + } finally { + await h.close(); + } + }); + + it('refuses unusable calls before sending anything', async () => { + const h = await harness(); + try { + await h.turn([ + createMockResponse([text('words'), explicitSend('s1')], 'tool_use'), + createMockResponse([]), + ]); + const [draft] = h.drafts(); + await h.turn([ + createMockResponse([ + drafts('a', { action: 'resend', draftIds: [draft!.id] }), + drafts('b', { action: 'resend', draftIds: [draft!.id], destination: '#nowhere' }), + drafts('c', { action: 'list', draftIds: [draft!.id] }), + drafts('d', { action: 'read', draftIds: ['d-zzzzz'] }), + drafts('e', { action: 'shout' }), + ], 'tool_use'), + createMockResponse([]), + ]); + const [noDest, badDest, unused, unknown, badAction] = h.toolResults(); + assert.match(noDest!, /resend needs a destination/); + assert.match(badDest!, /Destination "#nowhere" did not resolve.*Nothing was sent/); + assert.match(unused!, /draftIds is not used by action "list"/); + assert.match(unknown!, /No draft of yours with id d-zzzzz/); + assert.match(badAction!, /action must be one of/); + assert.deepEqual(h.publishes().map((p) => p.text), ['an explicit note elsewhere']); + } finally { + await h.close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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; + turnDrafts: Map; + settleTurnDrafts(agent: unknown): void; + }; + const agent = framework.getAgent('scout')!; + const [named, lost] = internals.proseDrafts.hold('scout', [{ text: 'named', source }, { text: 'lost', source }], 'explicit-send').held; + // Only the first draft's notice reached the history. + const cm = agent.getContextManager(); + const notice = cm.addMessage('user', [{ type: 'text', text: '[drafts] …' }], { system: true, kind: 'prose-drafts', draftOwner: 'scout', draftIds: [named!.id] } as never); + internals.turnDrafts.set('scout', [named, lost]); + internals.settleTurnDrafts(agent); + assert.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 }); + } + }); +}); + +describe('draft identity and risk evidence', () => { + const setupFramework = async () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-ident-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'scout' }, { name: 'other', model: 'test-model', systemPrompt: 'other' }], + modules: [], + }); + const internals = framework as unknown as { + proseDrafts: ProseDraftStore; + turnDrafts: Map; + settleTurnDrafts(agent: unknown): void; + bounceProse(agent: unknown, text: string, reason: string): void; + riskText(draft: unknown): string; + }; + return { dir, framework, internals, close: async () => { await framework.stop(); rmSync(dir, { recursive: true, force: true }); } }; + }; + + it('a notice names drafts of its own resident only: the same id under another resident is not settled by it', async () => { + const t = await setupFramework(); + try { + (t.internals.proseDrafts as unknown as { newId: (agent: string) => string }).newId = () => 'd-same2'; + const [mine] = t.internals.proseDrafts.hold('scout', [{ text: 'scout words', source }], 'explicit-send').held; + t.internals.proseDrafts.hold('other', [{ text: 'other words', source }], 'explicit-send'); + // Only the OTHER resident's notice for "d-same2" is in the shared history. + t.framework.getAgent('other')!.getContextManager().addMessage('user', [{ type: 'text', text: '[drafts] other: …' }], + { system: true, kind: 'prose-drafts', draftOwner: 'other', draftIds: ['d-same2'] } as never); + t.internals.turnDrafts.set('scout', [mine]); + t.internals.settleTurnDrafts(t.framework.getAgent('scout')!); + assert.equal(t.internals.proseDrafts.get('scout', 'd-same2')!.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', source }], 'bounced').held; + t.internals.proseDrafts.beginAttempt('scout', original!.id, dest(), 'unsent-token', false); // never resolved + t.internals.bounceProse(agent, '{{unsent}} second', 'no such channel'); + const copy = t.internals.proseDrafts.open('scout').find((d) => d.text === 'first second')!; + assert.equal(copy.inheritedRisk?.draftId, original!.id); + t.internals.bounceProse(agent, '{{unsent}} third', 'no such channel'); + const again = t.internals.proseDrafts.open('scout').find((d) => d.text === 'first second third')!; + assert.equal(again.inheritedRisk?.draftId, copy.id); + assert.equal(again.inheritedRisk?.sourceDraftId, original!.id); + assert.deepEqual(again.inheritedRisk?.destination, dest()); + assert.equal(again.inheritedRisk?.reason, 'its outcome was never recorded'); + const text = t.internals.riskText(again); + assert.match(text, new RegExp(`${again.id} includes the words of ${copy.id}, which itself includes the words of ${original!.id}, and ${original!.id}'s attempt to #room \\(Guild One\\)`)); + assert.doesNotMatch(text, /being sent/); + } finally { + await t.close(); + } + }); +}); + +describe('drafts resend ownership', () => { + const setup = async () => { + const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-own-')); + 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: () => [], + // Every channel declares an MCPL RFC-011 publish target. + publishTarget: () => 'root', + }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(stub, { + get: (t, prop: string) => (prop in t ? t[prop] : () => undefined), + }); + const internals = framework as unknown as { + proseDrafts: ProseDraftStore; + handleDraftsTool(agent: string, input: unknown): Promise<{ success: boolean; data?: Array<{ text: string }>; error?: string }>; + deliverProseEnvelope(agent: unknown, text: string, hold: { notice: 'later' }): Promise; + }; + const say = (r: { success: boolean; data?: Array<{ text: string }>; error?: string }) => + r.success ? r.data!.map((b) => b.text).join('') : r.error!; + /** Until a publish is waiting on the test, bounded: what the next step + * needs, rather than a fixed pause that a loaded machine can outrun. */ + const untilPublishing = async () => { + const deadline = Date.now() + 10_000; + while (pending.length === 0) { + if (Date.now() > deadline) throw new Error('no publish reached the stub within 10 s'); + await new Promise((r) => setTimeout(r, 2)); + } + }; + const [a, b] = internals.proseDrafts.hold('scout', [{ text: 'A words', source }, { text: 'B words', source }], 'explicit-send').held; + return { + dir, framework, internals, pending, say, 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 \(discord \/ chan\), message m-a/); + // The channel is gone: destination resolution would now fail. + const registry = (t.framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.resolveProseTarget = () => ({ error: 'no channel matches' }); + 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 \(discord \/ chan\), message m-a/); + assert.match(result, /delivered to \(discord \/ chan\), message m-b/); + assert.equal(t.pending.length, 0, 'B was published exactly once'); + } finally { + await t.close(); + } + }); + + 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("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', source }], 'explicit-send').held; + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, t.b.id, c!.id], destination: 'chan' }); + await t.untilPublishing(); + t.say(await t.internals.handleDraftsTool('scout', { action: 'dismiss', draftIds: [t.b.id] })); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + const result = t.say(await batch); + assert.match(result, new RegExp(`${c!.id}: not attempted \\(${t.b.id} was dismissed while it waited\\)\\.`)); + + const [d] = t.internals.proseDrafts.hold('scout', [{ text: 'D words', source }], 'explicit-send').held; + const again = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [c!.id, d!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + assert.match(t.say(await again), new RegExp(`${d!.id}: not attempted \\(${c!.id} was not confirmed delivered\\)\\.`)); + } finally { + await t.close(); + } + }); + + it('an attempt still out reads as being sent, not as one whose outcome was never recorded', async () => { + const t = await setup(); + try { + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id], destination: 'chan' }); + await t.untilPublishing(); + const read = t.say(await t.internals.handleDraftsTool('scout', { action: 'read', draftIds: [t.a.id] })); + assert.match(read, /attempt .* → \(discord \/ chan\): being sent right now — no outcome yet/); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await sending; + } finally { + await t.close(); + } + }); + + it('{{unsent}} of a bounce queued in a resend says so, and holds the envelope\'s words with the risk', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const batch = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [t.a.id, bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + const agent = t.framework.getAgent('scout')!; + await t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}} and a postscript', { notice: 'later' }); + assert.deepEqual(t.pending.map((p) => p.text), ['A words'], 'nothing more was sent'); + const fresh = t.internals.proseDrafts.latestBounce('scout')!; + assert.notEqual(fresh.id, bounce!.id); + assert.equal(fresh.text, 'bounced words and a postscript'); + assert.equal(fresh.inheritedRisk?.draftId, bounce!.id); + assert.match(fresh.inheritedRisk!.reason, /queued in a resend when these words were held/); + const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is queued in a resend in progress. Nothing was sent; ` + + `wait for that result. Your message is held as draft ${fresh.id}, with {{unsent}} written out; since it includes ` + + `${bounce!.id}'s words, it can be resent only with confirmDuplicate: true, after that.`), words.join('\n')); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-a' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'delivered', messageId: 'm-b' }); + await batch; + } finally { + await t.close(); + } + }); + + it('>>skip_reply {{unsent}} says when the bounce may already have been posted, or could not be set aside', async () => { + const t = await setup(); + try { + const agent = t.framework.getAgent('scout')!; + const words = () => agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + // The journal refuses the dismissal: the bounce stays, and the resident is told. + const dismiss = t.internals.proseDrafts.dismiss.bind(t.internals.proseDrafts); + t.internals.proseDrafts.dismiss = () => { throw new Error('EIO'); }; + await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + assert.ok(words().includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which could not be set aside (EIO): ` + + 'it is still held, and {{unsent}} still names it.'), words().join('\n')); + assert.equal(t.internals.proseDrafts.latestBounce('scout')?.id, bounce!.id); + t.internals.proseDrafts.dismiss = dismiss; + await t.internals.deliverProseEnvelope(agent, '>>skip_reply {{unsent}}', { notice: 'later' }); + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed'); + assert.ok(words().some((w) => w.startsWith(`[prose-routing] Draft ${bounce!.id} is set aside. Note that ${bounce!.id}'s attempt to (discord / chan) at `) && + w.endsWith('may already have been posted: no receipt.')), words().join('\n')); + } finally { + await t.close(); + } + }); + + it('>>skip_reply {{unsent}} leaves the latest bounce alone while it is being sent, and tells the resident', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const sending = t.internals.handleDraftsTool('scout', { action: 'resend', draftIds: [bounce!.id], destination: 'chan' }); + await t.untilPublishing(); + const agent = t.framework.getAgent('scout')!; + const envelope = (text: string) => t.internals.deliverProseEnvelope(agent, text, { notice: 'later' }); + await envelope('>>skip_reply {{unsent}}'); + assert.equal(t.internals.proseDrafts.get('scout', bounce!.id)!.dismissedAt, undefined); + const words = agent.getContextManager().getAllMessages().flatMap((m) => m.content).map((b) => (b as { text?: string }).text ?? ''); + assert.ok(words.includes(`[prose-routing] {{unsent}} is draft ${bounce!.id}, which is being sent right now. It was not set aside, ` + + 'since that can\'t call back words already on their way; wait for that result.'), words.join('\n')); + t.pending.shift()!.resolve({ status: 'unknown', reason: 'no receipt' }); + await sending; + await envelope('>>skip_reply {{unsent}}'); + assert.equal(draftState(t.internals.proseDrafts.get('scout', bounce!.id)!), 'dismissed', 'with the result in, it is set aside'); + } finally { + await t.close(); + } + }); + + it('a dismissal is refused while an {{unsent}} delivery of that draft is out', async () => { + const t = await setup(); + try { + const [bounce] = t.internals.proseDrafts.hold('scout', [{ text: 'bounced words', source }], 'bounced').held; + const agent = t.framework.getAgent('scout')!; + const delivering = t.internals.deliverProseEnvelope(agent, '>>chan {{unsent}}', { notice: 'later' }); + await t.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.deepEqual((h.framework as unknown as { proseTargetPins: Map }).proseTargetPins.get('scout'), { serverId: 'discord', channelId: ROOM }, + 'the named destination, on its server, is the sticky target again, as live routing would leave it'); + assert.match(h.texts().find((t) => t.startsWith('[delivered]'))!, / · 3 plain-speech segment\(s\) kept private \(skip_reply\)$/); + + // A live `>>>skip_reply` in an earlier round binds a later silenced one. + 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)'); + } 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) (discord / ${ROOM}) (draft ${ids[0]}, by your resend) · ` + + `1 plain-speech segment(s) held as draft ${ids[2]} (not sent — drafts can resend them unchanged, or dismiss them) · ` + + `draft ${ids[1]} dismissed by you`); + + 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\\) \\(discord / ${ROOM}\\) at .+ ` + + 'may already have been posted: .+ — check that channel before sending it again \\(resend needs confirmDuplicate: true\\)$')); + + // Dismissed knowingly after an unknown resend: the dismissal sets it + // aside, and the receipt still says its attempt may have been posted. + await h.turn([ + createMockResponse([text('dismissed after doubt'), explicitSend('s3')], 'tool_use'), + createMockResponse([drafts('r3', { action: 'resend', draftIds: [ids[4]], destination: ROOM })], 'tool_use'), + createMockResponse([drafts('x2', { action: 'dismiss', draftIds: [ids[4]] })], 'tool_use'), + createMockResponse([]), + ]); + assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, new RegExp( + `^\\[delivered\\] nothing confirmed — draft ${ids[4]} dismissed by you, but ${ids[4]}'s attempt to #room \\(Guild One\\) \\(.*${ROOM}\\) at .+ ` + + 'may already have been posted: .+$')); + } finally { + await h.close(); + } + }); + + it('hybrid: a held round starts from the state earlier rounds leave, even while an earlier delivery is still in flight', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + let release: (() => void) | undefined; + try { + // Round 1's delivery blocks; its `>>>skip_reply` waits behind it on the + // speech chain. Round 2's held prose must not run ahead of it. + const registry = (h.framework as unknown as { channelRegistry: { deliverSpeech: (...args: unknown[]) => Promise } }).channelRegistry; + const deliver = registry.deliverSpeech.bind(registry); + let entered = false; + registry.deliverSpeech = async (...args: unknown[]) => { + if (args[1] === 'slow prior') { + entered = true; + await new Promise((resolve) => { release = resolve; }); + } + return deliver(...args); + }; + const running = h.turn(rounds( + [text(`>>>${ROOM} slow prior\n>>>skip_reply private aside`), call('l1', 'channel_list', {})], + [text('later private prose'), explicitSend('s1')], + )); + await h.until(() => entered && (h.membrane.lastStream?.receivedToolResults.length ?? 0) >= 2, 'both rounds while the delivery is blocked'); + assert.deepEqual(h.drafts(), [], 'the held round waits for the earlier private transition'); + release!(); + await running; + assert.deepEqual(h.drafts(), [], 'the later prose stayed private'); + assert.deepEqual(h.publishes().map((p) => p.text).sort(), ['an explicit note elsewhere', 'slow prior']); + assert.match(h.texts().filter((t) => t.startsWith('[delivered]')).at(-1)!, /1 plain-speech segment\(s\) kept private/); + } finally { + release?.(); + await h.close(); + } + }); + + it('hybrid: the held-draft notice gives a copied, possibly-posted draft its risk and the confirmation it needs', async () => { + const h = await harness({ agents: [{ name: 'scout', proseRouting: 'hybrid' }] }); + try { + const original = h.store().hold('scout', [{ text: 'possibly sent', source }], 'bounced').held[0]!; + h.store().beginAttempt('scout', original.id, dest(), 'unsent-token', false); // its outcome never recorded + await h.turn(rounds([text(`plain words\n>>>${ROOM} {{unsent}} and a new line`), explicitSend('s1')])); + const free = h.drafts().find((d) => d.text === 'plain words')!; + const copy = h.drafts().find((d) => d.text === 'possibly sent and a new line')!; + assert.equal(draftState(free), 'held'); + assert.equal(draftState(copy), 'unconfirmed'); + const notice = h.messages().find((m) => (m.metadata as { kind?: string; draftIds?: string[] } | undefined)?.kind === 'prose-drafts' + && (m.metadata as { draftIds?: string[] }).draftIds?.includes(copy.id))!; + const words = notice.content.map((b) => (b as { text?: string }).text ?? '').join(''); + assert.ok(words.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) (discord / ${ROOM})`), words); + assert.ok(words.includes(`so check that channel before resending ${copy.id} (drafts(action: "resend", draftIds: ["${copy.id}"], destination: "#channel", confirmDuplicate: true))`), words); + assert.ok(!words.includes(`draftIds: ["${free.id}", "${copy.id}"], destination`), 'no plain resend offered for the copy'); + } finally { + 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 015b1594e..4fd2ff4f7 100644 --- a/test/prose-segments.test.ts +++ b/test/prose-segments.test.ts @@ -1,7 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { ContentBlock } from '@animalabs/membrane'; -import { splitProseSegments } from '../src/prose-segments.js'; +import { splitProseRuns } from '../src/prose-segments.js'; + +/** The runs as speech routes them: each trimmed, as the framework does. */ +const splitProseSegments = (content: Parameters[0]): string[] => + splitProseRuns(content).map((run) => run.trim()); const textBlock = (text: string): ContentBlock => ({ type: 'text', text } as ContentBlock); const toolUse = (id: string): ContentBlock => @@ -60,3 +64,24 @@ test('a plain no-tool turn yields a single segment (unchanged behaviour)', () => test('a tool-only turn (no prose) yields no segments', () => { assert.deepEqual(splitProseSegments([toolUse('t1'), toolResult('t1')]), []); }); + +test('runs keep the words exactly as written; segments are those runs trimmed for routing', () => { + const content: ContentBlock[] = [ + textBlock(' indented code\n second line\n'), + toolUse('t1'), toolResult('t1'), + textBlock('\n\nAfter the tool. '), + toolUse('t2'), toolResult('t2'), + textBlock(' \n\t'), + ]; + assert.deepEqual(splitProseRuns(content), [' indented code\n second line\n', '\n\nAfter the tool. ']); + assert.deepEqual(splitProseSegments(content), ['indented code\n second line', 'After the tool.']); +}); + +test('a refused XML attempt and its notice are boundaries too, and a turn-ending attempt leaves only its prose', () => { + const attempt = { type: 'tool_attempt', rawXml: '' } as unknown as ContentBlock; + const notice = { type: 'tool_notice', notices: [{ ordinal: 0, kind: 'refused', message: 'boundary' }] } as unknown as ContentBlock; + assert.deepEqual(splitProseSegments([textBlock('Before the attempt.'), attempt, notice, textBlock('After its notice.')]), + ['Before the attempt.', 'After its notice.']); + assert.deepEqual(splitProseSegments([textBlock('Again.'), attempt]), ['Again.']); + assert.deepEqual(splitProseRuns([textBlock('Before. '), attempt, notice, textBlock(' After.')]), ['Before. ', ' After.']); +}); diff --git a/test/prose-stream-router.test.ts b/test/prose-stream-router.test.ts deleted file mode 100644 index fa177c918..000000000 --- a/test/prose-stream-router.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// ProseStreamRouter: streamed routing must agree with the delivery path's -// grammar (prose-grammar.parseProsePrefix + the envelope splitter) regardless -// of how the token stream is chopped. The last test is the property that -// matters most: char-by-char feeding ≡ one-shot feeding. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { ProseStreamRouter, type RoutedDelta } from '../src/mcpl/prose-stream-router.js'; - -const RESOLVE: Record = { - '#general': 'discord:g:100', - '#ops': 'discord:g:200', - '@sol': 'discord:dm:300', -}; - -function explicit(): ProseStreamRouter { - return new ProseStreamRouter({ - mode: 'explicit', - initialTarget: null, - resolve: (spec) => RESOLVE[spec] ?? null, - }); -} - -function run(r: ProseStreamRouter, text: string, chunk = text.length): RoutedDelta[] { - const out: RoutedDelta[] = []; - for (let i = 0; i < text.length; i += chunk) out.push(...r.feed(text.slice(i, i + chunk))); - out.push(...r.finish()); - return out; -} - -function textFor(deltas: RoutedDelta[], ch: string): string { - return deltas.filter((d) => d.channelId === ch).map((d) => d.delta).join(''); -} - -test('locus mode: pure passthrough, >> lines are plain text', () => { - const r = new ProseStreamRouter({ mode: 'locus', initialTarget: 'discord:g:100', resolve: () => null }); - const out = run(r, 'hello\n>>#ops not a prefix here\nbye'); - assert.equal(textFor(out, 'discord:g:100'), 'hello\n>>#ops not a prefix here\nbye'); -}); - -test('explicit: unprefixed leading text is suppressed (delivery would bounce it)', () => { - const out = run(explicit(), 'no destination yet\n>>#general now routed'); - assert.equal(textFor(out, 'discord:g:100'), 'now routed'); - assert.equal(out.every((d) => !d.delta.includes('no destination')), true); -}); - -test('explicit: multi-envelope switching, prefixes swallowed', () => { - const out = run(explicit(), '>>#general hello there\nstill general\n>>#ops aside for ops\n>>@sol dm line'); - assert.equal(textFor(out, 'discord:g:100'), 'hello there\nstill general\n'); - assert.equal(textFor(out, 'discord:g:200'), 'aside for ops\n'); - assert.equal(textFor(out, 'discord:dm:300'), 'dm line'); -}); - -test('quoted `>> arrow` and lone `>` stay body text', () => { - const out = run(explicit(), '>>#general look:\n>> quoted arrow\n> markdown quote\ndone'); - assert.equal(textFor(out, 'discord:g:100'), 'look:\n>> quoted arrow\n> markdown quote\ndone'); -}); - -test('skip_reply suppresses; later envelope resumes', () => { - const out = run(explicit(), '>>skip_reply private thought\n>>#ops public line'); - assert.equal(out.every((d) => !d.delta.includes('private')), true); - assert.equal(textFor(out, 'discord:g:200'), 'public line'); -}); - -test('unresolvable target suppresses that envelope only', () => { - const out = run(explicit(), '>>#nonexistent to nowhere\n>>#general to somewhere'); - assert.equal(out.every((d) => !d.delta.includes('nowhere')), true); - assert.equal(textFor(out, 'discord:g:100'), 'to somewhere'); -}); - -test('` !` continuation modifier is swallowed; `!` as body survives', () => { - const out = run(explicit(), '>>#general ! next turn continues\n>>#ops !bang body'); - assert.equal(textFor(out, 'discord:g:100'), 'next turn continues\n'); - // grammar: `([ \t]+!)?` — the first ! after whitespace is the modifier, - // remainder is body (matches parseProsePrefix on ">>#ops !bang body"). - assert.equal(textFor(out, 'discord:g:200'), 'bang body'); -}); - -test('prefix on its own line routes the following lines', () => { - const out = run(explicit(), '>>#general\nbody on next line\nand more'); - assert.equal(textFor(out, 'discord:g:100'), 'body on next line\nand more'); -}); - -test('byChannel accumulates exactly the emitted text', () => { - const r = explicit(); - run(r, '>>#general one\n>>#ops two'); - assert.equal(r.byChannel().get('discord:g:100'), 'one\n'); - assert.equal(r.byChannel().get('discord:g:200'), 'two'); -}); - -test('PROPERTY: char-by-char ≡ one-shot, across every chunk size', () => { - const text = 'lead-in ignored\n>>#general alpha\nbeta > gamma\n>> not a prefix\n>>#ops ! delta\n>>skip_reply hidden\n>>#general {{unsent}} tail'; - const oneShot = run(explicit(), text); - for (const size of [1, 2, 3, 5, 7, 11]) { - const chunked = run(explicit(), text, size); - for (const ch of Object.values(RESOLVE)) { - assert.equal(textFor(chunked, ch), textFor(oneShot, ch)); - } - } -}); - -function hybrid(): ProseStreamRouter { - return new ProseStreamRouter({ - mode: 'hybrid', - initialTarget: 'world:commons', - resolve: (spec) => RESOLVE[spec] ?? (spec === 'world:commons' ? 'world:commons' : null), - }); -} - -test('hybrid: unprefixed prose stays on the frozen locus', () => { - const out = run(hybrid(), 'ordinary world speech'); - assert.equal(textFor(out, 'world:commons'), 'ordinary world speech'); -}); - -test('hybrid: triple-arrow target switches publication and consumes the envelope', () => { - const out = run(hybrid(), '>>>#general cafe body'); - assert.equal(textFor(out, 'discord:g:100'), 'cafe body'); - assert.equal(out.every(d => !d.delta.includes('>>>')), true); - assert.equal(textFor(out, 'world:commons'), ''); -}); - -test('hybrid: permissive whitespace and cross-surface canonical ids', () => { - const out = run(hybrid(), ' >>> #ops spaced\n\t>>> world:commons home'); - assert.equal(textFor(out, 'discord:g:200'), 'spaced\n'); - assert.equal(textFor(out, 'world:commons'), 'home'); -}); - -test('hybrid: double arrows and mid-line triple arrows remain ordinary locus prose', () => { - const text = '>>#ops old syntax is prose\nlook >>>#ops quoted'; - const out = run(hybrid(), text); - assert.equal(textFor(out, 'world:commons'), text); - assert.equal(textFor(out, 'discord:g:200'), ''); -}); - -test('hybrid: missing target suppresses that envelope; a later valid envelope resumes', () => { - const out = run(hybrid(), '>>>#missing nowhere\nplain remains on failed target\n>>>world:commons restored'); - assert.equal(out.every(d => !d.delta.includes('nowhere') && !d.delta.includes('plain remains')), true); - assert.equal(textFor(out, 'world:commons'), 'restored'); -}); - -test('hybrid PROPERTY: char-by-char equals one-shot', () => { - const text = 'locus first\n>>>#general alpha\nbeta\n>>> #ops delta\n>>>world:commons home'; - const one = run(hybrid(), text); - for (const size of [1, 2, 3, 4, 7, 11]) { - const chunked = run(hybrid(), text, size); - for (const ch of ['world:commons', ...Object.values(RESOLVE)]) { - assert.equal(textFor(chunked, ch), textFor(one, ch)); - } - } -}); diff --git a/test/publish-outcome.test.ts b/test/publish-outcome.test.ts new file mode 100644 index 000000000..054a1df64 --- /dev/null +++ b/test/publish-outcome.test.ts @@ -0,0 +1,565 @@ +/** + * 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. + * + * Every publish names its place inside the channel (MCPL RFC-011): a thread, + * or the root. It goes only to a channel that declares a publish target, a + * thread only to one that declares `exact`, and a delivery counts only when + * its echo names the place asked for. + */ +import { test, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +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 { McplServerConnection } from '../src/mcpl/server-connection.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +type Publish = (params: { channelId: string; threadId?: string | null }) => Promise; + +function registryWith( + servers: Record Promise }>, + failures: unknown[] = [], +) { + const published: Array<{ serverId: string; channelId: string; threadId?: string | null }> = []; + 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; threadId?: string | null }) => { + published.push({ serverId: id, channelId: params.channelId, ...('threadId' in params ? { threadId: params.threadId } : {}) }); + 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, + () => {}, + () => {}, + { onRouteFailure: (info) => { failures.push(info); } }, + ); + const channels = (registry as unknown as { + channels: Map; open: boolean }>; + }).channels; + /** A registered channel; it declares `target` (default root), or nothing + * for null, and is open unless `open` is false. */ + const seed = (serverId: string, id: string, label: string, target: 'exact' | 'root' | null = 'root', open = true) => + channels.set(`${serverId}:${id}`, { + serverId, + descriptor: { id, type: 'discord', label, ...(target ? { capabilities: { publish: { target } } } : {}) }, + open, + }); + return { registry, published, opened, seed }; +} + +/** A conforming delivery: it echoes the place it was asked for. */ +const ok: Publish = async (params) => ({ delivered: true, messageId: 'posted-1', threadId: params.threadId }); + +describe('ChannelRegistry server lookup and route failures', () => { + it('names the server of an id only one server registered; a shared or unknown id names none', () => { + const { registry, seed } = registryWith({ alpha: { publish: ok }, beta: { publish: ok } }); + seed('alpha', 'solo', '#solo'); + seed('alpha', 'shared', '#shared'); + seed('beta', 'shared', '#shared'); + assert.equal(registry.getChannelServerId('solo'), 'alpha'); + assert.equal(registry.getChannelServerId('shared'), null, 'never whichever server registered it first'); + assert.equal(registry.getChannelServerId('nowhere'), null); + }); + + it('a failed delivery into a thread reports the thread with its channel', async () => { + const failures: Array> = []; + const { registry, seed } = registryWith( + { discord: { publish: async () => ({ delivered: false, reason: 'thread t-1 is archived' }) } }, + failures, + ); + seed('discord', 'forum', '#forum', 'exact'); + await registry.deliverSpeech('agent', 'hello', { serverId: 'discord', channelId: 'forum', threadId: 't-1' }); + assert.equal(failures.length, 1); + assert.equal(failures[0]!.channelId, 'forum'); + assert.equal(failures[0]!.threadId, 't-1'); + await registry.deliverSpeech('agent', 'hello', { serverId: 'discord', channelId: 'forum' }); + assert.equal('threadId' in failures[1]!, false, 'a root delivery names no thread'); + }); +}); + +describe('ChannelRegistry.publish outcomes', () => { + it('confirms only delivered:true, naming the destination from the registry', async () => { + const { registry, seed } = registryWith({ discord: { publish: ok } }); + 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)', threadId: null }); + 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', 'root', 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', threadId: null }]); + + const fails = registryWith({ discord: { publish: ok, open: async () => { throw new Error('no such channel'); } } }); + fails.seed('discord', 'c', '#c', 'root', false); + const outcome = await fails.registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.reason, 'channel is closed and open failed: no such channel'); + assert.deepEqual(fails.opened, [{ serverId: 'discord', channelId: 'c' }]); + assert.deepEqual(fails.published, [], 'nothing dispatched after a failed open'); + }); + + it('refuses a send the grant forbids before opening anything', async () => { + // 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', 'root', 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('a root publish whose receipt says nothing of where it posted is unknown, keeping its message id', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: true, messageId: 'posted-1' }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.equal(outcome.messageId, 'posted-1'); + assert.match(outcome.reason!, /did not say where it posted/); + assert.doesNotMatch(outcome.reason!, /posted (at|in) the channel root/, 'not a claim that it landed at the root'); + }); + + it('a streamed publish is a Notification only when it names no place; a thread, or the root by null, is a Request', () => { + const sent: Array<['request' | 'notification', Record]> = []; + const connection = Object.create(McplServerConnection.prototype) as McplServerConnection; + Object.assign(connection, { + requestTimeoutMs: 0, + publishDeadlineMs: 60_000, + sendRequest: (_method: string, params: Record) => { sent.push(['request', params]); return Promise.resolve({}); }, + sendNotification: (_method: string, params: Record) => { sent.push(['notification', params]); }, + }); + const base = { conversationId: 'c', channelId: 'ch', content: [{ type: 'text' as const, text: 'x' }], stream: true }; + void connection.sendChannelsPublish({ ...base, threadId: 't-1' }); + void connection.sendChannelsPublish({ ...base, threadId: null }); + void connection.sendChannelsPublish(base); + assert.deepEqual(sent.map(([kind, params]) => [kind, params.threadId]), [['request', 't-1'], ['request', null], ['notification', undefined]]); + }); + + it('treats delivered:false with no posted message as failed', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false }) } }); + seed('discord', 'c', '#c'); + 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', -32000, { posted: ['111'] }); } }, + }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.ok(outcome.reason?.includes(partial), 'the connector\'s account is kept verbatim'); + assert.deepEqual(outcome.detail, { posted: ['111'] }); + assert.equal(outcome.destination?.channelId, 'c'); + }); + + it('treats a timeout or a lost connection as unknown', async () => { + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError('MCPL server "discord" did not respond to channels/publish (id=7) within 10ms', 'no-response'); } }, + }); + seed('discord', 'c', '#c'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'unknown'); + }); + + it('treats a request refused before it was written as failed', async () => { + const { registry, seed } = registryWith({ + discord: { publish: async () => { throw new McplRequestError('Cannot send request: connection to "discord" is closed', 'not-sent'); } }, + }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason ?? '', /connection to "discord" is closed/); + }); + + it('fails without a destination for an unknown channel', async () => { + const { registry, published } = registryWith({ discord: { publish: ok } }); + const outcome = await registry.publish('agent', 'x', { channelId: 'nowhere' }); + assert.equal(outcome.status, 'failed'); + assert.equal(outcome.destination, undefined); + assert.equal(published.length, 0); + }); + + it('refuses a channel id two servers share unless the server is named, and then uses exactly that server', async () => { + const { registry, published, seed } = registryWith({ alpha: { publish: ok }, beta: { publish: ok } }); + seed('alpha', 'discord:g1:room', '#room (Alpha)'); + seed('beta', 'discord:g1:room', '#room (Beta)'); + const shared = await registry.publish('agent', 'x', { channelId: 'discord:g1:room' }); + assert.equal(shared.status, 'failed'); + assert.match(shared.reason ?? '', /more than one MCPL server \(alpha, beta\); the destination must name its server/, + 'the refusal names the servers, so the sender has a serverId to give'); + assert.equal(published.length, 0, 'never routed through whichever server came first'); + const exact = await registry.publish('agent', 'x', { serverId: 'beta', channelId: 'discord:g1:room' }); + assert.equal(exact.status, 'delivered'); + assert.deepEqual(published, [{ serverId: 'beta', channelId: 'discord:g1:room', threadId: null }]); + 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('names the root explicitly: a publish without a thread sends threadId null, never omits it', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'c', '#c'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'delivered'); + assert.deepEqual(published, [{ serverId: 'discord', channelId: 'c', threadId: null }]); + }); + + it('posts into a thread only where the channel declares exact targeting', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'forum', '#forum', 'exact'); + seed('discord', 'plain', '#plain', 'root'); + const inThread = await registry.publish('agent', 'x', { channelId: 'forum', threadId: 't-1' }); + assert.equal(inThread.status, 'delivered'); + assert.equal(inThread.destination?.threadId, 't-1'); + const onRoot = await registry.publish('agent', 'x', { channelId: 'plain', threadId: 't-1' }); + assert.equal(onRoot.status, 'failed'); + assert.match(onRoot.reason ?? '', /has no threads/); + assert.deepEqual(published, [{ serverId: 'discord', channelId: 'forum', threadId: 't-1' }], 'the threadless channel saw nothing'); + }); + + it('never publishes to a channel that declares no publish target, and opens nothing first', async () => { + const opened: string[] = []; + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'legacy', '#legacy', null); + const channels = (registry as unknown as { channels: Map }).channels; + channels.get('discord:legacy')!.open = false; + (registry as unknown as { openChannelNow: (e: unknown) => Promise }).openChannelNow = async () => { opened.push('legacy'); }; + const outcome = await registry.publish('agent', 'x', { channelId: 'legacy' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason ?? '', /^#legacy \(discord \/ legacy\)'s connector doesn't declare where a post lands/, + 'the refusal names the channel as every conversation is named, its server included'); + assert.equal(outcome.destination?.channelId, 'legacy'); + assert.deepEqual(published, [], 'the connector would choose the place itself'); + assert.deepEqual(opened, [], 'refused before any side effect'); + }); + + it('refuses an invalid thread id before dispatch', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'forum', '#forum', 'exact'); + for (const threadId of ['', 7 as unknown as string, { id: 't' } as unknown as string]) { + assert.equal((await registry.publish('agent', 'x', { channelId: 'forum', threadId })).status, 'failed', JSON.stringify(threadId)); + } + assert.equal(published.length, 0); + }); + + for (const [what, echo] of [ + ['no echo at all', {}], + ['an echo of the root when a thread was asked for', { threadId: null }], + ['an echo of another thread', { threadId: 't-2' }], + ['an unreadable echo', { threadId: 42 }], + ] as const) { + it(`treats a delivery with ${what} as unknown: something was posted, not provably where asked`, async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: true, messageId: 'p-1', ...echo }) } }); + seed('discord', 'forum', '#forum', 'exact'); + const outcome = await registry.publish('agent', 'x', { channelId: 'forum', threadId: 't-1' }); + assert.equal(outcome.status, 'unknown'); + assert.equal(outcome.messageId, 'p-1'); + assert.match(outcome.reason ?? '', /not thread t-1/); + }); + } + + it('a root publish needs a root echo', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: true, messageId: 'p-1', threadId: 'stray' }) } }); + seed('discord', 'c', '#c'); + const outcome = await registry.publish('agent', 'x', { channelId: 'c' }); + assert.equal(outcome.status, 'unknown'); + assert.match(outcome.reason ?? '', /reported posting in thread stray, not the channel root/); + }); + + it('keeps a refusal\'s reason: delivered:false with no message is failed, in the connector\'s words', async () => { + const { registry, seed } = registryWith({ discord: { publish: async () => ({ delivered: false, reason: 'thread t-1 is archived' }) } }); + seed('discord', 'forum', '#forum', 'exact'); + const outcome = await registry.publish('agent', 'x', { channelId: 'forum', threadId: 't-1' }); + assert.equal(outcome.status, 'failed'); + assert.match(outcome.reason ?? '', /thread t-1 is archived/); + }); + + it('a channels/changed that withdraws the declaration applies to the next publish', async () => { + const { registry, published, seed } = registryWith({ discord: { publish: ok } }); + seed('discord', 'c', '#c', 'root'); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'delivered'); + seed('discord', 'c', '#c', null); + assert.equal((await registry.publish('agent', 'x', { channelId: 'c' })).status, 'failed'); + assert.equal(published.length, 1); + }); + + it('streams only to a declared channel, at its root, on the server the publish resolved (RFC-011 §6)', () => { + const chunks: Array<{ server: string; channelId: string; threadId?: unknown }> = []; + const completes: Array<{ server: string; channelId: string; threadId?: unknown }> = []; + const serverFor = (name: string) => ({ + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsOutgoingChunk: (p: { channelId: string; threadId?: unknown }) => chunks.push({ server: name, channelId: p.channelId, threadId: p.threadId }), + sendChannelsOutgoingComplete: (p: { channelId: string; threadId?: unknown }) => completes.push({ server: name, channelId: p.channelId, threadId: p.threadId }), + }); + const servers: Record> = { discord: serverFor('discord'), slack: serverFor('slack') }; + const registry = new ChannelRegistry( + { getServer: (id: string) => servers[id] } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + {}, + ); + const channels = (registry as unknown as { channels: Map }).channels; + const seedIt = (serverId: string, id: string, target: 'exact' | 'root' | null) => channels.set(`${serverId}:${id}`, { + serverId, + descriptor: { id, type: serverId, label: id, ...(target ? { capabilities: { publish: { target } } } : {}) }, + open: true, + }); + seedIt('discord', 'exact', 'exact'); + seedIt('discord', 'root', 'root'); + seedIt('discord', 'none', null); + // The same id on two servers: the destination's server decides, never a scan of bare ids. + seedIt('slack', 'root', 'root'); + for (const [serverId, channelId] of [['discord', 'exact'], ['discord', 'root'], ['discord', 'none'], ['slack', 'root'], ['discord', 'missing']] as const) { + registry.sendOutgoingChunk({ serverId, channelId }, 'agent', 'inf-1', 0, 'Hello'); + registry.sendOutgoingComplete({ serverId, channelId }, 'agent', 'inf-1', 'Hello'); + } + const expected = [ + { server: 'discord', channelId: 'exact', threadId: null }, + { server: 'discord', channelId: 'root', threadId: null }, + { server: 'slack', channelId: 'root', threadId: null }, + ]; + assert.deepEqual(chunks, expected, 'never to an undeclared or unregistered channel; always the root'); + assert.deepEqual(completes, expected); + }); + + it('routeSpeech reports a failed and an uncertain delivery as different outcomes', async () => { + const failures: Array<{ outcome?: string }> = []; + const { registry, seed } = registryWith({ discord: { publish: async () => ({}) }, other: { publish: async () => ({ delivered: false }) } }, failures); + seed('discord', 'c', '#c'); + seed('other', 'd', '#d'); + assert.equal(await registry.routeSpeech('agent', 'x', 'c'), null); + assert.equal(await registry.routeSpeech('agent', 'x', 'd'), null); + assert.deepEqual(failures.map((f) => f.outcome), ['unknown', 'failed']); + }); +}); + +test('the resident\'s failure marker never claims an uncertain delivery did not happen', async () => { + const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-')); + const commandPath = join(dir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { STATUS_PATH: join(dir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + try { + await framework.start(); + const registry = (framework as unknown as { channelRegistry: { listChannelsRaw(): unknown[] } }).channelRegistry; + const until = async (cond: () => boolean, what: string) => { + const deadline = Date.now() + 15_000; + while (!cond()) { if (Date.now() > deadline) throw new Error(`timed out: ${what}`); await new Promise((r) => setTimeout(r, 20)); } + }; + await until(() => registry.listChannelsRaw().length >= 2, 'registration'); + const markers = () => framework.getAgent('scout')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'discord-send-failed') + .map((m) => (m.content[0] as { text: string }).text); + const turn = async (mode: string, n: number) => { + appendFileSync(commandPath, JSON.stringify({ op: 'publish-mode', mode }) + '\n'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: `reply ${n}` }])); + appendFileSync(commandPath, JSON.stringify({ op: 'incoming', channelId: 'discord:g1:room', messageId: `m-${n}`, mode: 'addressed', text: 'hello?' }) + '\n'); + await until(() => markers().length === n, `marker ${n}`); + await framework.runUntilIdle(); + }; + await turn('no-receipt', 1); + await turn('not-delivered', 2); + const [uncertain, failed] = markers(); + assert.match(uncertain!, /was not confirmed/); + assert.match(uncertain!, /may or may not have been posted/); + assert.doesNotMatch(uncertain!, /did not receive|Nothing was posted/); + assert.match(failed!, /could not be delivered/); + assert.match(failed!, /Nothing was posted/); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a channel opened without the agent asking is announced by its server too', async () => { + const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-open-')); + const commandPath = join(dir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { STATUS_PATH: join(dir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + try { + await framework.start(); + // The real registry's callback, as a policy admission calls it: one + // server, its channels with their own labels. + const registry = (framework as unknown as { channelRegistry: { + onChannelAutoOpened(info: { serverId: string; source: 'subscription-policy'; channels: Array<{ channelId: string; label?: string }> }): void; + } }).channelRegistry; + registry.onChannelAutoOpened({ serverId: 'beta', source: 'subscription-policy', channels: [{ channelId: 'general', label: '#general' }] }); + const notice = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((t) => t.startsWith('[channels] Now open:')); + assert.match(notice ?? '', /^\[channels\] Now open: #general \(beta \/ general\) — your subscription policy just admitted it\./, + 'named as every conversation is, its server included'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a failure marker for a thread route names its server and thread, and says to check the thread', async () => { + const dir = mkdtempSync(join(tmpdir(), 'publish-outcome-thread-')); + const commandPath = join(dir, 'commands.jsonl'); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs')], + env: { + STATUS_PATH: join(dir, 'status.jsonl'), + COMMAND_PATH: commandPath, + CHANNELS: JSON.stringify([{ id: 'discord:g1:forum', label: '#forum (Guild One)', publishTarget: 'exact' }]), + }, + }], + modules: [], + }); + try { + await framework.start(); + const registry = (framework as unknown as { channelRegistry: { listChannelsRaw(): unknown[] } }).channelRegistry; + const until = async (cond: () => boolean, what: string) => { + const deadline = Date.now() + 15_000; + while (!cond()) { if (Date.now() > deadline) throw new Error(`timed out: ${what}`); await new Promise((r) => setTimeout(r, 20)); } + }; + await until(() => registry.listChannelsRaw().length >= 1, 'registration'); + const markers = () => framework.getAgent('scout')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'discord-send-failed'); + appendFileSync(commandPath, JSON.stringify({ op: 'publish-mode', mode: 'no-receipt' }) + '\n'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'reply in the thread' }])); + appendFileSync(commandPath, JSON.stringify({ + op: 'incoming', channelId: 'discord:g1:forum', threadId: 't1', messageId: 'm-1', mode: 'addressed', text: 'hello?', + }) + '\n'); + await until(() => markers().length === 1, 'marker'); + await framework.runUntilIdle(); + const [marker] = markers(); + const text = (marker!.content[0] as { text: string }).text; + assert.match(text, /to #forum \(Guild One\) \(discord \/ discord:g1:forum, thread t1\) was not confirmed/, + 'named as every conversation is: label, server / channel, thread'); + assert.match(text, /check the thread before sending it again/); + assert.equal((marker!.metadata as { threadId?: string }).threadId, 't1'); + assert.equal((marker!.metadata as { serverId?: string }).serverId, 'discord'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/push-gate-metadata.test.ts b/test/push-gate-metadata.test.ts new file mode 100644 index 000000000..5fbe62f47 --- /dev/null +++ b/test/push-gate-metadata.test.ts @@ -0,0 +1,138 @@ +/** + * The push lane's gate metadata: what a server puts in a push/event's + * `origin` is the server's own account, and can never stand in for which + * server sent it, what kind of event it is, or the conversation its frozen + * source envelope names (MCPL RFC-011 binds the thread the origin names, but + * the host's fields and envelope are the host's). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PushHandler } from '../src/mcpl/push-handler.js'; +import { INBOUND_SOURCE_KEY, type InboundSource } from '../src/mcpl/inbound-source.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; + +test('origin keys cannot override the host\'s fields, and the envelope rides last', async () => { + const seen: Array> = []; + const envelope: InboundSource = { + kind: 'channel', lane: 'push/event', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', + threadId: '1700.0001', messageId: 'm-1', eventId: 'ev-1', acceptedAt: 1, + }; + let accepted = 0; + const handler = new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + () => {}, + () => {}, + (_content, metadata) => { seen.push(metadata); return true; }, + undefined, + () => { accepted++; return envelope; }, + ); + await handler.handlePushEvent('slack', { + featureSet: 'chat', + eventId: 'ev-1', + timestamp: '2026-10-07T00:00:00.000Z', + origin: { serverId: 'forged', eventType: 'forged', featureSet: 'forged', eventId: 'forged', threadId: '1700.0001', [INBOUND_SOURCE_KEY]: { forged: true } }, + payload: { content: [{ type: 'text', text: 'hi' }] }, + } as never); + assert.equal(accepted, 1, 'the envelope is frozen once, before the gate reads it'); + assert.equal(seen.length, 1); + const m = seen[0]!; + assert.equal(m.serverId, 'slack'); + assert.equal(m.eventType, 'mcpl:push-event'); + assert.equal(m.featureSet, 'chat'); + assert.equal(m.eventId, 'ev-1'); + assert.equal(m.threadId, '1700.0001', 'the origin\'s own (RFC-011-bound) thread is kept'); + assert.deepEqual(m[INBOUND_SOURCE_KEY], envelope, 'the frozen envelope, not anything the origin carried under its key'); +}); + +test('a coalesced push carries its gate inputs, with the host envelope its coalescer will freeze, and nothing is gated or accepted yet', async () => { + const seen: Array> = []; + const envelope: InboundSource = { + kind: 'channel', lane: 'push/event', coalesced: true, serverId: 'slack', binding: 'b1', channelId: 'slack:C1', + messageId: 'm-1', eventId: 'ev-c1', acceptedAt: 1, + }; + let accepted = 0; + let built = 0; + const coalescedEvents: Array<{ inboundSource?: InboundSource; triggerInputs?: { content: string; metadata: Record } }> = []; + const handler = new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + () => {}, + () => {}, + (_content, metadata) => { seen.push(metadata); return false; }, + async (_serverId, _params, event) => { coalescedEvents.push(event); return { accepted: true } as never; }, + () => { accepted++; return undefined; }, + (_serverId, params) => { + built++; + assert.equal((params as { coalesce?: { channelId?: string } }).coalesce?.channelId, 'slack:C1'); + return envelope; + }, + ); + await handler.handlePushEvent('slack', { + featureSet: 'chat', + eventId: 'ev-c1', + timestamp: '2026-10-07T00:00:00.000Z', + // The subject names the channel; the origin names another, and carries a key posing as an envelope. + coalesce: { key: 'k1', channelId: 'slack:C1' }, + origin: { channelId: 'slack:OTHER', messageId: 'm-1', [INBOUND_SOURCE_KEY]: { forged: true } }, + payload: { content: [{ type: 'text', text: 'hi' }] }, + } as never); + assert.equal(built, 1); + assert.equal(accepted, 0, 'building a coalesced envelope accepts nothing: the coalescer observes its own admissions'); + assert.equal(seen.length, 0, 'the gate is not consulted before the coalescer admits the occurrence'); + assert.equal(coalescedEvents.length, 1); + const inputs = coalescedEvents[0]!.triggerInputs!; + assert.deepEqual(inputs.metadata[INBOUND_SOURCE_KEY], envelope, 'the gate will read the host envelope, not the origin\'s channel or key'); + assert.equal(inputs.content, 'hi'); + assert.equal(coalescedEvents[0]!.inboundSource, envelope, 'the coalescer gets the very envelope the gate will read'); + assert.deepEqual(structuredClone(inputs), inputs, 'the inputs are plain data, so a persisted occurrence keeps them'); +}); + +test('with no host envelope, an origin key can\'t pose as one', async () => { + const seen: Array> = []; + const handler = new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + () => {}, + () => {}, + (_content, metadata) => { seen.push(metadata); return true; }, + ); + const forged = { + kind: 'channel', lane: 'push/event', serverId: 'other-server', binding: 'b', channelId: 'other:C9', acceptedAt: 1, + }; + await handler.handlePushEvent('slack', { + featureSet: 'chat', + eventId: 'ev-2', + timestamp: '2026-10-07T00:00:00.000Z', + origin: { [INBOUND_SOURCE_KEY]: forged }, + payload: { content: [{ type: 'text', text: 'hi' }] }, + } as never); + assert.equal(seen.length, 1); + assert.ok(INBOUND_SOURCE_KEY in seen[0]!, 'the key is the host\'s, present even when it has no envelope'); + assert.equal(seen[0]![INBOUND_SOURCE_KEY], undefined); +}); + +test('an ordinary push is accepted only once its trigger filter returns: a filter that throws accepts nothing', async () => { + const envelope: InboundSource = { + kind: 'channel', lane: 'push/event', serverId: 'slack', binding: 'b1', channelId: 'slack:C1', eventId: 'ev-3', acceptedAt: 1, + }; + const observed: InboundSource[] = []; + const queued: unknown[] = []; + const handlerWith = (filter: () => boolean) => new PushHandler( + { validateInbound: () => {} } as unknown as FeatureSetManager, + (event) => queued.push(event), + () => {}, + filter, + undefined, + () => envelope, + undefined, + (source) => observed.push(source), + ); + const push = (eventId: string) => ({ + featureSet: 'chat', eventId, timestamp: '2026-10-07T00:00:00.000Z', + origin: { channelId: 'C1' }, payload: { content: [{ type: 'text', text: 'hi' }] }, + }); + await assert.rejects(handlerWith(() => { throw new Error('filter broke'); }).handlePushEvent('slack', push('ev-3') as never), /filter broke/); + assert.deepEqual(observed, [], 'nothing accepted'); + assert.deepEqual(queued, [], 'nothing queued'); + await handlerWith(() => false).handlePushEvent('slack', push('ev-4') as never); + assert.deepEqual(observed, [envelope], 'accepted once the filter returned, as it is queued'); + assert.equal(queued.length, 1); +}); diff --git a/test/record-journal.test.ts b/test/record-journal.test.ts new file mode 100644 index 000000000..3618ae077 --- /dev/null +++ b/test/record-journal.test.ts @@ -0,0 +1,203 @@ +/** + * RecordJournal — typed chronicle records that don't follow branch switches, + * replayed from the latest checkpoint, with the two durability barriers named. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { RecordJournal } from '../src/record-journal.js'; + +const KILL_FIXTURE = join(import.meta.dirname, 'fixtures/record-journal-kill.mjs'); + +function withStore(fn: (store: JsStore, path: string) => T): T { + const dir = mkdtempSync(join(tmpdir(), 'record-journal-')); + const path = join(dir, 'store'); + const store = JsStore.openOrCreate({ path }); + try { + return fn(store, path); + } finally { + if (!store.isClosed()) store.close(); + rmSync(dir, { recursive: true, force: true }); + } +} + +type Entry = { n: string }; +const entriesOf = (journal: RecordJournal): string[] => journal.load().entries.map((e) => e.entry.n); + +describe('RecordJournal', () => { + it('an empty journal loads nothing', () => { + withStore((store) => { + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + assert.deepStrictEqual(journal.load(), { snapshot: null, entries: [] }); + assert.strictEqual(journal.entriesSinceCheckpoint, 0); + }); + }); + + it('append returns where the record landed; a fresh load replays entries in append order', () => { + withStore((store) => { + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + const a = journal.append({ n: 'a' }); + const b = journal.append({ n: 'b' }); + assert.match(a.id, /^\d+$/); + assert.ok(BigInt(b.id) > BigInt(a.id), 'ids increase in append order'); + assert.strictEqual(typeof a.sequence, 'number'); + const loaded = new RecordJournal(store, { type: 'journal-test/entry' }).load(); + assert.deepStrictEqual(loaded.entries, [ + { id: a.id, sequence: a.sequence, entry: { n: 'a' } }, + { id: b.id, sequence: b.sequence, entry: { n: 'b' } }, + ]); + }); + }); + + it('entries from every branch load from any branch, in global order, through deleteBranch and reopen', () => { + withStore((store, path) => { + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + journal.append({ n: 'main-0' }); + const fork = store.currentSequence(); + journal.append({ n: 'main-1' }); + const main = store.currentBranch().name; + store.createBranchAt('rollback', main, fork); + store.switchBranch('rollback'); + journal.append({ n: 'rollback-2' }); + assert.deepStrictEqual(entriesOf(journal), ['main-0', 'main-1', 'rollback-2'], + 'the rolled-back branch still sees what main appended after the fork'); + store.switchBranch(main); + journal.append({ n: 'main-3' }); + store.deleteBranch('rollback'); + assert.deepStrictEqual(entriesOf(journal), ['main-0', 'main-1', 'rollback-2', 'main-3']); + store.close(); + const reopened = JsStore.openOrCreate({ path }); + try { + assert.deepStrictEqual(entriesOf(new RecordJournal(reopened, { type: 'journal-test/entry' })), + ['main-0', 'main-1', 'rollback-2', 'main-3']); + } finally { + reopened.close(); + } + }); + }); + + it('load returns the latest checkpoint and only the entries after it; other records do not disturb it', () => { + withStore((store) => { + store.registerState({ id: 'journal-test/tree', strategy: 'tree' }); + const journal = new RecordJournal(store, { type: 'journal-test/entry' }); + const other = new RecordJournal(store, { type: 'journal-test/other' }); + journal.append({ n: 'a' }); + other.append({ n: 'x' }); + journal.append({ n: 'b' }); + journal.checkpoint({ seen: ['a', 'b'] }); + assert.strictEqual(journal.entriesSinceCheckpoint, 0); + store.treeSet('journal-test/tree', 'f.txt', { blobHash: 'f'.repeat(64), size: 1, mode: 0o644 }); + journal.append({ n: 'c' }); + other.append({ n: 'y' }); + assert.strictEqual(journal.entriesSinceCheckpoint, 1); + + const reader = new RecordJournal(store, { type: 'journal-test/entry' }); + const first = reader.load(); + assert.deepStrictEqual(first.snapshot, { seen: ['a', 'b'] }); + assert.deepStrictEqual(first.entries.map((e) => e.entry.n), ['c']); + assert.strictEqual(reader.entriesSinceCheckpoint, 1); + + // A checkpoint after load covers what was loaded; the newest one wins. + reader.checkpoint({ seen: ['a', 'b', 'c'] }); + const second = new RecordJournal(store, { type: 'journal-test/entry' }).load(); + assert.deepStrictEqual(second, { snapshot: { seen: ['a', 'b', 'c'] }, entries: [] }); + assert.deepStrictEqual(entriesOf(new RecordJournal(store, { type: 'journal-test/other' })), ['x', 'y']); + }); + }); + + it('names its two barriers: afterCommittedState syncs before the append, durable after it, neither by default', () => { + withStore((store) => { + const calls: string[] = []; + const recording = { + getRecordIdsByType: (type: string) => store.getRecordIdsByType(type), + getRecord: (id: string) => store.getRecord(id), + appendJson: (type: string, data: unknown) => { calls.push('append'); return store.appendJson(type, data); }, + sync: () => { calls.push('sync'); store.sync(); }, + } as unknown as JsStore; + const journal = new RecordJournal(recording, { type: 'journal-test/entry' }); + journal.append({ n: 'plain' }); + assert.deepStrictEqual(calls.splice(0), ['append'], 'a plain append does not sync'); + journal.append({ n: 'asserting' }, { afterCommittedState: true }); + assert.deepStrictEqual(calls.splice(0), ['sync', 'append']); + journal.append({ n: 'durable' }, { durable: true }); + assert.deepStrictEqual(calls.splice(0), ['append', 'sync']); + journal.append({ n: 'both' }, { afterCommittedState: true, durable: true }); + assert.deepStrictEqual(calls.splice(0), ['sync', 'append', 'sync']); + journal.checkpoint({ n: 1 }, { durable: true }); + assert.deepStrictEqual(calls.splice(0), ['append', 'sync']); + }); + }); + + it('after a hard kill, an afterCommittedState entry never survives without the state it asserts', () => { + withStore((store, path) => { + store.registerState({ id: 'journal-test/tree', strategy: 'tree' }); + store.sync(); + store.close(); + const child = spawnSync(process.execPath, [KILL_FIXTURE, path, 'asserting'], { encoding: 'utf-8' }); + assert.strictEqual(child.signal, 'SIGKILL', `the child died by SIGKILL (stderr: ${child.stderr})`); + const reopened = JsStore.openOrCreate({ path }); + try { + try { reopened.registerState({ id: 'journal-test/tree', strategy: 'tree' }); } catch { /* exists */ } + const entries = new RecordJournal<{ asserts: string }, unknown>(reopened, { type: 'journal-test/entry' }).load().entries; + assert.strictEqual(entries.length, 1, 'the appended entry survived the kill'); + assert.notStrictEqual(reopened.treeGet('journal-test/tree', 'committed.txt'), null, + 'the state it asserts survived with it'); + } finally { + reopened.close(); + } + }); + }); + + it('a write that fails after reaching the store blocks further writes until load() replays it', () => { + withStore((store) => { + let failNextSync = false; + const flaky = { + getRecordIdsByType: (type: string) => store.getRecordIdsByType(type), + getRecord: (id: string) => store.getRecord(id), + appendJson: (type: string, data: unknown) => store.appendJson(type, data), + sync: () => { + if (failNextSync) { failNextSync = false; throw new Error('disk full'); } + store.sync(); + }, + } as unknown as JsStore; + const journal = new RecordJournal(flaky, { type: 'journal-test/entry' }); + + // The durable barrier fails after A reached the store: the caller never reduces A. + failNextSync = true; + assert.throws(() => journal.append({ n: 'A' }, { durable: true }), /disk full/); + assert.strictEqual(journal.needsReconcile, true); + // Without reconciliation, a later write and checkpoint would cover A unreduced. + assert.throws(() => journal.append({ n: 'B' }), /load\(\) and reduce its tail before writing again/); + assert.throws(() => journal.checkpoint({ seen: ['B'] }), /load\(\) and reduce its tail/); + + const reconciled = journal.load(); + assert.deepStrictEqual(reconciled.entries.map((e) => e.entry.n), ['A'], 'load replays the ambiguous record'); + assert.strictEqual(journal.needsReconcile, false); + journal.append({ n: 'B' }); + journal.checkpoint({ seen: ['A', 'B'] }); + assert.deepStrictEqual(new RecordJournal(store, { type: 'journal-test/entry' }).load(), + { snapshot: { seen: ['A', 'B'] }, entries: [] }); + + // A failure BEFORE the record reaches the store wrote nothing and leaves the journal usable. + failNextSync = true; + assert.throws(() => journal.append({ n: 'C' }, { afterCommittedState: true }), /disk full/); + assert.strictEqual(journal.needsReconcile, false); + journal.append({ n: 'D' }); + assert.deepStrictEqual( + new RecordJournal(store, { type: 'journal-test/entry' }).load().entries.map((e) => e.entry.n), + ['D'], + ); + }); + }); + + it('refuses a checkpoint type equal to the entry type', () => { + withStore((store) => { + assert.throws(() => new RecordJournal(store, { type: 't', checkpointType: 't' }), /distinct record types/); + }); + }); +}); diff --git a/test/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index b3b3f252e..5d5831419 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -126,9 +126,8 @@ function makeChannelRegistry(framework: AgentFramework) { routeSpeech: async (_agent: string, text: string) => { routed.push(text); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, + getDescriptor: () => undefined, publishTarget: () => 'root', }; (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy(explicit, { get: (target, prop: string) => (prop in target ? target[prop as keyof typeof target] : () => undefined), diff --git a/test/silent-heartbeat-rows.test.ts b/test/silent-heartbeat-rows.test.ts index 618d45257..c3acdba82 100644 --- a/test/silent-heartbeat-rows.test.ts +++ b/test/silent-heartbeat-rows.test.ts @@ -51,6 +51,9 @@ function ordinaryEvent(text: string) { return { type: 'mcpl:push-event', serverId: 'notes', featureSet: 'notes', eventId: `ev-${n}`, content: [{ type: 'text', text }], timestamp: new Date().toISOString(), + // Names its channel, so the turn has a speech route (shelf-355: speech + // goes only to a conversation that woke the turn, never a fallback). + origin: { mcplChannelId: 'world:commons' }, inferenceId: `inf-ev-${n}`, triggerInference: true, }; } @@ -105,10 +108,13 @@ async function make(opts: { modules: [mod], }); (framework as any).channelRegistry = new Proxy({ - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', + resolveLocus: () => 'world:commons', routeSpeech: async () => ({ delivered: opts.deliver ?? true, channelId: 'world:commons' }), startTyping: () => {}, stopTyping: () => {}, getChannelTools: () => [], getDescriptor: () => undefined, + // The channel declares a root publish target (MCPL RFC-011), so the + // route is publishable. + publishTarget: () => 'root', isChannelOpen: () => true, }, { get: (t, p: string) => p in t ? (t as any)[p] : () => undefined }); return { dir, membrane, mod, framework }; } diff --git a/test/silent-heartbeat.test.ts b/test/silent-heartbeat.test.ts index 328f60de8..9226f7156 100644 --- a/test/silent-heartbeat.test.ts +++ b/test/silent-heartbeat.test.ts @@ -45,11 +45,11 @@ async function make() { const routed: string[] = []; const typing: string[] = []; (framework as any).channelRegistry = new Proxy({ - resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', + resolveLocus: () => 'world:commons', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:commons' }; }, startTyping: (channel: string) => { typing.push(channel); }, stopTyping: () => {}, - getChannelTools: () => [], getDescriptor: () => undefined, + getChannelTools: () => [], getDescriptor: () => undefined, publishTarget: () => 'root', }, { get: (t, p: string) => p in t ? (t as any)[p] : () => undefined }); return { dir, membrane, mod, framework, routed, typing }; } diff --git a/test/speech-route-turns.test.ts b/test/speech-route-turns.test.ts new file mode 100644 index 000000000..00a1223cc --- /dev/null +++ b/test/speech-route-turns.test.ts @@ -0,0 +1,371 @@ +/** + * The turn's speech route across a logical turn (shelf-355): decided once at + * a true new turn, kept by continuations and retries (including a route the + * resident chose with channel_open), and decided afresh — never inherited — + * by the next true new turn. A local surface route shows speech there and + * publishes nothing. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import type { InferenceRequest, ProcessEvent, Module, ModuleContext, ProcessState, EventResponse, ToolDefinition, ToolCall, ToolResult } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { ContentBlock } from '@animalabs/membrane'; +import type { RouteCandidate, SpeechRoute } from '../src/speech-routes.js'; + +type Route = { route: { kind: string; channelId?: string; surface?: string; origin?: string } | null; hold?: unknown }; + +function internals(framework: AgentFramework) { + return framework as unknown as { + turnRoutes: Map; + startAgentStream(agent: unknown, trigger?: InferenceRequest, attempt?: number): Promise; + channelRegistry: unknown; + conversationAgentHomes: Map; + }; +} + +class SurfaceModule implements Module { + readonly name = 'console'; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { return []; } + async handleToolCall(_call: ToolCall): Promise { return { success: true }; } + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (event.type !== 'external-message') return {}; + return { + addMessages: [{ participant: 'Operator', content: [{ type: 'text', text: String((event as { content?: unknown }).content) }] }], + requestInference: true, + }; + } +} + +describe('speech route across a logical turn', () => { + let tempDir: string; + let membrane: MockMembrane; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'speech-route-turns-')); + membrane = new MockMembrane(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const makeFramework = (modules: Module[] = []) => AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules, + }); + + /** A channel subsystem whose channels declare an MCPL RFC-011 publish + * target (a route is only ever a place the framework publishes to + * exactly), and whose channel_open succeeds, recording what it opened. */ + const channelsThatOpen = (opens: string[] = []) => new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + routeSpeech: async () => ({ delivered: true, channelId: 'x' }), + resolveDestination: ({ channelId }: { channelId: string }) => ({ destination: { serverId: 'discord', channelId } }), + handleChannelToolCall: async (name: string, input: { channelId?: string }) => { + if (name !== 'channel_open') return { success: true }; + opens.push(input.channelId!); + return { success: true, data: { channelId: input.channelId, opened: true } }; + }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + + const fromChannel = (channelId: string): InferenceRequest => ({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId }, addressed: true, messageId: 'm1', at: Date.now() }], + }); + + it('continuations and retries keep the route — including one chosen with channel_open — and a new turn decides afresh', async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + // A channel subsystem whose channels declare an MCPL RFC-011 publish + // target: a route is only ever a place the framework publishes to exactly. + i.channelRegistry = channelsThatOpen(); + + // The turn starts routed to its wake, and the resident opens another + // channel during it, through the tool. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'one' }, + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'opened' }])); + await i.startAgentStream(scout, fromChannel('chan-A')); + await framework.runUntilIdle(); + const opened = i.turnRoutes.get('scout')!; + assert.equal(opened.route?.channelId, 'chan-open'); + assert.equal(opened.route?.origin, 'open', 'channel_open set the route'); + + // A context-budget restart, a guard retry and a framework retry continue + // the same logical turn: none of them re-decides the route, even with + // candidates from elsewhere in hand. + for (const [trigger, attempt] of [ + [{ ...fromChannel('chan-B'), reason: 'context_budget_restart' }, 0], + [{ ...fromChannel('chan-B'), reason: 'tool_result_guard_retry' }, 0], + [fromChannel('chan-B'), 1], + ] as Array<[InferenceRequest, number]>) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'continuing' }])); + await i.startAgentStream(scout, trigger, attempt); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout'), opened, `${trigger.reason} (attempt ${attempt}) kept the route`); + } + + // A true new turn decides from its own wake: another conversation's. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await i.startAgentStream(scout, fromChannel('chan-C')); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'chan-C', 'the new turn routes to its own wake'); + assert.equal(i.turnRoutes.get('scout')?.route?.origin, 'trigger'); + await framework.stop(); + }); + + it("a silent wake's channel_open opens the channel and sets no route, and its result says so", async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + const opens: string[] = []; + i.channelRegistry = channelsThatOpen(opens); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + await i.startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now(), suppressProse: true }); + await framework.runUntilIdle(); + assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, silent: {} }, 'and no route set'); + const result = JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); + assert.match(result, /This call came from a silent wake, so its plain speech stays private; to post there, use a send tool\./); + await framework.stop(); + }); + + it("a channel_open whose result lands after a silent tick has ended still sets no route", async () => { + const framework = await makeFramework(); + const i = internals(framework); + const opens: string[] = []; + i.channelRegistry = channelsThatOpen(opens); + // As a silent tick leaves its record once its turn has ended: no trigger + // is running, and the record says the turn was silent. + const record = { route: null, silent: {} }; + i.turnRoutes.set('scout', record as never); + // The open's result, as it is delivered. + const results: Array<{ type?: string; callId?: string; result?: { data?: { routing?: string } } }> = []; + const host = framework as unknown as { pushEvent(event: unknown): unknown }; + const pushEvent = host.pushEvent.bind(framework); + host.pushEvent = (event) => { + if ((event as { type?: string }).type === 'tool-result') results.push(event as never); + return pushEvent(event); + }; + (framework as unknown as { dispatchChannelToolCall(agentName: string, call: unknown): void }) + .dispatchChannelToolCall('scout', { id: 'o1', name: 'channel_open', input: { channelId: 'chan-open' } }); + await new Promise((r) => setImmediate(r)); + await framework.runUntilIdle(); + assert.deepEqual(opens, ['chan-open'], 'the channel was opened'); + assert.equal(i.turnRoutes.get('scout'), record, 'and the silent record kept, with no route'); + const delivered = results.find((r) => r.callId === 'o1'); + assert.match(delivered?.result?.data?.routing ?? '', /^Opened\. This call came from a silent wake, so its plain speech stays private; to post there, use a send tool\.$/, + 'its result, delivered, says so'); + await framework.stop(); + }); + + it('a channel_open into a channel a conversation fork owns opens it but takes no route, and its result says so', async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + const opens: string[] = []; + i.channelRegistry = channelsThatOpen(opens); + i.conversationAgentHomes.set('fork-1', 'chan-fork'); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'chan-fork' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still here' }])); + await i.startAgentStream(scout, fromChannel('chan-A')); + await framework.runUntilIdle(); + assert.deepEqual(opens, ['chan-fork'], 'the channel was opened'); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'chan-A', 'the route stayed'); + const result = JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); + assert.match(result, /Opened, but your plain speech can't go there: a conversation fork owns that channel\. Your plain speech still goes to/); + await framework.stop(); + }); + + it('a local surface wake routes speech to that surface: shown there, published nowhere, receipted as such', async () => { + const framework = await makeFramework([new SurfaceModule()]); + const i = internals(framework); + const published: string[] = []; + i.channelRegistry = new Proxy({ + resolveLocus: () => null, + routeSpeech: async (_a: string, text: string) => { published.push(text); return { delivered: true, channelId: 'x' }; }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello, operator.' }])); + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'hi scout', metadata: {} } as unknown as ProcessEvent); + await framework.runUntilIdle(); + + assert.deepEqual(i.turnRoutes.get('scout')?.route, { kind: 'surface', surface: 'tui', origin: 'trigger' }); + assert.deepEqual(published, [], 'surface speech is not published to any channel'); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + assert.ok(texts.includes('[delivered] plain speech → tui (the local surface that messaged you; not published to any channel)')); + assert.ok(texts.some((t) => t.startsWith('[routing] Your plain speech now goes to tui (the local surface that messaged you)'))); + await framework.stop(); + }); + + it('a channel id no single server registers gets no guessed server: unroutable as a candidate, unresolved as a home', async () => { + const framework = await makeFramework(); + const registrants: Record = { solo: ['alpha'], shared: ['alpha', 'beta'] }; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + getChannelServerId: (id: string) => (registrants[id]?.length === 1 ? registrants[id]![0] : null), + resolveDestination: (c: { serverId?: string; channelId: string }) => + (c.serverId || registrants[c.channelId]?.length === 1) + ? { destination: { serverId: c.serverId ?? registrants[c.channelId]![0], channelId: c.channelId } } + : { error: 'shared or unknown' }, + // As the registry reads it: a declaration only for exactly one registration. + publishTarget: (c: { serverId?: string; channelId: string }) => + (c.serverId || registrants[c.channelId]?.length === 1 ? 'root' : undefined), + // The first registrant's label, as a bare-id lookup finds it: never the + // label of a conversation whose server is another one. + getDescriptor: () => ({ label: '#alpha-room' }), + getChannelLabel: (serverId: string, id: string) => (registrants[id]?.includes(serverId) ? `#${serverId}-room` : undefined), + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const f = framework as unknown as { + candidateForChannel(channelId: string, addressed: boolean, at: number, serverId?: string): RouteCandidate; + homeRoute(agentName: string): SpeechRoute | null; + publishTargetOf(c: { serverId?: string; channelId: string }): string | undefined; + conversationAgentHomes: Map; + }; + const solo = f.candidateForChannel('solo', true, 1); + assert.equal(solo.conversation.kind === 'channel' ? solo.conversation.serverId : '', 'alpha', 'a sole registrant is the server'); + assert.equal(solo.unroutable, undefined); + const shared = f.candidateForChannel('shared', true, 1); + assert.equal(shared.conversation.kind === 'channel' ? shared.conversation.serverId : '', undefined, 'never the first registrant'); + assert.equal(shared.unroutable, true, 'it competes, but is no route'); + assert.equal(shared.conversation.kind === 'channel' ? shared.conversation.label : '', undefined, + 'and no label: with no server, any label would be a guess'); + const named = f.candidateForChannel('shared', true, 1, 'beta'); + assert.equal(named.conversation.kind === 'channel' ? named.conversation.serverId : '', 'beta', "the event's own server stands"); + assert.equal(named.conversation.kind === 'channel' ? named.conversation.label : '', '#beta-room', 'with its own label, not the first registrant\'s'); + assert.equal(named.unroutable, undefined); + assert.equal(solo.conversation.kind === 'channel' ? solo.conversation.label : '', '#alpha-room'); + f.conversationAgentHomes.set('scout', 'shared'); + const home = f.homeRoute('scout'); + assert.equal(home?.kind === 'channel' ? home.serverId : '', undefined, 'a shared home gets no server'); + assert.equal(home?.kind === 'channel' ? home.label : '', undefined, 'nor a label'); + assert.equal(f.publishTargetOf({ channelId: 'shared' }), 'unresolved', 'and is unresolved, not undeclared'); + assert.equal(f.publishTargetOf({ channelId: 'solo' }), 'root'); + await framework.stop(); + }); + + it('a hold between two servers\' conversations with one channel id and label names each server', async () => { + const framework = await makeFramework(); + const i = internals(framework); + const scout = framework.getAgent('scout')!; + i.channelRegistry = new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + getDescriptor: () => ({ label: '#general' }), + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const from = (serverId: string, messageId: string, at: number): RouteCandidate => ({ + conversation: { kind: 'channel', serverId, channelId: 'general', label: '#general' }, addressed: true, messageId, at, + }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'which general?' }])); + await i.startAgentStream(scout, { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + routeCandidates: [from('alpha', 'm1', 1), from('beta', 'm2', 2)], + }); + await framework.runUntilIdle(); + const notice = scout.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((t) => t.startsWith('[routing]')); + assert.match(notice ?? '', /^\[routing\] More than one conversation is waiting for you: /); + for (const address of ['#general (alpha / general)', '#general (beta / general)']) { + assert.ok(notice?.includes(address), `${address} is an address the resident can send to, server included: ${notice}`); + } + await framework.stop(); + }); + + it('the [delivered] receipt names each destination as delivered: server, label then, and thread', async () => { + const framework = await makeFramework(); + const scout = framework.getAgent('scout')!; + let currentLabel = '#Alpha (renamed later)'; + (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ + getDescriptor: () => ({ label: currentLabel }), + resolveDestination: () => ({ error: 'shared id' }), + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const f = framework as unknown as { + recordProseDelivery(agent: string, outcome: unknown): void; + appendProseDeliveryReceipt(agent: unknown): void; + }; + // The same channel id and thread on two servers, then a repeat of the first. + f.recordProseDelivery('scout', { delivered: true, serverId: 'alpha', channelId: 'shared', label: '#Alpha', threadId: 'topic' }); + f.recordProseDelivery('scout', { delivered: true, serverId: 'beta', channelId: 'shared', label: '#Beta', threadId: 'topic' }); + f.recordProseDelivery('scout', { delivered: true, serverId: 'alpha', channelId: 'shared', label: '#Alpha', threadId: 'topic' }); + currentLabel = '#Renamed'; + f.appendProseDeliveryReceipt(scout); + const receipt = scout.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .find((t) => t.startsWith('[delivered]')); + assert.equal(receipt, '[delivered] plain speech → #Alpha (alpha / shared, thread topic) · #Beta (beta / shared, thread topic)', + 'two servers stay two destinations, each with the label it had when the words went out'); + // A single delivery names its server too: the receipt is self-contained. + f.recordProseDelivery('scout', { delivered: true, serverId: 'beta', channelId: 'shared', label: '#Beta', threadId: 'topic' }); + f.appendProseDeliveryReceipt(scout); + const second = scout.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text) + .filter((t) => t.startsWith('[delivered]')).at(-1); + assert.equal(second, '[delivered] plain speech → #Beta (beta / shared, thread topic)'); + await framework.stop(); + }); + + it('the sleep announcement is unaddressed speech: a held, a routeless and a surface turn each keep it as such, publishing nothing', async () => { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + gate: { config: { policies: [], default: 'always' } }, + }); + const i = framework as unknown as { + turnRoutes: Map; + channelRegistry: unknown; + speechChains?: Map>; + dispatchSleepToolCall(agent: string, call: unknown): void; + proseDrafts: { open(agent: string): Array<{ reason: string; text: string }> }; + }; + const published: unknown[] = []; + i.channelRegistry = new Proxy({ + publishTarget: () => 'root', + resolveLocus: () => null, + routeSpeech: async (...args: unknown[]) => { published.push(args); return null; }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + const sleepUnder = async (turn: Route, id: string) => { + i.turnRoutes.set('scout', turn); + i.dispatchSleepToolCall('scout', { id, name: 'sleep', input: { seconds: 60 } }); + await (i.speechChains?.get('scout') ?? Promise.resolve()); + }; + await sleepUnder({ route: null, hold: { conversations: [ + { kind: 'channel', serverId: 'discord', channelId: 'a' }, + { kind: 'channel', serverId: 'discord', channelId: 'b' }, + ], since: 'turn-start' } }, 's1'); + await sleepUnder({ route: null }, 's2'); + await sleepUnder({ route: { kind: 'surface', surface: 'tui', origin: 'trigger' } }, 's3'); + assert.deepEqual(published, [], 'nothing was published, so no failure can be reported'); + const held = i.proseDrafts.open('scout'); + assert.deepEqual(held.map((d) => d.reason).sort(), ['ambiguous', 'no-destination'], 'held, then routeless; the surface shows it'); + assert.ok(held.every((d) => d.text.startsWith('💤 Going quiet for 1m'))); + await framework.stop(); + }); +}); diff --git a/test/speech-routes.test.ts b/test/speech-routes.test.ts new file mode 100644 index 000000000..2e041d4d1 --- /dev/null +++ b/test/speech-routes.test.ts @@ -0,0 +1,447 @@ +/** + * Speech-route inference (src/speech-routes.ts, shelf-355): a turn's route + * from its wake's candidates, and which mid-turn arrivals hold it. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + continuationCandidates, + continuationOf, + conversationKey, + describeConversation, + inferTurnRoute, + isConversational, + routeRefusal, + suspendsRoute, + type ConversationRef, + type RouteCandidate, + type SpeechRoute, + type TurnRoute, +} from '../src/speech-routes.js'; + +const channel = (channelId: string, extra: Partial> = {}): ConversationRef => + ({ kind: 'channel', serverId: 'discord', channelId, ...extra }); +const candidate = ( + conversation: ConversationRef, + addressed: boolean, + at: number, + messageId?: string, +): RouteCandidate => ({ conversation, addressed, at, ...(messageId ? { messageId } : {}) }); +/** Every channel declares `root` (MCPL RFC-011): the common, threadless case. */ +const rootEverywhere = (): 'root' => 'root'; +const exactEverywhere = (): 'exact' => 'exact'; +const declaredNowhere = (): undefined => undefined; + +describe('inferTurnRoute', () => { + it('a fork home always wins', () => { + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + const turn = inferTurnRoute([candidate(channel('elsewhere'), true, 5, 'm1')], home, rootEverywhere); + assert.deepEqual(turn, { route: home }); + }); + + it('a fork home answers the newest message the wake carried in the home conversation itself', () => { + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + const turn = inferTurnRoute([ + candidate(channel('home'), false, 1, 'm-old'), + candidate(channel('home'), false, 3, 'm-new'), + candidate(channel('elsewhere'), true, 4, 'm-else'), + candidate(channel('home', { threadId: 't1' }), true, 5, 'm-thread'), + ], home, exactEverywhere); + assert.deepEqual(turn, { route: { ...home, replyTo: 'm-new' }, replyAt: 3 }); + // Addressed candidates in the home conversation come first, as for an inferred route. + const addressed = inferTurnRoute([ + candidate(channel('home'), true, 1, 'm-asked'), + candidate(channel('home'), false, 2, 'm-later'), + ], home, rootEverywhere); + assert.deepEqual(addressed, { route: { ...home, replyTo: 'm-asked' }, replyAt: 1 }); + // A candidate without a message gives no reply edge. + assert.deepEqual(inferTurnRoute([candidate(channel('home'), true, 1)], home, rootEverywhere), { route: home }); + }); + + it('no candidates: no route', () => { + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere), { route: null }); + }); + + it('no candidates takes the fallback: where the last delivered message came in, its thread included, answering no message', () => { + const last = candidate(channel('last', { threadId: 't-9', label: '#last' }), false, 7, 'm-last'); + assert.deepEqual(inferTurnRoute([], undefined, exactEverywhere, last), { + route: { kind: 'channel', serverId: 'discord', channelId: 'last', threadId: 't-9', label: '#last', origin: 'fallback' }, + }, 'no reply edge: the last message did not trigger this turn'); + // A root channel's fallback is its root. + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere, candidate(channel('plain'), false, 7)), { + route: { kind: 'channel', serverId: 'discord', channelId: 'plain', origin: 'fallback' }, + }); + }); + + it('a fallback plain speech can\'t go to exactly is no route, not an unroutable one', () => { + // Nothing in the wake named it, so no conversation is in front of the + // resident: the turn simply has no destination. + const last = candidate(channel('last'), false, 7, 'm-last'); + assert.deepEqual(inferTurnRoute([], undefined, declaredNowhere, last), { route: null }); + const threaded = candidate(channel('last', { threadId: 't-9' }), false, 7); + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere, threaded), { route: null }, 'a thread on a root channel'); + const unresolved: RouteCandidate = { ...candidate(channel('last'), false, 7), unroutable: true }; + assert.deepEqual(inferTurnRoute([], undefined, rootEverywhere, unresolved), { route: null }); + }); + + it('the fallback only answers a wake with no candidates: a wake\'s own conversation and a fork home both win', () => { + const last = candidate(channel('last'), false, 7, 'm-last'); + const own = inferTurnRoute([candidate(channel('own'), false, 5, 'm-own')], undefined, rootEverywhere, last); + assert.equal((own.route as { channelId: string; origin: string }).channelId, 'own'); + assert.equal((own.route as { origin: string }).origin, 'trigger'); + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + assert.deepEqual(inferTurnRoute([], home, rootEverywhere, last), { route: home }); + // Two conversations still hold: the fallback never breaks a tie. + const held = inferTurnRoute([ + candidate(channel('a'), true, 1), candidate(channel('b'), true, 2), + ], undefined, rootEverywhere, last); + assert.equal(held.route, null); + assert.ok(held.hold); + }); + + it('an addressed candidate outranks newer ambient chatter elsewhere', () => { + const turn = inferTurnRoute([ + candidate(channel('dm'), true, 1, 'm-dm'), + candidate(channel('general'), false, 2, 'm-gen'), + ], undefined, rootEverywhere); + assert.deepEqual(turn.route, { kind: 'channel', serverId: 'discord', channelId: 'dm', replyTo: 'm-dm', origin: 'trigger' }); + assert.equal(turn.hold, undefined); + }); + + it('two addressed conversations hold the turn, naming both, newest first', () => { + const turn = inferTurnRoute([ + candidate(channel('room'), true, 1, 'm1'), + candidate(channel('dm'), true, 2, 'm2'), + candidate(channel('general'), false, 3, 'm3'), + ], undefined, rootEverywhere); + assert.equal(turn.route, null); + assert.deepEqual(turn.hold, { since: 'turn-start', conversations: [channel('dm'), channel('room')] }); + }); + + it('ambient-only: one conversation routes there; two hold', () => { + const one = inferTurnRoute([candidate(channel('room'), false, 1, 'm1'), candidate(channel('room'), false, 2, 'm2')], undefined, rootEverywhere); + assert.deepEqual(one.route, { kind: 'channel', serverId: 'discord', channelId: 'room', replyTo: 'm2', origin: 'trigger' }); + const two = inferTurnRoute([candidate(channel('room'), false, 1), candidate(channel('general'), false, 2)], undefined, rootEverywhere); + assert.equal(two.route, null); + assert.equal(two.hold?.conversations.length, 2); + }); + + it('several candidates in one conversation answer the newest of the considered ones', () => { + const turn = inferTurnRoute([ + candidate(channel('room'), true, 1, 'mention'), + candidate(channel('room'), false, 5, 'later-ambient'), + candidate(channel('room'), true, 3, 'second-mention'), + ], undefined, rootEverywhere); + assert.equal(turn.route?.kind === 'channel' ? turn.route.replyTo : undefined, 'second-mention', + 'the addressed pool decides, so its newest message is the reply edge'); + }); + + it('candidates of one conversation at the same moment: the later one is the reply edge', () => { + const turn = inferTurnRoute([ + candidate(channel('room'), true, 4, 'first'), + candidate(channel('room'), true, 4, 'second'), + ], undefined, rootEverywhere); + assert.equal(turn.route?.kind === 'channel' ? turn.route.replyTo : undefined, 'second', + 'a tie goes to the candidate that arrived later in the wake'); + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'room', origin: 'home' }; + const atHome = inferTurnRoute([ + candidate(channel('room'), true, 4, 'first'), + candidate(channel('room'), true, 4, 'second'), + ], home, rootEverywhere); + assert.equal(atHome.route?.kind === 'channel' ? atHome.route.replyTo : undefined, 'second', 'and so for a home'); + }); + + it('a channel that resolves to no single registration is unresolved, not undeclared', () => { + const turn = inferTurnRoute([candidate(channel('shared'), true, 1)], undefined, () => 'unresolved'); + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'unresolved'); + const home: SpeechRoute = { kind: 'channel', channelId: 'shared', origin: 'home' }; + assert.equal(inferTurnRoute([], home, () => 'unresolved').unroutable?.reason, 'unresolved', + 'a fork home on a shared id is held as unresolved'); + assert.equal(routeRefusal(channel('room'), declaredNowhere), 'untargetable'); + }); + + it('the same channel id on two servers, or two threads of one channel, are different conversations', () => { + const servers = inferTurnRoute([ + candidate({ kind: 'channel', serverId: 'a', channelId: 'shared' }, true, 1), + candidate({ kind: 'channel', serverId: 'b', channelId: 'shared' }, true, 2), + ], undefined, rootEverywhere); + assert.equal(servers.hold?.conversations.length, 2); + const threads = inferTurnRoute([ + candidate(channel('forum', { threadId: 't1' }), true, 1), + candidate(channel('forum', { threadId: 't2' }), true, 2), + ], undefined, rootEverywhere); + assert.equal(threads.hold?.conversations.length, 2); + }); + + it('a thread is a route only where the connector posts into a named thread (RFC-011 exact)', () => { + const thread = channel('forum', { threadId: 't1', label: 'forum' }); + const exact = inferTurnRoute([candidate(thread, true, 1, 'm1')], undefined, exactEverywhere); + assert.deepEqual(exact.route, { + kind: 'channel', serverId: 'discord', channelId: 'forum', threadId: 't1', replyTo: 'm1', label: 'forum', origin: 'trigger', + }); + // A thread on a channel declared threadless is a contradiction: held, + // never posted to the root (a different conversation). + const root = inferTurnRoute([candidate(thread, true, 1, 'm1')], undefined, rootEverywhere); + assert.deepEqual(root, { route: null, unroutable: { conversation: thread, reason: 'thread' } }); + const held = inferTurnRoute([candidate(thread, true, 1), candidate(channel('room'), true, 2)], undefined, exactEverywhere); + assert.equal(held.hold?.conversations.length, 2, 'it still holds a turn when it competes'); + }); + + it('a channel whose connector declares no publish target is never a route (RFC-011)', () => { + const one = inferTurnRoute([candidate(channel('room'), true, 1, 'm1')], undefined, declaredNowhere); + assert.deepEqual(one, { route: null, unroutable: { conversation: channel('room'), reason: 'untargetable' } }, + 'the connector would choose the place — perhaps a thread — itself'); + const thread = channel('forum', { threadId: 't1' }); + assert.equal(inferTurnRoute([candidate(thread, true, 1)], undefined, declaredNowhere).unroutable?.reason, 'untargetable', + 'no declaration outranks the thread question'); + // A fork home is the same: no declaration, no route. + const home: SpeechRoute = { kind: 'channel', serverId: 'discord', channelId: 'home', origin: 'home' }; + assert.deepEqual(inferTurnRoute([], home, declaredNowhere), { + route: null, unroutable: { conversation: { kind: 'channel', serverId: 'discord', channelId: 'home' }, reason: 'untargetable' }, + }); + // The lookup is per channel. + const mixed = (c: { channelId: string }): 'root' | undefined => (c.channelId === 'room' ? 'root' : undefined); + assert.equal(inferTurnRoute([candidate(channel('room'), true, 1)], undefined, mixed).route?.kind, 'channel'); + assert.equal(inferTurnRoute([candidate(channel('other'), true, 1)], undefined, mixed).unroutable?.reason, 'untargetable'); + }); + + it('a channel whose server is unknown routes without a placeholder server', () => { + const turn = inferTurnRoute([candidate({ kind: 'channel', channelId: 'solo' }, true, 1, 'm1')], undefined, rootEverywhere); + assert.deepEqual(turn.route, { kind: 'channel', channelId: 'solo', replyTo: 'm1', origin: 'trigger' }); + }); + + it('a conversation whose channel could not be resolved competes, but is never the route', () => { + const raw = { conversation: channel('1548'), addressed: true, at: 2, unroutable: true as const }; + const competing = inferTurnRoute([candidate(channel('room'), true, 1, 'm1'), raw], undefined, rootEverywhere); + assert.equal(competing.route, null); + assert.equal(competing.hold?.conversations.length, 2, 'never the older conversation by default'); + assert.deepEqual(inferTurnRoute([raw], undefined, rootEverywhere), { route: null, unroutable: { conversation: channel('1548'), reason: 'unresolved', neverRoutes: true } }); + }); + + it('a local surface is addressed by nature, and routes to the surface', () => { + const turn = inferTurnRoute([ + candidate({ kind: 'surface', surface: 'tui' }, true, 1), + candidate(channel('general'), false, 2), + ], undefined, rootEverywhere); + assert.deepEqual(turn.route, { kind: 'surface', surface: 'tui', origin: 'trigger' }); + }); +}); + +describe('suspendsRoute', () => { + const routeA: TurnRoute = { route: { kind: 'channel', serverId: 'discord', channelId: 'A', replyTo: 'm1', origin: 'trigger' } }; + const notEngaged = () => false; + + it('an addressed arrival from another conversation suspends an inferred route', () => { + assert.equal(suspendsRoute(routeA, { conversation: channel('dm'), addressed: true }, notEngaged), true); + }); + + it('a fallback route is inferred too: an addressed arrival from elsewhere suspends it, its own conversation doesn\'t', () => { + const fallback: TurnRoute = { route: { kind: 'channel', serverId: 'discord', channelId: 'A', origin: 'fallback' } }; + assert.equal(suspendsRoute(fallback, { conversation: channel('dm'), addressed: true }, notEngaged), true); + assert.equal(suspendsRoute(fallback, { conversation: channel('A'), addressed: true }, notEngaged), false); + assert.equal(suspendsRoute(fallback, { conversation: channel('dm'), addressed: false }, notEngaged), false); + }); + + it('the same conversation never does, addressed or not', () => { + assert.equal(suspendsRoute(routeA, { conversation: channel('A'), addressed: true }, notEngaged), false); + }); + + it('a thread route is its thread: the same thread never suspends it, the channel root does', () => { + const inThread: TurnRoute = { + route: { kind: 'channel', serverId: 'discord', channelId: 'forum', threadId: 't1', replyTo: 'm1', origin: 'trigger' }, + }; + assert.equal(suspendsRoute(inThread, { conversation: channel('forum', { threadId: 't1' }), addressed: true }, notEngaged), false); + assert.equal(suspendsRoute(inThread, { conversation: channel('forum'), addressed: true }, notEngaged), true); + }); + + it('ambient chatter does only in a conversation the resident engaged this turn', () => { + const arrival = { conversation: channel('lounge'), addressed: false }; + assert.equal(suspendsRoute(routeA, arrival, notEngaged), false); + assert.equal(suspendsRoute(routeA, arrival, (c) => c.kind === 'channel' && c.channelId === 'lounge'), true); + }); + + it('deliberate routes, held turns and route-less turns are never suspended', () => { + const arrival = { conversation: channel('dm'), addressed: true }; + for (const origin of ['home', 'open'] as const) { + assert.equal(suspendsRoute({ route: { kind: 'channel', serverId: 'discord', channelId: 'A', origin } }, arrival, notEngaged), false); + } + assert.equal(suspendsRoute({ ...routeA, hold: { since: 'turn-start', conversations: [] } }, arrival, notEngaged), false); + assert.equal(suspendsRoute({ route: null }, arrival, notEngaged), false); + }); +}); + +describe('isConversational', () => { + it('reactions and system markers are machinery, everything else is conversation', () => { + assert.equal(isConversational(['chat:reaction'], {}), false); + assert.equal(isConversational(['chat:reaction-remove'], {}), false, 'a removal is machinery too'); + assert.equal(isConversational(['chat:ambient'], { system: true }), false); + assert.equal(isConversational(['chat:addressed'], {}), true); + assert.equal(isConversational(undefined, undefined), true); + }); +}); + +describe('conversation identity and wording', () => { + it('keys include server, channel and thread; surfaces are their own', () => { + assert.notEqual(conversationKey(channel('x')), conversationKey({ kind: 'channel', serverId: 'other', channelId: 'x' })); + assert.notEqual(conversationKey(channel('x')), conversationKey(channel('x', { threadId: 't' }))); + assert.notEqual(conversationKey({ kind: 'surface', surface: 'x' }), conversationKey(channel('x'))); + // Server-chosen parts can't run into each other, and an unknown server + // is not an empty one. + assert.notEqual( + conversationKey(channel('a\u0000b', { threadId: 'c' })), + conversationKey(channel('a', { threadId: 'b\u0000c' })), + ); + assert.notEqual(conversationKey({ kind: 'channel', channelId: 'x' }), conversationKey({ kind: 'channel', serverId: '', channelId: 'x' })); + }); + + it('describes a conversation by a usable address: its label, then server / channel id, then its thread', () => { + assert.equal(describeConversation(channel('discord:g1:room', { label: '#room' })), '#room (discord / discord:g1:room)'); + assert.equal(describeConversation(channel('discord:dm:7')), '(discord / discord:dm:7)'); + assert.equal(describeConversation(channel('discord:dm:7', { label: 'discord:dm:7' })), '(discord / discord:dm:7)', + 'a label that is only the id adds nothing'); + assert.equal(describeConversation(channel('forum', { threadId: 't1' })), '(discord / forum, thread t1)'); + assert.equal(describeConversation({ kind: 'channel', channelId: 'general', label: 'general' }), 'general', + 'a conversation whose server is unknown is named by its id alone'); + assert.equal(describeConversation({ kind: 'surface', surface: 'tui' }), 'tui (the local surface that messaged you)'); + }); + + it("shows a label as its connector gave it, adding or withholding nothing for how it's spelled", () => { + assert.equal(describeConversation(channel('telegram:42', { label: 'Ada Lovelace' })), 'Ada Lovelace (discord / telegram:42)', + 'a DM named after a person'); + assert.equal(describeConversation(channel('telegram:-100', { label: 'Book Club' })), 'Book Club (discord / telegram:-100)', + 'a group named by its title'); + assert.equal(describeConversation(channel('discord:dm:9', { label: 'DM with antra' })), 'DM with antra (discord / discord:dm:9)'); + assert.equal(describeConversation(channel('discord:g1:room', { label: 'room' })), 'room (discord / discord:g1:room)', 'no # added'); + assert.equal(describeConversation({ kind: 'channel', channelId: 'c-1', label: 'general' }), 'general (c-1)', + 'a label with no server, beside its channel id'); + }); + + it('the same channel id and label on two servers read as two addresses, each naming its server', () => { + const on = (serverId: string) => describeConversation({ kind: 'channel', serverId, channelId: 'general', label: '#general' }); + assert.equal(on('alpha'), '#general (alpha / general)'); + assert.equal(on('beta'), '#general (beta / general)'); + }); +}); + +describe('continuationCandidates and continuationOf', () => { + const route = (channelId: string, extra: Partial> = {}): SpeechRoute => + ({ kind: 'channel', serverId: 'discord', channelId, origin: 'trigger', ...extra }); + + it('a turn with a route names its conversation, addressed, with its reply edge', () => { + const turn: TurnRoute = { route: route('general', { threadId: 't-1', replyTo: 'm-7', label: '#general', origin: 'open' }) }; + assert.deepEqual(continuationCandidates(turn, 9), [{ + conversation: { kind: 'channel', serverId: 'discord', channelId: 'general', threadId: 't-1', label: '#general' }, + addressed: true, + at: 9, + messageId: 'm-7', + }]); + assert.equal(continuationOf(turn, 9).namesConversation, true); + assert.deepEqual(continuationCandidates({ route: { kind: 'surface', surface: 'tui', origin: 'trigger' } }, 9), + [{ conversation: { kind: 'surface', surface: 'tui' }, addressed: true, at: 9 }]); + }); + + it("a fallback turn answered no conversation: its continuation names none, and keeps the turn's place as its fallback", () => { + const fallback: TurnRoute = { route: route('last', { threadId: 't-2', label: '#last', origin: 'fallback' }) }; + assert.deepEqual(continuationCandidates(fallback, 9), []); + assert.deepEqual(continuationOf(fallback, 9), { + candidates: [], + namesConversation: false, + fallback: { + conversation: { kind: 'channel', serverId: 'discord', channelId: 'last', threadId: 't-2', label: '#last' }, + addressed: false, + at: 9, + }, + }); + // As a fallback, it is taken only when nothing in the batch names a + // conversation: an ambient arrival's conversation is the route instead. + const kept = continuationOf(fallback, 9).fallback!; + assert.equal(inferTurnRoute([], null, () => 'exact', kept).route?.origin, 'fallback'); + const arrival = inferTurnRoute([candidate(channel('b'), false, 20)], null, rootEverywhere, kept).route; + assert.equal(arrival?.kind === 'channel' && arrival.channelId, 'b'); + // Held mid-turn, it names what it held between, as any held turn does, + // and keeps no fallback. + const held: TurnRoute = { ...fallback, hold: { conversations: [channel('last'), channel('b')], since: 'mid-turn' } }; + assert.equal(continuationCandidates(held, 9).length, 2); + assert.equal(continuationOf(held, 9).namesConversation, true); + assert.equal(continuationOf(held, 9).fallback, undefined); + }); + + it('a hold remembers which of its conversations came from a candidate marked unroutable, and its continuation keeps the mark', () => { + const raw: RouteCandidate = { conversation: channel('user:U5'), addressed: true, at: 2, unroutable: true }; + const held = inferTurnRoute([candidate(channel('b'), true, 1), raw], undefined, rootEverywhere); + assert.deepEqual(held.hold?.neverRoutes, [conversationKey(channel('user:U5'))]); + const continued = continuationCandidates(held, 5); + assert.deepEqual(continued.map((c) => [c.conversation.kind === 'channel' && c.conversation.channelId, c.unroutable ?? false]), + [['user:U5', true], ['b', false]]); + }); + + it('a held turn names every conversation it held between, so its continuation is held again', () => { + const held: TurnRoute = { route: route('a'), hold: { conversations: [channel('a'), channel('b')], since: 'mid-turn' } }; + const candidates = continuationCandidates(held, 5); + assert.deepEqual(candidates.map((c) => conversationKey(c.conversation)), [conversationKey(channel('a')), conversationKey(channel('b'))]); + assert.ok(candidates.every((c) => c.addressed && c.at === 5 && !c.messageId)); + assert.deepEqual(inferTurnRoute(candidates, null, rootEverywhere).hold?.conversations.length, 2); + }); + + it('a turn whose one conversation could not take plain speech names it, to be checked again', () => { + const unresolved = continuationCandidates({ route: null, unroutable: { conversation: channel('dup'), reason: 'unresolved' } }, 3); + assert.deepEqual(unresolved, [{ conversation: channel('dup'), addressed: true, at: 3 }]); + const untargetable = continuationCandidates({ route: null, unroutable: { conversation: channel('quiet'), reason: 'untargetable' } }, 3); + assert.deepEqual(untargetable, [{ conversation: channel('quiet'), addressed: true, at: 3 }]); + const thread = channel('forum', { threadId: 't1' }); + assert.deepEqual(continuationCandidates({ route: null, unroutable: { conversation: thread, reason: 'thread' } }, 3), + [{ conversation: thread, addressed: true, at: 3 }]); + // One that came from a candidate marked unroutable stays marked: a raw + // id named with its server never routes, and a channel with no server + // routes only once the framework finds its server. + for (const conversation of [channel('user:U5'), { kind: 'channel', channelId: 'bare' } as ConversationRef]) { + assert.deepEqual(continuationCandidates({ route: null, unroutable: { conversation, reason: 'unresolved', neverRoutes: true } }, 3), + [{ conversation, addressed: true, at: 3, unroutable: true }]); + } + // Checked again: still refused while the channel declares nothing, a route once it does. + assert.equal(inferTurnRoute(untargetable, null, declaredNowhere).unroutable?.reason, 'untargetable'); + assert.equal(inferTurnRoute(untargetable, null, rootEverywhere).route?.kind, 'channel'); + // An unresolved one too: unresolved while it still is, a route once it resolves. + assert.equal(inferTurnRoute(unresolved, null, () => 'unresolved').unroutable?.reason, 'unresolved'); + assert.equal(inferTurnRoute(unresolved, null, rootEverywhere).route?.kind, 'channel'); + }); + + it('a turn whose words had no place gives no candidate; its continuation names a conversation exactly when the turn\'s wake did', () => { + // A reaction, a channel a fork owns, a silent wake: the wake named one, + // so the continuation names one, gets no place, and takes no fallback. + assert.deepEqual(continuationOf({ route: null, named: true }, 1), { candidates: [], namesConversation: true }); + // A wake that named none and found no fallback place: the continuation + // names none either, and takes the fallback as it stands then. + assert.deepEqual(continuationOf({ route: null }, 1), { candidates: [], namesConversation: false }); + // No record (no turn yet, an explicit-prose turn): nothing. + assert.deepEqual(continuationOf(undefined, 1), { candidates: [], namesConversation: false }); + }); + + it("a continuation's conversation keeps the time of its reply message, so a newer message batched there takes the reply edge and an older one doesn't", () => { + // The turn answered m-1, which arrived at 100. + const turn = inferTurnRoute([candidate(channel('a'), true, 100, 'm-1')], null, rootEverywhere); + assert.equal(turn.replyAt, 100); + const continued = continuationCandidates(turn, 0); + assert.deepEqual(continued.map((c) => [c.at, c.messageId]), [[100, 'm-1']]); + const replyEdge = (batched: RouteCandidate): string | undefined => { + const next = inferTurnRoute([...continued, batched], null, rootEverywhere); + return next.route?.kind === 'channel' ? next.route.replyTo : undefined; + }; + assert.equal(replyEdge(candidate(channel('a'), true, 150, 'm-newer')), 'm-newer'); + assert.equal(replyEdge(candidate(channel('a'), true, 50, 'm-older')), 'm-1', 'an older message held back in the gate'); + // A route opened by channel_open names no message, so its continuation's + // conversation counts as older than anything batched with it. + const opened = continuationCandidates({ route: route('a', { origin: 'open' }) }, 0); + assert.deepEqual(opened.map((c) => [c.at, c.messageId]), [[0, undefined]]); + }); + + it('beside an ambient arrival from elsewhere the continued conversation wins; beside an addressed one the turn holds', () => { + const continued = continuationCandidates({ route: route('a', { replyTo: 'm-1' }) }, 10); + const ambient = inferTurnRoute([...continued, candidate(channel('b'), false, 20, 'm-2')], null, rootEverywhere); + assert.deepEqual(ambient.route, { kind: 'channel', serverId: 'discord', channelId: 'a', replyTo: 'm-1', origin: 'trigger' }); + const addressed = inferTurnRoute([...continued, candidate(channel('b'), true, 20, 'm-2')], null, rootEverywhere); + assert.equal(addressed.route, null); + assert.equal(addressed.hold?.conversations.length, 2); + }); +}); diff --git a/test/stop-with-work-in-flight.test.ts b/test/stop-with-work-in-flight.test.ts new file mode 100644 index 000000000..7efd55eb9 --- /dev/null +++ b/test/stop-with-work-in-flight.test.ts @@ -0,0 +1,354 @@ +/** + * stop() with work still in flight. + * + * stop() closes the process queue first and closes MCPL connections later, + * so for a while work started before stop() keeps completing, and servers + * keep sending. Two outcomes, one boundary (found through #256's aborted- + * stream test, where a robot--move outlived the framework; the race itself + * is older than #256): + * - a tool's result that arrives after stop() is logged and dropped: the + * stream that asked is gone, and the tool has already run. Before, its + * push threw "Queue is closed", the throw was misreported as the tool's + * failure, and that failure's push threw again, unhandled; + * - a server's push or incoming message the host can't take any more is + * answered with a JSON-RPC error, never "accepted", and never thrown + * out of the connection's listener. + * Fresh input to a stopped framework still throws to its caller. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ContentBlock } from '@animalabs/membrane'; +import type { + Module, + ModuleContext, + ProcessEvent, + ProcessState, + EventResponse, + ToolCall, + ToolDefinition, + ToolResult, +} from '../src/index.js'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +/** Until `cond` holds, bounded: wait for the event a check stands for. */ +async function until(cond: () => boolean, what: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 5)); + } +} + +/** A tool that settles only when the test says so, so the test owns the work it started. */ +class SlowModule implements Module { + readonly name = 'slow'; + private settle: { resolve: (r: ToolResult) => void; reject: (e: Error) => void } | null = null; + readonly started: Promise; + private markStarted!: () => void; + + constructor() { + this.started = new Promise((r) => { this.markStarted = r; }); + } + + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + + getTools(): ToolDefinition[] { + return [{ name: 'work', description: 'Slow work', inputSchema: { type: 'object', properties: {} } }]; + } + + handleToolCall(_call: ToolCall): Promise { + return new Promise((resolve, reject) => { + this.settle = { resolve, reject }; + this.markStarted(); + }); + } + + /** Settle the running call. What its completion does is the test's to wait for. */ + finish(outcome: 'success' | 'failure'): void { + if (outcome === 'success') this.settle!.resolve({ success: true, data: { done: true } }); + else this.settle!.reject(new Error('the work failed')); + } + + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (event.type === 'external-message') { + return { + addMessages: [{ participant: 'User', content: [{ type: 'text', text: String((event as { content?: unknown }).content) }] }], + requestInference: true, + }; + } + return {}; + } +} + +describe('stop() with work still in flight', () => { + let tempDir: string; + let rejections: unknown[]; + let logged: string[]; + const onRejection = (reason: unknown): void => { rejections.push(reason); }; + const consoleError = console.error; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'af-stop-in-flight-')); + rejections = []; + logged = []; + process.on('unhandledRejection', onRejection); + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + }); + + afterEach(() => { + console.error = consoleError; + process.off('unhandledRejection', onRejection); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const dropped = (): string[] => logged.filter((line) => line.includes('arrived after stop')); + + async function frameworkWithRunningTool( + opts: { dispatch?: 'rejects' } = {}, + ): Promise<{ framework: AgentFramework; module: SlowModule; failed: string[] }> { + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Working on it.' }, + { type: 'tool_use', id: 'c1', name: 'slow--work', input: {} }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Never written.' }] as ContentBlock[])); + const module = new SlowModule(); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [module], + }); + const failed: string[] = []; + framework.onTrace((e) => { + if (e.type === 'tool:failed') failed.push(String((e as { tool?: unknown }).tool)); + }); + if (opts.dispatch === 'rejects') { + // The module registry turns a module's throw into an error result; this + // is the dispatch itself rejecting (the host got no result at all). + const registry = (framework as unknown as { moduleRegistry: { handleToolCall(call: ToolCall): Promise } }).moduleRegistry; + const handle = registry.handleToolCall.bind(registry); + registry.handleToolCall = (call) => handle(call).then(() => { throw new Error('the dispatch was lost'); }); + } + framework.start(); + framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); + await module.started; + return { framework, module, failed }; + } + + it('a tool that succeeds after stop() has its result dropped, not thrown or misreported as a failure', async () => { + const { framework, module, failed } = await frameworkWithRunningTool(); + await framework.stop(); + module.finish('success'); + await until(() => dropped().length > 0, 'the late result is dropped'); + + assert.deepEqual(rejections, []); + assert.deepEqual(failed, [], 'the tool succeeded; nothing reports it as a failure'); + assert.equal(dropped().length, 1, logged.join('\n')); + }); + + it('a tool that fails after stop() has its error result dropped, not thrown', async () => { + const { framework, module, failed } = await frameworkWithRunningTool(); + await framework.stop(); + module.finish('failure'); + await until(() => dropped().length > 0, 'the late error result is dropped'); + + assert.deepEqual(rejections, []); + assert.deepEqual(failed, [], 'the registry answers a module throw with an error result'); + assert.equal(dropped().length, 1, logged.join('\n')); + }); + + it('a dispatch that rejects after stop() is reported as failed, and its failure result dropped, not thrown', async () => { + const { framework, module, failed } = await frameworkWithRunningTool({ dispatch: 'rejects' }); + await framework.stop(); + module.finish('success'); + await until(() => dropped().length > 0, 'the failure result is dropped'); + + assert.deepEqual(rejections, []); + assert.equal(failed.length, 1, 'the host got no result: a dispatch failure'); + assert.equal(dropped().length, 1, logged.join('\n')); + }); + + it('fresh input to a stopped framework still throws to its caller', async () => { + const { framework, module } = await frameworkWithRunningTool(); + await framework.stop(); + assert.throws( + () => framework.pushEvent({ type: 'external-message', source: 'test', content: 'too late', metadata: {} } as unknown as ProcessEvent), + /Queue is closed/, + ); + module.finish('success'); + await until(() => dropped().length > 0, 'the late result is dropped'); + assert.deepEqual(rejections, []); + }); + + it('stop() lets speech already on a speech chain publish before the connections close, and is bounded', async () => { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const internals = framework as unknown as { + channelRegistry: unknown; + chainSpeech(agent: string, what: string, work: () => Promise): void; + }; + const statics = AgentFramework as unknown as { SPEECH_DRAIN_MS: number }; + const drain = statics.SPEECH_DRAIN_MS; + statics.SPEECH_DRAIN_MS = 300; + const events: string[] = []; + let release: (() => void) | undefined; + internals.channelRegistry = new Proxy({ + routeSpeech: async (_agent: string, text: string) => { + await new Promise((r) => { release = r; }); + events.push(`published ${text}`); + return { delivered: true, serverId: 'srv', channelId: 'c' }; + }, + stopAll: () => { events.push('connections closing'); }, + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + try { + const registry = internals.channelRegistry as { routeSpeech(a: string, t: string, l: string): Promise }; + internals.chainSpeech('assistant', 'last words', async () => { await registry.routeSpeech('assistant', 'last words', 'c'); }); + await until(() => release !== undefined, 'the publish is in flight'); + let stopped = false; + const stopping = framework.stop().then(() => { stopped = true; }); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(stopped, false, 'stop() waits for the speech in flight'); + release!(); + await stopping; + assert.deepEqual(events, ['published last words', 'connections closing'], 'published before the connections closed'); + + // A publish that never answers holds stop() only for the bound. + const second = await AgentFramework.create({ + storePath: join(tempDir, 'store2'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const s = second as unknown as typeof internals; + s.channelRegistry = new Proxy({ routeSpeech: () => new Promise(() => {}) } as Record, + { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + s.chainSpeech('assistant', 'stuck words', async () => { + await (s.channelRegistry as { routeSpeech(): Promise }).routeSpeech(); + }); + const started = Date.now(); + await second.stop(); + assert.ok(Date.now() - started >= 2 * 300, 'it waited the bound at each point'); + assert.ok(logged.some((line) => line.includes('[stop] speech of assistant still settling after 300 ms')), logged.join('\n')); + } finally { + statics.SPEECH_DRAIN_MS = drain; + } + }); + + describe('a server sending while the host stops', () => { + async function wiredFramework() { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'assistant', model: 'test-model', systemPrompt: 'test' }], + modules: [], + }); + const connection = Object.assign(new EventEmitter(), { id: 'srv' }); + (framework as unknown as { wireMcplEvents(c: unknown): void }).wireMcplEvents(connection); + return { framework, connection }; + } + + function responder() { + const answers: Array<{ result?: unknown; error?: { code: number; message: string } }> = []; + return { + answers, + respond: (result: unknown) => { answers.push({ result }); }, + respondError: (code: number, message: string) => { answers.push({ error: { code, message } }); }, + }; + } + + + it('a push or incoming message after stop() is answered with an error, never accepted', async () => { + const { framework, connection } = await wiredFramework(); + let handled = 0; + (framework as unknown as { pushHandler: unknown }).pushHandler = { handlePushEvent: async () => { handled++; } }; + await framework.stop(); + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e1', payload: { content: [{ type: 'text', text: 'late' }] } }, push); + const incoming = responder(); + connection.emit('channels-incoming', { messages: [{ channelId: 'c', messageId: 'm', content: [] }] }, incoming); + await until(() => push.answers.length > 0 && incoming.answers.length > 0, 'both are answered'); + + assert.equal(handled, 0, 'never handed to the push handler, which would record it as accepted'); + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); + assert.deepEqual(incoming.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); + assert.deepEqual(rejections, []); + }); + + it('a push held at the awareness barrier while the host stops is refused once the barrier lifts', async () => { + const { framework, connection } = await wiredFramework(); + let lift!: () => void; + (framework as unknown as { discordAwarenessBarrier: unknown }).discordAwarenessBarrier = { + promise: new Promise((r) => { lift = r; }), + }; + let handled = 0; + (framework as unknown as { pushHandler: unknown }).pushHandler = { handlePushEvent: async () => { handled++; } }; + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e2', payload: { content: [{ type: 'text', text: 'waiting' }] } }, push); + await framework.stop(); + lift(); + await until(() => push.answers.length > 0, 'the push is answered'); + + assert.equal(handled, 0); + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the host is stopping' } }]); + assert.deepEqual(rejections, []); + }); + + it('a handler that throws is answered with its error, not rejected into the listener; a notification is only logged', async () => { + const { framework, connection } = await wiredFramework(); + (framework as unknown as { pushHandler: unknown }).pushHandler = { + handlePushEvent: async () => { throw new Error('the handler broke'); }, + }; + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e3', payload: { content: [{ type: 'text', text: 'x' }] } }, push); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e4', payload: { content: [{ type: 'text', text: 'y' }] } }); + await until( + () => push.answers.length > 0 && logged.filter((line) => line.includes('the handler broke')).length >= 2, + 'the request is answered, and both failures are logged', + ); + + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the handler broke' } }]); + assert.deepEqual(rejections, []); + await framework.stop(); + }); + + it('a handler that rejects with nothing is a failed request on a live host, not a stopping refusal', async () => { + const { framework, connection } = await wiredFramework(); + (framework as unknown as { pushHandler: unknown }).pushHandler = { + // eslint-disable-next-line prefer-promise-reject-errors + handlePushEvent: () => Promise.reject(), + }; + const fw = framework as unknown as { channelRegistry: unknown }; + const registry = fw.channelRegistry; + fw.channelRegistry = { handleIncoming: async () => { throw undefined; } }; + + const push = responder(); + connection.emit('push-event', { featureSet: 'chat', eventId: 'e5', payload: { content: [{ type: 'text', text: 'x' }] } }, push); + const incoming = responder(); + connection.emit('channels-incoming', { messages: [{ channelId: 'c', messageId: 'm', content: [] }] }, incoming); + await until(() => push.answers.length > 0 && incoming.answers.length > 0, 'both are answered'); + + assert.deepEqual(push.answers, [{ error: { code: -32603, message: 'the handler failed' } }]); + assert.deepEqual(incoming.answers, [{ error: { code: -32603, message: 'the handler failed' } }]); + assert.ok(logged.some((line) => line.includes('push/event from srv failed: the handler failed')), logged.join('\n')); + assert.ok(!logged.some((line) => line.includes('refused')), 'a live host refuses nothing'); + assert.deepEqual(rejections, []); + fw.channelRegistry = registry; + await framework.stop(); + }); + }); +}); diff --git a/test/targeted-publish.test.ts b/test/targeted-publish.test.ts new file mode 100644 index 000000000..5e384086a --- /dev/null +++ b/test/targeted-publish.test.ts @@ -0,0 +1,293 @@ +/** + * Targeted publish (MCPL RFC-011), end to end over a real MCPL child that + * implements it (fixtures/speech-route-mcpl-server.mjs): every publication + * the framework makes names its place inside the channel — a thread, or the + * root — and goes only where the connector declares it posts exactly + * (`capabilities.publish.target`). Plain speech, channel_publish, draft + * resends and channel_open's speech target all follow the one rule; a + * delivery counts only when its echo names the place asked for. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, appendFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework, type ContentBlock } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { ProseDraftStore } from '../src/prose-drafts.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); +/** Posts exactly where asked, threads included. */ +const ROOM = 'discord:g1:room'; +/** Has no threads. */ +const GENERAL = 'discord:g1:general'; +/** Declares nothing: the connector would choose the place itself. */ +const LEGACY = 'discord:g1:legacy'; +const CHANNELS = [ + { id: ROOM, label: '#room (Guild One)', publishTarget: 'exact' }, + { id: GENERAL, label: '#general (Guild One)', publishTarget: 'root' }, + { id: LEGACY, label: '#legacy (Guild One)', publishTarget: null }, +]; + +interface Logged { event: string; channelId?: string; text?: string; threadId?: string | null; reason?: string } + +describe('targeted publish (MCPL RFC-011)', () => { + let dir: string; + let commandPath: string; + let statusPath: string; + let membrane: MockMembrane; + let framework: AgentFramework; + let incoming = 0; + + const until = async (cond: () => boolean, what: string): Promise => { + const deadline = Date.now() + 15_000; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out: ${what}`); + await new Promise((r) => setTimeout(r, 20)); + } + }; + const command = (c: Record): void => appendFileSync(commandPath, JSON.stringify(c) + '\n'); + const logged = (): Logged[] => existsSync(statusPath) + ? readFileSync(statusPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l) as Logged) + : []; + const publishes = (): Logged[] => logged().filter((e) => e.event === 'publish' || e.event === 'publish-refused'); + const texts = (): string[] => framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + const drafts = () => (framework as unknown as { proseDrafts: ProseDraftStore }).proseDrafts.open('scout'); + const toolResults = (): string => JSON.stringify(membrane.lastStream?.receivedToolResults ?? []); + const registry = () => (framework as unknown as { + channelRegistry: { listChannelsRaw(): unknown[]; publishTarget(t: { channelId: string }): string | undefined }; + }).channelRegistry; + + /** One message in `channelId` (a thread of it with `threadId`) starts a turn; resolves when it settles. */ + const turn = async ( + responses: Array>, + channelId: string, + extra: Record = {}, + ): Promise => { + for (const r of responses) membrane.pushResponse(r); + const before = membrane.calls.length; + command({ op: 'incoming', channelId, messageId: `in-${++incoming}`, mode: 'addressed', text: `message ${incoming}`, ...extra }); + await until(() => membrane.calls.length > before, 'turn start'); + await framework.runUntilIdle(); + await until(() => !(framework as unknown as { activeTurnTokens: Map }).activeTurnTokens.has('scout'), 'turn end'); + await framework.runUntilIdle(); + }; + const say = (text: string) => createMockResponse([{ type: 'text', text }]); + const call = (name: string, input: Record, id = `c-${name}`) => + createMockResponse([{ type: 'tool_use', id, name, input }] as ContentBlock[], 'tool_use'); + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'targeted-publish-')); + commandPath = join(dir, 'commands.jsonl'); + statusPath = join(dir, 'status.jsonl'); + writeFileSync(commandPath, ''); + membrane = new MockMembrane(); + incoming = 0; + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + mcplServers: [{ + id: 'discord', + command: process.execPath, + args: [FIXTURE], + env: { + STATUS_PATH: statusPath, + COMMAND_PATH: commandPath, + CHANNELS: JSON.stringify(CHANNELS), + TOOLS: JSON.stringify(['reply_message', 'ring']), + }, + }], + modules: [], + }); + await framework.start(); + await until(() => registry().listChannelsRaw().length >= 3, 'registration'); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads each channel\'s declaration from its descriptor', () => { + assert.equal(registry().publishTarget({ channelId: ROOM }), 'exact'); + assert.equal(registry().publishTarget({ channelId: GENERAL }), 'root'); + assert.equal(registry().publishTarget({ channelId: LEGACY }), undefined); + }); + + it('plain speech answering a thread goes into that thread, and its receipt says so', async () => { + await turn([say('answering the thread')], ROOM, { threadId: 't-1' }); + assert.deepEqual(publishes().map((p) => [p.event, p.channelId, p.threadId, p.text]), [['publish', ROOM, 't-1', 'answering the thread']]); + assert.ok(texts().some((t) => t.startsWith('[routing] Your plain speech now lands in #room (Guild One) (discord / discord:g1:room, thread t-1)'))); + assert.ok(texts().some((t) => t.startsWith('[delivered]') && t.includes('thread t-1')), texts().filter((t) => t.startsWith('[delivered]')).join(' | ')); + }); + + it('a wake that names no conversation speaks where the last message came in, its thread named, through the real registry', async () => { + // A delivered message in a thread: its own turn answers it there. + await turn([say('answering the thread')], ROOM, { threadId: 't-7' }); + // Then a heartbeat-like wake that names no conversation: the fallback. + membrane.pushResponse(say('a scheduled note')); + const before = membrane.calls.length; + await (framework as unknown as { startAgentStream(a: unknown, t?: unknown): Promise }) + .startAgentStream(framework.getAgent('scout')!, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await until(() => membrane.calls.length > before, 'heartbeat turn start'); + await framework.runUntilIdle(); + await until(() => publishes().length >= 2, 'the fallback publish'); + assert.deepEqual(publishes().map((p) => [p.event, p.channelId, p.threadId, p.text]), [ + ['publish', ROOM, 't-7', 'answering the thread'], + ['publish', ROOM, 't-7', 'a scheduled note'], + ], 'the fallback named the thread the last message came in'); + assert.ok(texts().some((t) => t.startsWith('[routing] Your plain speech now lands in #room (Guild One) (discord / discord:g1:room, thread t-7)')), + 'the first turn announced its route'); + assert.ok(texts().some((t) => t.startsWith('[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message came in')) === false, + 'the place is unchanged since the last announced route, so no new notice'); + }); + + it('plain speech answering the channel itself names the root explicitly: threadId null on the wire', async () => { + await turn([say('at the top level')], ROOM); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[ROOM, null]]); + }); + + it('a channel that declares nothing: speech is held as a draft, nothing is published, and the connector\'s tools are named to consult', async () => { + await turn([say('hello legacy')], LEGACY); + assert.deepEqual(publishes(), []); + assert.deepEqual(drafts().map((d) => [d.text, d.reason]), [['hello legacy', 'no-destination']]); + const notice = texts().find((t) => t.startsWith('[routing]')) ?? ''; + assert.match(notice, /(?:^|[^#])#legacy \(Guild One\) \(discord \/ discord:g1:legacy\): its connector doesn't declare where a post lands \(MCPL RFC-011\)/, + 'the label once, with its own # and no second one'); + assert.match(notice, /publication from here is unavailable until it does/); + assert.match(notice, /may reach it; consult them: `mcpl--discord--reply_message`\./, 'a send-named tool, not the unrelated one'); + assert.doesNotMatch(notice, /ring/); + }); + + it('a thread on a channel that declares no threads: held, never posted to the root', async () => { + await turn([say('into the void')], GENERAL, { threadId: 't-9' }); + assert.deepEqual(publishes(), []); + assert.deepEqual(drafts().map((d) => d.reason), ['no-destination']); + assert.match(drafts()[0]!.note ?? '', /is a thread, but its connector declares the channel has no threads/); + }); + + it('channel_publish posts into a named thread, refuses a thread where there are none, and means the root without one', async () => { + await turn([ + call('channel_publish', { channelId: ROOM, threadId: 't-2', content: 'into t-2' }, 'p1'), + call('channel_publish', { channelId: GENERAL, threadId: 't-3', content: 'nowhere' }, 'p2'), + call('channel_publish', { channelId: ROOM, content: 'at the root' }, 'p3'), + call('channel_publish', { channelId: LEGACY, content: 'legacy' }, 'p4'), + createMockResponse([]), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [ + [ROOM, 't-2', 'into t-2'], + [ROOM, null, 'at the root'], + ], 'the threadless and the undeclared channels saw nothing'); + const results = toolResults(); + assert.match(results, /has no threads \(its connector declares root\), so a post into thread t-3 is refused/); + assert.match(results, /doesn't declare where a post lands \(MCPL RFC-011\)/); + }); + + it('every tool reads a thread id one way: a blank one is refused with nothing sent or set, and a padded one is trimmed', async () => { + await turn([say('kept for later')], LEGACY); + const [held] = drafts(); + await turn([ + call('channel_publish', { channelId: ROOM, threadId: ' ', content: 'blank' }, 'p1'), + call('channel_publish', { channelId: ROOM, threadId: ' t-10 ', content: 'padded' }, 'p2'), + call('drafts', { action: 'resend', draftIds: [held!.id], destination: ROOM, threadId: ' ' }, 'r1'), + createMockResponse([]), + ], GENERAL); + const sends = toolResults(); + assert.equal(sends.match(/threadId must be a thread id, not blank/g)?.length, 2, 'publish and resend each refused the blank one'); + // A turn with no explicit send, so its plain speech follows the route. + await turn([ + call('channel_open', { channelId: ROOM, threadId: '\t' }, 'o1'), + call('channel_open', { channelId: ROOM, threadId: ' t-11 ' }, 'o2'), + say('in t-11'), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [ + [ROOM, 't-10', 'padded'], + [ROOM, 't-11', 'in t-11'], + ], 'nothing went out for a blank thread; padded ones were trimmed'); + assert.match(toolResults(), /threadId must be a thread id, not blank: leave it out, or null, for the channel root\. Nothing was opened\./); + assert.equal(drafts()[0]!.attempts.length, 0, 'the refused resend recorded no attempt'); + }); + + it('channel_publish without a channel follows the route, thread included', async () => { + await turn([call('channel_publish', { content: 'explicitly here' }), createMockResponse([])], ROOM, { threadId: 't-4' }); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[ROOM, 't-4', 'explicitly here']]); + }); + + it('a held draft can be resent into a named thread, or to a channel\'s root', async () => { + await turn([say('words for later')], LEGACY); + const [held] = drafts(); + assert.ok(held); + await turn([ + call('drafts', { action: 'resend', draftIds: [held!.id], destination: ROOM, threadId: 't-5' }), + createMockResponse([]), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[ROOM, 't-5', 'words for later']]); + assert.match(toolResults(), /delivered to #room \(Guild One\) \(discord \/ discord:g1:room, thread t-5\)/); + }); + + it('resending to a channel that declares nothing is refused before anything is attempted', async () => { + await turn([say('kept')], LEGACY); + const [held] = drafts(); + await turn([ + call('drafts', { action: 'resend', draftIds: [held!.id], destination: LEGACY }), + createMockResponse([]), + ], GENERAL); + assert.deepEqual(publishes().filter((p) => p.text === 'kept'), []); + assert.match(toolResults(), /doesn't declare where a post lands[^]*consult them: `mcpl--discord--reply_message`[^]*Nothing was sent/); + assert.equal(drafts()[0]!.attempts.length, 0, 'no attempt was recorded'); + }); + + it('channel_open can make a thread the speech target; a channel named alone means its root', async () => { + await turn([ + call('channel_open', { channelId: ROOM, threadId: 't-6' }), + say('in t-6'), + ], GENERAL); + await turn([ + call('channel_open', { channelId: ROOM }), + say('at the root of room'), + ], ROOM, { threadId: 't-7' }); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [ + [ROOM, 't-6', 'in t-6'], + [ROOM, null, 'at the root of room'], + ], 'an explicit channel-only choice never borrows the thread the turn came from'); + }); + + it('channel_open with a thread but setSpeechTarget: false says the thread was not used', async () => { + await turn([ + call('channel_open', { channelId: ROOM, threadId: 't-9', setSpeechTarget: false }), + say('still to general'), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[GENERAL, null]]); + assert.match(toolResults(), /Opened for reading\. threadId t-9 chooses where speech goes, so with setSpeechTarget: false it was not used\./); + }); + + it('channel_open on a channel that declares nothing opens it but sets no speech target', async () => { + await turn([ + call('channel_open', { channelId: LEGACY }), + say('still to general'), + ], GENERAL); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId, p.text]), [[GENERAL, null, 'still to general']]); + assert.match(toolResults(), /Opened, but your plain speech can't go there: .*doesn't declare where a post lands.*Your plain speech still goes to #general/); + }); + + it('a delivery whose echo names another place is unconfirmed, never receipted as delivered', async () => { + command({ op: 'publish-mode', mode: 'wrong-echo' }); + await until(() => logged().some((e) => e.event === 'publish-mode'), 'mode'); + await turn([say('somewhere?')], ROOM, { threadId: 't-8' }); + assert.deepEqual(publishes().map((p) => [p.channelId, p.threadId]), [[ROOM, 't-8']]); + assert.ok(!texts().some((t) => t.startsWith('[delivered]')), 'not claimed as delivered'); + assert.ok(texts().some((t) => /was not confirmed/.test(t) && /reported posting at the channel root, not thread t-8/.test(t)), + texts().filter((t) => t.includes('confirmed')).join(' | ')); + }); + + it('a channels/changed that withdraws the declaration applies to the next turn', async () => { + await turn([say('first')], GENERAL); + command({ op: 'declare', channelId: GENERAL, target: null }); + await until(() => registry().publishTarget({ channelId: GENERAL }) === undefined, 'withdrawn'); + await turn([say('second')], GENERAL); + assert.deepEqual(publishes().map((p) => p.text), ['first']); + assert.deepEqual(drafts().map((d) => d.text), ['second']); + }); +}); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 71ad0787a..1ec7ca4e2 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -321,8 +321,8 @@ test('native Membrane observes the first refusal even when guard is enabled by a routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, + getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); const tokenTraces: string[] = []; framework.onTrace((event) => { @@ -539,8 +539,8 @@ function fakeRegistry(framework: AgentFramework) { routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: () => {}, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, - getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, + getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, key: string) => key in target ? (target as Record)[key] : () => undefined }); return routed; } diff --git a/test/tool-wrapper-prose-guard-integration.test.ts b/test/tool-wrapper-prose-guard-integration.test.ts index 5d8010f0b..a7031841a 100644 --- a/test/tool-wrapper-prose-guard-integration.test.ts +++ b/test/tool-wrapper-prose-guard-integration.test.ts @@ -123,7 +123,7 @@ async function run(enabled?: boolean, allowedTools?: string[]) { resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, speech: string) => { routed.push(speech); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -174,7 +174,7 @@ describe('tool wrapper prose guard integration', () => { (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, sendOutgoingChunk: (_c: string, _a: string, _id: string, _i: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -191,7 +191,7 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; @@ -210,7 +210,7 @@ describe('tool wrapper prose guard integration', () => { const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), agents: [{ name: 'assistant', model: 'test', systemPrompt: 'sys', maxStreamTokens: 1, toolWrapperProseGuard: true }], modules: [module] }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; @@ -231,8 +231,12 @@ describe('tool wrapper prose guard integration', () => { (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_agent: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + // A chunk names whether its words had been published when it was sent. + sendOutgoingChunk: (_destination: unknown, _agent: string, _id: string, _index: number, delta: string) => { + outgoing.push(routed.includes(delta) ? delta : `unpublished: ${delta}`); + return true; + }, + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); (framework as unknown as Record).channelEventModule = { getChannelId: () => 'world:test' }; framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); @@ -240,7 +244,7 @@ describe('tool wrapper prose guard integration', () => { const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); assert.deepEqual(routed, ['Ordinary answer.']); - assert.deepEqual(outgoing, [], 'guarded turns buffer ordinary prose until completion'); + assert.deepEqual(outgoing, ['Ordinary answer.'], 'guarded turns stream ordinary prose only once it is published'); assert.ok(all.some((m) => m.content.some((b) => b.type === 'text' && b.text === 'Ordinary answer.'))); assert.ok(!all.some((m) => m.metadata?.kind === 'tool-wrapper-prose-contained')); }); @@ -262,7 +266,7 @@ describe('tool wrapper prose guard integration', () => { }); (framework as unknown as { channelRegistry: unknown }).channelRegistry = new Proxy({ resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, - getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); @@ -416,7 +420,7 @@ describe('tool wrapper prose guard integration', () => { routeSpeech: async () => ({ delivered: true, channelId: 'world:test' }), sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => outgoing.push(['chunk', delta]), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => outgoing.push(['complete', text]), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], resolveProseTarget: () => ({ channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'ephemeral-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); @@ -452,7 +456,7 @@ describe('tool wrapper prose guard integration', () => { }); - it('idle-disposed ephemeral stops typing and finalizes every started outgoing stream', async () => { + it('idle-disposed ephemeral stops typing, and the stalled stream\'s unpublished words never stream', async () => { const dir = mkdtempSync(join(tmpdir(), 'af-wrapper-guard-idle-terminal-')); dirs.push(dir); const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: new LateCompletionAfterIdleMembrane().asMembrane(), agents: [], modules: [] }); const events: string[] = []; @@ -463,7 +467,7 @@ describe('tool wrapper prose guard integration', () => { routeSpeech: async () => ({ delivered: true, channelId: 'world:test' }), sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => events.push(`chunk:${delta}`), sendOutgoingComplete: (_channel: string, _agent: string, _id: string, text: string) => events.push(`complete:${text}`), - getDefaultPublishChannel: () => 'world:test', isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], + isChannelOpen: () => true, getDescriptor: () => undefined, publishTarget: () => 'root', getChannelTools: () => [], resolveProseTarget: () => ({ channelId: 'world:test' }), }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); const created = await framework.createEphemeralAgent({ name: 'idle-terminal', model: 'test', systemPrompt: 'sys', allowedTools: 'all' }); @@ -473,8 +477,9 @@ describe('tool wrapper prose guard integration', () => { await assert.rejects(run, /stalled/); await new Promise((resolve) => setTimeout(resolve, 180)); assert.ok(events.includes('stop:(all)'), 'disposer-owned frame stops typing'); - assert.ok(events.some((event) => event.startsWith('chunk:')), 'probe actually opened an outgoing stream'); - assert.ok(events.some((event) => event.startsWith('complete:')), 'every started outgoing stream receives a terminal complete'); + // Outgoing streams carry only published prose: the provider's partial + // tokens, and the late answer of a run already disposed, never were. + assert.ok(!events.some((event) => event.startsWith('chunk:') || event.startsWith('complete:')), JSON.stringify(events)); await framework.stop(); }); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56c..ecdc1c059 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -1,19 +1,24 @@ /** * Item-3 redux: single-TRUNK output routing. * - * These tests cover the FRAMEWORK-side plumbing that feeds the ChannelRegistry's - * `activeChannelResolver` (the routing DECISION itself is covered in - * channel-registry-routing.test.ts): + * These tests cover the FRAMEWORK-side plumbing that gives a trunk turn its + * speech route (shelf-355; the route DECISION itself is unit-tested in + * speech-routes.test.ts): * - derivePushEventChannel() reconstructs the MCPL composite channel for a * push event (Discord DMs arrive this way), preferring an explicit * origin.mcplChannelId. - * - a channel-incoming turn records its triggering channel per-agent. - * - a DM push-event turn records the reconstructed DM channel per-agent. - * - a batched wake picks the MOST-RECENT triggering channel. + * - a channel-incoming turn, and a DM push-event turn, take their + * triggering conversation as the turn's route; a reaction is no candidate. + * - the route belongs to the current turn; a no-trigger turn takes only the + * fallback, the last delivered message's conversation, and none without + * one. + * - threads are routes only where the channel's connector posts into named + * threads (MCPL RFC-011 exact), and a channel that declares no publish + * target is no route at all. * * connectome-host runs every agent as a single trunk (it never sets - * `conversations`), so no fork/home exists — the active triggering channel is - * the only thing that keeps a reply in the channel it is answering. + * `conversations`), so no fork/home exists: the turn's triggering + * conversation is what keeps a reply in the channel it is answering. */ import { describe, it, beforeEach, afterEach } from 'node:test'; @@ -23,21 +28,60 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { ProcessEvent } from '../src/index.js'; -import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; -/** Reach the private per-turn triggering-channel map + the pure channel-deriver. */ +/** A framework request that continues a turn, as the tests read it. */ +interface ContinuationView { + agentName: string; reason: string; source: string; timestamp: number; + continuesTurn?: true; + namesConversation?: true; + routeCandidates?: Array<{ conversation: { channelId?: string }; addressed: boolean }>; + fallbackPlace?: { conversation: { channelId?: string } }; + suppressProse?: boolean; +} + +/** Reach the private per-turn speech routes + the pure channel-deriver. */ function internals(framework: AgentFramework) { return framework as unknown as { - activeTriggerChannels: Map; + turnRoutes: Map; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number; channelId?: string }>; derivePushEventChannel( origin: Record | undefined, ): { channelId: string; label?: string } | undefined; channelRegistry: unknown; handleMcplPushEvent(event: unknown): void; + continuationRequest(agentName: string, reason: string): ContinuationView; + /** A continuation as its batch would start now: what the turn record gives it. */ + continuationFor(request: ContinuationView): ContinuationView; + conversationAgentHomes: Map; }; } +/** + * A channel subsystem stand-in whose channels all declare `target` as their + * MCPL RFC-011 publish target: a route is only ever a place the framework + * can publish to exactly. Plain speech it is asked to publish is recorded. + */ +function declaringRegistry(target: 'exact' | 'root' | undefined) { + const published: Array<{ text: string; to: unknown }> = []; + const registry = new Proxy({ + publishTarget: () => target, + // Every channel it is asked about is one registration. + resolveDestination: (c: { serverId?: string; channelId: string }) => + ({ destination: { serverId: c.serverId ?? 'discord', channelId: c.channelId } }), + resolveLocus: () => null, + routeSpeech: async (_a: string, text: string, to: unknown) => { + published.push({ text, to }); + // As the real registry reports a delivery: where it went, its thread included. + const dest = (to ?? {}) as { serverId?: string; channelId?: string; threadId?: string | null }; + return { delivered: true, serverId: dest.serverId ?? 'x', channelId: dest.channelId ?? 'x', ...(dest.threadId ? { threadId: dest.threadId } : {}) }; + }, + getDescriptor: () => undefined, + getChannelTools: () => [], + } as Record, { get: (t, p: string) => (p in t ? t[p] : () => undefined) }); + return { registry, published }; +} + function channelIncoming(channelId: string, text: string): ProcessEvent { return { type: 'mcpl:channel-incoming', @@ -146,6 +190,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -197,6 +242,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), stopAll: () => {}, }; @@ -262,6 +308,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: (id: string, serverId?: string) => { asked.push(`${serverId}/${id}`); return '@_reim0n'; }, stopAll: () => {}, @@ -280,6 +327,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i2.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => undefined, stopAll: () => {}, @@ -297,6 +345,7 @@ describe('Trunk channel routing (item-3 redux)', () => { i3.channelRegistry = { ensureChannelRegistered: () => {}, isChannelOpen: () => false, + getChannelLabel: () => undefined, getDescriptor: () => undefined, stopAll: () => {}, }; @@ -305,73 +354,181 @@ describe('Trunk channel routing (item-3 redux)', () => { await fw3.stop(); }); - it('a channel-incoming trunk turn records its triggering channel', async () => { + it('a channel-incoming trunk turn takes its triggering conversation as its speech route', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); + internals(framework).channelRegistry = declaringRegistry('root').registry; - framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: sleep && date')); + const event = channelIncoming('discord:guild:chanA', 'A: sleep && date'); + framework.pushEvent(event); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1, 'the trunk should have run one turn'); - assert.equal( - internals(framework).activeTriggerChannels.get('scout'), - 'discord:guild:chanA', - 'the turn must be routed to the channel that triggered it', - ); + const route = internals(framework).turnRoutes.get('scout')?.route; + assert.equal(route?.channelId, 'discord:guild:chanA', 'the turn must be routed to the channel that triggered it'); + assert.equal(route?.replyTo, (event as unknown as { messageId: string }).messageId, 'its message is the reply edge'); + assert.equal(route?.origin, 'trigger'); await framework.stop(); }); - it('a DM push-event turn records the reconstructed DM channel (item-3 redux DM sub-case)', async () => { + it('a DM push-event turn takes the reconstructed DM channel as its route (item-3 redux DM sub-case)', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hi in the DM' }])); const framework = await makeFramework(); + internals(framework).channelRegistry = declaringRegistry('root').registry; framework.pushEvent(dmPushEvent('42', 'hey scout, ping')); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 1, 'the trunk should wake for the DM'); assert.equal( - internals(framework).activeTriggerChannels.get('scout'), + internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:dm:42', - 'the DM reply must route to the DM channel, not the global locus', + 'the DM reply must route to the DM channel', ); await framework.stop(); }); - it('the triggering channel tracks the CURRENT turn, never a stale one', async () => { + it('the route belongs to the CURRENT turn, never a stale one', async () => { const framework = await makeFramework(); const i = internals(framework); + i.channelRegistry = declaringRegistry('root').registry; - // A channel-A turn sets the active channel... (push the response right before - // each turn: MockMembrane's stream consumes ALL queued responses at once.) + // (Push the response right before each turn: MockMembrane's stream + // consumes ALL queued responses at once.) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ans A' }])); framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); await framework.runUntilIdle(); - assert.equal(i.activeTriggerChannels.get('scout'), 'discord:guild:chanA'); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); - // ...then a DM turn OVERWRITES it — the next turn's reply must never inherit - // the previous turn's channel (the concurrency hazard this fix removes). + // A DM turn decides its own route — never inherits the previous turn's. membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ans DM' }])); framework.pushEvent(dmPushEvent('42', 'now in a DM')); await framework.runUntilIdle(); assert.equal(membrane.calls.length, 2, 'both turns should have run'); - assert.equal( - i.activeTriggerChannels.get('scout'), - 'discord:dm:42', - 'the map must reflect the CURRENT turn’s channel, not chanA', - ); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:dm:42', 'the CURRENT turn’s channel, not chanA'); + await framework.stop(); + }); + + it('a reaction that wakes a turn answers nothing: no route candidate', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); + const framework = await makeFramework(); + framework.pushEvent({ + ...(channelIncoming('discord:guild:chanA', '👍') as unknown as Record), + tags: ['chat:reaction', 'chat:addressed'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }); + await framework.stop(); + }); + + it('a thread is a route where its channel posts into named threads (MCPL RFC-011 exact)', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the topic' }])); + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('exact'); + internals(framework).channelRegistry = registry; + framework.pushEvent({ + ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), + serverId: 'zulip', + threadId: 'topic-a', + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: { channelId?: string; threadId?: string } | null }; + assert.equal(turn.route?.channelId, 'zulip:stream:7'); + assert.equal(turn.route?.threadId, 'topic-a'); + assert.deepEqual(published, [{ text: 'answering the topic', to: { serverId: 'zulip', channelId: 'zulip:stream:7', threadId: 'topic-a' } }], + 'into the thread, never the channel root'); + await framework.stop(); + }); + + it('a thread placement is not streamed; a root publication streams after it is confirmed, at the root (RFC-011 §6)', async () => { + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('exact'); + const streamed: Array<{ kind: string; serverId: string; channelId: string; text: string; published: number }> = []; + Object.assign(registry as Record, { + sendOutgoingChunk: (d: { serverId: string; channelId: string }, _a: string, _i: string, _n: number, delta: string) => + streamed.push({ kind: 'chunk', serverId: d.serverId, channelId: d.channelId, text: delta, published: published.length }), + sendOutgoingComplete: (d: { serverId: string; channelId: string }, _a: string, _i: string, text: string) => + streamed.push({ kind: 'complete', serverId: d.serverId, channelId: d.channelId, text, published: published.length }), + }); + internals(framework).channelRegistry = registry; + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'into the thread' }])); + framework.pushEvent({ + ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), + serverId: 'zulip', + threadId: 'topic-a', + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(streamed.length, 0, 'one channel stream could carry thread speech and root envelopes alike: none at all'); + assert.equal(published.length, 1, 'the speech itself was still published, into the thread'); + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'at the root' }])); + framework.pushEvent({ ...(channelIncoming('zulip:stream:7', 'at root') as unknown as Record), serverId: 'zulip' } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.deepEqual(streamed, [ + { kind: 'chunk', serverId: 'zulip', channelId: 'zulip:stream:7', text: 'at the root', published: 2 }, + { kind: 'complete', serverId: 'zulip', channelId: 'zulip:stream:7', text: 'at the root', published: 2 }, + ], 'after its confirmed publish, on the server it resolved to'); + await framework.stop(); + }); + + it('a thread on a channel that declares no threads wakes the turn but plain speech is held, never sent to the root', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the topic' }])); + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('root'); + internals(framework).channelRegistry = registry; + framework.pushEvent({ + ...(channelIncoming('zulip:stream:7', 'on topic') as unknown as Record), + serverId: 'zulip', + threadId: 'topic-a', + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'thread'); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + assert.ok(texts.some((t) => t.startsWith('[routing] Your plain speech can\'t go to the conversation in front of you') && + /is a thread, but its connector declares the channel has no threads/.test(t))); + const drafts = (framework as unknown as { proseDrafts: { open(a: string): Array<{ text: string; reason: string; note?: string }> } }) + .proseDrafts.open('scout'); + assert.deepEqual(drafts.map((d) => [d.text, d.reason]), [['answering the topic', 'no-destination']]); + assert.match(drafts[0]!.note ?? '', /is a thread, but its connector declares the channel has no threads/); + assert.deepEqual(published, [], 'nothing went to the channel root'); + await framework.stop(); + }); + + it('a channel whose connector declares no publish target is no route: speech is held, nothing published', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'hello there' }])); + const framework = await makeFramework(); + const { registry, published } = declaringRegistry(undefined); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const turn = internals(framework).turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'untargetable'); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + const notice = texts.find((t) => t.startsWith('[routing]')); + assert.match(notice ?? '', /its connector doesn't declare where a post lands \(MCPL RFC-011\)/); + assert.match(notice ?? '', /publication from here is unavailable until it does/); + assert.doesNotMatch(notice ?? '', /consult/, 'no connector tools are claimed when the connector lists none'); + assert.deepEqual(published, []); await framework.stop(); }); - it('startAgentStream clears the triggering channel for a no-channel (heartbeat) turn', async () => { + it('a no-trigger (heartbeat) turn never carries a stale route over: with no delivered message it has none', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); const i = internals(framework); - // Simulate a stale channel left by a prior turn, then run a heartbeat/timer - // turn (an InferenceRequest with no channelId). startAgentStream must clear - // the entry up front so routeSpeech falls back to the global locus rather - // than replaying the stale channel. - i.activeTriggerChannels.set('scout', 'discord:guild:stale'); + // A stale route left by a prior turn must not carry over: a turn with no + // route candidates (a heartbeat/timer) takes only the fallback, the last + // delivered message's conversation, and with none delivered it has no + // destination, so its plain speech is held as a draft. + i.turnRoutes.set('scout', { route: { kind: 'channel', channelId: 'discord:guild:stale', origin: 'trigger' } }); const scout = framework.getAgent('scout')!; await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise; @@ -380,11 +537,977 @@ describe('Trunk channel routing (item-3 redux)', () => { }); await framework.runUntilIdle(); - assert.equal( - i.activeTriggerChannels.has('scout'), - false, - 'a no-trigger turn must clear any stale triggering channel', + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'a no-trigger turn has no route'); + await framework.stop(); + }); + + /** A registry stand-in whose last delivered message is `last`. */ + function withLastInbound(target: 'exact' | 'root' | undefined, last: { serverId: string; channelId: string; threadId?: string }) { + const declaring = declaringRegistry(target); + (declaring.registry as Record).getLastInbound = () => ({ ...last }); + return declaring; + } + + async function heartbeat(framework: AgentFramework): Promise { + const scout = framework.getAgent('scout')!; + await (framework as unknown as { + startAgentStream(agent: unknown, trigger?: unknown): Promise; + }).startAgentStream(scout, { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + await framework.runUntilIdle(); + } + + it('a no-trigger (heartbeat) turn takes the fallback, the last delivered message\'s conversation, and speaks there with its thread', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('exact', { + serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01', + }); + internals(framework).channelRegistry = registry; + await heartbeat(framework); + const turn = internals(framework).turnRoutes.get('scout') as { route: Record | null }; + assert.deepEqual(turn.route, { + kind: 'channel', serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01', origin: 'fallback', + }); + // It answers no message, so the beforeInference context names where the + // speech lands and no `incoming` (MCPL's triggering message). + const view = (framework as unknown as { speechRouteView(a: string): Record }).speechRouteView('scout'); + assert.deepEqual(view, { kind: 'channel', serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01' }); + assert.equal(published.length, 1); + assert.equal(published[0]!.text, 'tick'); + assert.deepEqual( + { ...(published[0]!.to as Record) }, + { serverId: 'slack', channelId: 'slack:C1', threadId: '1700.01' }, + 'published into the conversation the last message came from, its thread named', + ); + const texts = framework.getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content).filter((b) => b.type === 'text').map((b) => (b as { text: string }).text); + assert.ok(texts.some((t) => t.startsWith('[routing] Nothing in this wake named a conversation, so your plain speech lands where the last message came in')), + 'the resident is told why its speech goes there'); + await framework.stop(); + }); + + it('a wake that names a conversation never takes the fallback: a reaction, or a channel another agent\'s fork owns', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + // A reaction names its channel but answers nothing: no route, as before + // the fallback, rather than the last delivered message's channel. + framework.pushEvent({ + ...(channelIncoming('discord:guild:chanA', '👍') as unknown as Record), + tags: ['chat:reaction', 'chat:addressed'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a reaction-woken turn takes no fallback'); + + // A message in a channel another agent's fork owns: the trunk takes no + // candidate there, and no fallback either. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not mine' }])); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes + .set('fork-1', 'discord:guild:forked'); + framework.pushEvent(channelIncoming('discord:guild:forked', 'hello fork')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a fork-filtered wake takes no fallback'); + + // A pushed reaction in a DM names that DM: no fallback either. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'saw it' }])); + framework.pushEvent({ + ...(dmPushEvent('42', '👍') as unknown as Record), + tags: ['chat:reaction'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 3); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a pushed reaction takes no fallback'); + + // A push that names only its author names a conversation too. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'who was that' }])); + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'discord', featureSet: 'discord.messaging', + eventId: `e-${Math.random().toString(36).slice(2)}`, content: [{ type: 'text', text: 'hm' }], + origin: { source: 'discord', authorId: 'U9' }, timestamp: new Date().toISOString(), + inferenceId: `inf-${Math.random().toString(36).slice(2)}`, triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 4); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'an author-only push takes no fallback'); + + // So does one that names only a thread. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'in that thread' }])); + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'discord', featureSet: 'discord.messaging', + eventId: `e-${Math.random().toString(36).slice(2)}`, content: [{ type: 'text', text: 'hm' }], + origin: { source: 'discord', threadId: 'T-9' }, timestamp: new Date().toISOString(), + inferenceId: `inf-${Math.random().toString(36).slice(2)}`, triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 5); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'a thread-only push takes no fallback'); + assert.deepEqual(published, [], 'nothing went to the last delivered message\'s channel'); + await framework.stop(); + }); + + it('a console message requesting inference without adding a message still answers on the console, never the fallback', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'on the console' }])); + // A module that requests inference for console input without adding it + // as a message (the input reaches context some other way). + const nudger = { + name: 'nudger', + async start() {}, + async stop() {}, + getTools: () => [], + async handleToolCall() { return { success: false, error: 'none', isError: true }; }, + async onProcess(event: ProcessEvent) { + return event.type === 'external-message' ? { requestInference: true } : {}; + }, + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'nudge.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [nudger as never], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + framework.pushEvent({ type: 'external-message', source: 'tui', content: 'status?', metadata: {} } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + const turn = internals(framework).turnRoutes.get('scout') as { route: { kind: string; surface?: string } | null }; + assert.equal(turn.route?.kind, 'surface', 'the console is the route'); + assert.equal(turn.route?.surface, 'tui'); + assert.deepEqual(published, [], 'nothing was published to the last channel'); + await framework.stop(); + }); + + it('a refusal\'s rewind retry names the refused turn\'s conversation, so it speaks there, never where the last message came in', async () => { + // One response per stream: the refused turn, then its retry. + const streams = [createMockResponse([], 'refusal'), createMockResponse([{ type: 'text', text: 'after the rewind' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'rewind.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', refusalHandling: { autoRewind: true } }], + modules: [], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, 'the refused turn, then its retry'); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanA', 'after the rewind']]); + await framework.stop(); + }); + + it('a continuation names its turn\'s conversation in any batch: it keeps it beside a heartbeat in either order and beside an ambient arrival, and holds beside an addressed one', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const i = internals(framework); + // What a continuation carries is read when its batch starts. + assert.equal(i.continuationRequest('scout', 'refusal-rewind-retry').routeCandidates, undefined); + const chanA = i.continuationFor(i.continuationRequest('scout', 'refusal-rewind-retry')); + assert.equal(chanA.namesConversation, true); + assert.deepEqual(chanA.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); + const heartbeatRequest = () => ({ agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }); + const arrivalFromB = (addressed: boolean) => ({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed, at: Date.now() }], + }) as never; + const batches: Array<[string, () => unknown[]]> = [ + ['first', () => [i.continuationRequest('scout', 'inference-failure-rewind-retry'), heartbeatRequest()]], + ['behind a heartbeat', () => [heartbeatRequest(), i.continuationRequest('scout', 'prose-bounce')]], + ['beside an ambient arrival', () => [arrivalFromB(false), i.continuationRequest('scout', 'unstick')]], + ]; + for (const [label, batch] of batches) { + const calls = membrane.calls.length; + membrane.pushResponse(createMockResponse([{ type: 'text', text: label }])); + i.pendingRequests.push(...(batch() as never[])); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, calls + 1, `${label}: one turn`); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA', `${label}: it keeps chanA`); + } + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), + ['discord:guild:chanA', 'discord:guild:chanA', 'discord:guild:chanA', 'discord:guild:chanA']); + // An addressed arrival from chanB competes with it: the turn is held. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'which one' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry'), arrivalFromB(true)); + await framework.runUntilIdle(); + const held = i.turnRoutes.get('scout') as { route: null; hold?: { conversations: Array<{ channelId: string }> } }; + assert.equal(held.route, null); + assert.deepEqual(held.hold?.conversations.map((c) => c.channelId).sort(), ['discord:guild:chanA', 'discord:guild:chanB']); + assert.equal(published.length, 4, 'nothing published from the held turn'); + // Its continuation names both, and is held again. + const both = i.continuationFor(i.continuationRequest('scout', 'prose-bounce')); + assert.deepEqual(both.routeCandidates?.map((c) => c.conversation.channelId).sort(), ['discord:guild:chanA', 'discord:guild:chanB']); + await framework.stop(); + }); + + it('the fork rule judges a continuation when its turn starts: a claim made after it was queued keeps it out, one given up lets it in', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const i = internals(framework); + const chanARoute = i.turnRoutes.get('scout')!; + // Queued while chanA is free; a fork claims it before the turn starts. + const queued = i.continuationRequest('scout', 'refusal-rewind-retry'); + const asQueued = i.continuationFor(queued); + assert.equal(asQueued.namesConversation, true); + assert.deepEqual(asQueued.routeCandidates?.map((c) => [c.conversation.channelId, c.addressed]), [['discord:guild:chanA', true]]); + i.pendingRequests.push(queued); + i.conversationAgentHomes.set('fork-1', 'discord:guild:chanA'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }, 'no route into the fork, and no fallback'); + assert.equal(published.length, 1, 'only the first answer was published'); + // Queued while the fork owns chanA; the fork gives it up before the turn starts. + i.turnRoutes.set('scout', chanARoute); + const released = i.continuationRequest('scout', 'unstick'); + assert.deepEqual(i.continuationFor(released).routeCandidates?.map((c) => c.conversation.channelId), ['discord:guild:chanA'], 'named whoever owns it'); + i.pendingRequests.push(released); + i.conversationAgentHomes.delete('fork-1'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back in chanA' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:chanA', 'discord:guild:chanA']); + // An unroutable candidate in a fork's channel never routes, but it would + // compete: the rule keeps it from holding another agent's turn. + i.conversationAgentHomes.set('fork-1', 'discord:guild:chanA'); + const now = Date.now(); + i.pendingRequests.push({ + agentName: 'scout', reason: 'mcpl:push-event', source: 'discord', timestamp: now, namesConversation: true, + routeCandidates: [ + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanA' }, addressed: true, at: now, unroutable: true }, + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed: true, at: now - 1 }, + ], + } as never); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'to chanB' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB', "the fork's channel holds nothing"); + await framework.stop(); + }); + + it("a continuation of a turn whose words had no place gets none, never the fallback, when the turn's wake named a conversation", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noticed' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + // A reaction names its channel but answers nothing: the turn has no place. + framework.pushEvent({ + ...(channelIncoming('discord:guild:chanA', '👍') as unknown as Record), + tags: ['chat:reaction', 'chat:addressed'], + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); + const placeless = i.continuationRequest('scout', 'refusal-rewind-retry'); + assert.equal(i.continuationFor(placeless).namesConversation, true); + assert.equal(i.continuationFor(placeless).routeCandidates, undefined); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still nowhere' }])); + i.pendingRequests.push(placeless); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); + assert.deepEqual(published, [], 'nothing went where the last message came in'); + // No record at all (no locus-mode turn yet): the wake names nothing, and + // takes the fallback like any such wake. + i.turnRoutes.delete('scout'); + const fresh = i.continuationRequest('scout', 'unstick'); + assert.equal(i.continuationFor(fresh).namesConversation, undefined); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'where the last message came in' }])); + i.pendingRequests.push(fresh); + await framework.runUntilIdle(); + const fallback = i.turnRoutes.get('scout')?.route; + assert.deepEqual([fallback?.channelId, fallback?.origin], ['discord:guild:elsewhere', 'fallback']); + await framework.stop(); + }); + + it("a fallback turn's continuation keeps that turn's place as its fallback when the last message came in elsewhere since, and yields to an addressed arrival", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await heartbeat(framework); + assert.equal(i.turnRoutes.get('scout')?.route?.origin, 'fallback'); + const continued = i.continuationRequest('scout', 'refusal-rewind-retry'); + const view = i.continuationFor(continued); + assert.equal(view.namesConversation, undefined); + assert.equal(view.routeCandidates, undefined); + assert.equal(view.fallbackPlace?.conversation.channelId, 'discord:guild:elsewhere'); + // A message comes in on another channel before the continuation starts: + // the rest of the reply still goes where its first part went. + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:moved' }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick again' }])); + i.pendingRequests.push(continued); + await framework.runUntilIdle(); + assert.deepEqual([i.turnRoutes.get('scout')?.route?.channelId, i.turnRoutes.get('scout')?.route?.origin], + ['discord:guild:elsewhere', 'fallback']); + // Beside an addressed arrival from chanB, the arrival takes the turn: the + // fallback's place was never a conversation in front of the resident. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'to chanB' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'unstick'), { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed: true, at: Date.now() }], + } as never); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB'); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), + ['discord:guild:elsewhere', 'discord:guild:elsewhere', 'discord:guild:chanB']); + await framework.stop(); + }); + + it("a fallback turn's kept place that the fork rule refuses when its continuation starts gives no place, then or after, even with the last message come in elsewhere", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await heartbeat(framework); + assert.equal(i.turnRoutes.get('scout')?.route?.channelId, 'discord:guild:elsewhere'); + // A fork claims the kept place, and a message comes in on another channel. + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + i.conversationAgentHomes.set('fork-1', 'discord:guild:elsewhere'); + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:moved' }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not into the fork' }])); + await framework.runUntilIdle(); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }, "no route into the fork's channel, and none elsewhere"); + // The next continuation of the same reply has no place either. + const before = i.turnRoutes.get('scout'); + i.pendingRequests.push(i.continuationRequest('scout', 'inference-failure-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still not moved' }])); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 3, 'the second continuation ran'); + assert.notEqual(i.turnRoutes.get('scout'), before, 'and wrote its own record'); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null, named: true }); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:elsewhere'], 'only the tick was published'); + i.conversationAgentHomes.delete('fork-1'); + await framework.stop(); + }); + + it("a fallback turn's kept place whose channel no longer declares a publish target gives its continuation no place", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + await heartbeat(framework); + // The kept place's channel stops declaring; the last inbound moves to one that does. + (registry as Record).publishTarget = (c: { channelId?: string }) => (c.channelId === 'discord:guild:elsewhere' ? undefined : 'root'); + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:moved' }); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'nowhere now' }])); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout')?.route, null); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:elsewhere'], 'only the tick was published'); + await framework.stop(); + }); + + it("a no-name turn that found no fallback place gives a continuation that names none, so it takes the fallback once there is one", async () => { + const framework = await makeFramework(); + const { registry, published } = declaringRegistry('root'); + (registry as Record).getLastInbound = () => null; + internals(framework).channelRegistry = registry; + const i = internals(framework); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'nowhere to go' }])); + await heartbeat(framework); + assert.deepEqual(i.turnRoutes.get('scout'), { route: null }, 'no place, and its wake named none'); + const continued = i.continuationRequest('scout', 'inference-failure-rewind-retry'); + assert.equal(i.continuationFor(continued).namesConversation, undefined); + // A message comes in; the continuation's turn takes it as its fallback. + (registry as Record).getLastInbound = () => ({ serverId: 'discord', channelId: 'discord:guild:first' }); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'now somewhere' }])); + i.pendingRequests.push(continued); + await framework.runUntilIdle(); + assert.deepEqual([i.turnRoutes.get('scout')?.route?.channelId, i.turnRoutes.get('scout')?.route?.origin], + ['discord:guild:first', 'fallback']); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:first', 'now somewhere']]); + await framework.stop(); + }); + + it("a silent tick's rewind retry is a silent tick too: no place for its words, and the tick's own prompt", async () => { + const streams = [createMockResponse([], 'refusal'), createMockResponse([{ type: 'text', text: 'thinking privately' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'silent.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', refusalHandling: { autoRewind: true } }], + modules: [], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', eventId: 'hb-1', content: [], + timestamp: new Date().toISOString(), origin: { source: 'heartbeat', reason: 'schedule', silent: true }, + inferenceId: 'inf-hb-1', triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, 'the refused tick, then its retry'); + assert.ok(JSON.stringify(membrane.calls[1]).includes('[silent heartbeat]'), "the retry carries the tick's prompt"); + const retryRows = framework.getAgent('scout')!.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('thinking privately')); + assert.equal(retryRows.length > 0 && retryRows.every((m) => (m.metadata as { silentHeartbeat?: { eventId?: string } } | undefined)?.silentHeartbeat?.eventId === 'hb-1'), true, + "the retry's rows carry the tick's stamp"); + // The retry's own turn was a silent tick: its record keeps the tick. + const record = internals(framework).turnRoutes.get('scout') as { route: unknown; silent?: { silentHeartbeat?: { eventId?: string } } }; + assert.equal(record.route, null); + assert.equal(record.silent?.silentHeartbeat?.eventId, 'hb-1', 'the retry was a silent tick'); + assert.deepEqual(published, [], 'nothing was published'); + await framework.stop(); + }); + + it("an unstick names the conversation its agent's last turn answered", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const host = framework as unknown as { handleHostCommand(serverId: string, params: Record): Promise<{ ok: boolean; error?: string }> }; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'unstuck' }])); + const result = await host.handleHostCommand('discord', { command: 'unstick', agentName: 'scout' }); + assert.equal(result.ok, true, result.error); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanA'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanA', 'first answer'], ['discord:guild:chanA', 'unstuck']]); + await framework.stop(); + }); + + it("a silent tick whose start fails at the failure threshold: its rewind retry is a silent tick too, with nothing left to resend", async () => { + // The retry is queued from startAgentStream's terminal branch, after the + // tick's trigger is torn down: its silence comes from the turn record. + const streams = [createMockResponse([{ type: 'text', text: 'thinking privately' }])]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.splice(0, 1)); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'silent-start.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const scout = framework.getAgent('scout')!; + const cm = scout.getContextManager(); + cm.addMessage('user', [{ type: 'text', text: 'An earlier question' }]); + cm.addMessage('scout', [{ type: 'text', text: 'An earlier answer' }]); + // One failure short of the threshold, so this start's failure rewinds. + (framework as unknown as { consecutiveInferenceFailures: Map }).consecutiveInferenceFailures.set('scout', 2); + const { MembraneError } = await import('@animalabs/membrane'); + const compile = cm.compile.bind(cm); + let failNext = true; + (cm as unknown as { compile: (...args: unknown[]) => Promise }).compile = async (...args: unknown[]) => { + if (failNext) { + failNext = false; + throw new MembraneError({ type: 'invalid_request', message: 'compile 400', retryable: false, httpStatus: 400 } as never); + } + return (compile as (...a: unknown[]) => Promise)(...args); + }; + framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', eventId: 'hb-2', content: [], + timestamp: new Date().toISOString(), origin: { source: 'heartbeat', reason: 'schedule', silent: true }, + inferenceId: 'inf-hb-2', triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1, 'the failed start reached no model; its retry did'); + assert.ok(JSON.stringify(membrane.calls[0]).includes('[silent heartbeat]'), "the retry carries the tick's prompt"); + const retryRows = cm.getAllMessages().filter((m) => JSON.stringify(m.content).includes('thinking privately')); + assert.equal(retryRows.length > 0 && retryRows.every((m) => (m.metadata as { silentHeartbeat?: { eventId?: string } } | undefined)?.silentHeartbeat?.eventId === 'hb-2'), true, + "the retry's rows carry the tick's stamp"); + const drafts = (framework as unknown as { proseDrafts: { open(agent: string): unknown[] } }).proseDrafts.open('scout'); + assert.deepEqual(drafts, [], 'its words stayed private: no draft to resend'); + const record = internals(framework).turnRoutes.get('scout') as { silent?: { silentHeartbeat?: { eventId?: string } } }; + assert.equal(record.silent?.silentHeartbeat?.eventId, 'hb-2', 'the retry was a silent tick'); + assert.deepEqual(published, [], 'nothing was published'); + await framework.stop(); + }); + + it('an unstick is refused while a turn is alive, even when the agent reads idle', async () => { + const framework = await makeFramework(); + const fw = framework as unknown as { + activeTurnTokens: Map; + handleHostCommand(serverId: string, params: Record): Promise<{ ok: boolean; error?: string }>; + }; + assert.equal(framework.getAgent('scout')!.state.status, 'idle'); + fw.activeTurnTokens.set('scout', 99); + const refused = await fw.handleHostCommand('discord', { command: 'unstick', agentName: 'scout' }); + assert.equal(refused.ok, false); + assert.match(refused.error ?? '', /while a turn is in progress/); + assert.equal(internals(framework).pendingRequests.some((r) => r.reason === 'unstick'), false, 'no unstick queued'); + fw.activeTurnTokens.delete('scout'); + await framework.stop(); + }); + + it("a continuation of a turn whose channel had no server resolves it again when it starts, and takes the route with its server", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + let registered = false; + (registry as Record).getChannelServerId = (id: string) => (registered && id === 'discord:guild:late' ? 'discord' : undefined); + internals(framework).channelRegistry = registry; + const i = internals(framework); + const fw = framework as unknown as { candidateForChannel(channelId: string, addressed: boolean, at: number): unknown }; + const now = Date.now(); + i.pendingRequests.push({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: now, namesConversation: true, + routeCandidates: [fw.candidateForChannel('discord:guild:late', true, now)], + } as never); + await framework.runUntilIdle(); + const unresolved = i.turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(unresolved.route, null); + assert.equal(unresolved.unroutable?.reason, 'unresolved'); + // The server registers; the turn's retry takes the channel, with its server. + registered = true; + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'there now' }])); + await framework.runUntilIdle(); + const route = i.turnRoutes.get('scout')?.route as { channelId?: string; serverId?: string } | null; + assert.deepEqual([route?.serverId, route?.channelId], ['discord', 'discord:guild:late']); + assert.deepEqual(published.map((p) => [(p.to as { serverId?: string }).serverId, (p.to as { channelId: string }).channelId, p.text]), + [['discord', 'discord:guild:late', 'there now']]); + await framework.stop(); + }); + + it("the inference policy sees a continuation already filled from its turn's record", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const policy = (framework as unknown as { inferencePolicy: { shouldInfer(agentName: string, requests: unknown[], state: unknown): boolean } }).inferencePolicy; + const seen: Array<{ reason: string; namesConversation?: true; routeCandidates?: Array<{ conversation: { channelId?: string } }> }> = []; + const shouldInfer = policy.shouldInfer.bind(policy); + policy.shouldInfer = (agentName, requests, state) => { + seen.push(...(requests as typeof seen)); + return shouldInfer(agentName, requests, state); + }; + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'second answer' }])); + await framework.runUntilIdle(); + const retry = seen.find((r) => r.reason === 'refusal-rewind-retry'); + assert.deepEqual([retry?.namesConversation, retry?.routeCandidates?.map((c) => c.conversation.channelId)], + [true, ['discord:guild:chanA']]); + await framework.stop(); + }); + + it("a continuation's conversation counts as older than a message batched with it, so that message gives the reply edge", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'first answer' }])); + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + const first = i.turnRoutes.get('scout')?.route?.replyTo; + assert.ok(first); + // A newer message in chanA is queued first; the turn's retry is queued after it. + const arrival = { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanA' }, addressed: true, at: Date.now(), messageId: 'm-newer' }], + }; + i.pendingRequests.push(arrival as never, i.continuationRequest('scout', 'refusal-rewind-retry')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'second answer' }])); + await framework.runUntilIdle(); + assert.deepEqual([i.turnRoutes.get('scout')?.route?.channelId, i.turnRoutes.get('scout')?.route?.replyTo], ['discord:guild:chanA', 'm-newer']); + await framework.stop(); + }); + + it("a silent tick's record survives a restart or a guard retry, and a restart batched with an ordinary wake runs ordinary, routed by that wake", async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + const scout = framework.getAgent('scout')!; + const start = (trigger: Record) => (framework as unknown as { + startAgentStream(agent: unknown, trigger?: unknown, attempt?: number): Promise; + }).startAgentStream(scout, { agentName: 'scout', source: 'framework', timestamp: Date.now(), ...trigger }); + const silentHeartbeat = { eventId: 'hb-9', origin: { source: 'heartbeat', silent: true } }; + // A silent tick whose wake named a conversation. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private' }])); + await start({ reason: 'heartbeat', suppressProse: true, namesConversation: true, silentHeartbeat }); + await framework.runUntilIdle(); + const record = i.turnRoutes.get('scout') as { route: unknown; named?: true; silent?: { silentHeartbeat?: { eventId?: string } } }; + assert.deepEqual([record.route, record.named, record.silent?.silentHeartbeat?.eventId], [null, true, 'hb-9']); + // Its restart and its guard retry, each alone: silent, and the record is kept whole. + for (const reason of ['context_budget_restart', 'tool_result_guard_retry']) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still private' }])); + await start({ reason, suppressProse: true, silentHeartbeat }); + await framework.runUntilIdle(); + assert.equal(i.turnRoutes.get('scout'), record, `the ${reason} kept the record`); + } + // A restart that shares its batch with an ordinary wake runs ordinary. + // The tick had no route to keep, so the wake's conversation decides it. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ordinary now' }])); + i.pendingRequests.push( + { agentName: 'scout', reason: 'context_budget_restart', source: 'framework', timestamp: Date.now(), suppressProse: true, silentHeartbeat } as never, + { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:asked' }, addressed: true, at: Date.now(), messageId: 'm-asked' }], + } as never, ); + await framework.runUntilIdle(); + const ordinary = i.turnRoutes.get('scout') as { route: { channelId?: string; replyTo?: string } | null; named?: true; silent?: unknown }; + assert.deepEqual([ordinary.route?.channelId, ordinary.route?.replyTo, ordinary.named, ordinary.silent], + ['discord:guild:asked', 'm-asked', true, undefined]); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), [['discord:guild:asked', 'ordinary now']]); + await framework.stop(); + }); + + it("an unstick and a bounce's resend after a silent tick are ordinary turns; the tick's own retry stays silent", async () => { + const framework = await makeFramework(); + const { registry } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + const i = internals(framework); + const silentTick = (eventId: string) => framework.pushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', eventId, content: [], + timestamp: new Date().toISOString(), origin: { source: 'heartbeat', reason: 'schedule', silent: true }, + inferenceId: `inf-${eventId}`, triggerInference: true, + } as unknown as ProcessEvent); + const silent = () => (i.turnRoutes.get('scout') as { silent?: unknown } | undefined)?.silent !== undefined; + const lastCallSilent = () => JSON.stringify(membrane.calls.at(-1)).includes('[silent heartbeat]'); + // The tick's own retry is a silent tick. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + silentTick('hb-a'); + await framework.runUntilIdle(); + assert.equal(silent(), true); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'retry' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + await framework.runUntilIdle(); + assert.equal(lastCallSilent(), true, 'the retry carries the tick\'s prompt'); + assert.equal(silent(), true); + // An operator's unstick after it is an ordinary turn. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'unstuck' }])); + const host = framework as unknown as { handleHostCommand(serverId: string, params: Record): Promise<{ ok: boolean; error?: string }> }; + const result = await host.handleHostCommand('discord', { command: 'unstick', agentName: 'scout' }); + assert.equal(result.ok, true, result.error); + await framework.runUntilIdle(); + assert.equal(lastCallSilent(), false, 'no tick prompt'); + assert.equal(silent(), false, 'an ordinary record'); + // So is a bounce's resend after another silent tick. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick 2' }])); + silentTick('hb-b'); + await framework.runUntilIdle(); + assert.equal(silent(), true); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'resending' }])); + (framework as unknown as { bounceProse(agent: unknown, text: string, reason: string): void }) + .bounceProse(framework.getAgent('scout'), 'lost words', 'a late bounce'); + await framework.runUntilIdle(); + assert.equal(lastCallSilent(), false, 'no tick prompt'); + assert.equal(silent(), false, 'an ordinary record'); + await framework.stop(); + }); + + it("a gate's unroutable candidate that names its server stays unroutable when its turn starts, and in its continuations", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + // Every id has a sole registrant and declares a target here, so a raw id + // checked again would look routable. + (registry as Record).getChannelServerId = () => 'discord'; + internals(framework).channelRegistry = registry; + const i = internals(framework); + const now = Date.now(); + // As the gate passes an event whose registered channel the host couldn't + // resolve: the raw author id, its server named, unroutable. + i.pendingRequests.push({ + agentName: 'scout', reason: 'gate', source: 'gate', timestamp: now, namesConversation: true, + routeCandidates: [{ conversation: { kind: 'channel', serverId: 'discord', channelId: 'user:U5' }, addressed: true, at: now, unroutable: true }], + } as never); + await framework.runUntilIdle(); + const turn = i.turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string } }; + assert.equal(turn.route, null); + assert.equal(turn.unroutable?.reason, 'unresolved'); + // Its retry names the same conversation, still marked: no route. + const first = i.turnRoutes.get('scout'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'still held' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, 'the retry ran'); + const retried = i.turnRoutes.get('scout') as { route: unknown; unroutable?: { reason: string; neverRoutes?: true } }; + assert.notEqual(retried, first, 'and wrote its own record'); + assert.deepEqual([retried.route, retried.unroutable?.reason, retried.unroutable?.neverRoutes], [null, 'unresolved', true]); + assert.deepEqual(published, [], 'nothing went to the raw id'); + await framework.stop(); + }); + + it("a hold's conversation from an unroutable candidate stays marked in its continuation, even when it is the one left", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'held between them' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + (registry as Record).getChannelServerId = () => 'discord'; + internals(framework).channelRegistry = registry; + const i = internals(framework); + const now = Date.now(); + i.pendingRequests.push({ + agentName: 'scout', reason: 'gate', source: 'gate', timestamp: now, namesConversation: true, + routeCandidates: [ + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'user:U5' }, addressed: true, at: now, unroutable: true }, + { conversation: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB' }, addressed: true, at: now - 1 }, + ], + } as never); + await framework.runUntilIdle(); + assert.equal((i.turnRoutes.get('scout') as { hold?: unknown }).hold !== undefined, true, 'held'); + // A fork claims chanB, so the raw id is the only conversation left. + i.conversationAgentHomes.set('fork-1', 'discord:guild:chanB'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'not to the raw id' }])); + i.pendingRequests.push(i.continuationRequest('scout', 'refusal-rewind-retry')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, 'the retry ran'); + const retried = i.turnRoutes.get('scout') as { route: unknown; hold?: unknown; unroutable?: { reason: string; neverRoutes?: true } }; + assert.deepEqual([retried.route, retried.hold, retried.unroutable?.reason, retried.unroutable?.neverRoutes], [null, undefined, 'unresolved', true]); + assert.deepEqual(published, [], 'nothing went to the raw id'); + i.conversationAgentHomes.delete('fork-1'); + await framework.stop(); + }); + + it("a wake's conversation whose server registered while it waited is routable when its turn starts", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'there now' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + let registered = false; + (registry as Record).getChannelServerId = (id: string) => (registered && id === 'discord:guild:late' ? 'discord' : undefined); + internals(framework).channelRegistry = registry; + const fw = framework as unknown as { + candidateForChannel(channelId: string, addressed: boolean, at: number): { unroutable?: true }; + }; + // Queued while no server held the channel: unroutable then. + const now = Date.now(); + const candidate = fw.candidateForChannel('discord:guild:late', true, now); + assert.equal(candidate.unroutable, true); + internals(framework).pendingRequests.push({ + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: now, namesConversation: true, + routeCandidates: [candidate], + } as never); + // The server registers before the turn starts. + registered = true; + await framework.runUntilIdle(); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:late'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string; serverId?: string }).serverId, (p.to as { channelId: string }).channelId]), + [['discord', 'discord:guild:late']]); + await framework.stop(); + }); + + it("a bounce during a turn queues its wake at once, and the wake names where that turn's words went by its end", async () => { + // Turn 1 answers chanA; mid-turn, a tool bounces words and moves the + // route to chanB, as a channel_open would. Turn 2 is the bounce's wake: + // queued at once, it starts only after turn 1 has ended, and reads the + // record then. A bounce that lands while a successor turn is alive (the + // previous turn's speech failing late) waits for that turn the same way. + const mover = { + name: 'mover', + framework: undefined as AgentFramework | undefined, + queuedMidTurn: undefined as boolean | undefined, + async start() {}, + async stop() {}, + getTools: () => [{ name: 'move', description: 'Move along', inputSchema: { type: 'object', properties: {} } }], + async handleToolCall() { + const fw = this.framework as unknown as { + bounceProse(agent: unknown, text: string, reason: string): void; + turnRoutes: Map; + pendingRequests: Array<{ reason: string }>; + }; + fw.bounceProse(this.framework!.getAgent('scout'), 'lost words', 'a test bounce'); + this.queuedMidTurn = fw.pendingRequests.some((r) => r.reason === 'prose-bounce'); + fw.turnRoutes.set('scout', { route: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:chanB', origin: 'open' } }); + return { success: true, data: { ok: true } }; + }, + async onProcess() { return {}; }, + }; + const streams = [ + [createMockResponse([{ type: 'tool_use', id: 't1', name: 'mover--move', input: {} }], 'tool_use'), createMockResponse([{ type: 'text', text: 'moved' }])], + [createMockResponse([{ type: 'text', text: 'resending' }])], + ]; + (membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + membrane.calls.push(request as never); + return new MockYieldingStream(streams.shift() ?? []); + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'bounce.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [mover as never], + }); + mover.framework = framework; + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(mover.queuedMidTurn, true, 'the wake is queued at once'); + assert.equal(membrane.calls.length, 2, 'the turn, then the bounce\'s wake'); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:chanB'); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanB', 'moved'], ['discord:guild:chanB', 'resending']]); + await framework.stop(); + }); + + it("a bounce outside a turn queues its wake at once, which names the conversation the turn answered", async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answered' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:elsewhere' }); + internals(framework).channelRegistry = registry; + framework.pushEvent(channelIncoming('discord:guild:chanA', 'A: hi')); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + // The turn is over; its words fail to land (a delivery after it). + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'resending' }])); + (framework as unknown as { bounceProse(agent: unknown, text: string, reason: string): void }) + .bounceProse(framework.getAgent('scout'), 'lost words', 'a test bounce'); + assert.ok(internals(framework).pendingRequests.some((r) => r.reason === 'prose-bounce'), 'queued at once'); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2, "the bounce's wake"); + assert.deepEqual(published.map((p) => [(p.to as { channelId: string }).channelId, p.text]), + [['discord:guild:chanA', 'answered'], ['discord:guild:chanA', 'resending']]); + await framework.stop(); + }); + + it('an ephemeral subagent never takes the fallback, and several requests batched into one turn name a conversation if any does', async () => { + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + // A real ephemeral subagent: its prose is its caller's. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'diver notes' }])); + const diver = await framework.createEphemeralAgent({ name: 'diver', model: 'test-model', systemPrompt: 'Dive.', allowedTools: 'all' }); + diver.contextManager.addMessage('user', [{ type: 'text', text: 'Look around.' }]); + const run = framework.runEphemeralToCompletion(diver.agent, diver.contextManager); + await framework.runUntilIdle(); + assert.equal((await run).speech, 'diver notes'); + assert.deepEqual(internals(framework).turnRoutes.get('diver'), { route: null }, 'no fallback for an ephemeral'); + + // A heartbeat batched with a wake that names a conversation (as a + // reaction's does) is one turn that names one: no fallback. + const calls = membrane.calls.length; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'batched' }])); + internals(framework).pendingRequests.push( + { agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now() }, + { agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), namesConversation: true } as never, + ); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, calls + 1, 'the batch ran, as one turn'); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'the batch named a conversation'); + assert.deepEqual(published, []); + await framework.stop(); + }); + + it('a tune-out wake about a channel names it, so the primary\'s turn after a cancel takes no fallback', async () => { + const framework = await makeFramework(); + // The coordinator's requestInference hook (tune-out/coordinator.ts). + const wake = (framework as unknown as { + queueTuneOutWake(agent: string, reason: string, source: string, channelId?: string): void; + }).queueTuneOutWake.bind(framework); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'back from the noisy channel' }])); + wake('scout', 'tune-out cancelled (done)', 'tune-out', 'discord:guild:noisy'); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }, 'it names the noisy channel: no fallback'); + assert.deepEqual(published, []); + // A wake through the same hook that names no channel takes the fallback. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + wake('scout', 'a wake about nothing', 'tune-out'); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 2); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId, 'discord:guild:last'); + await framework.stop(); + }); + + it('a module event that names a channel, a thread or an author in its metadata never takes the fallback', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'about that channel' }])); + const relay = { + name: 'relay', + async start() {}, + async stop() {}, + getTools: () => [], + async handleToolCall() { return { success: false, error: 'none', isError: true }; }, + async onProcess(event: ProcessEvent) { + return event.type === 'relay:note' ? { requestInference: true } : {}; + }, + }; + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'relay.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + modules: [relay as never], + }); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:last' }); + internals(framework).channelRegistry = registry; + framework.pushEvent({ type: 'relay:note', source: 'relay', metadata: { channelId: 'discord:guild:other' } } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(membrane.calls.length, 1); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null, named: true }); + // A thread alone, or an author alone, names a conversation too; a note + // naming none takes the fallback. + const cases: Array<[Record, string | null]> = [ + [{ threadId: 'T-1' }, null], + [{ authorId: 'U-5' }, null], + [{}, 'discord:guild:last'], + ]; + for (const [metadata, expected] of cases) { + membrane.pushResponse(createMockResponse([{ type: 'text', text: `about ${JSON.stringify(metadata)}` }])); + framework.pushEvent({ type: 'relay:note', source: 'relay', metadata } as unknown as ProcessEvent); + await framework.runUntilIdle(); + assert.equal(internals(framework).turnRoutes.get('scout')?.route?.channelId ?? null, expected, JSON.stringify(metadata)); + } + assert.equal(membrane.calls.length, 4); + assert.deepEqual(published.map((p) => (p.to as { channelId: string }).channelId), ['discord:guild:last'], + 'only the note naming nothing spoke, where the last message came in'); + await framework.stop(); + }); + + it('a threadless root-channel fallback speaks at its root when no fork owns it', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + const framework = await makeFramework(); + const { registry, published } = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:general' }); + internals(framework).channelRegistry = registry; + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { + route: { kind: 'channel', serverId: 'discord', channelId: 'discord:guild:general', origin: 'fallback' }, + }); + assert.equal(published.length, 1); + assert.equal((published[0]!.to as { channelId: string }).channelId, 'discord:guild:general'); + await framework.stop(); + }); + + it('the fallback never goes into a channel a conversation fork owns, nor into one that declares no publish target', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); + const framework = await makeFramework(); + const owned = withLastInbound('root', { serverId: 'discord', channelId: 'discord:guild:forked' }); + internals(framework).channelRegistry = owned.registry; + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes + .set('fork-1', 'discord:guild:forked'); + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, 'a fork\'s channel is the fork\'s'); + assert.deepEqual(owned.published, []); + (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.delete('fork-1'); + + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tock' }])); + const undeclared = withLastInbound(undefined, { serverId: 'discord', channelId: 'discord:guild:plain' }); + internals(framework).channelRegistry = undeclared.registry; + await heartbeat(framework); + assert.deepEqual(internals(framework).turnRoutes.get('scout'), { route: null }, + 'an undeclared channel is no fallback: no route, not an unroutable conversation in front of the resident'); + assert.deepEqual(undeclared.published, []); await framework.stop(); }); }); diff --git a/test/tune-out-gate-composition.test.ts b/test/tune-out-gate-composition.test.ts index 24a00d03f..716322f0e 100644 --- a/test/tune-out-gate-composition.test.ts +++ b/test/tune-out-gate-composition.test.ts @@ -238,3 +238,37 @@ describe('gate composition on subconscious wakes', () => { assert.equal(acks.length, 0); }); }); + +describe('tune-out wakes name their channel', () => { + it('the cancel wakes and a subconscious wake each pass the channel, so none takes the fallback route', () => { + const requested: Array<[string, string, string | undefined]> = []; + const registry = { + cancelTuneOut: () => ({ params: PARAMS, wakeCount: 0 }), + } as unknown as ChannelRegistry; + const hooks = { + addMessage: () => '', + requestInference: (agentName: string, _reason: string, source: string, channelId?: string) => { + requested.push([agentName, source, channelId]); + }, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + const coordinator = new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); + assert.deepEqual(coordinator.cancel('disc', '#a', 'resident', 'done'), { ok: true }); + (coordinator as unknown as { invokeSubconscious(s: string, c: string, t: 'cadence' | 'wake', n: number): void }) + .invokeSubconscious('disc', '#b', 'cadence', 2); + coordinator.stop(); + assert.deepEqual(requested, [ + ['scout', 'tune-out', '#a'], + ['Subconscious', 'tune-out', '#a'], + ['Subconscious', 'tune-out', '#b'], + ]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c10092cff..beb230fc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": ["ES2022", "ES2024.String"], "outDir": "./dist", "rootDir": ".", "strict": true,