From 3a423a1e0ce38051a46522b34441114b37e26556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ram=C3=B3n=20Mart=C3=ADnez?= Date: Mon, 21 Sep 2026 22:31:26 +0200 Subject: [PATCH 01/19] fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isConversationalInjection treated only chat:reaction as a marker, so the sibling RFC-001 tags — chat:edited, chat:deleted, chat:reaction-remove — counted as real conversational input: an "[edited] ..." synthetic message arriving right after an explicit send cleared same-round suppression and re-opened auto-routing (surfaced by the zulip_mcp#23 rollout, which starts delivering edits/moves/deletions as tagged incoming messages). The four tags now live in NON_CONVERSATIONAL_TAGS (src/mcpl/tags.ts, next to the core-tag closure they belong to) with a hasNonConversationalTag helper used by both consumers: - framework.ts isConversationalInjection exempts all four; - ChannelRegistry skips them when tracking defaultPublish{Channel, MessageId,ThreadId}, so a marker cannot retarget outbound speech or become context.incoming's reply anchor (review sibling from zulip_mcp#23 round 2). The open=true transport repair stays unconditional — a marker is still authoritative liveness evidence. Tags remain non-authority (§16.6): the set only withholds default conversational treatment. Fixes #157 Co-Authored-By: Claude Fable 5 --- .../nonconversational-markers.fixed.md | 1 + src/framework.ts | 9 +++-- src/mcpl/channel-registry.ts | 13 ++++--- src/mcpl/tags.ts | 21 ++++++++++++ test/channel-registry-routing.test.ts | 34 +++++++++++++++++-- test/present-while-acting.test.ts | 30 ++++++++++++++++ 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 changelog.d/nonconversational-markers.fixed.md diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md new file mode 100644 index 000000000..076bd684f --- /dev/null +++ b/changelog.d/nonconversational-markers.fixed.md @@ -0,0 +1 @@ +- **Edit/delete/reaction-remove markers no longer count as conversational input** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`), and `ChannelRegistry` now also skips them when tracking the default publish locus, so a marker can no longer retarget outbound speech or become the reply anchor. diff --git a/src/framework.ts b/src/framework.ts index dec36d20a..821219eb8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -62,6 +62,7 @@ import { ProcessQueueImpl } from './queue.js'; import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reactions.js'; import { Agent } from './agent.js'; import { ModuleRegistry, isStateExistsError } from './module-registry.js'; +import { hasNonConversationalTag } from './mcpl/tags.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; import { FeatureSetManager } from './mcpl/feature-set-manager.js'; import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js'; @@ -166,14 +167,16 @@ const isSilencingTool = (name: string): boolean => { * True when an injected message is real conversational input — something the * agent might actually be replying to — rather than ambient machinery. * System markers (`system: true` — send-failed notices, routing notices) and - * reactions (`chat:reaction` tag, MCPL RFC-001) don't count: they must not - * clear explicit-send suppression, and they never influence routing. + * non-conversational marker tags (reactions added/removed, edits, deletions + * — MCPL RFC-001) don't count: they are about a message the agent has + * already seen, so they must not clear explicit-send suppression, and they + * never influence routing. */ 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; + if (hasNonConversationalTag(m.tags)) return false; return true; }; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 636e032dd..d9d920d5c 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -35,7 +35,7 @@ import type { import type { McplServerRegistry } from './server-registry.js'; import type { FeatureSetManager } from './feature-set-manager.js'; import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js'; -import { expandCoreTags } from './tags.js'; +import { expandCoreTags, hasNonConversationalTag } from './tags.js'; import { CapabilityGrant } from './capability-grant.js'; // ============================================================================ @@ -870,9 +870,14 @@ export class ChannelRegistry { // 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). - this.defaultPublishChannel = message.channelId; - this.defaultPublishMessageId = message.messageId; - this.defaultPublishThreadId = message.threadId; + // Non-conversational markers (reactions, edits, deletions) are ABOUT a + // message, not one the agent replies to: they must not retarget the + // default publish locus or become `context.incoming`'s reply anchor. + if (!hasNonConversationalTag(message.tags)) { + 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; diff --git a/src/mcpl/tags.ts b/src/mcpl/tags.ts index a09285c12..3bccef840 100644 --- a/src/mcpl/tags.ts +++ b/src/mcpl/tags.ts @@ -18,6 +18,27 @@ const CORE_IMPLIES: Readonly> = { 'chat:dm': ['chat:addressed', 'chat:private'], }; +/** + * RFC-001 core tags that mark a message as being ABOUT the conversation + * rather than part of it: reactions (added or removed), edits and deletions + * of messages the agent has already seen. These are markers, not turns — + * they must not clear explicit-send suppression, count as a new + * conversational round, or retarget the default publish channel. Tags are + * still never authority (§16.6): this set only ever WITHHOLDS default + * conversational treatment, it never grants anything. + */ +export const NON_CONVERSATIONAL_TAGS: ReadonlySet = new Set([ + 'chat:reaction', + 'chat:reaction-remove', + 'chat:edited', + 'chat:deleted', +]); + +/** True when a (possibly untyped) tag list carries a non-conversational marker tag. */ +export function hasNonConversationalTag(tags: unknown): boolean { + return Array.isArray(tags) && tags.some((t) => typeof t === 'string' && NON_CONVERSATIONAL_TAGS.has(t)); +} + /** * Expand a raw tag list over the normative core closure, then resolve the * §16.2 mutual exclusion: `chat:addressed` and `chat:ambient` cannot both diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 8468c2d37..46ca1dccf 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -77,14 +77,15 @@ function makeRegistry( }; } -function incoming(channelId: string, text: string, channelName?: string) { +function incoming(channelId: string, text: string, channelName?: string, tags?: string[]) { return { messages: [{ channelId, - messageId: 'm1', + messageId: tags ? `marker-${channelId}` : 'm1', author: { id: 'u1', name: 'Antra' }, timestamp: '2026-05-30T00:00:00.000Z', content: [{ type: 'text' as const, text }], + tags, metadata: channelName ? { channelName } : undefined, }], }; @@ -526,3 +527,32 @@ test('DM prose targets resolve people-first: @name, prefix-lenient names, and <@ assert.ok('error' in missing && /send_dm/.test(missing.error), 'no-match points at send_dm'); assert.ok('error' in missing && (missing.candidates?.length ?? 0) === 2, 'lists known DMs by name'); }); + +test('non-conversational markers do not retarget the default publish locus (#157)', async () => { + // A reaction/edit/delete marker (RFC-001 tags) is ABOUT a message, not one + // the agent replies to: arriving on chanB it must not flip the + // process-global defaultPublishChannel away from the real conversation on + // chanA, and must not become context.incoming's reply anchor. + const { registry } = makeRegistry(undefined); + seedRegistered(registry, 'discord', 'chanA'); + seedRegistered(registry, 'discord', 'chanB'); + registry.handleIncoming('discord', incoming('chanA', 'hi from A')); + + for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted']) { + registry.handleIncoming('discord', incoming('chanB', `[${tag}] marker`, undefined, [tag])); + assert.equal(registry.getDefaultPublishChannel(), 'chanA', + `a ${tag} marker on chanB must not steal the default publish locus`); + } + + // A real message still retargets — the skip is tag-scoped, not a freeze. + registry.handleIncoming('discord', incoming('chanB', 'real message on B')); + assert.equal(registry.getDefaultPublishChannel(), 'chanB'); +}); + +test('a marker on a NEVER-conversed channel leaves the locus null, not marker-anchored (#157)', async () => { + const { registry } = makeRegistry(undefined); + seedRegistered(registry, 'discord', 'chanB'); + registry.handleIncoming('discord', incoming('chanB', '[edited] marker', undefined, ['chat:edited'])); + assert.equal(registry.getDefaultPublishChannel(), null, + 'an edit marker alone must not establish a publish locus'); +}); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157bc..d6a7852f5 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -757,6 +757,36 @@ describe('present while acting', () => { await framework.stop(); }); + for (const tag of ['chat:reaction-remove', 'chat:edited', 'chat:deleted']) { + it(`a ${tag} marker injected mid-turn does not clear send suppression (#157)`, async () => { + // Edits, deletions and reaction removals (RFC-001 core tags, delivered + // as synthetic incoming messages by e.g. zulip_mcp#23) are markers + // ABOUT a message the agent has seen — the same category as a + // reaction. Before #157 only `chat:reaction` was exempt, so an + // "[edited] Ann: typo fix" arriving right after an explicit send + // re-opened auto-routing and double-posted the same-round prose. + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--send_message', input: { text: 'reply to room4' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Sent it.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.interjection = `[${tag}] marker about an earlier message`; + module.interjectionChannelId = null; + module.interjectionMetadata = { channelId: '999888777', tags: [tag] }; + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [], `post-send prose stayed suppressed after a ${tag} marker`); + + await framework.stop(); + }); + } + it('text-only turns route to the turn-frozen locus, not a live re-resolution', async () => { // The text-only dispatch runs after the agent is idle; a live resolution // there can read the NEXT turn's trigger state or a post-restart cleared From 5d58d940ebf0416f72e8c9319fe5aaa4d34d30d5 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 04:40:01 -0400 Subject: [PATCH 02/19] fix(changelog), test: narrow the marker claim to what holds, and pin the reply anchor Anarchid's review at 843e4c0d found the fragment's "a marker can no longer retarget outbound speech or become the reply anchor" true for ChannelRegistry's default publish channel and context.incoming, but not for the speech channel of a turn a marker wakes, which anima-research/agent-framework#189 covers. The fragment now says what holds, in his wording. His review and ours both noted that the registry tests read only getDefaultPublishChannel(). They now also read context.incoming's message id and thread id after each marker, and check that a marker on a channel nobody has spoken in sets no anchor. --- changelog.d/nonconversational-markers.fixed.md | 2 +- test/channel-registry-routing.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index 076bd684f..d4b0632a6 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers no longer count as conversational input** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`), and `ChannelRegistry` now also skips them when tracking the default publish locus, so a marker can no longer retarget outbound speech or become the reply anchor. +- **Edit/delete/reaction-remove markers no longer count as conversational input** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index b2929ff76..a4f7f48bb 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -78,7 +78,7 @@ function makeRegistry( }; } -function incoming(channelId: string, text: string, channelName?: string, tags?: string[]) { +function incoming(channelId: string, text: string, channelName?: string, tags?: string[], threadId?: string) { return { messages: [{ channelId, @@ -87,6 +87,7 @@ function incoming(channelId: string, text: string, channelName?: string, tags?: timestamp: '2026-05-30T00:00:00.000Z', content: [{ type: 'text' as const, text }], tags, + threadId, metadata: channelName ? { channelName } : undefined, }], }; @@ -537,12 +538,15 @@ test('non-conversational markers do not retarget the default publish locus (#157 const { registry } = makeRegistry(undefined); seedRegistered(registry, 'discord', 'chanA'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); + registry.handleIncoming('discord', incoming('chanA', 'hi from A', undefined, undefined, 'thread-A')); for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted']) { registry.handleIncoming('discord', incoming('chanB', `[${tag}] marker`, undefined, [tag])); assert.equal(registry.getDefaultPublishChannel(), 'chanA', `a ${tag} marker on chanB must not steal the default publish locus`); + const anchor = registry.buildChannelContext()?.incoming; + assert.equal(anchor?.messageId, 'm1', `a ${tag} marker leaves the reply anchor's message`); + assert.equal(anchor?.threadId, 'thread-A', `a ${tag} marker leaves the reply anchor's thread`); } // A real message still retargets — the skip is tag-scoped, not a freeze. @@ -556,6 +560,8 @@ test('a marker on a NEVER-conversed channel leaves the locus null, not marker-an registry.handleIncoming('discord', incoming('chanB', '[edited] marker', undefined, ['chat:edited'])); assert.equal(registry.getDefaultPublishChannel(), null, 'an edit marker alone must not establish a publish locus'); + assert.equal(registry.buildChannelContext()?.incoming, undefined, + 'nor a reply anchor'); }); test('proseTargetFor: one whitespace-free token per channel that resolves back to it', async () => { From 0236f40a063df9116940bda9af92589484becc84 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 06:59:25 -0400 Subject: [PATCH 03/19] docs, test: say what holds for markers, and pin re-pin, the open repair and the anchor for them The internal review of this push found texts that still describe the old marker set or say markers never influence routing, and pieces no test pinned. - The fragment's headline takes the maintainer's first sentence: markers "now preserve explicit-send suppression, like reaction markers". "No longer count as conversational input" said more than holds, since a marker that wakes the agent can still set that turn's speech channel (#189). - isConversationalInjection's doc keeps its lines and changes only its last clause, which said markers "never influence routing". It now says they neither clear explicit-send suppression nor re-pin a turn's locus at a tool boundary, and that a waking marker can still set the turn's speech channel (#189). - channel_publish's description and its channelId parameter say an omitted channel means the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion, which is what markAccepted tracks. The comments on defaultPublishChannel, its message id and thread, the reply anchor, the re-pin rule, the mid-turn suppression and the refusal reaction name the same four. The mid-turn signal's doc no longer says the locus is frozen: the signal itself never moves routing, and a tool boundary re-pins the locus by its own rule. - The tags.js import moves below log-value.js, off the module-registry import line, which #424 and #428 change. - Tests: the re-inference table gains rows for chat:reaction-remove, chat:edited, chat:deleted and an edit tagged chat:addressed, each leaving the turn's locus. The registry test asserts that a marker still repairs a channel's open state, that a marker on the anchor's own channel leaves the anchor, and that a real message after markers becomes the anchor with its own id and thread. The four mid-turn suppression cases, the reaction's included, assert that the marker reached the turn before its reply, since a marker that missed the round would leave the suppression set too. --- .../nonconversational-markers.fixed.md | 2 +- src/framework.ts | 48 +++++++++++-------- src/mcpl/channel-registry.ts | 47 +++++++++++------- test/channel-registry-routing.test.ts | 20 ++++++-- test/present-while-acting.test.ts | 24 ++++++++++ 5 files changed, 99 insertions(+), 42 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index d4b0632a6..9eaeb3e5e 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers no longer count as conversational input** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. diff --git a/src/framework.ts b/src/framework.ts index 66712de4f..299fc9594 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -73,9 +73,9 @@ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMess import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; import { Agent } from './agent.js'; import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js'; -import { hasNonConversationalTag } from './mcpl/tags.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; import { logValue } from './mcpl/log-value.js'; +import { hasNonConversationalTag } from './mcpl/tags.js'; import { FeatureSetManager } from './mcpl/feature-set-manager.js'; import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js'; import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js'; @@ -197,8 +197,9 @@ const isSilencingTool = (name: string): boolean => { * System markers (`system: true` — send-failed notices, routing notices) and * non-conversational marker tags (reactions added/removed, edits, deletions * — MCPL RFC-001) don't count: they are about a message the agent has - * already seen, so they must not clear explicit-send suppression, and they - * never influence routing. + * already seen, so they neither clear explicit-send suppression nor re-pin a + * turn's locus at a tool boundary. A marker that wakes the agent can still + * set that turn's speech channel (agent-framework#189). */ const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; @@ -1664,10 +1665,12 @@ export class AgentFramework { private processLoggingBroadcast: boolean; private activeStreams: Map> = new Map(); - /** A tool boundary injected fresh CONVERSATIONAL input (a real message — - * not a reaction or a system marker) into the live stream. Tells - * driveStream to clear sticky explicit-send suppression before handling - * the next model round. Never affects routing: the turn locus is frozen. */ + /** A tool boundary injected fresh CONVERSATIONAL input (a real message, + * not a system marker or a reaction, reaction removal, edit or deletion + * marker) into the live stream. Tells driveStream to clear sticky + * explicit-send suppression before handling the next model round. The + * signal itself never moves routing: the boundary re-pins the turn's + * locus by its own rule (repinChannel). */ 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 @@ -6555,7 +6558,8 @@ 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 + * conversational locus (the most recent incoming channel message that + * isn't a reaction, reaction removal, edit or deletion marker) with a * category-specific emoji. Best-effort — failures are logged, never thrown, * and non-Discord loci are silently skipped. */ @@ -8916,8 +8920,9 @@ export class AgentFramework { // affected — the turn locus is frozen at turn start, so ambient // chatter, reactions, and system markers injected mid-turn can no // longer hijack where the agent's prose lands. Non-conversational - // injections (reactions, `system: true` markers) don't even clear - // the send suppression: nothing new was said to the agent. + // injections (`system: true` markers, and reaction, reaction + // removal, edit and deletion markers) don't even clear the send + // suppression: nothing new was said to the agent. if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) { this.midTurnInputSignals.add(agent.name); } @@ -8969,11 +8974,12 @@ export class AgentFramework { // 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). + // which was correct when it was delivered. Ambient chatter, system + // markers, and reaction, reaction removal, edit and deletion + // 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. // @@ -9002,7 +9008,8 @@ export class AgentFramework { // 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 + // System markers and reaction, reaction removal, edit and + // deletion markers are already excluded by // isConversationalInjection, and the engaged-this-turn scope // keeps unrelated channels from moving the pin. // The last injection that qualifies wins (repinChannel). @@ -10919,10 +10926,11 @@ export class AgentFramework { /** * The channel a message moves its turn's locus to, by the addressed * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a - * reaction or a system marker) that addressed the agent, or that follows - * up in a channel the turn sent into (TurnRouting.engaged). Undefined for - * any other message. A tool boundary weighs the messages it injects by it, - * and noteArrival the ones no boundary of the turn weighs. + * system marker, nor a reaction, reaction removal, edit or deletion + * marker) that addressed the agent, or that follows up in a channel the + * turn sent into (TurnRouting.engaged). Undefined for any other message. A + * tool boundary weighs the messages it injects by it, and noteArrival the + * ones no boundary of the turn weighs. */ private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { if (!isConversationalInjection(metadata)) return undefined; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 15fa92400..c16e5e752 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -390,11 +390,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.', + description: "Publish a message to a channel. If channelId is omitted, publishes to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion.", inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' }, + channelId: { type: 'string', description: "ID of the channel to publish to (defaults to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion)" }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, @@ -521,9 +521,10 @@ interface ChannelRegistryOptions { * 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 + * tracks only the most recent inbound message across ALL channels (reaction, + * reaction-removal, edit and deletion markers aside), 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.). */ homeChannelResolver?: (agentName: string) => string | undefined; @@ -533,11 +534,12 @@ interface ChannelRegistryOptions { * turn. 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 keeps both on the live - * turn's routing state and exposes them here. For a heartbeat / no-trigger - * turn it resolves to undefined until the turn's locus is resolved, so that + * process-global `defaultPublishChannel`, which tracks the most recent inbound + * message across ALL channels (markers aside) and misroutes a reply to + * whichever channel last spoke under concurrency (item-3 redux). The + * framework keeps both on the live turn's routing state and exposes them + * here. For a heartbeat / no-trigger turn it resolves to undefined until the + * turn's locus is resolved, so that * resolution takes the global fallback, which the turn then keeps. * Consulted AFTER `homeChannelResolver` (a fork's home always wins), BEFORE * `defaultPublishChannel`. @@ -649,10 +651,12 @@ export class ChannelRegistry { */ private dmClassified = new Map(); - /** Most recent incoming channel ID — used for speech routing / default publish. */ + /** The channel of the most recent incoming message that isn't a reaction, + * reaction removal, edit or deletion marker: used for speech routing and + * the default publish. */ private defaultPublishChannel: string | null = null; - /** Most recent incoming message metadata, used for buildChannelContext. */ + /** The same message's id and thread, used for buildChannelContext. */ private defaultPublishMessageId: string | null = null; private defaultPublishThreadId: string | undefined = undefined; @@ -1044,9 +1048,10 @@ export class ChannelRegistry { // A coalesced item is "accepted" only once the coalescer admits it, so // for those this runs after the hook (below). const markAccepted = () => { - // Non-conversational markers (reactions, edits, deletions) are ABOUT a - // message, not one the agent replies to: they must not retarget the - // default publish locus or become `context.incoming`'s reply anchor. + // Non-conversational markers (a reaction added or removed, an edit, a + // deletion) are ABOUT a message, not one the agent replies to: they + // must not retarget the default publish locus or become + // `context.incoming`'s reply anchor. if (!hasNonConversationalTag(message.tags)) { this.defaultPublishChannel = message.channelId; this.defaultPublishMessageId = message.messageId; @@ -1323,7 +1328,9 @@ export class ChannelRegistry { // ========================================================================== /** - * Get the default publish channel ID (most recent incoming channel). + * Get the default publish channel ID: the channel of the most recent + * incoming message that isn't a reaction, reaction removal, edit or + * deletion marker. */ getDefaultPublishChannel(): string | null { return this.defaultPublishChannel; @@ -1475,7 +1482,9 @@ export class ChannelRegistry { const context: ChannelContext = {}; - // Incoming: the most-recent inbound message (what the agent is replying to). + // Incoming: the most recent inbound message that isn't a reaction, + // reaction removal, edit or deletion marker (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) { @@ -3197,7 +3206,9 @@ export class ChannelRegistry { }; } - // Resolve channelId: default to most recent incoming channel + // Resolve channelId: default to the channel of the most recent incoming + // message that isn't a reaction, reaction removal, edit or deletion + // marker const channelId = input.channelId ?? this.defaultPublishChannel; if (!channelId) { return { diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index a4f7f48bb..854f27423 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -535,23 +535,37 @@ test('non-conversational markers do not retarget the default publish locus (#157 // the agent replies to: arriving on chanB it must not flip the // process-global defaultPublishChannel away from the real conversation on // chanA, and must not become context.incoming's reply anchor. - const { registry } = makeRegistry(undefined); + const { registry, lookup } = makeRegistry(undefined); seedRegistered(registry, 'discord', 'chanA'); seedRegistered(registry, 'discord', 'chanB'); registry.handleIncoming('discord', incoming('chanA', 'hi from A', undefined, undefined, 'thread-A')); for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted']) { + lookup('chanB')!.open = false; registry.handleIncoming('discord', incoming('chanB', `[${tag}] marker`, undefined, [tag])); assert.equal(registry.getDefaultPublishChannel(), 'chanA', `a ${tag} marker on chanB must not steal the default publish locus`); const anchor = registry.buildChannelContext()?.incoming; assert.equal(anchor?.messageId, 'm1', `a ${tag} marker leaves the reply anchor's message`); assert.equal(anchor?.threadId, 'thread-A', `a ${tag} marker leaves the reply anchor's thread`); + // The liveness repair still runs for a marker: a server sending + // channels/incoming is evidence that its transport is open. + assert.equal(lookup('chanB')?.open, true, `a ${tag} marker still repairs chanB's open state`); + // On the anchor's own channel, a marker leaves the anchor too. + registry.handleIncoming('discord', incoming('chanA', `[${tag}] marker on A`, undefined, [tag])); + const sameChannel = registry.buildChannelContext()?.incoming; + assert.equal(sameChannel?.messageId, 'm1', `a ${tag} marker on the anchor's channel leaves its message`); + assert.equal(sameChannel?.threadId, 'thread-A', `a ${tag} marker on the anchor's channel leaves its thread`); } - // A real message still retargets — the skip is tag-scoped, not a freeze. - registry.handleIncoming('discord', incoming('chanB', 'real message on B')); + // A real message still retargets: the skip is tag-scoped, not a freeze. + const real = incoming('chanB', 'real message on B', undefined, undefined, 'thread-B'); + real.messages[0]!.messageId = 'm2'; + registry.handleIncoming('discord', real); assert.equal(registry.getDefaultPublishChannel(), 'chanB'); + const after = registry.buildChannelContext()?.incoming; + assert.equal(after?.messageId, 'm2', 'a real message becomes the reply anchor'); + assert.equal(after?.threadId, 'thread-B', 'with its own thread'); }); test('a marker on a NEVER-conversed channel leaves the locus null, not marker-anchored (#157)', async () => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 2779f4a84..040197ac8 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -997,6 +997,16 @@ describe('present while acting', () => { [ambient('discord:guild:portables')], 'discord:guild:portables'], ['leaves it for a reaction in a channel the turn engaged', [{ channelId: 'discord:guild:portables', tags: ['chat:reaction'] }], 'chan-turn'], + ['leaves it for a reaction removal in a channel the turn engaged', + [{ channelId: 'discord:guild:portables', tags: ['chat:reaction-remove'] }], 'chan-turn'], + ['leaves it for an edit in a channel the turn engaged', + [{ channelId: 'discord:guild:portables', tags: ['chat:edited'] }], 'chan-turn'], + ['leaves it for a deletion in a channel the turn engaged', + [{ channelId: 'discord:guild:portables', tags: ['chat:deleted'] }], 'chan-turn'], + // An edit of a message addressed to the agent can arrive tagged + // chat:addressed too; it still moves nothing. + ['leaves it for an edit that carries the addressed tag', + [{ channelId: 'discord:dm:q', tags: ['chat:edited', 'chat:addressed'] }], 'chan-turn'], ] as const) { it(`a re-inference on unstreamed tool results ${label}`, async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); @@ -1817,6 +1827,13 @@ describe('present while acting', () => { await framework.runUntilIdle(); assert.deepEqual(routed, [], 'post-send prose stayed suppressed after a reaction'); + // That verdict counts only if the reaction was in the round: one that + // missed it would leave the suppression set as well. + const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .map((m) => m.content.map((b) => (b as { text?: string }).text ?? '').join('')); + const markerAt = texts.findIndex((t) => t.includes('[reaction] @someone reacted')); + const replyAt = texts.findIndex((t) => t.includes('Sent it.')); + assert.ok(markerAt >= 0 && markerAt < replyAt, 'the reaction reached the turn before its reply'); await framework.stop(); }); @@ -1846,6 +1863,13 @@ describe('present while acting', () => { await framework.runUntilIdle(); assert.deepEqual(routed, [], `post-send prose stayed suppressed after a ${tag} marker`); + // That verdict counts only if the marker was in the round: a marker that + // missed it would leave the suppression set as well. + const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .map((m) => m.content.map((b) => (b as { text?: string }).text ?? '').join('')); + const markerAt = texts.findIndex((t) => t.includes(`[${tag}] marker about an earlier message`)); + const replyAt = texts.findIndex((t) => t.includes('Sent it.')); + assert.ok(markerAt >= 0 && markerAt < replyAt, `the ${tag} marker reached the turn before its reply`); await framework.stop(); }); From 26afe3bdf688c238be3962ec78df8b82c4d6eaf3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 23:13:39 -0400 Subject: [PATCH 04/19] fix: count an edit the agent sees first as the message it replaced (R3-1) Anarchid's round 3 at 0236f40a found that a coalesced edit can be the agent's first view of a message. discord-mcpl sends a message edit as a push keyed to that message and tagged chat:edited. When the agent hasn't been shown the original yet, the coalescer removes it and delivers the edit in its place, so the agent sees only the edit, and under this branch's marker rule that edit was a marker. After an explicit send, the agent's prose reply to the edited follow-up stayed withheld, where main posts it. The coalescer can tell the case apart. A subject whose first occurrence the producer marks `initial` starts with history `none`, and stays `none` while nothing of it has been read. An occurrence that arrives while its agent's turn is live waits in the host's deferred queue until a tool boundary injects it, so while it waits the agent hasn't been shown it. The coalescer now notes on an occupant whether it is the agent's first view of the subject: the subject's `initial` birth, or a replacement marked as one. Such an occupant exists only while the history is `none`, since a read takes the occupant away. When acceptPlain replaces such an occupant while it is still waiting in the deferred queue, the replacement carries `replacesUnread` and is a first view in turn. deliverCoalesced passes the mark on in each lane (the channel lane, a channel-scoped push and a feature-set push), the two handlers put it on the message's metadata, and isConversationalInjection reads the tags of a message so marked without chat:edited. That covers the explicit-send suppression and the tool-boundary re-pin. Each condition keeps #157's fix where a `replaced` outcome alone would undo it: - Once the agent has read a message, an edit of it is appended, and a second edit that arrives before the first is read replaces the first. The appended edit isn't a first view, so the second edit, still about a message the agent has seen, isn't marked. Nor is an edit that replaces an occurrence sent without `initial`, whose history the host can't know. - The host counts a message injected at a tool boundary as unread until the turn's next committing compile, so an edit arriving before then replaces it, although the agent was shown it. By then it has left the deferred queue, so that edit isn't marked, and an edit of that edit, which is still queued, isn't either: its prior wasn't a first view. - Only an edit carries the message it replaced. A reaction, reaction removal or deletion occurrence that replaced an unread message is still a notice about that message, so it stays a marker. The note on the occupant lives in memory only. A snapshot keeps the occupant's event id and placement as before, and a restored occupant counts as read, so no first view outlives a restart. The mark is the host's alone: both handlers drop a `replacesUnread` that arrives in a server's own metadata. An edit of a message the host never received looks like any other edit and stays a marker, as with zulip_mcp's edit that newly mentions the bot. markAccepted, which runs only for the channel lane, keeps reading tags. The original set the default publish channel and the reply anchor to its own message when it was admitted, so a first-view edit leaves them there unless another channel message has arrived since. The comments in tags.ts, on isConversationalInjection, on the mid-turn signal and on the re-pin rule said the marker tags are about a message the agent has already seen, or never move the pin. They now say the tags are about an existing message, and that an edit the agent sees first counts as its message. Tests, through the coalescing fixture's WebSocket server, each with a turn whose explicit send is streaming: - in each lane, a follow-up and its edit arrive before the agent is shown the follow-up, and the reply after the send posts; - two edits of a follow-up not yet shown leave the last one its first view, and the reply posts; - a reaction, a reaction removal and a deletion that replace an unseen follow-up, an edit of an edit after the agent read the message, an edit of a follow-up shown at the previous tool boundary, an edit of that edit, and an edit of a follow-up sent without `initial` all leave the prose after the send withheld; - a server's own replacesUnread on a channel message, a channel-scoped push and a feature-set push is dropped. Co-Authored-By: Claude Opus 5.5 --- .../nonconversational-markers.fixed.md | 2 +- src/framework.ts | 56 +++-- src/mcpl/push-coalescer.ts | 33 ++- src/mcpl/push-handler.ts | 3 + src/mcpl/tags.ts | 8 +- test/mcpl-coalescing.test.ts | 195 +++++++++++++++++- 6 files changed, 269 insertions(+), 28 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index 9eaeb3e5e..db6a5fa4d 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first, because it replaced a message the agent hadn't been shown yet, still counts as that message and clears explicit-send suppression like one. The host knows that case when RFC-006 coalescing replaces a message whose producer marked its first occurrence `initial` and of which no version has reached the agent yet. diff --git a/src/framework.ts b/src/framework.ts index 299fc9594..a36e71169 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -196,16 +196,24 @@ const isSilencingTool = (name: string): boolean => { * agent might actually be replying to — rather than ambient machinery. * System markers (`system: true` — send-failed notices, routing notices) and * non-conversational marker tags (reactions added/removed, edits, deletions - * — MCPL RFC-001) don't count: they are about a message the agent has - * already seen, so they neither clear explicit-send suppression nor re-pin a - * turn's locus at a tool boundary. A marker that wakes the agent can still - * set that turn's speech channel (agent-framework#189). + * — MCPL RFC-001) don't count: they are about an existing message, so they + * neither clear explicit-send suppression nor re-pin a turn's locus at a tool + * boundary. An edit can also be the first version of a message the agent + * sees: when the coalescer replaced a message of which the agent had been + * shown no version while it waited in the deferred queue, the host marks + * the replacement (`replacesUnread`), and an edit so marked counts as the + * message it replaced. A reaction or + * deletion so marked is still a notice about that message and stays a + * marker. An edit of a message the host never received looks like any other + * edit and stays a marker too. A marker that wakes the agent can still set + * that turn's speech channel (agent-framework#189). */ const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; const m = metadata as Record; if (m.system === true) return false; - if (hasNonConversationalTag(m.tags)) return false; + const tags = m.replacesUnread === true && Array.isArray(m.tags) ? m.tags.filter((t) => t !== 'chat:edited') : m.tags; + if (hasNonConversationalTag(tags)) return false; return true; }; @@ -464,6 +472,9 @@ interface CoalescedChannelEvent { assemblingFor?: string; /** RFC-006 §3.2: deliver into this agent only (replacement / notice audience). */ deliverTo?: string; + /** The coalescer's mark: the agent's first view of the message (see + * CoalescedOccurrence.replacesUnread). */ + replacesUnread?: boolean; } type CoalescedDelivery = | { lane: 'channel'; event: CoalescedChannelEvent } @@ -1666,11 +1677,11 @@ export class AgentFramework { private activeStreams: Map> = new Map(); /** A tool boundary injected fresh CONVERSATIONAL input (a real message, - * not a system marker or a reaction, reaction removal, edit or deletion - * marker) into the live stream. Tells driveStream to clear sticky - * explicit-send suppression before handling the next model round. The - * signal itself never moves routing: the boundary re-pins the turn's - * locus by its own rule (repinChannel). */ + * or an edit the agent sees first; not a system marker or a reaction, + * reaction removal, edit or deletion marker) into the live stream. Tells + * driveStream to clear sticky explicit-send suppression before handling + * the next model round. The signal itself never moves routing: the + * boundary re-pins the turn's locus by its own rule (repinChannel). */ 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 @@ -8976,7 +8987,8 @@ export class AgentFramework { // prose routed in earlier rounds already went to the old pin, // which was correct when it was delivered. Ambient chatter, system // markers, and reaction, reaction removal, edit and deletion - // markers still cannot move the pin — the 2026-07-21 Cairn hijack + // markers (an edit the agent sees first aside, which counts as its + // message) 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). @@ -9010,7 +9022,8 @@ export class AgentFramework { // tracks nothing that matters here (antra, 2026-07-31). // System markers and reaction, reaction removal, edit and // deletion markers are already excluded by - // isConversationalInjection, and the engaged-this-turn scope + // isConversationalInjection (apart from an edit the agent + // sees first), and the engaged-this-turn scope // keeps unrelated channels from moving the pin. // The last injection that qualifies wins (repinChannel). const routing = this.routingOf(agent.name); @@ -9283,6 +9296,7 @@ export class AgentFramework { eventId?: string; assemblingFor?: string; deliverTo?: string; + replacesUnread?: boolean; }): Promise { // Same backstop as the push lane: no row, no wake, no fork spawned for a // message with nothing visible in it. @@ -9306,6 +9320,9 @@ export class AgentFramework { }; if (event.threadId) metadata.threadId = event.threadId; if (event.tags) metadata.tags = event.tags; + // The coalescer's mark is the host's alone: a server's own is dropped. + if (event.replacesUnread === true) metadata.replacesUnread = true; + else delete metadata.replacesUnread; // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never @@ -10141,8 +10158,9 @@ export class AgentFramework { ): Promise { const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; + const firstView = occ.replacesUnread ? { replacesUnread: true } : {}; if (occ.event.lane === 'channel') { - const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) }; + const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...firstView, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); return placement; @@ -10172,6 +10190,7 @@ export class AgentFramework { coalescingSubject, eventId: occ.eventId, ...narrowing, + ...firstView, ...(assemblingFor ? { assemblingFor } : {}), }; const placement = await this.handleMcplChannelIncoming(event); @@ -10188,6 +10207,7 @@ export class AgentFramework { // assembled; the wake that started it is not repeated. ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}), ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}), + ...firstView, }; const placement = this.handleMcplPushEvent(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); @@ -10357,6 +10377,9 @@ export class AgentFramework { ...(event.tags ? { tags: event.tags } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }; + // The coalescer's mark is the host's alone: a server's own is dropped. + if (event.replacesUnread === true) metadata.replacesUnread = true; + else delete metadata.replacesUnread; // `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 @@ -10925,9 +10948,10 @@ export class AgentFramework { /** * The channel a message moves its turn's locus to, by the addressed - * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a - * system marker, nor a reaction, reaction removal, edit or deletion - * marker) that addressed the agent, or that follows up in a channel the + * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (by + * isConversationalInjection: not a system marker, nor a reaction, reaction + * removal, edit or deletion marker, apart from an edit the agent sees + * first) that addressed the agent, or that follows up in a channel the * turn sent into (TurnRouting.engaged). Undefined for any other message. A * tool boundary weighs the messages it injects by it, and noteArrival the * ones no boundary of the turn weighs. diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 7615256b1..687d8ec4d 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -96,6 +96,12 @@ export interface CoalescedOccurrence { /** Set by the coalescer: deliver into this agent only (§3.2 audience may * narrow, never widen — a replacement or notice follows the prior delivery). */ deliverTo?: string; + /** Set by the coalescer when a plain occurrence replaces one that was the + * agent's first view of the subject (its `initial` birth, or a + * replacement so marked) and is still waiting in the host's deferred + * queue, so never shown to the agent: the replacement is then the agent's + * first view. No other path sets it. */ + replacesUnread?: boolean; } export interface DeferredNotice { eventId: string; timestamp: string; data?: unknown } @@ -139,7 +145,11 @@ export interface AssemblyResult { interface SubjectState { history: 'none' | 'some' | 'unknown'; - occupant?: { eventId: string; placement: CoalescingPlacement }; + /** `firstView`: this occupant is the agent's first view of the subject, + * as `replacesUnread` defines it, so nothing of the subject has been read: + * a read takes the occupant away. Unknown counts as not one. In memory + * only: a snapshot's occupant comes back as read. */ + occupant?: { eventId: string; placement: CoalescingPlacement; firstView?: true }; batch?: DeferredBatch; rendering?: Rendering; consumedEventId?: string; @@ -593,12 +603,20 @@ export class PushCoalescer { const hadBatch = this.dropBatches(subject, state); if (occupantUnread && state.occupant) { const prior = state.occupant; + // The replacement is the agent's first view only when its prior was + // one and the agent was never shown it: still waiting in the host's + // deferred queue, not even injected at a tool boundary. An occupant + // that isn't a first view ends the chain for every later replacement. + const firstView = prior.firstView === true && prior.placement.deferredId !== undefined; this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId }); if (this.host.remove(prior.placement)) { this.host.cancelWake(subject); // The replacement follows the prior delivery's audience (§3.2). - const placement = await this.host.deliver({ ...occurrence, deliverTo: prior.placement.agent }); - state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined; + const placement = await this.host.deliver({ + ...occurrence, deliverTo: prior.placement.agent, + ...(firstView ? { replacesUnread: true } : {}), + }); + state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; return 'replaced'; } @@ -610,10 +628,11 @@ export class PushCoalescer { } if (hadBatch) this.host.cancelWake(subject); const placement = await this.host.deliver(occurrence); - state.occupant = placement ? { eventId: occurrence.eventId, placement } : undefined; + const outcome: CoalesceOutcome = hadBatch ? 'replaced' : state.consumedEventId || state.history !== 'none' ? 'appended' : 'first'; + // The subject's `initial` birth is the agent's first view of it. + state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(outcome === 'first' ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; - if (hadBatch) return 'replaced'; - return state.consumedEventId || state.history !== 'none' ? 'appended' : 'first'; + return outcome; } private async acceptDeferred(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { @@ -897,7 +916,7 @@ export class PushCoalescer { subjects: [...this.subjects].map(([subject, s]) => ({ subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), - ...(s.occupant ? { occupant: s.occupant } : {}), + ...(s.occupant ? { occupant: { eventId: s.occupant.eventId, placement: s.occupant.placement } } : {}), ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } } : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } : {}), diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 22dc9fb38..4fed8a76d 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -50,6 +50,9 @@ export interface McplPushEvent { coalescingSubject?: string; /** RFC-006 assembly: materialized for this agent's turn (store directly). */ assemblingFor?: string; + /** The coalescer's mark: the agent's first view of the subject (see + * CoalescedOccurrence.replacesUnread). */ + replacesUnread?: boolean; } // ============================================================================ diff --git a/src/mcpl/tags.ts b/src/mcpl/tags.ts index 3bccef840..6c8540497 100644 --- a/src/mcpl/tags.ts +++ b/src/mcpl/tags.ts @@ -21,9 +21,11 @@ const CORE_IMPLIES: Readonly> = { /** * RFC-001 core tags that mark a message as being ABOUT the conversation * rather than part of it: reactions (added or removed), edits and deletions - * of messages the agent has already seen. These are markers, not turns — - * they must not clear explicit-send suppression, count as a new - * conversational round, or retarget the default publish channel. Tags are + * of an existing message. These are markers, not turns — they must not clear + * explicit-send suppression, count as a new conversational round, or + * retarget the default publish channel. An edit can also be the first + * version of a message the agent sees; isConversationalInjection reads the + * host's mark for the case the coalescer knows (`replacesUnread`). Tags are * still never authority (§16.6): this set only ever WITHHOLDS default * conversational treatment, it never grants anything. */ diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index c5bac52d1..4dc976def 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -12,7 +12,8 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createMockResponse } from './helpers/mock-membrane.js'; +import type { ContentBlock } from '@animalabs/membrane'; +import { createMockResponse, MockYieldingStream } from './helpers/mock-membrane.js'; import { fixture, eventually, crash, ok, TS } from './helpers/coalescing-fixture.js'; // --------------------------------------------------------------------------- @@ -1093,3 +1094,195 @@ 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')); }); + +// --------------------------------------------------------------------------- +// #157's markers: an edit the agent sees first is the message it replaced +// --------------------------------------------------------------------------- + +type Fixture = Awaited>; + +/** + * A turn, started by a mention in `chat`, with one round per `during` that + * sends explicitly (channel_publish), then a round that speaks. Each sending + * round streams only once its `during` has run: the first while the turn is + * live, each later one after the previous round's tool boundary. So what a + * `during` delivers reaches the agent at its round's tool boundary. + */ +async function sendThenSpeak(f: Fixture, ...during: Array<() => Promise>) { + const gates = during.map(() => { + const gate = {} as { open: () => void; opened: Promise; reached: () => void; atGate: Promise }; + gate.opened = new Promise((resolve) => { gate.open = resolve; }); + gate.atGate = new Promise((resolve) => { gate.reached = resolve; }); + return gate; + }); + let first: MockYieldingStream | undefined; + (f.membrane as unknown as { streamYielding: unknown }).streamYielding = (request: unknown) => { + f.membrane.calls.push(request as never); + if (first) return new MockYieldingStream([ok()]); + const stream = new MockYieldingStream([ + ...during.map((_, i) => createMockResponse([{ type: 'tool_use', id: `p${i}`, name: 'channel_publish', input: { content: `explicit_send_${i}` } }] as ContentBlock[], 'tool_use')), + createMockResponse([{ type: 'text', text: 'prose_reply' }] as ContentBlock[]), + ]); + first = stream; + let provided = 0; + const provide = stream.provideToolResults.bind(stream); + stream.provideToolResults = (results, options) => { provided++; provide(results, options); }; + const inner = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as Record)[Symbol.asyncIterator] = async function* () { + gates[0].reached(); + await gates[0].opened; + const it = inner(); + let streamed = 0; + while (true) { + const n = await it.next(); + if (n.done) return; + // A later sending round's first event: hold it until its `during` ran. + if (provided > streamed && provided < gates.length) { + streamed = provided; + gates[streamed].reached(); + await gates[streamed].opened; + } + yield n.value; + } + }; + return stream; + }; + await f.send('channels/incoming', { messages: [f.channel('c1', 'question', { initial: true }, 'chat', 'm1')] }); + const run = f.framework.runUntilIdle(); + for (const [i, deliver] of during.entries()) { + await gates[i].atGate; + await deliver(); + gates[i].open(); + } + await run; + const posted = f.published.map((p) => JSON.stringify(p.content)); + return { + /** What each tool boundary injected, in order. */ + injected: first!.receivedToolResultOptions.map((o) => JSON.stringify(o?.injectedMessages ?? [])), + sent: during.every((_, i) => posted.some((c) => c.includes(`explicit_send_${i}`))), + replied: posted.some((c) => c.includes('prose_reply')), + }; +} + +const unreadFollowUp = (f: Fixture, initial = true) => + f.send('channels/incoming', { messages: [f.channel('c2', 'follow_up_wiht_typo', initial ? { initial: true } : {}, 'chat', 'm2')] }); +const pushTagged = async (f: Fixture, tags: string[], eventId = 'e2', text = 'follow_up_fixed') => + (await f.send('push/event', { ...f.params(eventId, text, { channelId: 'chat', key: 'message:m2' }), tags })).result.coalesce.outcome as string; + +/** A follow-up the agent hasn't been shown, then its edit, in each lane; returns the edit's outcome. */ +const followUpThenEdit: Record Promise> = { + 'channel-scoped push': async (f) => { + await unreadFollowUp(f); + return pushTagged(f, ['chat:edited']); + }, + 'channel message': async (f) => { + await unreadFollowUp(f); + return (await f.send('channels/incoming', { messages: [{ ...f.channel('e2', 'follow_up_fixed', {}, 'chat', 'm2'), tags: ['chat:edited'] }] })).result.results[0].coalesce.outcome; + }, + 'feature-set push': async (f) => { + await f.send('push/event', { ...f.params('c2', 'follow_up_wiht_typo', { key: 'message:m2', initial: true }), tags: ['chat:mention'] }); + return (await f.send('push/event', { ...f.params('e2', 'follow_up_fixed', { key: 'message:m2' }), tags: ['chat:edited'] })).result.coalesce.outcome; + }, +}; + +for (const [lane, arrive] of Object.entries(followUpThenEdit)) test(`#157 (${lane}): an edit of a follow-up the agent hasn't been shown is its first view, so the reply after an explicit send routes`, async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + let outcome: string | undefined; + const r = await sendThenSpeak(f, async () => { outcome = await arrive(f); }); + assert.equal(outcome, 'replaced'); + assert(r.injected[0].includes('follow_up_fixed') && !r.injected[0].includes('follow_up_wiht_typo'), 'the edit alone reached the boundary'); + assert(r.sent, 'the explicit send posted'); + assert(r.replied, 'the reply to the follow-up posted'); +}); + +for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:deleted']) test(`#157: a ${tag} occurrence that replaced an unread follow-up is still a notice about it, so the prose after an explicit send stays withheld`, async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + let outcome: string | undefined; + const r = await sendThenSpeak(f, async () => { await unreadFollowUp(f); outcome = await pushTagged(f, [tag], 'e2', `${tag}_notice`); }); + assert.equal(outcome, 'replaced'); + assert(r.injected[0].includes(`${tag}_notice`), 'the notice reached the boundary'); + assert(r.sent, 'the explicit send posted'); + assert(!r.replied, 'the prose after the send stayed withheld'); +}); + +test('#157: an edit of an edit, after the agent read the message, stays a marker, so the prose after an explicit send stays withheld', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + const outcomes: string[] = []; + const r = await sendThenSpeak(f, async () => { + for (const [eventId, text] of [['e1', 'question_edited_once'], ['e2', 'question_edited_twice']]) { + outcomes.push((await f.send('push/event', { ...f.params(eventId, text, { channelId: 'chat', key: 'message:m1' }), tags: ['chat:edited'] })).result.coalesce.outcome); + } + }); + assert.deepEqual(outcomes, ['appended', 'replaced'], 'the agent had read the question, and the second edit replaced the unread first'); + assert(r.injected[0].includes('question_edited_twice') && !r.injected[0].includes('question_edited_once'), 'the second edit alone reached the boundary'); + assert(r.sent, 'the explicit send posted'); + assert(!r.replied, 'the prose after the send stayed withheld'); +}); + +test('#157: an edit of a follow-up the agent was shown at a tool boundary stays a marker, so the prose after the next explicit send stays withheld', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + const r = await sendThenSpeak(f, async () => { await unreadFollowUp(f); }, async () => { await pushTagged(f, ['chat:edited']); }); + assert(r.injected[0].includes('follow_up_wiht_typo'), 'the first boundary showed the agent the follow-up'); + assert(r.injected[1].includes('follow_up_fixed'), 'the second boundary showed it the edit'); + assert(r.sent, 'both explicit sends posted'); + assert(!r.replied, 'the prose after the second send stayed withheld'); +}); + +test('#157: an edit of an edit of a follow-up the agent was shown at a tool boundary stays a marker, so the prose after the next explicit send stays withheld', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + const r = await sendThenSpeak(f, async () => { await unreadFollowUp(f); }, async () => { + await pushTagged(f, ['chat:edited'], 'e2', 'follow_up_fixed'); + await pushTagged(f, ['chat:edited'], 'e3', 'follow_up_fixed_again'); + }); + assert(r.injected[0].includes('follow_up_wiht_typo'), 'the first boundary showed the agent the follow-up'); + assert(r.injected[1].includes('follow_up_fixed_again') && !r.injected[1].includes('replacesUnread'), 'the second boundary showed it the last edit, unmarked'); + assert(r.sent, 'both explicit sends posted'); + assert(!r.replied, 'the prose after the second send stayed withheld'); +}); + +test('#157: two edits of a follow-up the agent hasn\'t been shown leave the last its first view, so the reply after an explicit send routes', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + const outcomes: string[] = []; + const r = await sendThenSpeak(f, async () => { + await unreadFollowUp(f); + outcomes.push(await pushTagged(f, ['chat:edited'], 'e2', 'follow_up_fixed')); + outcomes.push(await pushTagged(f, ['chat:edited'], 'e3', 'follow_up_fixed_again')); + }); + assert.deepEqual(outcomes, ['replaced', 'replaced']); + assert(r.injected[0].includes('follow_up_fixed_again') && r.injected[0].includes('replacesUnread'), 'the boundary showed the agent the last edit, marked'); + assert(r.sent, 'the explicit send posted'); + assert(r.replied, 'the reply to the follow-up posted'); +}); + +test('#157: an edit of a follow-up whose producer never marked it initial stays a marker, so the prose after an explicit send stays withheld', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + let outcome: string | undefined; + const r = await sendThenSpeak(f, async () => { await unreadFollowUp(f, false); outcome = await pushTagged(f, ['chat:edited']); }); + assert.equal(outcome, 'replaced', 'the edit replaced the unread follow-up, whose history the host can\'t know'); + assert(r.injected[0].includes('follow_up_fixed'), 'the edit reached the boundary'); + assert(r.sent, 'the explicit send posted'); + assert(!r.replied, 'the prose after the send stayed withheld'); +}); + +const serverMarked: Record Promise> = { + 'channel message': async (f) => (await f.send('channels/incoming', { messages: [{ + ...f.channel('e9', 'edit_claiming_first_view', {}, 'chat', 'm9'), tags: ['chat:edited'], metadata: { replacesUnread: true }, + }] })).result.results[0].coalesce.outcome, + 'channel-scoped push': async (f) => (await f.send('push/event', { + ...f.params('e9', 'edit_claiming_first_view', { channelId: 'chat', key: 'message:m9' }), tags: ['chat:edited'], origin: { replacesUnread: true }, + })).result.coalesce.outcome, + 'feature-set push': async (f) => (await f.send('push/event', { + ...f.params('e9', 'edit_claiming_first_view', { key: 'message:m9' }), tags: ['chat:edited'], origin: { replacesUnread: true }, + })).result.coalesce.outcome, +}; + +for (const [lane, deliver] of Object.entries(serverMarked)) test(`#157 (${lane}): a server can't mark its own edit as the agent's first view`, async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + let outcome: string | undefined; + const r = await sendThenSpeak(f, async () => { outcome = await deliver(f); }); + assert.equal(outcome, 'appended'); + assert(r.injected[0].includes('edit_claiming_first_view'), 'the edit reached the boundary'); + assert(!r.injected[0].includes('replacesUnread'), "the server's own mark was dropped"); + assert(r.sent, 'the explicit send posted'); + assert(!r.replied, 'the prose after the send stayed withheld'); +}); From 7dc9ce020a55c45bbd08bd566919178c3214de3a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 23:13:51 -0400 Subject: [PATCH 05/19] docs, test: re-pin wording, channel_publish for forks, injection asserts (R3-2, R3-3, R3-4) R3-2: two comments still said the turn locus is frozen, the mid-turn suppression paragraph at the tool boundary and adoptInjectedRound's. The boundary re-pins the locus a few dozen lines further on, so both now say what Anarchid's review suggested: the signal itself never moves routing, and the boundary re-pins by repinChannel's rule. The paragraph also says that an edit the agent sees first does clear the suppression, since the previous commit counts it as its message. R3-3: channel_publish's description and its channelId parameter said an omitted channel goes to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion. For a conversation agent bound to a channel, the framework fills in that channel before the registry sees the call, so they now say that first: an omitted channelId goes to the agent's conversation channel when it is bound to one, and otherwise to the channel of that most recent message. The changelog fragment says so too. R3-4: the suppression tests for a reaction and for the three other marker tags checked that the marker was stored before the reply, which holds only while the boundary stores and injects in the same loop. They now assert that the marker is among the messages injected at the send's tool boundary, as the injection test in the same file does. The registry's reply-anchor test lost a case when 0236f40a gave its real message a thread: that a message with no thread leaves no thread on the anchor. A real message with no thread now follows the threaded one. Co-Authored-By: Claude Opus 5.5 --- changelog.d/nonconversational-markers.fixed.md | 2 +- src/framework.ts | 17 +++++++++-------- src/mcpl/channel-registry.ts | 4 ++-- test/channel-registry-routing.test.ts | 7 +++++++ test/present-while-acting.test.ts | 14 ++++---------- 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index db6a5fa4d..a2b77ee19 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first, because it replaced a message the agent hadn't been shown yet, still counts as that message and clears explicit-send suppression like one. The host knows that case when RFC-006 coalescing replaces a message whose producer marked its first occurrence `initial` and of which no version has reached the agent yet. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first, because it replaced a message the agent hadn't been shown yet, still counts as that message and clears explicit-send suppression like one. The host knows that case when RFC-006 coalescing replaces a message whose producer marked its first occurrence `initial` and of which no version has reached the agent yet. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one. diff --git a/src/framework.ts b/src/framework.ts index a36e71169..434d9d76f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8927,13 +8927,13 @@ export class AgentFramework { // A newly injected CONVERSATIONAL message begins a new // conversational round inside the same provider inference. Remember // that boundary for driveStream: an explicit send in the preceding - // round must not silence the reply. Routing is deliberately NOT - // affected — the turn locus is frozen at turn start, so ambient - // chatter, reactions, and system markers injected mid-turn can no - // longer hijack where the agent's prose lands. Non-conversational - // injections (`system: true` markers, and reaction, reaction - // removal, edit and deletion markers) don't even clear the send - // suppression: nothing new was said to the agent. + // round must not silence the reply. The signal itself never moves + // routing; the boundary re-pins by repinChannel's rule (below). + // Non-conversational injections (`system: true` markers, and + // reaction, reaction removal, edit and deletion markers) don't even + // clear the send suppression: nothing new was said to the agent. + // An edit the agent sees first is the message it replaced, so it + // does (isConversationalInjection). if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) { this.midTurnInputSignals.add(agent.name); } @@ -12365,7 +12365,8 @@ export class AgentFramework { // A new conversational message, not merely a tool result, means any // earlier explicit delivery has completed its conversational job. - // Routing is NOT touched: the turn locus stays frozen. + // The signal itself never moves routing; the boundary re-pins by + // repinChannel's rule. turnSilenced = trigger?.suppressProse === true; }; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index c16e5e752..4d697737b 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -390,11 +390,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: "Publish a message to a channel. If channelId is omitted, publishes to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion.", + description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion.", inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: "ID of the channel to publish to (defaults to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion)" }, + channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion)" }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 854f27423..15356f985 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -566,6 +566,13 @@ test('non-conversational markers do not retarget the default publish locus (#157 const after = registry.buildChannelContext()?.incoming; assert.equal(after?.messageId, 'm2', 'a real message becomes the reply anchor'); assert.equal(after?.threadId, 'thread-B', 'with its own thread'); + // One with no thread leaves none behind. + const unthreaded = incoming('chanA', 'real message on A, no thread'); + unthreaded.messages[0]!.messageId = 'm3'; + registry.handleIncoming('discord', unthreaded); + const last = registry.buildChannelContext()?.incoming; + assert.equal(last?.messageId, 'm3', 'a real message with no thread becomes the reply anchor'); + assert.equal(last?.threadId, undefined, 'and takes no thread from the one before it'); }); test('a marker on a NEVER-conversed channel leaves the locus null, not marker-anchored (#157)', async () => { diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 040197ac8..dc9f905c8 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1829,11 +1829,8 @@ describe('present while acting', () => { assert.deepEqual(routed, [], 'post-send prose stayed suppressed after a reaction'); // That verdict counts only if the reaction was in the round: one that // missed it would leave the suppression set as well. - const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() - .map((m) => m.content.map((b) => (b as { text?: string }).text ?? '').join('')); - const markerAt = texts.findIndex((t) => t.includes('[reaction] @someone reacted')); - const replyAt = texts.findIndex((t) => t.includes('Sent it.')); - assert.ok(markerAt >= 0 && markerAt < replyAt, 'the reaction reached the turn before its reply'); + const injected = JSON.stringify(membrane.lastStream!.receivedToolResultOptions[0]?.injectedMessages ?? []); + assert.ok(injected.includes('[reaction] @someone reacted'), 'the reaction was injected at the send\'s tool boundary'); await framework.stop(); }); @@ -1865,11 +1862,8 @@ describe('present while acting', () => { assert.deepEqual(routed, [], `post-send prose stayed suppressed after a ${tag} marker`); // That verdict counts only if the marker was in the round: a marker that // missed it would leave the suppression set as well. - const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() - .map((m) => m.content.map((b) => (b as { text?: string }).text ?? '').join('')); - const markerAt = texts.findIndex((t) => t.includes(`[${tag}] marker about an earlier message`)); - const replyAt = texts.findIndex((t) => t.includes('Sent it.')); - assert.ok(markerAt >= 0 && markerAt < replyAt, `the ${tag} marker reached the turn before its reply`); + const injected = JSON.stringify(membrane.lastStream!.receivedToolResultOptions[0]?.injectedMessages ?? []); + assert.ok(injected.includes(`[${tag}] marker about an earlier message`), `the ${tag} marker was injected at the send's tool boundary`); await framework.stop(); }); From 044a80d9376e9dfecf0deb98698a95ceb377a68e Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 00:58:39 -0400 Subject: [PATCH 06/19] fix: count a replacement of a message stored outside the deferred queue as a first view Greptile's review of 7dc9ce02 found the first-view condition narrower than "never shown". The mark needed the replaced occurrence to be still waiting in the host's deferred queue, so it held only for messages that arrived while one of the agent's turns was alive. A message that arrives while none is alive is stored at once, and the agent hasn't seen it either until its next compile. That matters for a turn waiting out a capacity hold, or between any two of its streams: the turn's next start weighs such a message (noteArrival), and an edit that replaced it unseen was a marker there, so it couldn't move the turn's locus where main moves it. The host now notes on the placement when it stores a coalesced occurrence at once (CoalescingPlacement.direct, in addMessage's direct-store branch, on the same object the coalescer keeps). The coalescer's condition counts a prior that is still queued or was stored at once. A prior drained from the queue still doesn't count, since a tool boundary may have injected it, which leaves it unread; a message drained at a turn's end without injection stays a marker too. The predicate's comment says the same. Tests, through the coalescing fixture: - a turn waits out a capacity hold; an ambient follow-up in another channel arrives and, before the agent has seen it, an edit that addresses the agent replaces it; the turn's next start speaks in the follow-up's channel, as on main; - a follow-up stored at once is shown to the agent by the compile of the turn it wakes, and its edit arrives during a later turn: the coalescer appends it, since that compile consumed the follow-up, so it stays a marker and the prose after an explicit send stays withheld. The mark on a message stored at once is safe only because a compile consumes it, and this pins that. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 18 ++++----- src/mcpl/push-coalescer.ts | 19 ++++++--- test/mcpl-coalescing.test.ts | 78 +++++++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 16 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 434d9d76f..1b105d43f 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -200,13 +200,13 @@ const isSilencingTool = (name: string): boolean => { * neither clear explicit-send suppression nor re-pin a turn's locus at a tool * boundary. An edit can also be the first version of a message the agent * sees: when the coalescer replaced a message of which the agent had been - * shown no version while it waited in the deferred queue, the host marks - * the replacement (`replacesUnread`), and an edit so marked counts as the - * message it replaced. A reaction or - * deletion so marked is still a notice about that message and stays a - * marker. An edit of a message the host never received looks like any other - * edit and stays a marker too. A marker that wakes the agent can still set - * that turn's speech channel (agent-framework#189). + * shown no version, still waiting in the deferred queue or stored at once + * outside it, the host marks the replacement (`replacesUnread`), and an edit + * so marked counts as the message it replaced. A reaction or deletion so + * marked is still a notice about that message and stays a marker. An edit of + * a message the host never received looks like any other edit and stays a + * marker too. A marker that wakes the agent can still set that turn's speech + * channel (agent-framework#189). */ const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; @@ -15458,7 +15458,7 @@ export class AgentFramework { */ deferredWriteId?: string; /** Out-param: where the message landed (RFC-006 needs to find it again). */ - placement?: { agent?: string; messageId?: MessageId; deferredId?: string }; + placement?: { agent?: string; messageId?: MessageId; deferredId?: string; direct?: true }; /** * RFC-006 assembly: the named agent's turn is alive but its compile has * not run yet (the same window the turn-start deferred flush writes @@ -15540,7 +15540,7 @@ export class AgentFramework { content, opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata, ); - if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; } + if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; opts.placement.direct = true; } // Stored while none of the agent's turns is alive, or assembled for the // start now beginning: no tool boundary will weigh it. if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) { diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 687d8ec4d..814438a06 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -66,6 +66,10 @@ export interface CoalescingPlacement { messageId?: string; /** Entry id in the framework's deferred-write queue (turn-alive deferral). */ deferredId?: string; + /** Stored at once, outside the deferred queue: while none of the agent's + * turns was alive, or assembled for a start before its compile. No tool + * boundary drained it, so the agent sees it first at a compile. */ + direct?: true; } export interface CoalescingScope { kind: 'featureSet' | 'channel'; id: string } @@ -98,9 +102,10 @@ export interface CoalescedOccurrence { deliverTo?: string; /** Set by the coalescer when a plain occurrence replaces one that was the * agent's first view of the subject (its `initial` birth, or a - * replacement so marked) and is still waiting in the host's deferred - * queue, so never shown to the agent: the replacement is then the agent's - * first view. No other path sets it. */ + * replacement so marked) and that the agent was never shown: still + * waiting in the host's deferred queue, or stored at once, outside it + * (CoalescingPlacement.direct). The replacement is then the agent's first + * view. No other path sets it. */ replacesUnread?: boolean; } @@ -605,9 +610,11 @@ export class PushCoalescer { const prior = state.occupant; // The replacement is the agent's first view only when its prior was // one and the agent was never shown it: still waiting in the host's - // deferred queue, not even injected at a tool boundary. An occupant - // that isn't a first view ends the chain for every later replacement. - const firstView = prior.firstView === true && prior.placement.deferredId !== undefined; + // deferred queue, or stored at once, outside it. One drained from the + // queue may have been injected at a tool boundary, which leaves it + // unread, so it doesn't count. An occupant that isn't a first view + // ends the chain for every later replacement. + const firstView = prior.firstView === true && (prior.placement.deferredId !== undefined || prior.placement.direct === true); this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId }); if (this.host.remove(prior.placement)) { this.host.cancelWake(subject); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 4dc976def..a4978557c 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -12,7 +12,8 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { ContentBlock } from '@animalabs/membrane'; +import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; +import { MembraneError } from '@animalabs/membrane'; import { createMockResponse, MockYieldingStream } from './helpers/mock-membrane.js'; import { fixture, eventually, crash, ok, TS } from './helpers/coalescing-fixture.js'; @@ -1286,3 +1287,78 @@ for (const [lane, deliver] of Object.entries(serverMarked)) test(`#157 (${lane}) assert(r.sent, 'the explicit send posted'); assert(!r.replied, 'the prose after the send stayed withheld'); }); + +/** An organization acceleration 429, which holds the turn for a later compile. */ +const accelerationLimit = (request: NormalizedRequest): MembraneError => new MembraneError({ + type: 'rate_limit', retryable: true, httpStatus: 429, + message: "This request would exceed your organization's maximum usage increase rate for input tokens per minute", + rawError: { status: 429 }, rawRequest: request, +}); + +/** + * A turn, started by a mention in `chat`, whose first stream sends into + * `chat` and then ends with an acceleration 429, so the turn waits for a + * later compile with none of its streams running. `during` runs while it + * waits. Returns the channel the turn's next start spoke into. + */ +async function heldTurnSpeaksInto(f: Fixture, during: () => Promise): Promise { + let streams = 0; + (f.membrane as unknown as { streamYielding: unknown }).streamYielding = (request: NormalizedRequest) => { + f.membrane.calls.push(request as never); + if (++streams > 1) return new MockYieldingStream([createMockResponse([{ type: 'text', text: 'prose_after_hold' }] as ContentBlock[])]); + const stream = new MockYieldingStream([ + createMockResponse([{ type: 'tool_use', id: 'p0', name: 'channel_publish', input: { channelId: 'chat', content: 'explicit_send' } }] as ContentBlock[], 'tool_use'), + createMockResponse([] as ContentBlock[]), + ]); + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as Record)[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if ((event as { type?: string }).type === 'complete') { yield { type: 'error' as const, error: accelerationLimit(request) }; return; } + yield event; + } + }; + return stream; + }; + await f.send('channels/incoming', { messages: [f.channel('c1', 'question', { initial: true }, 'chat', 'm1')] }); + await f.framework.runUntilIdle(); + const internals = f.framework as unknown as { + providerAccelerationCooldowns: Map; + releaseProviderAccelerationCooldown(agentName: string): void; + }; + const hold = internals.providerAccelerationCooldowns.get('agent'); + assert.ok(hold, 'the turn is held after its first stream'); + await during(); + hold.until = Date.now(); + internals.releaseProviderAccelerationCooldown('agent'); + await f.framework.runUntilIdle(); + assert.equal(streams, 2, 'one deferred compile'); + return f.published.find((p) => JSON.stringify(p.content).includes('prose_after_hold'))?.channelId; +} + +test("#157: an edit that addresses the agent, of a follow-up stored while the turn waits out a capacity hold and not yet seen, moves the turn to the follow-up's channel", async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); await f.register('other'); + let outcome: string | undefined; + const channel = await heldTurnSpeaksInto(f, async () => { + await f.send('channels/incoming', { messages: [{ ...f.channel('q1', 'follow_up_wiht_typo', { initial: true }, 'other', 'mq'), tags: ['chat:ambient', 'chat:from-human'] }] }); + outcome = (await f.send('push/event', { + ...f.params('e1', 'follow_up_fixed @agent', { channelId: 'other', key: 'message:mq' }), tags: ['chat:edited', 'chat:mention'], + })).result.coalesce.outcome; + }); + assert.equal(outcome, 'replaced', 'the edit replaced the follow-up the agent had not seen'); + assert.equal(channel, 'other'); +}); + +test('#157: an edit of a follow-up stored at once, which a compile then showed the agent, stays a marker, so the prose after an explicit send stays withheld', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + // Stored at once, with no turn alive. The turn it wakes shows it to the agent in its compile. + await f.send('channels/incoming', { messages: [{ ...f.channel('c2', 'follow_up_wiht_typo', { initial: true }, 'chat', 'm2'), tags: ['chat:ambient', 'chat:from-human'] }] }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1, 'one turn ran'); + assert(JSON.stringify(f.membrane.calls[0]).includes('follow_up_wiht_typo'), "that turn's compile showed the agent the follow-up"); + let outcome: string | undefined; + const r = await sendThenSpeak(f, async () => { outcome = await pushTagged(f, ['chat:edited']); }); + assert.equal(outcome, 'appended', 'the edit came after the agent read the follow-up, so it replaced nothing'); + assert(r.injected[0].includes('follow_up_fixed') && !r.injected[0].includes('replacesUnread'), 'the edit reached the boundary unmarked'); + assert(r.sent, 'the explicit send posted'); + assert(!r.replied, 'the prose after the send stayed withheld'); +}); From bf351924d04f27a722a86c72daf301f25b943690 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 01:16:14 -0400 Subject: [PATCH 07/19] fix: end a message's direct window when a compile assembles it A message stored at once stays unread until the compile that assembles it returns, since the agent takes its consumed watermark before compiling and applies it after. In that window, an edit that replaced the message was marked as the agent's first view, though the request being built already held the original. After an explicit send, the agent's prose reply to it then posted where it should stay withheld. Before a committing compile assembles its request, the host now ends the direct window (PushCoalescer.endDirectWindow) for the stores that compile reads: the compiling agent's own, and every resident's where it reads the shared slot. A message that arrives after that point is deferred, since the turn is alive. A compile that fails leaves those messages as markers, and a dry run, which doesn't reach the stream start, ends nothing. The test admits an edit after the compile has assembled a message stored at once and before the compile returns. The edit reaches the tool boundary unmarked, and the prose after an explicit send stays withheld. present-while-acting's stand-in coalescer, which reaches the stream start, gains the method. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 8 ++++++++ src/mcpl/push-coalescer.ts | 15 ++++++++++++++- test/mcpl-coalescing.test.ts | 25 +++++++++++++++++++++++++ test/present-while-acting.test.ts | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/framework.ts b/src/framework.ts index 1b105d43f..e81cc0116 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11992,6 +11992,14 @@ export class AgentFramework { } } + // The compile below assembles every message stored at once for this + // agent, and for a shared slot every resident's, though the watermark + // moves only once it returns. So none of them is unseen any more + // (CoalescingPlacement.direct). A compile that fails leaves them so, + // which errs toward the marker. + this.pushCoalescer?.endDirectWindow(new Set( + this.sharedSlotAgents.has(agent) ? [...this.sharedSlotAgents].map((a) => a.name) : [agent.name], + )); const { stream, request: compiledRequest, diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 814438a06..1d30ab66f 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -68,7 +68,9 @@ export interface CoalescingPlacement { deferredId?: string; /** Stored at once, outside the deferred queue: while none of the agent's * turns was alive, or assembled for a start before its compile. No tool - * boundary drained it, so the agent sees it first at a compile. */ + * boundary drained it, so the agent sees it first at a compile, and the + * host clears this as a committing compile that reads it begins + * (PushCoalescer.endDirectWindow). */ direct?: true; } @@ -442,6 +444,17 @@ export class PushCoalescer { return structuredClone(entry.result); } + /** A committing compile of an agent that reads the stores of `agents` is + * about to assemble its request. An occupant stored at once there is in + * that request from now on, though it counts as unread until the compile + * returns, so it is no longer one the agent hasn't seen. */ + endDirectWindow(agents: ReadonlySet): void { + for (const state of this.subjects.values()) { + const placement = state.occupant?.placement; + if (placement?.direct && agents.has(placement.agent)) delete placement.direct; + } + } + // --------------------------------------------------------------------------- // Admission // --------------------------------------------------------------------------- diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index a4978557c..62b9bb977 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1362,3 +1362,28 @@ test('#157: an edit of a follow-up stored at once, which a compile then showed t assert(r.sent, 'the explicit send posted'); assert(!r.replied, 'the prose after the send stayed withheld'); }); + +test('#157: an edit admitted while a compile is assembling a message stored at once stays a marker, so the prose after an explicit send stays withheld', async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); + // The question is stored at once and wakes the turn. The edit is admitted + // after the compile has assembled it and before the compile returns, so + // the question still counts as unread. + const cm = f.framework.getAgent('agent')!.getContextManager() as unknown as { compile: (...args: unknown[]) => Promise }; + const compile = cm.compile.bind(cm); + let shown = false; + let edited = false; + cm.compile = async (...args: unknown[]) => { + const result = await compile(...args); + if (!edited) { + edited = true; + shown = JSON.stringify(result).includes('question'); + await f.send('push/event', { ...f.params('e1', 'question_edited', { channelId: 'chat', key: 'message:m1' }), tags: ['chat:edited'] }); + } + return result; + }; + const r = await sendThenSpeak(f, async () => {}); + assert(shown, 'the compile assembled the question'); + assert(r.injected[0].includes('question_edited') && !r.injected[0].includes('replacesUnread'), 'the edit reached the boundary unmarked'); + assert(r.sent, 'the explicit send posted'); + assert(!r.replied, 'the prose after the send stayed withheld'); +}); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index dc9f905c8..5302808f4 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1456,6 +1456,7 @@ describe('present while acting', () => { }, suspend: () => {}, isRendering: () => false, + endDirectWindow: () => {}, }; const lines: string[] = []; const error = console.error; From 9cac20344f53c8e066bfc64760fc5673fd5139de Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 01:28:11 -0400 Subject: [PATCH 08/19] test: pin which compiles end a message's direct window A committing compile ends the direct window for the stores it reads: the compiling agent's own, and every resident's where it reads the shared slot, the same readers isUnreadStoredMessage uses. Nothing pinned that scoping, so a call narrowed to the compiling agent, or widened to every placement, failed no test. Two tests now do: - an ephemeral agent, whose store is its own, runs a turn while the agent's message stored at once waits unread; an edit that replaces that message afterwards is still marked as the agent's first view. Widening the call fails it; - the other of two residents compiles the shared slot's message stored at once, and an edit admitted during that compile isn't marked. Narrowing the call to the compiling agent fails it. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-coalescing.test.ts | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 62b9bb977..2b8f82d68 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1387,3 +1387,56 @@ test('#157: an edit admitted while a compile is assembling a message stored at o assert(r.sent, 'the explicit send posted'); assert(!r.replied, 'the prose after the send stayed withheld'); }); + +/** A message in an agent's store whose text includes `text`. */ +const storedWith = (f: Fixture, text: string, agent = 'agent') => + f.framework.getAgent(agent)!.getContextManager().getAllMessages().find((m) => JSON.stringify(m.content).includes(text)); +const isMarked = (message: { metadata?: unknown } | undefined) => (message?.metadata as { replacesUnread?: unknown } | undefined)?.replacesUnread === true; + +test("#157: a compile of an agent with a store of its own leaves another agent's message stored at once unseen, so its edit is still marked", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); + // Stored at once in the agent's store, waking no turn. + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'nothing has compiled yet'); + // An ephemeral agent, whose store is its own, runs a turn. + const helper = await f.framework.createEphemeralAgent({ name: 'helper', model: 'test', systemPrompt: 'Do the task.' }); + t.after(helper.cleanup); + helper.contextManager.addMessage('User', [{ type: 'text', text: 'helper_task' }]); + const run = f.framework.runEphemeralToCompletion(helper.agent, helper.contextManager); + await f.framework.runUntilIdle(); + await run; + assert.equal(f.membrane.calls.length, 1, "the helper's compile ran"); + assert(!JSON.stringify(f.membrane.calls[0]).includes('doc_unseen'), "the helper's request doesn't hold the agent's message"); + const edit = await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced'); + assert(isMarked(storedWith(f, 'doc_edited')), "the edit is the agent's first view"); +}); + +test("#157: another resident's compile ends the window of a message stored at once in the shared slot, so an edit admitted during it isn't marked", async (t) => { + const f = await fixture({ agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }, { name: 'other', model: 'test', systemPrompt: 'test' }], server: { shouldTriggerInference: () => true } }); t.after(f.close); + // The edit is admitted after the other resident's compile has assembled + // the message and before that compile returns. + const cm = f.framework.getAgent('other')!.getContextManager() as unknown as { compile: (...args: unknown[]) => Promise }; + const compile = cm.compile.bind(cm); + let shown = false; + let edited = false; + cm.compile = async (...args: unknown[]) => { + const result = await compile(...args); + if (!edited) { + edited = true; + shown = JSON.stringify(result).includes('doc_unseen'); + await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] }); + } + return result; + }; + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + const internals = f.framework as unknown as { pendingRequests: Array<{ agentName: string }> }; + internals.pendingRequests = internals.pendingRequests.filter((r) => r.agentName === 'other'); + f.alwaysRespond(); + await f.framework.runUntilIdle(); + assert(shown, "the other resident's compile assembled the message"); + const stored = storedWith(f, 'doc_edited'); + assert(stored, 'the edit is stored'); + assert(!isMarked(stored), "the edit isn't marked as the agent's first view"); +}); From 44e9ce72502e0198ea71360e0524245869e7549a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 02:38:03 -0400 Subject: [PATCH 09/19] fix: judge a first view by a shown watermark, not a flag on the placement CoalescingPlacement.direct marked a message stored at once as one the agent hadn't seen, and endDirectWindow cleared the mark as a committing compile began. A message deferred during a turn and then stored by a flush (at the turn's end, at a capacity hold, at a stop) got neither the queue's id nor the flag, though no compile or tool boundary had shown it. So its edit lost the first-view mark: an edit that addressed the agent left the turn speaking in the old channel, where main moves it. Every path that stores without showing needed the flag set by hand. Each agent now keeps a shown watermark beside its consumed one: the newest stored message a compile or a tool boundary may have put in front of the agent. It moves when a compile takes its watermark, before the compile awaits; at every tool boundary that injects messages, whether or not the stream says it carries them; and wherever the consumed watermark is marked, such as at the agent's creation. A watermark on another branch counts every message as shown, as the consumed one counts it as read. The coalescer's first-view test asks the host whether nothing may have shown the prior (isUnshown): it is still in the deferred queue, or stored above every reader's shown watermark, by isUnreadStoredMessage's readers and compression rule. The consumed watermark, and so replace or append, is unchanged. A scalar counts a prefix. A message stored below the watermark without being shown counts as shown, such as another resident's message stored at once in the shared slot while this agent's boundary injected later entries. That errs toward the marker, as a doubt at the boundary does. This takes out CoalescingPlacement.direct, addMessage's line that set it, PushCoalescer.endDirectWindow and its call, and the stand-in coalescer's method in present-while-acting. Tests, in mcpl-coalescing: - an edit of a follow-up that arrived during a turn and was flushed when a capacity hold held the turn, and one flushed at the turn's end, are each the agent's first view. Both fail at the merge before this commit; - an edit admitted while the agent's compileContext assembles a message stored at once stays a marker. Co-Authored-By: Claude Opus 5.5 --- src/agent.ts | 25 ++++++++++ src/framework.ts | 33 +++++++------ src/mcpl/push-coalescer.ts | 40 +++++---------- test/mcpl-coalescing.test.ts | 82 +++++++++++++++++++++++++++++-- test/present-while-acting.test.ts | 1 - 5 files changed, 133 insertions(+), 48 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index af0872904..f00f82155 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -870,11 +870,25 @@ export class Agent { /** Counts marks: a compile writes its watermark back only if no mark was * made while it ran (a tool boundary's, or another compile's). */ private watermarkMarks = 0; + /** + * The newest stored message a compile or a tool boundary may have put in + * front of the agent, per branch. It moves where the consumed watermark + * does, and sooner: when a committing compile takes its value, before it + * awaits, and at every tool boundary that injects, whether or not the + * stream says it carries injected messages. So nothing above it can have + * been shown. A scalar counts a prefix, so a message stored below it + * without having been shown counts as shown, which errs toward a marker. + */ + private shownWatermark: { branch: string; sequence: number } | null = null; getConsumedWatermark(): { branch: string; sequence: number } | null { return this.consumedWatermark; } + getShownWatermark(): { branch: string; sequence: number } | null { + return this.shownWatermark; + } + /** Mark everything currently stored as consumed (the agent's creation, at * boot or later, and a tool boundary that injected messages into the live * stream). */ @@ -883,6 +897,13 @@ export class Agent { // stubs): without a watermark nothing is ever treated as unread. this.consumedWatermark = this.pendingWatermark(); this.watermarkMarks++; + this.shownWatermark = this.consumedWatermark; + } + + /** Mark everything currently stored as possibly shown (a tool boundary + * that injected messages, whatever its stream says it carries). */ + markContextShown(): void { + this.shownWatermark = this.pendingWatermark() ?? this.shownWatermark; } /** @@ -930,6 +951,7 @@ export class Agent { // fails assembled nothing, so the watermark moves only on success. const watermark = this.pendingWatermark(); const marksBefore = this.watermarkMarks; + if (watermark) this.shownWatermark = watermark; const compile = () => this.contextManager.compile(this.compileBudget(budget)); const result = budget ? await compile() : await this.compileAtLiveBudget(compile); if (watermark) this.applyCompileWatermark(watermark, marksBefore); @@ -972,6 +994,9 @@ export class Agent { // any compile before it selects. const watermark = opts?.dryRun ? null : this.pendingWatermark(); const marksBefore = this.watermarkMarks; + // Shown from the compile's start: what it assembles is in front of the + // agent before the consumed watermark moves, once it returns. + if (watermark) this.shownWatermark = watermark; const compile = () => this.contextManager.compile(this.compileBudget(budget), injections, opts as never); const result = budget || opts?.dryRun ? await compile() : await this.compileAtLiveBudget(compile); if (watermark) this.applyCompileWatermark(watermark, marksBefore); diff --git a/src/framework.ts b/src/framework.ts index 4f3b2f8d4..e52c2a627 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -10273,8 +10273,11 @@ export class AgentFramework { // Under context-manager#160 such a write already gives the // replies stored after it stamps their requests didn't carry, // whatever the mark says; deferring those writers is the fix. - if (midTurnInjections.length > 0 && currentState.supportsInjectedMessages === true) { - agent.markContextConsumed(); + if (midTurnInjections.length > 0) { + // Possibly shown, whatever the stream declares: the first-view + // mark (CoalescerHost.isUnshown) counts a doubt as shown. + agent.markContextShown(); + if (currentState.supportsInjectedMessages === true) agent.markContextConsumed(); } agent.setStreaming(currentState.stream); this.emitTrace({ @@ -11177,6 +11180,7 @@ export class AgentFramework { let unreadable: { agent: string; error: unknown } | undefined; const coalescer = new PushCoalescer({ isUnread: (p) => this.isUnreadPlacement(p), + isUnshown: (p) => this.isUnreadPlacement(p, 'shown'), remove: (p) => this.removePlacement(p), deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor), wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ), @@ -11571,20 +11575,25 @@ export class AgentFramework { } } - private isUnreadPlacement(p: CoalescingPlacement): boolean { + /** Unread (the coalescer's isUnread) by the consumed watermark, or, by the + * shown watermark, never possibly shown (its isUnshown). A placement still + * in the deferred queue is both. */ + private isUnreadPlacement(p: CoalescingPlacement, by: 'consumed' | 'shown' = 'consumed'): boolean { this.resolvePlacement(p); if (p.deferredId) return this.deferredMessages.some((m) => m.id === p.deferredId); if (!p.messageId) return false; const agent = this.agents.get(p.agent); - return !!agent && this.isUnreadStoredMessage(agent, p.messageId); + return !!agent && this.isUnreadStoredMessage(agent, p.messageId, by); } /** * Unread = stored, above the agent's consumed watermark on the current * branch, not a body-group shard (immutable), and not folded into any * summary (compression is consumption, §3.3). Conservative on every doubt. + * By the shown watermark (Agent.getShownWatermark), the same test says the + * message may never have been shown. */ - private isUnreadStoredMessage(agent: Agent, messageId: MessageId): boolean { + private isUnreadStoredMessage(agent: Agent, messageId: MessageId, by: 'consumed' | 'shown' = 'consumed'): boolean { const cm = agent.getContextManager(); let message: ReturnType; try { message = cm.getMessage(messageId); } catch { return false; } @@ -11608,7 +11617,7 @@ export class AgentFramework { const branch = cm.currentBranch().name; const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp); for (const reader of readers) { - const watermark = reader.getConsumedWatermark(); + const watermark = by === 'shown' ? reader.getShownWatermark() : reader.getConsumedWatermark(); if (!watermark || watermark.branch !== branch || message.sequence <= watermark.sequence) return false; // Compression is consumption (§3.3): folded into any reader's summary. try { @@ -13278,14 +13287,6 @@ export class AgentFramework { } } - // The compile below assembles every message stored at once for this - // agent, and for a shared slot every resident's, though the watermark - // moves only once it returns. So none of them is unseen any more - // (CoalescingPlacement.direct). A compile that fails leaves them so, - // which errs toward the marker. - this.pushCoalescer?.endDirectWindow(new Set( - this.sharedSlotAgents.has(agent) ? [...this.sharedSlotAgents].map((a) => a.name) : [agent.name], - )); const { stream, request: compiledRequest, @@ -16926,7 +16927,7 @@ export class AgentFramework { */ fromQueue?: { id: string; seq: number }; /** Out-param: where the message landed (RFC-006 needs to find it again). */ - placement?: { agent?: string; messageId?: MessageId; deferredId?: string; direct?: true }; + placement?: { agent?: string; messageId?: MessageId; deferredId?: string }; /** * RFC-006 assembly: the named agent's turn is alive but its compile has * not run yet (the same window the turn-start deferred flush writes @@ -17011,7 +17012,7 @@ export class AgentFramework { content, opts?.fromQueue ? withDeferredWriteId(metadata, opts.fromQueue.id) : metadata, ); - if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; opts.placement.direct = true; } + if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; } this.noteStored(agent, metadata, stored, opts?.bypassDeferralFor); return stored; } diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index b1e9417eb..ebb7dbd8b 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -66,12 +66,6 @@ export interface CoalescingPlacement { messageId?: string; /** Entry id in the framework's deferred-write queue (turn-alive deferral). */ deferredId?: string; - /** Stored at once, outside the deferred queue: while none of the agent's - * turns was alive, or assembled for a start before its compile. No tool - * boundary drained it, so the agent sees it first at a compile, and the - * host clears this as a committing compile that reads it begins - * (PushCoalescer.endDirectWindow). */ - direct?: true; } export interface CoalescingScope { kind: 'featureSet' | 'channel'; id: string } @@ -104,9 +98,8 @@ export interface CoalescedOccurrence { deliverTo?: string; /** Set by the coalescer when a plain occurrence replaces one that was the * agent's first view of the subject (its `initial` birth, or a - * replacement so marked) and that the agent was never shown: still - * waiting in the host's deferred queue, or stored at once, outside it - * (CoalescingPlacement.direct). The replacement is then the agent's first + * replacement so marked) and that the agent may never have been shown + * (CoalescerHost.isUnshown). The replacement is then the agent's first * view. No other path sets it. */ replacesUnread?: boolean; } @@ -198,6 +191,13 @@ export interface CoalescingReceiptRecord { export interface CoalescerHost { /** True iff the placement still exists and no model request has included it. */ isUnread(placement: CoalescingPlacement): boolean; + /** True iff nothing may have shown the placement to the agent: it is still + * in the deferred queue, or stored where no committing compile has begun + * to assemble it and no tool boundary has injected past it. Unlike + * isUnread, a compile counts from its start, and a boundary counts + * whether or not its stream says it carries injected messages: a doubt + * counts as shown. */ + isUnshown(placement: CoalescingPlacement): boolean; /** False when the unread occurrence could not be removed (it may still be read). */ remove(placement: CoalescingPlacement): boolean; /** Ordinary delivery of the event; undefined when routing delivered nowhere. @@ -451,17 +451,6 @@ export class PushCoalescer { return structuredClone(entry.result); } - /** A committing compile of an agent that reads the stores of `agents` is - * about to assemble its request. An occupant stored at once there is in - * that request from now on, though it counts as unread until the compile - * returns, so it is no longer one the agent hasn't seen. */ - endDirectWindow(agents: ReadonlySet): void { - for (const state of this.subjects.values()) { - const placement = state.occupant?.placement; - if (placement?.direct && agents.has(placement.agent)) delete placement.direct; - } - } - // --------------------------------------------------------------------------- // Admission // --------------------------------------------------------------------------- @@ -629,13 +618,10 @@ export class PushCoalescer { if (occupantUnread && state.occupant) { const prior = state.occupant; // The replacement is the agent's first view only when its prior was - // one and the agent was never shown it: still waiting in the host's - // deferred queue, or stored at once, outside it. One drained from the - // queue may have been injected at a tool boundary, which leaves it - // unread where the stream doesn't say it carries injected messages, so - // it doesn't count. An occupant that isn't a first view - // ends the chain for every later replacement. - const firstView = prior.firstView === true && (prior.placement.deferredId !== undefined || prior.placement.direct === true); + // one and nothing may have shown it to the agent (isUnshown). An + // occupant that isn't a first view ends the chain for every later + // replacement. + const firstView = prior.firstView === true && this.host.isUnshown(prior.placement); this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId }); if (this.host.remove(prior.placement)) { this.host.cancelWake(subject); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 846de654e..3a78cea08 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1353,10 +1353,11 @@ const accelerationLimit = (request: NormalizedRequest): MembraneError => new Mem /** * A turn, started by a mention in `chat`, whose first stream sends into * `chat` and then ends with an acceleration 429, so the turn waits for a - * later compile with none of its streams running. `during` runs while it - * waits. Returns the channel the turn's next start spoke into. + * later compile with none of its streams running. `midStream` runs while + * that first stream is still alive, and `during` while the turn waits. + * Returns the channel the turn's next start spoke into. */ -async function heldTurnSpeaksInto(f: Fixture, during: () => Promise): Promise { +async function heldTurnSpeaksInto(f: Fixture, during: () => Promise, midStream?: () => Promise): Promise { let streams = 0; (f.membrane as unknown as { streamYielding: unknown }).streamYielding = (request: NormalizedRequest) => { f.membrane.calls.push(request as never); @@ -1368,7 +1369,7 @@ async function heldTurnSpeaksInto(f: Fixture, during: () => Promise): Prom const iterate = stream[Symbol.asyncIterator].bind(stream); (stream as unknown as Record)[Symbol.asyncIterator] = async function* () { for await (const event of { [Symbol.asyncIterator]: iterate }) { - if ((event as { type?: string }).type === 'complete') { yield { type: 'error' as const, error: accelerationLimit(request) }; return; } + if ((event as { type?: string }).type === 'complete') { await midStream?.(); yield { type: 'error' as const, error: accelerationLimit(request) }; return; } yield event; } }; @@ -1495,3 +1496,76 @@ test("#157: another resident's compile ends the window of a message stored at on assert(stored, 'the edit is stored'); assert(!isMarked(stored), "the edit isn't marked as the agent's first view"); }); + +test("#157: an edit that addresses the agent, of a follow-up that arrived during the turn and was flushed when the turn was held, moves the turn to the follow-up's channel", async (t) => { + const f = await fixture(); t.after(f.close); await f.register(); await f.register('other'); + let outcome: string | undefined; + const channel = await heldTurnSpeaksInto(f, async () => { + assert(storedWith(f, 'follow_up_wiht_typo'), 'the hold flushed the follow-up into the store'); + outcome = (await f.send('push/event', { + ...f.params('e1', 'follow_up_fixed @agent', { channelId: 'other', key: 'message:mq' }), tags: ['chat:edited', 'chat:mention'], + })).result.coalesce.outcome; + }, async () => { + // The follow-up arrives while the first stream runs, so it waits in the deferred queue. + await f.send('channels/incoming', { messages: [{ ...f.channel('q1', 'follow_up_wiht_typo', { initial: true }, 'other', 'mq'), tags: ['chat:ambient', 'chat:from-human'] }] }); + assert(!storedWith(f, 'follow_up_wiht_typo'), 'the follow-up was deferred, not stored at once'); + }); + assert.equal(outcome, 'replaced', 'the edit replaced the follow-up the agent had not seen'); + assert.equal(channel, 'other'); +}); + +test("#157: an edit of a follow-up that arrived during a turn and was flushed at the turn's end, before any compile showed it, is the agent's first view", async (t) => { + // The question wakes the turn. The follow-up and its edit wake nothing, so + // no compile shows the follow-up after the flush. + const f = await fixture({ server: { shouldTriggerInference: (content: string) => !content.includes('follow_up') } }); t.after(f.close); await f.register(); + let streams = 0; + (f.membrane as unknown as { streamYielding: unknown }).streamYielding = (request: NormalizedRequest) => { + f.membrane.calls.push(request as never); + const stream = new MockYieldingStream([createMockResponse([{ type: 'text', text: 'answer' }] as ContentBlock[])]); + if (++streams > 1) return stream; + const iterate = stream[Symbol.asyncIterator].bind(stream); + (stream as unknown as Record)[Symbol.asyncIterator] = async function* () { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + // The follow-up arrives after the turn's last round, so no tool + // boundary drains it: it waits for the turn-end flush. + if ((event as { type?: string }).type === 'complete') { + await f.send('push/event', f.params('d1', 'follow_up_wiht_typo', { initial: true })); + assert(!storedWith(f, 'follow_up_wiht_typo'), 'the follow-up was deferred, not stored at once'); + } + yield event; + } + }; + return stream; + }; + await f.send('channels/incoming', { messages: [f.channel('c1', 'question', { initial: true }, 'chat', 'm1')] }); + await f.framework.runUntilIdle(); + assert.equal(streams, 1, 'one turn ran'); + assert(storedWith(f, 'follow_up_wiht_typo'), "the turn-end flush stored the follow-up"); + assert(!f.membrane.calls.some((c) => JSON.stringify(c).includes('follow_up_wiht_typo')), 'no request held the follow-up'); + const edit = await f.send('push/event', { ...f.params('d2', 'follow_up_fixed'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced'); + assert(isMarked(storedWith(f, 'follow_up_fixed')), "the edit is the agent's first view"); +}); + +test("#157: an edit admitted while the agent's compileContext is assembling a message stored at once stays a marker", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); + // Stored at once, waking no turn. + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'no turn ran'); + const agent = f.framework.getAgent('agent')!; + const cm = agent.getContextManager() as unknown as { compile: (...args: unknown[]) => Promise }; + const compile = cm.compile.bind(cm); + let shown = false; + let outcome: string | undefined; + cm.compile = async (...args: unknown[]) => { + const result = await compile(...args); + shown = JSON.stringify(result).includes('doc_unseen'); + outcome = (await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] })).result.coalesce.outcome; + return result; + }; + await agent.compileContext(); + assert(shown, 'compileContext assembled the message'); + assert.equal(outcome, 'replaced', 'the message still counted as unread while compileContext ran'); + assert(!isMarked(storedWith(f, 'doc_edited')), "the edit isn't the agent's first view"); +}); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 0e41ebb4f..15e25a2be 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1479,7 +1479,6 @@ describe('present while acting', () => { }, suspend: () => {}, isRendering: () => false, - endDirectWindow: () => {}, }; const lines: string[] = []; const error = console.error; From 772f70a0821cacc4ff13b867027841e40388ed83 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 03:33:31 -0400 Subject: [PATCH 10/19] fix: first views after a failed compile, in the deferred lane, and in the reply anchor A compile that throws assembled nothing, so it gives back its move of the shown watermark, unless a tool boundary or another compile has moved the watermark since (compileContext and compileWithInjections). A tool boundary without a watermark marks everything shown, as markContextConsumed marks it read. The compiles keep skipping a null watermark, which there is a dry run's. The first-view mark now holds in the deferred lane too. A batch opens as the agent's first view when its notice is a fresh `initial` birth with nothing read, or displaces a first-view occupant that nothing has shown. Its first delivery is then marked `replacesUnread`, while nothing of the subject has been read and no other version of it is placed: either its render, which keeps the first view when it lands unread in another agent's deferred queue, or a plain occurrence that drops the pending batch and takes its place. A snapshot keeps a pending batch's first view. A batch that was rendering may have been shown, so it comes back without one. ChannelRegistry's default publish anchor, `context.incoming`, now follows a channel message the coalescer delivered as the agent's first view, as the turn's locus already did. The coalesced-incoming handler reports the mark beside its result, never in the answer to the server, and markAccepted applies the rule isConversationalInjection applies, now one function, isMarkerTagged. The changelog fragment says what the host counts as a first view and that a doubt counts as shown. The replacesUnread and tool-boundary comments describe the watermark, not the removed flag. Two tests are renamed to what they check, and one now asserts that the edit is stored before asserting it isn't marked. New tests: - a committing compileContext, and a committing compileWithInjections, that throws leaves an edit of a message stored at once a first view; - a dry-run preview leaves it one too; - the render of a notice that births its subject is a first view; - a render that lands in another agent's deferred queue keeps its first view, and the edit that replaces it is marked; - a notice that displaces an unread first view opens a first-view batch only when nothing has shown that view; - a plain edit that takes a pending first-view batch's place is a first view, and so is an edit of it; - a pending first-view batch is still one after a restart; - context.incoming follows a first-view channel edit, and stays where it was for an edit of a message the agent has read. Co-Authored-By: Claude Opus 5.5 --- .../nonconversational-markers.fixed.md | 2 +- src/agent.ts | 39 ++++- src/framework.ts | 35 ++-- src/mcpl/channel-registry.ts | 20 ++- src/mcpl/push-coalescer.ts | 55 ++++-- src/mcpl/tags.ts | 10 ++ test/mcpl-coalescing.test.ts | 156 +++++++++++++++++- 7 files changed, 273 insertions(+), 44 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index a2b77ee19..1bca3ef2b 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first, because it replaced a message the agent hadn't been shown yet, still counts as that message and clears explicit-send suppression like one. The host knows that case when RFC-006 coalescing replaces a message whose producer marked its first occurrence `initial` and of which no version has reached the agent yet. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of the agent's has begun to assemble it or a tool boundary may have injected it, and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one. diff --git a/src/agent.ts b/src/agent.ts index f00f82155..f7409c916 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -876,8 +876,9 @@ export class Agent { * does, and sooner: when a committing compile takes its value, before it * awaits, and at every tool boundary that injects, whether or not the * stream says it carries injected messages. So nothing above it can have - * been shown. A scalar counts a prefix, so a message stored below it - * without having been shown counts as shown, which errs toward a marker. + * been shown. A compile that throws assembled nothing, and gives its move + * back. A scalar counts a prefix, so a message stored below it without + * having been shown counts as shown, which errs toward a marker. */ private shownWatermark: { branch: string; sequence: number } | null = null; @@ -901,9 +902,21 @@ export class Agent { } /** Mark everything currently stored as possibly shown (a tool boundary - * that injected messages, whatever its stream says it carries). */ + * that injected messages, whatever its stream says it carries). Without + * a watermark everything counts as shown, as markContextConsumed counts + * it read. */ markContextShown(): void { - this.shownWatermark = this.pendingWatermark() ?? this.shownWatermark; + this.shownWatermark = this.pendingWatermark(); + } + + /** A committing compile that threw assembled nothing, so its own move of + * the shown watermark goes back, unless a boundary or another compile has + * moved the watermark since. */ + private unshowFailedCompile( + watermark: { branch: string; sequence: number } | null, + before: { branch: string; sequence: number } | null, + ): void { + if (watermark && this.shownWatermark === watermark) this.shownWatermark = before; } /** @@ -951,9 +964,16 @@ export class Agent { // fails assembled nothing, so the watermark moves only on success. const watermark = this.pendingWatermark(); const marksBefore = this.watermarkMarks; + const shownBefore = this.shownWatermark; if (watermark) this.shownWatermark = watermark; const compile = () => this.contextManager.compile(this.compileBudget(budget)); - const result = budget ? await compile() : await this.compileAtLiveBudget(compile); + let result: CompileResult; + try { + result = budget ? await compile() : await this.compileAtLiveBudget(compile); + } catch (error) { + this.unshowFailedCompile(watermark, shownBefore); + throw error; + } if (watermark) this.applyCompileWatermark(watermark, marksBefore); return result; } @@ -996,9 +1016,16 @@ export class Agent { const marksBefore = this.watermarkMarks; // Shown from the compile's start: what it assembles is in front of the // agent before the consumed watermark moves, once it returns. + const shownBefore = this.shownWatermark; if (watermark) this.shownWatermark = watermark; const compile = () => this.contextManager.compile(this.compileBudget(budget), injections, opts as never); - const result = budget || opts?.dryRun ? await compile() : await this.compileAtLiveBudget(compile); + let result: CompileResult; + try { + result = budget || opts?.dryRun ? await compile() : await this.compileAtLiveBudget(compile); + } catch (error) { + this.unshowFailedCompile(watermark, shownBefore); + throw error; + } if (watermark) this.applyCompileWatermark(watermark, marksBefore); return result; } diff --git a/src/framework.ts b/src/framework.ts index e52c2a627..bcd0820b5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -78,7 +78,7 @@ import { Agent } from './agent.js'; import { ModuleRegistry, checkedToolResult, ensureStateRegistered, storableToolResult, thrownResult } from './module-registry.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; import { logValue } from './mcpl/log-value.js'; -import { hasNonConversationalTag } from './mcpl/tags.js'; +import { isMarkerTagged } from './mcpl/tags.js'; import { FeatureSetManager } from './mcpl/feature-set-manager.js'; import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js'; import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js'; @@ -218,12 +218,14 @@ const requestedSelfWakeSeconds = (input: unknown): number | undefined => { * — MCPL RFC-001) don't count: they are about an existing message, so they * neither clear explicit-send suppression nor re-pin a turn's locus at a tool * boundary. An edit can also be the first version of a message the agent - * sees: when the coalescer replaced a message of which the agent had been - * shown no version, still waiting in the deferred queue or stored at once - * outside it, the host marks the replacement (`replacesUnread`), and an edit - * so marked counts as the message it replaced. A reaction or deletion so - * marked is still a notice about that message and stays a marker. An edit of - * a message the host never received looks like any other edit and stays a + * sees: when the coalescer delivers a version of a message of which no + * version may have been shown to the agent (still queued, or stored above + * every reader's shown watermark), the host marks the delivery + * (`replacesUnread`), and an edit so marked counts as the message it + * replaced. A doubt counts as shown, so a message a scalar watermark covers + * without having been shown gets no mark. A reaction or deletion so marked + * is still a notice about that message and stays a marker. An edit of a + * message the host never received looks like any other edit and stays a * marker too. A marker that wakes the agent can still set that turn's speech * channel (agent-framework#189). */ @@ -231,8 +233,7 @@ const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; const m = metadata as Record; if (m.system === true) return false; - const tags = m.replacesUnread === true && Array.isArray(m.tags) ? m.tags.filter((t) => t !== 'chat:edited') : m.tags; - if (hasNonConversationalTag(tags)) return false; + if (isMarkerTagged(m.tags, m.replacesUnread === true)) return false; return true; }; @@ -2155,6 +2156,10 @@ export class AgentFramework { /** Both lanes are admitted in arrival order: a create, edit and delete of * one subject must not overtake one another while routing awaits. */ private coalescingAdmission: Promise = Promise.resolve(); + /** The event of the channel message the admission under way delivered as + * the agent's first view (`replacesUnread`), for the registry's anchor. + * Admissions are serialized, so it is only ever the current one's. */ + private coalescedFirstView: string | undefined; private coalescingSaveTimer: ReturnType | null = null; private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null; private coalescingRecentReceipts: CoalescingReceiptRecord[] = []; @@ -10259,12 +10264,14 @@ export class AgentFramework { // the watermark of the turn's compile and read as unread, so a // coalesced replacement could remove a message the agent heard // and answered (and that its later replies were minted after). - // Only a stream that declares it carries them is marked + // Only a stream that declares it carries them is marked read // (supportsInjectedMessages true: membrane's native path). One // that doesn't say may be membrane 0.5.87's XML path, which // drops them with a warning, and a message marked read there // could no longer be replaced though the agent never saw it. So // without the declaration they stay unread, as before this mark. + // The shown mark moves at every boundary that injects, declared + // or not, since there a doubt counts as shown. // The mark is the store's head, a scalar, as a compile's is: it // also covers what was stored since the compile without being // carried, such as another slot's messages, and a write that @@ -11338,6 +11345,7 @@ export class AgentFramework { if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required'); validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true }); const c = message.coalesce; + this.coalescedFirstView = undefined; const result = await this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -11353,7 +11361,11 @@ export class AgentFramework { identity: { messageId: message.messageId, author: message.author, threadId: message.threadId }, event: { lane: 'channel', event: { ...event, eventId: message.eventId } }, }); - return { messageId: message.messageId, accepted: true, coalesce: result.coalesce }; + // The registry's anchor follows a delivery the coalescer marked as the + // agent's first view, as the turn's locus does. + const firstView = this.coalescedFirstView === message.eventId; + this.coalescedFirstView = undefined; + return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) }; }); } @@ -11467,6 +11479,7 @@ export class AgentFramework { const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; const firstView = occ.replacesUnread ? { replacesUnread: true } : {}; if (occ.event.lane === 'channel') { + if (occ.replacesUnread) this.coalescedFirstView = occ.eventId; const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...firstView, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 28bf8642a..2c52823c6 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -36,7 +36,7 @@ import type { import type { McplServerRegistry } from './server-registry.js'; import type { FeatureSetManager } from './feature-set-manager.js'; import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js'; -import { expandCoreTags, hasNonConversationalTag } from './tags.js'; +import { expandCoreTags, isMarkerTagged } from './tags.js'; import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js'; import { isVisiblyEmptyConversion } from './visible-content.js'; import { logDetail, logValue } from './log-value.js'; @@ -394,13 +394,16 @@ interface ChannelRegistryOptions { * RFC-006: an admitted `channels/incoming` message carrying `coalesce`, with * the event the ordinary path would have queued. The handler decides * replace / append / withdraw and returns the per-message result. Throws a - * `CoalesceError` for a malformed or unauthorized occurrence. + * `CoalesceError` for a malformed or unauthorized occurrence. `firstView` + * says the coalescer delivered the message as the agent's first view of it + * (`replacesUnread`); it is the host's own, and isn't in the answer to the + * server. */ handleCoalescedIncoming?: ( serverId: string, message: ChannelIncomingMessage, event: McplChannelIncomingEvent, - ) => Promise; + ) => Promise; /** Chronicle store used for durable desired channel lifecycle state. */ store?: JsStore; /** Callback to determine whether an incoming message should trigger inference. */ @@ -987,12 +990,13 @@ export class ChannelRegistry { // 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). - const markAccepted = () => { + const markAccepted = (firstView = false) => { // Non-conversational markers (a reaction added or removed, an edit, a // deletion) are ABOUT a message, not one the agent replies to: they // must not retarget the default publish locus or become - // `context.incoming`'s reply anchor. - if (!hasNonConversationalTag(message.tags)) { + // `context.incoming`'s reply anchor. An edit the coalescer delivered + // as the agent's first view counts as the message it replaced. + if (!isMarkerTagged(message.tags, firstView)) { this.defaultPublishChannel = message.channelId; this.defaultPublishMessageId = message.messageId; this.defaultPublishThreadId = message.threadId; @@ -1051,8 +1055,8 @@ export class ChannelRegistry { // the coalescer, which replaces, appends or withdraws. Malformed // `coalesce` is a per-message failure; siblings are unaffected. try { - const result = await this.handleCoalescedIncoming!(serverId, message, event); - if (result.accepted) markAccepted(); + const { firstView, ...result } = await this.handleCoalescedIncoming!(serverId, message, event); + if (result.accepted) markAccepted(firstView === true); results.push(result); } catch (error) { const err = error as Error & { code?: number; field?: string }; diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index ebb7dbd8b..f19143364 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -96,11 +96,12 @@ export interface CoalescedOccurrence { /** Set by the coalescer: deliver into this agent only (§3.2 audience may * narrow, never widen — a replacement or notice follows the prior delivery). */ deliverTo?: string; - /** Set by the coalescer when a plain occurrence replaces one that was the - * agent's first view of the subject (its `initial` birth, or a - * replacement so marked) and that the agent may never have been shown - * (CoalescerHost.isUnshown). The replacement is then the agent's first - * view. No other path sets it. */ + /** Set by the coalescer when the delivery is the agent's first view of + * the subject: a plain occurrence that replaces one that was the agent's + * first view (its `initial` birth, or a replacement so marked) and that + * the agent may never have been shown (CoalescerHost.isUnshown), or the + * first delivery of a batch that opened on a first view (its render, or + * a plain occurrence that drops it). No other path sets it. */ replacesUnread?: boolean; } @@ -123,6 +124,11 @@ interface DeferredBatch { dropped: number; /** Set on a batch restored from a snapshot whose render may have started. */ noRender?: boolean; + /** The batch opened on the agent's first view of its subject: a fresh + * `initial` birth, or a first-view occupant it displaced that nothing had + * shown. Its first delivery is a first view too, while nothing of the + * subject has been read since and no other version of it is placed. */ + firstView?: true; } interface Rendering { @@ -169,7 +175,9 @@ export interface CoalescingSnapshot { identity?: CoalescedOccurrence['identity']; occupant?: { eventId: string; placement: CoalescingPlacement }; audienceAgent?: string; - batch?: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number; rendering?: boolean }; + /** `firstView` only on a batch that wasn't rendering: one that was may + * have been shown. */ + batch?: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number; rendering?: boolean; firstView?: true }; }>; } /** One receipt, written synchronously at acceptance (bridges the snapshot throttle). */ @@ -614,6 +622,9 @@ export class PushCoalescer { } private async acceptPlain(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { + // A batch this occurrence drops delivered nothing, so when it opened on + // the agent's first view, this occurrence takes that view. + const batchFirstView = (state.batch ?? state.rendering?.batch)?.firstView === true; const hadBatch = this.dropBatches(subject, state); if (occupantUnread && state.occupant) { const prior = state.occupant; @@ -641,24 +652,32 @@ export class PushCoalescer { state.occupant = undefined; } if (hadBatch) this.host.cancelWake(subject); - const placement = await this.host.deliver(occurrence); const outcome: CoalesceOutcome = hadBatch ? 'replaced' : state.consumedEventId || state.history !== 'none' ? 'appended' : 'first'; - // The subject's `initial` birth is the agent's first view of it. - state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(outcome === 'first' ? { firstView: true as const } : {}) } : undefined; + // The subject's `initial` birth is the agent's first view of it, and so + // is an occurrence that takes a first-view batch's place. + const firstView = hadBatch ? batchFirstView && state.history === 'none' : outcome === 'first'; + const placement = await this.host.deliver(hadBatch && firstView ? { ...occurrence, replacesUnread: true } : occurrence); + state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; return outcome; } private async acceptDeferred(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { let outcome: CoalesceOutcome = 'first'; + // Whether a batch this notice opens is the agent's first view of the + // subject: a fresh `initial` birth with nothing read, or a first-view + // occupant it displaces that nothing has shown. A render still under way + // is weighed when this batch's first delivery is. + let firstView = state.history === 'none'; if (occupantUnread && state.occupant) { // §5.1: a notice displaces an unread plain occurrence and opens a batch // — in the audience that held the plain occurrence (§3.2). + firstView = state.occupant.firstView === true && this.host.isUnshown(state.occupant.placement); this.host.audit({ kind: 'displaced', subject, eventId: state.occupant.eventId }); if (this.host.remove(state.occupant.placement)) { outcome = 'replaced'; occurrence = { ...occurrence, deliverTo: state.occupant.placement.agent }; - } else { this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId }); state.history = 'unknown'; } + } else { this.host.audit({ kind: 'remove-failed', subject, eventId: state.occupant.eventId }); state.history = 'unknown'; firstView = false; } this.host.cancelWake(subject); state.occupant = undefined; } else if (state.batch?.latest.deliverTo) { @@ -673,7 +692,7 @@ export class PushCoalescer { outcome = 'replaced'; } else { // Rule 1: a notice during a render opens a NEW batch ("first"). - state.batch = { latest: occurrence, notices: [notice], dropped: 0 }; + state.batch = { latest: occurrence, notices: [notice], dropped: 0, ...(firstView ? { firstView: true as const } : {}) }; } await this.host.wakeForBatch(occurrence); return outcome; @@ -820,12 +839,15 @@ export class PushCoalescer { const empty = isVisiblyEmptyContent(content); this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty }); if (empty) return; // §5.2: nothing happened (blank text included) - const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor); + // The batch's first delivery is the agent's first view while nothing + // of the subject has been read and no other version of it is placed. + const firstView = batch.firstView === true && state.history === 'none' && !state.occupant; + const placement = await this.host.deliver({ ...occurrence, timestamp, content, ...(firstView ? { replacesUnread: true } : {}) }, content, assemblingFor); rendering.materialized = true; if (placement?.deferredId) { // Landed in another agent's deferred queue (its turn is alive): still // unread there, so it stays replaceable and withdrawable. - state.occupant = { eventId: occurrence.eventId, placement }; + state.occupant = { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) }; state.audienceAgent = placement.agent; return; } @@ -864,7 +886,10 @@ export class PushCoalescer { // An interrupted render is not replayed (§3.2): a batch that was // RENDERING keeps its fallback; a batch that was only pending renders // normally at the next assembly. - state.batch = { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) }; + state.batch = { + latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, + ...(s.batch.rendering ? { noRender: true } : s.batch.firstView ? { firstView: true as const } : {}), + }; } this.subjects.set(s.subject, state); } @@ -931,7 +956,7 @@ export class PushCoalescer { subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), ...(s.occupant ? { occupant: { eventId: s.occupant.eventId, placement: s.occupant.placement } } : {}), - ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } } + ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : s.batch.firstView ? { firstView: true as const } : {}) } } : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } : {}), })), diff --git a/src/mcpl/tags.ts b/src/mcpl/tags.ts index 6c8540497..156a41288 100644 --- a/src/mcpl/tags.ts +++ b/src/mcpl/tags.ts @@ -41,6 +41,16 @@ export function hasNonConversationalTag(tags: unknown): boolean { return Array.isArray(tags) && tags.some((t) => typeof t === 'string' && NON_CONVERSATIONAL_TAGS.has(t)); } +/** + * True when a message is a marker by its tags. `firstView` is the host's own + * mark that the coalescer delivered the message as the agent's first view of + * it (`replacesUnread`), never a server's claim: an edit so delivered counts + * as the message it replaced, while a reaction or deletion stays a notice. + */ +export function isMarkerTagged(tags: unknown, firstView: boolean): boolean { + return hasNonConversationalTag(firstView && Array.isArray(tags) ? tags.filter((t) => t !== 'chat:edited') : tags); +} + /** * Expand a raw tag list over the normative core closure, then resolve the * §16.2 mutual exclusion: `chat:addressed` and `chat:ambient` cannot both diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 3a78cea08..8bd4dc882 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -16,6 +16,7 @@ import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; import { createMockResponse, MockYieldingStream } from './helpers/mock-membrane.js'; import { fixture, eventually, crash, ok, TS, REPLY_DEADLINE_MS } from './helpers/coalescing-fixture.js'; +import { PushCoalescer, type CoalescerHost, type CoalescedOccurrence } from '../src/mcpl/push-coalescer.js'; // --------------------------------------------------------------------------- // Plain coalescing, feature-set scope (vectors 1–6, 35) @@ -1404,7 +1405,7 @@ test("#157: an edit that addresses the agent, of a follow-up stored while the tu assert.equal(channel, 'other'); }); -test('#157: an edit of a follow-up stored at once, which a compile then showed the agent, stays a marker, so the prose after an explicit send stays withheld', async (t) => { +test('#157: an edit of a follow-up stored at once, which a turn then read, is appended as a marker, so the prose after an explicit send stays withheld', async (t) => { const f = await fixture(); t.after(f.close); await f.register(); // Stored at once, with no turn alive. The turn it wakes shows it to the agent in its compile. await f.send('channels/incoming', { messages: [{ ...f.channel('c2', 'follow_up_wiht_typo', { initial: true }, 'chat', 'm2'), tags: ['chat:ambient', 'chat:from-human'] }] }); @@ -1469,7 +1470,7 @@ test("#157: a compile of an agent with a store of its own leaves another agent's assert(isMarked(storedWith(f, 'doc_edited')), "the edit is the agent's first view"); }); -test("#157: another resident's compile ends the window of a message stored at once in the shared slot, so an edit admitted during it isn't marked", async (t) => { +test("#157: another resident's compile that assembles a message stored at once in the shared slot shows it, so an edit admitted during that compile isn't marked", async (t) => { const f = await fixture({ agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }, { name: 'other', model: 'test', systemPrompt: 'test' }], server: { shouldTriggerInference: () => true } }); t.after(f.close); // The edit is admitted after the other resident's compile has assembled // the message and before that compile returns. @@ -1567,5 +1568,154 @@ test("#157: an edit admitted while the agent's compileContext is assembling a me await agent.compileContext(); assert(shown, 'compileContext assembled the message'); assert.equal(outcome, 'replaced', 'the message still counted as unread while compileContext ran'); - assert(!isMarked(storedWith(f, 'doc_edited')), "the edit isn't the agent's first view"); + const stored = storedWith(f, 'doc_edited'); + assert(stored, 'the edit is stored'); + assert(!isMarked(stored), "the edit isn't the agent's first view"); +}); + +for (const method of ['compileContext', 'compileWithInjections'] as const) test(`#157: a committing ${method} that throws gives its shown mark back, so an edit of a message stored at once is still the agent's first view`, async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); + // Stored at once, waking no turn. + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'no turn ran'); + const agent = f.framework.getAgent('agent')!; + const cm = agent.getContextManager() as unknown as { compile: (...args: unknown[]) => Promise }; + const compile = cm.compile; + cm.compile = async () => { throw new Error('compile_failed'); }; + await assert.rejects(agent[method](), /compile_failed/); + cm.compile = compile; + const edit = await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced'); + const stored = storedWith(f, 'doc_edited'); + assert(stored, 'the edit is stored'); + assert(isMarked(stored), "the edit is the agent's first view"); +}); + +test("#157: a dry-run preview shows the agent nothing, so an edit of a message stored at once after one is still its first view", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'no turn ran'); + const preview = await f.framework.previewActivation('agent'); + assert(JSON.stringify(preview).includes('doc_unseen'), 'the preview assembled the message'); + const edit = await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced'); + const stored = storedWith(f, 'doc_edited'); + assert(stored, 'the edit is stored'); + assert(isMarked(stored), "the edit is the agent's first view"); +}); + +test("#157: the render of a deferred notice that births its subject is the agent's first view, so an edit tag on it doesn't make it a marker", async (t) => { + const f = await fixture(); t.after(f.close); + await f.send('push/event', { ...f.params('n1', 'fallback_text', { deferred: true, initial: true }), tags: ['chat:edited'] }); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 1, 'the notice rendered at assembly'); + const stored = storedWith(f, 'document_diff'); + assert(stored, 'the render is stored'); + assert(isMarked(stored), "the render is the agent's first view"); +}); + +test("#157: a render that lands unread in another agent's deferred queue keeps its first view, so an edit that replaces it is marked", async () => { + const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const host: CoalescerHost = { + isUnread: () => true, isUnshown: () => true, remove: () => true, + // Every delivery lands in the deferred queue of an agent whose turn is alive. + deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, + wakeForBatch: async () => {}, cancelWake: () => {}, authorized: () => true, + audience: async () => ['agent', 'other'], + render: async () => ({ content: [{ type: 'text', text: 'document_diff' }] }), + audit: () => {}, save: () => {}, + }; + const coalescer = new PushCoalescer(host); + const occurrence = (eventId: string, flags: Partial>): CoalescedOccurrence => ({ + serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS, + retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags, + }); + await coalescer.accept(occurrence('n1', { deferred: true, initial: true })); + await coalescer.assemble('agent'); + const edit = await coalescer.accept(occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(edit.coalesce?.outcome, 'replaced', 'the edit replaced the unread render'); + assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'e1', replacesUnread: true }], 'the render and the edit that replaced it are each marked'); +}); + +for (const shown of [false, true]) test(`#157: a notice that displaces an unread first view ${shown ? 'that something may have shown the agent opens a batch whose render is no first view' : 'that nothing has shown the agent opens a batch whose render is the first view'}`, async () => { + const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const host: CoalescerHost = { + isUnread: () => true, isUnshown: () => !shown, remove: () => true, + deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'agent', messageId: `stored-${delivered.length}` }; }, + wakeForBatch: async () => {}, cancelWake: () => {}, authorized: () => true, + audience: async () => ['agent'], + render: async () => ({ content: [{ type: 'text', text: 'document_diff' }] }), + audit: () => {}, save: () => {}, + }; + const coalescer = new PushCoalescer(host); + const occurrence = (eventId: string, flags: Partial>): CoalescedOccurrence => ({ + serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS, + retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags, + }); + await coalescer.accept(occurrence('p1', { initial: true })); + const notice = await coalescer.accept(occurrence('n1', { deferred: true, tags: ['chat:edited'] })); + assert.equal(notice.coalesce?.outcome, 'replaced', 'the notice displaced the unread birth'); + await coalescer.assemble('agent'); + assert.deepEqual(delivered, [{ eventId: 'p1' }, { eventId: 'n1', ...(shown ? {} : { replacesUnread: true as const }) }]); +}); + +test("#157: a plain edit that takes the place of a pending notice that birthed its subject is the agent's first view, and so is an edit of that edit", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); + // The notice waits as a batch: no turn assembles it. + await f.send('push/event', f.params('n1', 'fallback_text', { deferred: true, initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 0, 'nothing rendered the notice'); + const first = await f.send('push/event', { ...f.params('e1', 'doc_edit_one'), tags: ['chat:edited'] }); + assert.equal(first.result.coalesce.outcome, 'replaced', "the edit took the batch's place"); + const one = storedWith(f, 'doc_edit_one'); + assert(one, 'the edit is stored'); + assert(isMarked(one), "the edit is the agent's first view"); + const second = await f.send('push/event', { ...f.params('e2', 'doc_edit_two'), tags: ['chat:edited'] }); + assert.equal(second.result.coalesce.outcome, 'replaced', 'the second edit replaced the first, unread'); + const two = storedWith(f, 'doc_edit_two'); + assert(two, 'the second edit is stored'); + assert(isMarked(two), "the second edit is the agent's first view too"); +}); + +test("#157: a notice that births its subject and is still pending at a restart is still the agent's first view after it", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); + await f.send('push/event', f.params('n1', 'fallback_text', { deferred: true, initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 0, 'nothing rendered the notice'); + await f.framework.stop(); await f.create(); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 0, 'nothing rendered the notice after the restart'); + const edit = await f.send('push/event', { ...f.params('e1', 'doc_edit_one'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced', "the edit took the restored batch's place"); + const stored = storedWith(f, 'doc_edit_one'); + assert(stored, 'the edit is stored'); + assert(isMarked(stored), "the edit is the agent's first view"); +}); + +type IncomingAnchor = { buildChannelContext: (agent?: string) => { incoming?: { channelId: string; messageId: string } } | undefined }; +const incomingAnchor = (f: Fixture) => (f.framework as unknown as { channelRegistry: IncomingAnchor }).channelRegistry.buildChannelContext('agent')?.incoming; + +test("#157: a channel edit that is the agent's first view moves context.incoming to its message, as the message would", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.register('other'); + await f.send('channels/incoming', { messages: [f.channel('a1', 'question_wiht_typo', { initial: true }, 'other', 'ma')] }); + await f.send('channels/incoming', { messages: [f.channel('b1', 'unrelated', { initial: true }, 'chat', 'mb')] }); + assert.equal(incomingAnchor(f)?.messageId, 'mb', 'the later message is the anchor'); + const edit = await f.send('channels/incoming', { messages: [{ ...f.channel('a2', 'question_fixed', {}, 'other', 'ma'), tags: ['chat:edited'] }] }); + assert.equal(edit.result.results[0].coalesce.outcome, 'replaced', 'the edit replaced the question the agent had not seen'); + assert(!('firstView' in edit.result.results[0]), "the host's mark isn't in the answer to the server"); + assert(isMarked(storedWith(f, 'question_fixed')), "the edit is the agent's first view"); + assert.equal(incomingAnchor(f)?.channelId, 'other', "the anchor moved to the edit's channel"); + assert.equal(incomingAnchor(f)?.messageId, 'ma', "and to the edit's message"); +}); + +test('#157: a channel edit of a message the agent has read leaves context.incoming where it was', async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.register('other'); + await f.send('channels/incoming', { messages: [f.channel('a1', 'question_wiht_typo', { initial: true }, 'other', 'ma')] }); + await f.framework.getAgent('agent')!.compileContext(); + await f.send('channels/incoming', { messages: [f.channel('b1', 'unrelated', { initial: true }, 'chat', 'mb')] }); + const edit = await f.send('channels/incoming', { messages: [{ ...f.channel('a2', 'question_fixed', {}, 'other', 'ma'), tags: ['chat:edited'] }] }); + assert.equal(edit.result.results[0].coalesce.outcome, 'appended', 'the agent had read the question'); + assert.equal(incomingAnchor(f)?.messageId, 'mb', 'the marker left the anchor at the later message'); }); From db15e9ba43bcaba0466c655b8d6fabf606806edd Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 03:49:53 -0400 Subject: [PATCH 11/19] test: pin the three first-view pieces no test reached Each of these could be undone at the previous commit without failing a test: - a compile that throws gives back its move of the shown watermark only when nothing moved it since: a compile that throws after a tool boundary has moved the mark leaves the boundary's move, so an edit of a message stored at once stays a marker; - a tool boundary that can't read the store counts every message as shown, so an edit of a message stored at once stays a marker; - a notice's render is no first view while an earlier render of its subject waits unread in a deferred queue, so only the first of two renders is marked. Co-Authored-By: Claude Opus 5.5 --- test/mcpl-coalescing.test.ts | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 8bd4dc882..dac65ed68 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1592,6 +1592,43 @@ for (const method of ['compileContext', 'compileWithInjections'] as const) test( assert(isMarked(stored), "the edit is the agent's first view"); }); +test("#157: a compile that throws after a tool boundary has moved the shown mark leaves the boundary's move, so an edit of a message stored at once is a marker", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'no turn ran'); + const agent = f.framework.getAgent('agent')!; + const cm = agent.getContextManager() as unknown as { compile: (...args: unknown[]) => Promise }; + const compile = cm.compile; + // A boundary of another turn injects while the compile runs, then the compile fails. + cm.compile = async () => { agent.markContextShown(); throw new Error('compile_failed'); }; + await assert.rejects(agent.compileContext(), /compile_failed/); + cm.compile = compile; + const edit = await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced', 'the message is still unread'); + const stored = storedWith(f, 'doc_edited'); + assert(stored, 'the edit is stored'); + assert(!isMarked(stored), "the edit isn't the agent's first view"); +}); + +test('#157: a tool boundary that cannot read the store counts every message as shown, so an edit of a message stored at once is a marker', async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); + await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'no turn ran'); + const agent = f.framework.getAgent('agent')!; + const cm = agent.getContextManager() as unknown as { currentBranch: () => unknown }; + const currentBranch = cm.currentBranch; + cm.currentBranch = () => { throw new Error('store_unreadable'); }; + agent.markContextShown(); + cm.currentBranch = currentBranch; + const edit = await f.send('push/event', { ...f.params('d2', 'doc_edited'), tags: ['chat:edited'] }); + assert.equal(edit.result.coalesce.outcome, 'replaced', 'the message is still unread'); + const stored = storedWith(f, 'doc_edited'); + assert(stored, 'the edit is stored'); + assert(!isMarked(stored), "the edit isn't the agent's first view"); +}); + test("#157: a dry-run preview shows the agent nothing, so an edit of a message stored at once after one is still its first view", async (t) => { const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.send('push/event', f.params('d1', 'doc_unseen', { initial: true })); @@ -1639,6 +1676,34 @@ test("#157: a render that lands unread in another agent's deferred queue keeps i assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'e1', replacesUnread: true }], 'the render and the edit that replaced it are each marked'); }); +test("#157: a notice's render that lands while an earlier render of its subject waits unread in a deferred queue is no first view", async () => { + const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + let releaseFirst!: () => void; + const firstRender = new Promise((resolve) => { releaseFirst = resolve; }); + const host: CoalescerHost = { + isUnread: () => true, isUnshown: () => true, remove: () => true, + // Every delivery lands in the deferred queue of an agent whose turn is alive. + deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, + wakeForBatch: async () => {}, cancelWake: () => {}, authorized: () => true, + audience: async () => ['agent', 'other'], + render: async (occ) => { if (occ.eventId === 'n1') await firstRender; return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] }; }, + audit: () => {}, save: () => {}, + }; + const coalescer = new PushCoalescer(host); + const occurrence = (eventId: string, flags: Partial>): CoalescedOccurrence => ({ + serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS, + retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags, + }); + await coalescer.accept(occurrence('n1', { deferred: true, initial: true })); + const first = coalescer.assemble('agent'); + // A second notice arrives while the first batch renders, so it opens a new batch. + await coalescer.accept(occurrence('n2', { deferred: true })); + releaseFirst(); + await first; + await coalescer.assemble('agent'); + assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'n2' }], 'only the first render is the first view'); +}); + for (const shown of [false, true]) test(`#157: a notice that displaces an unread first view ${shown ? 'that something may have shown the agent opens a batch whose render is no first view' : 'that nothing has shown the agent opens a batch whose render is the first view'}`, async () => { const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; const host: CoalescerHost = { From 1789e8cd64a33427bbb2385e51879829651cfeff Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Sun, 18 Oct 2026 12:23:54 -0400 Subject: [PATCH 12/19] fix(mcpl): a notice that may take the agent's first view clears it on a pending or rendering batch acceptDeferred decided whether the agent can still get its first view of a subject from a batch (firstView: nothing read, and any first-view occupant the notice displaces still unshown), but applied that only to a batch the notice opened. A pending batch the notice joined, or a batch already rendering, kept the firstView it opened with. So after a notice displaced a queued first-view render that the agent had been shown (unread, but no longer unshown, as at a boundary whose stream doesn't declare what it carries injected), that batch's render was still delivered with replacesUnread, and an edit tag on it made the edit count as the message itself, though the agent had seen an earlier version. slimepriestess's re-review of 2ed67d2 found the pending case; the rendering case is the same rule one state over, since a notice during a render opens a new batch and the rendering one settles after it. When firstView is false, the notice now deletes firstView from the pending batch and from the rendering batch, before it joins or opens one. A notice that finds the subject read clears it too, which the render's own check already required. Tests, in test/mcpl-coalescing.test.ts, on a coalescer whose renders land in another agent's deferred queue (slimepriestess's probe, with held renders and a stored placement added): - a notice that displaces a queued first-view render and joins a pending batch: the batch's render is no first view when the agent was shown the render, and still is when nothing showed it; - the same with the batch already rendering when the notice arrives; - a batch opened during a render whose content the turn then read has a render that is no first view (the render's state.history check); - a plain edit that takes the place of a pending first-view batch after the agent read an earlier render is no first view (acceptPlain's state.history check). Co-Authored-By: Claude Opus 5.5 --- src/mcpl/push-coalescer.ts | 24 ++++++-- test/mcpl-coalescing.test.ts | 106 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index f42b28597..15b6c51d4 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -127,7 +127,10 @@ interface DeferredBatch { /** The batch opened on the agent's first view of its subject: a fresh * `initial` birth, or a first-view occupant it displaced that nothing had * shown. Its first delivery is a first view too, while nothing of the - * subject has been read since and no other version of it is placed. */ + * subject has been read since and no other version of it is placed. A + * later notice that may have taken away the first view (it displaced an + * occupant something may have shown, or found the subject read) clears + * it, whether the batch is pending or rendering. */ firstView?: true; } @@ -668,10 +671,12 @@ export class PushCoalescer { private async acceptDeferred(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { let outcome: CoalesceOutcome = 'first'; - // Whether a batch this notice opens is the agent's first view of the - // subject: a fresh `initial` birth with nothing read, or a first-view - // occupant it displaces that nothing has shown. A render still under way - // is weighed when this batch's first delivery is. + // Whether the agent can still get its first view of the subject from a + // batch: nothing read, and a first-view occupant this notice displaces + // that nothing has shown. It decides a batch this notice opens, and a + // pending or rendering batch keeps its own first view only while it holds + // (below). A render still under way is weighed when a batch's first + // delivery is. let firstView = state.history === 'none'; if (occupantUnread && state.occupant) { // §5.1: a notice displaces an unread plain occurrence and opens a batch @@ -688,6 +693,15 @@ export class PushCoalescer { // Later notices stay in the batch's audience. occurrence = { ...occurrence, deliverTo: state.batch.latest.deliverTo }; } + // A batch opened as the agent's first view stays one only while nothing + // may have shown the agent a version of the subject. A notice that + // displaced an occupant something may have shown, or that finds the + // subject read, ends that for the batch it joins and for one already + // rendering, whose render settles after this notice. + if (!firstView) { + if (state.batch) delete state.batch.firstView; + if (state.rendering) delete state.rendering.batch.firstView; + } const notice: DeferredNotice = { eventId: occurrence.eventId, timestamp: occurrence.timestamp, ...(occurrence.data !== undefined ? { data: structuredClone(occurrence.data) } : {}) }; if (state.batch) { state.batch.notices.push(notice); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 0b079d709..f67c4e01f 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1780,6 +1780,112 @@ test("#157: a notice's render that lands while an earlier render of its subject assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'n2' }], 'only the first render is the first view'); }); +/** A coalescer whose every render lands in the deferred queue of 'other', an + * agent whose turn is alive, unless `stored` names the event, whose render is + * then stored and so materialized for the assembling turn. The renders of + * `held` wait for `release`. `state` answers the host's read and shown + * questions for every placement. (slimepriestess's probe for #168, with the + * held renders and the stored placement added.) */ +function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1'], stored = [] as string[] } = {}) { + const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const releases = new Map void>(); + const gates = new Map(held.map((id) => [id, new Promise((resolve) => { releases.set(id, resolve); })])); + const started = new Map void>(); + const renderStarted = new Map(held.map((id) => [id, new Promise((resolve) => { started.set(id, resolve); })])); + const host: CoalescerHost = { + isUnread: () => !state.read, isUnshown: () => !state.shown && !state.read, remove: () => true, + deliver: async (occ) => { + delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); + return stored.includes(occ.eventId) + ? { agent: 'agent', messageId: `stored-${delivered.length}` } + : { agent: 'other', deferredId: `deferred-${delivered.length}` }; + }, + wakeForBatch: async () => {}, cancelWake: () => {}, authorized: () => true, + audience: async () => ['agent', 'other'], + render: async (occ) => { + started.get(occ.eventId)?.(); + await gates.get(occ.eventId); + return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] }; + }, + audit: () => {}, save: () => {}, + }; + const coalescer = new PushCoalescer(host); + const occurrence = (eventId: string, flags: Partial>): CoalescedOccurrence => ({ + serverId: 'editor', binding: 'binding', scope: { kind: 'featureSet', id: 'doc' }, key: 'document', eventId, timestamp: TS, + retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags, + }); + return { + coalescer, delivered, occurrence, + release: (id: string) => releases.get(id)!(), + renderStarted: (id: string) => renderStarted.get(id)!, + }; +} + +/** n1's render lands in 'other''s deferred queue as a first view, after n2 + * opened a batch of the same subject during that render. */ +async function firstViewQueuedWithBatchPending(p: ReturnType): Promise { + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + const first = p.coalescer.assemble('agent'); + await p.coalescer.accept(p.occurrence('n2', { deferred: true })); + p.release('n1'); + await first; + assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }], "n1's render is the agent's first view"); +} + +for (const shown of [false, true]) test(`#157: a notice that displaces a queued first-view render ${shown ? 'the agent was shown, and joins a pending batch, leaves that batch no first view' : 'nothing has shown, and joins a pending batch, leaves that batch its first view'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state); + await firstViewQueuedWithBatchPending(p); + // Shown at a boundary whose stream doesn't declare what it carries + // injected: still unread, but no longer unshown. + state.shown = shown; + const n3 = await p.coalescer.accept(p.occurrence('n3', { deferred: true, tags: ['chat:edited'] })); + assert.equal(n3.coalesce?.outcome, 'replaced', 'n3 displaced the queued render and joined the pending batch'); + await p.coalescer.assemble('agent'); + assert.equal(p.delivered[1]?.eventId, 'n3'); + assert.equal(p.delivered[1]?.replacesUnread === true, !shown, shown ? 'the agent was shown n1, so the batch\'s render is no first view' : 'nothing showed n1, so the batch\'s render is still the first view'); +}); + +for (const shown of [false, true]) test(`#157: a notice that displaces a queued first-view render ${shown ? 'the agent was shown leaves a batch already rendering no first view' : 'nothing has shown leaves a batch already rendering its first view'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: ['n1', 'n2'] }); + await firstViewQueuedWithBatchPending(p); + // n2's batch renders, held, so no batch is pending when n3 arrives. + const second = p.coalescer.assemble('agent'); + await settles(p.renderStarted('n2'), "n2's render"); + state.shown = shown; + const n3 = await p.coalescer.accept(p.occurrence('n3', { deferred: true, tags: ['chat:edited'] })); + assert.equal(n3.coalesce?.outcome, 'replaced', 'n3 displaced the queued render'); + p.release('n2'); + await second; + assert.equal(p.delivered[1]?.eventId, 'n2'); + assert.equal(p.delivered[1]?.replacesUnread === true, !shown, shown ? 'the agent was shown n1, so the rendering batch\'s render is no first view' : 'nothing showed n1, so the rendering batch\'s render is still the first view'); +}); + +test("#157: a batch opened during a render whose content the turn then read has a render that is no first view", async () => { + const state = { shown: false, read: false }; + // n1's render is stored where the assembling turn's compile reads it, so + // that turn consumes it. + const p = firstViewProbe(state, { stored: ['n1'] }); + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + const first = p.coalescer.assemble('agent'); + await p.coalescer.accept(p.occurrence('n2', { deferred: true })); + p.release('n1'); + await first; + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'n2' }], "n2's batch opened before the agent read n1, but its render comes after"); +}); + +test('#157: a plain edit that takes the place of a pending first-view batch after the agent read an earlier render is no first view', async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state); + await firstViewQueuedWithBatchPending(p); + state.read = true; + const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(e1.coalesce?.outcome, 'replaced'); + assert.deepEqual(p.delivered.at(-1), { eventId: 'e1' }); +}); + for (const shown of [false, true]) test(`#157: a notice that displaces an unread first view ${shown ? 'that something may have shown the agent opens a batch whose render is no first view' : 'that nothing has shown the agent opens a batch whose render is the first view'}`, async () => { const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; const host: CoalescerHost = { From f40c54a430765851e04621cec6ab389af3873130 Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Sun, 18 Oct 2026 12:36:11 -0400 Subject: [PATCH 13/19] fix(mcpl): a channel retry answered from its receipt keeps its first view for the reply anchor The channel registry moves context.incoming to an edit the coalescer delivered as the agent's first view (markAccepted(firstView)). The first view reached it through coalescedFirstView, set while the acceptance delivered the message. When the acceptance's commit then failed (acknowledge), the registry never marked it accepted, and the producer's retry was answered from the receipt with no delivery, so it carried no first view: the stored edit had replacesUnread and the turn's locus followed it, while context.incoming stayed on the earlier message. slimepriestess raised this in her re-review of 2ed67d2, from reading. The coalescer now records the first view on the receipt itself. Its deliveries go through one private deliver(), which notes one that carries replacesUnread; admit() clears the note before an admission's effects and writes it to the receipt entry it creates (every delivery runs under the lock, so the note is that admission's alone). receiptFirstView() reads it, and handleCoalescedIncoming takes the first view from the receipt, for a fresh acceptance and for a retry it answers alike, so coalescedFirstView and its resets are gone. It stays internal: the answer to the server is as before. The snapshot writes each receipt as { result, at }, its declared shape, so the mark is kept in memory only, and a receipt restored from a snapshot or the crash-window bridge reads as no first view. Tests, in test/mcpl-coalescing.test.ts: - a channel edit that is the agent's first view moves context.incoming to its message when its acceptance is retried after a failed commit (the commit rejects once, after the delivery); - a channel edit after an earlier first-view edit, of a message the agent has since read, leaves context.incoming where it was (each admission records only its own delivery's mark). Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 13 ++++------- src/mcpl/push-coalescer.ts | 44 ++++++++++++++++++++++++++++-------- test/mcpl-coalescing.test.ts | 36 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index f070f5a7a..db4d85076 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2341,10 +2341,6 @@ export class AgentFramework { /** Both lanes are admitted in arrival order: a create, edit and delete of * one subject must not overtake one another while routing awaits. */ private coalescingAdmission: Promise = Promise.resolve(); - /** The event of the channel message the admission under way delivered as - * the agent's first view (`replacesUnread`), for the registry's anchor. - * Admissions are serialized, so it is only ever the current one's. */ - private coalescedFirstView: string | undefined; private coalescingSaveTimer: ReturnType | null = null; private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null; private coalescingRecentReceipts: CoalescingReceiptRecord[] = []; @@ -12222,7 +12218,6 @@ export class AgentFramework { if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required'); validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true }); const c = message.coalesce; - this.coalescedFirstView = undefined; // The inbound source envelope, frozen at acceptance: deferral, fan-out // and replay deliver it unchanged (mcpl/inbound-source.ts). const inboundSource = this.channelSource({ @@ -12253,9 +12248,10 @@ export class AgentFramework { event: { lane: 'channel', event: { ...event, eventId: message.eventId, inboundSource } }, }); // The registry's anchor follows a delivery the coalescer marked as the - // agent's first view, as the turn's locus does. - const firstView = this.coalescedFirstView === message.eventId; - this.coalescedFirstView = undefined; + // agent's first view, as the turn's locus does. The receipt says so for + // this acceptance, and for a retry it answers, whose first attempt + // delivered the message but failed before its acknowledgement. + const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId); return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) }; }); } @@ -12400,7 +12396,6 @@ export class AgentFramework { ? { inboundSource: materialized && source.kind !== 'surface' ? { ...source, materialized: true } : source } : {}; if (occ.event.lane === 'channel') { - if (occ.replacesUnread) this.coalescedFirstView = occ.eventId; const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...firstView, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 3bfdba06b..e670872dd 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -177,6 +177,8 @@ interface SubjectState { touchedAt: number; } +interface ReceiptEntry { result: PushEventResult; at: number; committed: boolean; firstView?: true } + export interface CoalescingSnapshot { version: 1; receipts: Array<[string, { result: PushEventResult; at: number }]>; @@ -395,8 +397,15 @@ export class PushCoalescer { private readonly subjects = new Map>(); /** `committed`: the durability barrier succeeded after this receipt was * written. An uncommitted receipt deduplicates effects but is not an - * acknowledgement until a commit succeeds. */ - private readonly receipts = new Map(); + * acknowledgement until a commit succeeds. `firstView`: the admission it + * records delivered its occurrence as the agent's first view + * (`replacesUnread`), so a retry answered from it says so too. Kept in + * memory only: a receipt restored from a snapshot reads as no first view. */ + private readonly receipts = new Map(); + /** Set by a delivery that carries `replacesUnread`. admit() clears it before + * an admission's effects and reads it after them; every delivery runs + * under the lock, so between the two it is that admission's alone. */ + private deliveredFirstView = false; private readonly retryWindowMs: number; private readonly maxSubjects: number; private readonly maxNotices: number; @@ -452,12 +461,26 @@ export class PushCoalescer { return (this.rendersInFlight.get(serverId) ?? 0) > 0; } + /** The host's delivery, noting one that carries `replacesUnread` for the + * receipt of the admission it belongs to (deliveredFirstView). */ + private deliver(...args: Parameters['deliver']>): ReturnType['deliver']> { + if (args[0].replacesUnread) this.deliveredFirstView = true; + return this.host.deliver(...args); + } + receipt(serverId: string, binding: string, eventId: string): PushEventResult | undefined { const entry = this.receiptEntry(coalescingReceiptKey(serverId, binding, eventId)); return entry && structuredClone(entry.result); } - private receiptEntry(key: string): { result: PushEventResult; at: number; committed: boolean } | undefined { + /** Whether the admission this receipt records delivered its occurrence as + * the agent's first view: for the host's own bookkeeping, such as the + * channel registry's reply anchor, and never part of the wire result. */ + receiptFirstView(serverId: string, binding: string, eventId: string): boolean { + return this.receiptEntry(coalescingReceiptKey(serverId, binding, eventId))?.firstView === true; + } + + private receiptEntry(key: string): ReceiptEntry | undefined { const entry = this.receipts.get(key); if (!entry) return undefined; if (this.now() - entry.at > this.retryWindowMs) { this.receipts.delete(key); return undefined; } @@ -467,7 +490,7 @@ export class PushCoalescer { /** Acknowledge a receipt: durable first. A receipt whose commit failed is * committed now (no effects are repeated); if that fails again, so does * the acknowledgement, and the producer retries once more. */ - private async acknowledge(key: string, entry: { result: PushEventResult; at: number; committed: boolean }, subject: string, eventId: string): Promise { + private async acknowledge(key: string, entry: ReceiptEntry, subject: string, eventId: string): Promise { if (!entry.committed) { try { await this.host.commit?.(); @@ -512,6 +535,7 @@ export class PushCoalescer { this.host.audit({ kind: 'received', subject, eventId: occurrence.eventId, retract: occurrence.retract, deferred: occurrence.deferred }); let outcome: CoalesceOutcome; + this.deliveredFirstView = false; if (occurrence.retract) { outcome = await this.retract(subject, state, occurrence, occupantUnread); } else if (occurrence.deferred) { @@ -525,7 +549,7 @@ export class PushCoalescer { state.touchedAt = this.now(); const result: PushEventResult = { accepted: true, coalesce: { outcome, ...(priorEventId ? { priorEventId } : {}) } }; const receiptKey = coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId); - const entry = { result: structuredClone(result), at: this.now(), committed: false }; + const entry: ReceiptEntry = { result: structuredClone(result), at: this.now(), committed: false, ...(this.deliveredFirstView ? { firstView: true as const } : {}) }; try { this.host.recordReceipt?.({ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}), @@ -639,7 +663,7 @@ export class PushCoalescer { // The notice is an ordinary occurrence: it rides the normal delivery path // (tags → gate policy → wake) and is consumed like any message — in the // context that read a version, never a new one (§3.2). - const placement = await this.host.deliver({ ...occurrence, deliverTo: state.audienceAgent }); + const placement = await this.deliver({ ...occurrence, deliverTo: state.audienceAgent }); if (placement) { state.occupant = { eventId: occurrence.eventId, placement }; state.audienceAgent = placement.agent; @@ -663,7 +687,7 @@ export class PushCoalescer { if (this.host.remove(prior.placement)) { this.host.cancelWake(subject); // The replacement follows the prior delivery's audience (§3.2). - const placement = await this.host.deliver({ + const placement = await this.deliver({ ...occurrence, deliverTo: prior.placement.agent, ...(firstView ? { replacesUnread: true } : {}), }); @@ -682,7 +706,7 @@ export class PushCoalescer { // The subject's `initial` birth is the agent's first view of it, and so // is an occurrence that takes a first-view batch's place. const firstView = hadBatch ? batchFirstView && state.history === 'none' : outcome === 'first'; - const placement = await this.host.deliver(hadBatch && firstView ? { ...occurrence, replacesUnread: true } : occurrence); + const placement = await this.deliver(hadBatch && firstView ? { ...occurrence, replacesUnread: true } : occurrence); state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; return outcome; @@ -917,7 +941,7 @@ export class PushCoalescer { // The batch's first delivery is the agent's first view while nothing // of the subject has been read and no other version of it is placed. const firstView = batch.firstView === true && state.history === 'none' && !state.occupant; - const placement = await this.host.deliver({ ...occurrence, timestamp, content, ...(firstView ? { replacesUnread: true } : {}) }, content, assemblingFor); + const placement = await this.deliver({ ...occurrence, timestamp, content, ...(firstView ? { replacesUnread: true } : {}) }, content, assemblingFor); if (placement?.deferredId) { // Landed in another agent's deferred queue (its turn is alive): still // unread there, so it stays replaceable and withdrawable. The turn @@ -1032,7 +1056,7 @@ export class PushCoalescer { snapshot(): CoalescingSnapshot { return { version: 1, - receipts: [...this.receipts], + receipts: [...this.receipts].map(([key, { result, at }]): [string, { result: PushEventResult; at: number }] => [key, { result, at }]), subjects: [...this.subjects].map(([subject, s]) => ({ subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 51e1eee3c..2f2879e25 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -2488,6 +2488,42 @@ test("#157: a channel edit that is the agent's first view moves context.incoming assert.equal(incomingAnchor(f)?.messageId, 'ma', "and to the edit's message"); }); +test("#157: a channel edit that is the agent's first view moves context.incoming to its message when its acceptance is retried after a failed commit", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.register('other'); + await f.send('channels/incoming', { messages: [f.channel('a1', 'question_wiht_typo', { initial: true }, 'other', 'ma')] }); + await f.send('channels/incoming', { messages: [f.channel('b1', 'unrelated', { initial: true }, 'chat', 'mb')] }); + assert.equal(incomingAnchor(f)?.messageId, 'mb', 'the later message is the anchor'); + // The edit's acceptance commit fails once, after its delivery (R10's seam), + // so the server retries, and the retry is answered from the receipt. + const internals = f.framework as unknown as { commitCoalescingDurable: () => Promise }; + const commit = internals.commitCoalescingDurable.bind(internals); + internals.commitCoalescingDurable = () => { internals.commitCoalescingDurable = commit; return Promise.reject(new Error('EIO simulated')); }; + const message = { ...f.channel('a2', 'question_fixed', {}, 'other', 'ma'), tags: ['chat:edited'] }; + const first = await f.send('channels/incoming', { messages: [message] }); + assert.equal(first.result.results[0].accepted, false, 'the failed commit leaves the edit unacknowledged'); + assert(isMarked(storedWith(f, 'question_fixed')), "the edit was delivered as the agent's first view"); + assert.equal(incomingAnchor(f)?.messageId, 'mb', 'an unacknowledged acceptance moves no anchor'); + const retry = await f.send('channels/incoming', { messages: [message] }); + assert.equal(retry.result.results[0].coalesce.outcome, 'replaced', "the retry is answered from the first attempt's receipt"); + assert(!('firstView' in retry.result.results[0]), "the host's mark isn't in the answer to the server"); + assert.equal(incomingAnchor(f)?.channelId, 'other', "the anchor moved to the edit's channel"); + assert.equal(incomingAnchor(f)?.messageId, 'ma', "and to the edit's message"); +}); + +test("#157: a channel edit after an earlier edit that was the agent's first view, of a message the agent has since read, leaves context.incoming where it was", async (t) => { + const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.register('other'); + await f.send('channels/incoming', { messages: [f.channel('a1', 'question_wiht_typo', { initial: true }, 'other', 'ma')] }); + await f.send('channels/incoming', { messages: [f.channel('b1', 'unrelated', { initial: true }, 'chat', 'mb')] }); + await f.send('channels/incoming', { messages: [{ ...f.channel('a2', 'question_fixed', {}, 'other', 'ma'), tags: ['chat:edited'] }] }); + assert.equal(incomingAnchor(f)?.messageId, 'ma', "the first-view edit moved the anchor to its message"); + await f.send('channels/incoming', { messages: [f.channel('c1', 'later', { initial: true }, 'chat', 'mc')] }); + await f.framework.getAgent('agent')!.compileContext(); + // Each admission records only its own delivery's mark. + const edit = await f.send('channels/incoming', { messages: [{ ...f.channel('a3', 'question_fixed_again', {}, 'other', 'ma'), tags: ['chat:edited'] }] }); + assert.equal(edit.result.results[0].coalesce.outcome, 'appended', 'the agent had read the edit'); + assert.equal(incomingAnchor(f)?.messageId, 'mc', 'the marker left the anchor at the later message'); +}); + test('#157: a channel edit of a message the agent has read leaves context.incoming where it was', async (t) => { const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); await f.register('other'); await f.send('channels/incoming', { messages: [f.channel('a1', 'question_wiht_typo', { initial: true }, 'other', 'ma')] }); From 7e13dd9cadca35a41c14d289006d515ae4a62153 Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Sun, 18 Oct 2026 13:26:52 -0400 Subject: [PATCH 14/19] fix(mcpl): end a subject's first view where an occupant that may have been shown leaves A version of a subject the agent may have been shown could leave the coalescer's state without the subject remembering it, and the first-view decisions, reading `history === 'none'` and no occupant, then granted a first view again. Three runs at f40c54a4 ended with `replacesUnread`: - a queued first-view render the agent was shown is displaced by a notice whose batch starts rendering; a notice opens a new batch during that render, and a plain edit takes that batch's place; - the same, but the render comes back blank, and the new batch renders; - a retraction withdraws the shown render, and the next notice's batch renders. The clearing in acceptDeferred (1789e8cd) reached only the batches open at the moment of displacement. The coalescer now ends the first view in one place. occupantLeaves() runs wherever an unread occupant leaves the subject: a notice displacing it (acceptDeferred), a plain occurrence displacing it (acceptPlain), and a retraction withdrawing it (retract). It returns whether the occupant was a first view nothing may have shown, which a replacement carries on; otherwise it sets the subject's new `firstViewEnded` and deletes `firstView` from the batch pending and the batch rendering, the clearing that moves out of acceptDeferred. A batch acceptDeferred opens, and a birth acceptPlain accepts, are first views only while the first view hasn't ended. The render's check is unchanged, since each batch's `firstView` stays exact. The snapshot writes `firstViewEnded` per subject and restore reads it; a snapshot written before this field reads as not ended, so once after an upgrade a subject whose first view had already ended can give one delivery a spurious `replacesUnread`, which the snapshot type's doc says. Text: - tags.ts: the paragraph on NON_CONVERSATIONAL_TAGS named only isConversationalInjection as the mark's reader; isMarkerTagged reads it for that and for the channel registry's publish channel and reply anchor. - the changelog fragment: the default publish channel and context.incoming move for a channel message, the only lane whose acceptance sets them. - handleCoalescedIncoming's comment: a receipt restored after a restart carries no mark, which matches the shown watermark starting at the head at boot; the receipt's doc says its mark is about the delivery handed to the host, placed or not, and that the crash-window bridge's receipts carry none either. Tests, in test/mcpl-coalescing.test.ts, on firstViewProbe (now with blank renders, unplaced deliveries and its host returned), each shown and unshown where it has both: - a plain edit that takes the place of a batch opened during the render that followed a displaced first-view render; - a batch opened while that render came back blank; - the next batch after a retraction withdraws a first-view render; - a plain birth after such a retraction, and its edit; - the next batch after a plain occurrence that displaced a first-view render is placed nowhere; - a snapshot keeping a subject's ended first view across a restore; - a receipt keeping an admission's first view in memory only, the snapshot writing its declared shape; - a plain edit arriving while a first-view batch renders takes its place as the first view (acceptPlain's `state.rendering` fallback). The channel retry test now asserts the retry was answered from the receipt (priorEventId a1) and delivered nothing again. Co-Authored-By: Claude Opus 5.5 --- .../nonconversational-markers.fixed.md | 2 +- src/framework.ts | 6 +- src/mcpl/push-coalescer.ts | 84 +++++---- src/mcpl/tags.ts | 6 +- test/mcpl-coalescing.test.ts | 167 ++++++++++++++++-- 5 files changed, 219 insertions(+), 46 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index 1bca3ef2b..b5c38b5a7 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of the agent's has begun to assemble it or a tool boundary may have injected it, and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of the agent's has begun to assemble it or a tool boundary may have injected it, and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one. diff --git a/src/framework.ts b/src/framework.ts index db4d85076..f82d900e9 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -12250,7 +12250,11 @@ export class AgentFramework { // The registry's anchor follows a delivery the coalescer marked as the // agent's first view, as the turn's locus does. The receipt says so for // this acceptance, and for a retry it answers, whose first attempt - // delivered the message but failed before its acknowledgement. + // delivered the message but failed before its acknowledgement. A + // receipt restored after a restart carries no mark, so a retry it + // answers leaves the anchor where a marker would, as the host already + // counts what was stored before a restart as shown: every agent's + // shown watermark starts at the head at boot. const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId); return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) }; }); diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index e670872dd..5bd18815d 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -127,10 +127,9 @@ interface DeferredBatch { /** The batch opened on the agent's first view of its subject: a fresh * `initial` birth, or a first-view occupant it displaced that nothing had * shown. Its first delivery is a first view too, while nothing of the - * subject has been read since and no other version of it is placed. A - * later notice that may have taken away the first view (it displaced an - * occupant something may have shown, or found the subject read) clears - * it, whether the batch is pending or rendering. */ + * subject has been read since and no other version of it is placed. It is + * cleared when the subject's first view ends while the batch is pending + * or rendering (occupantLeaves). */ firstView?: true; } @@ -174,6 +173,11 @@ interface SubjectState { identity?: CoalescedOccurrence['identity']; /** Agent that received the last delivery; replacements and notices follow it. */ audienceAgent?: string; + /** The agent's first view of the subject has ended: an occupant that may + * have been shown left it without handing a first view on + * (occupantLeaves). No later batch or birth of the subject is a first + * view. */ + firstViewEnded?: true; touchedAt: number; } @@ -189,6 +193,11 @@ export interface CoalescingSnapshot { identity?: CoalescedOccurrence['identity']; occupant?: { eventId: string; placement: CoalescingPlacement }; audienceAgent?: string; + /** The subject's first view has ended (SubjectState.firstViewEnded). A + * snapshot written before this field existed has none and reads as not + * ended, so once after an upgrade a subject whose first view had + * already ended can give one delivery a spurious `replacesUnread`. */ + firstViewEnded?: true; /** `firstView` only on a batch that wasn't rendering: one that was may * have been shown. */ batch?: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number; rendering?: boolean; firstView?: true }; @@ -398,9 +407,11 @@ export class PushCoalescer { /** `committed`: the durability barrier succeeded after this receipt was * written. An uncommitted receipt deduplicates effects but is not an * acknowledgement until a commit succeeds. `firstView`: the admission it - * records delivered its occurrence as the agent's first view - * (`replacesUnread`), so a retry answered from it says so too. Kept in - * memory only: a receipt restored from a snapshot reads as no first view. */ + * records handed its occurrence to the host's delivery marked as the + * agent's first view (`replacesUnread`), whether or not the host placed + * it, so a retry answered from it says so too. Kept in memory only: a + * receipt restored from a snapshot or the crash-window bridge reads as no + * first view. */ private readonly receipts = new Map(); /** Set by a delivery that carries `replacesUnread`. admit() clears it before * an admission's effects and reads it after them; every delivery runs @@ -596,6 +607,24 @@ export class PushCoalescer { return false; } + /** + * An unread occupant leaves the subject: a notice or a plain occurrence + * displaces it, or a retraction withdraws it. Returns whether it was a + * first view that nothing may have shown (isUnshown), which a replacement + * carries on. Any other occupant may have been seen, and this is the last + * point where the host can say so, since the removal takes its placement: + * the subject's first view ends here, for a batch pending or rendering, + * whose first delivery would come after it, and for every later batch and + * birth (firstViewEnded). + */ + private occupantLeaves(state: SubjectState, occupant: NonNullable['occupant']>): boolean { + if (occupant.firstView === true && this.host.isUnshown(occupant.placement)) return true; + state.firstViewEnded = true; + if (state.batch) delete state.batch.firstView; + if (state.rendering) delete state.rendering.batch.firstView; + return false; + } + private scheduleRecovery(subject: string, state: SubjectState): void { const prior = this.recovery.get(subject); if (prior?.timer) clearTimeout(prior.timer); @@ -645,6 +674,7 @@ export class PushCoalescer { private async retract(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { if (occupantUnread && state.occupant) { + this.occupantLeaves(state, state.occupant); this.host.audit({ kind: 'removed', subject, eventId: state.occupant.eventId }); if (!this.host.remove(state.occupant.placement)) { // Still in context and possibly about to be read: say so, append the notice. @@ -679,10 +709,9 @@ export class PushCoalescer { if (occupantUnread && state.occupant) { const prior = state.occupant; // The replacement is the agent's first view only when its prior was - // one and nothing may have shown it to the agent (isUnshown). An - // occupant that isn't a first view ends the chain for every later - // replacement. - const firstView = prior.firstView === true && this.host.isUnshown(prior.placement); + // one and nothing may have shown it to the agent. Otherwise the + // subject's first view ends here (occupantLeaves). + const firstView = this.occupantLeaves(state, prior); this.host.audit({ kind: 'displaced', subject, eventId: prior.eventId }); if (this.host.remove(prior.placement)) { this.host.cancelWake(subject); @@ -703,9 +732,10 @@ export class PushCoalescer { } if (hadBatch) this.host.cancelWake(subject); const outcome: CoalesceOutcome = hadBatch ? 'replaced' : state.consumedEventId || state.history !== 'none' ? 'appended' : 'first'; - // The subject's `initial` birth is the agent's first view of it, and so - // is an occurrence that takes a first-view batch's place. - const firstView = hadBatch ? batchFirstView && state.history === 'none' : outcome === 'first'; + // The subject's `initial` birth is the agent's first view of it unless + // that view has ended, and so is an occurrence that takes a first-view + // batch's place (a batch's own first view ends with the subject's). + const firstView = hadBatch ? batchFirstView && state.history === 'none' : outcome === 'first' && !state.firstViewEnded; const placement = await this.deliver(hadBatch && firstView ? { ...occurrence, replacesUnread: true } : occurrence); state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; @@ -714,17 +744,16 @@ export class PushCoalescer { private async acceptDeferred(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { let outcome: CoalesceOutcome = 'first'; - // Whether the agent can still get its first view of the subject from a - // batch: nothing read, and a first-view occupant this notice displaces - // that nothing has shown. It decides a batch this notice opens, and a - // pending or rendering batch keeps its own first view only while it holds - // (below). A render still under way is weighed when a batch's first - // delivery is. - let firstView = state.history === 'none'; + // Whether a batch this notice opens is the agent's first view of the + // subject: nothing of it read, its first view not ended, and a first-view + // occupant this notice displaces that nothing has shown (occupantLeaves, + // which otherwise ends the first view for the batches already open too). + // A render still under way is weighed when a batch's first delivery is. + let firstView = state.history === 'none' && !state.firstViewEnded; if (occupantUnread && state.occupant) { // §5.1: a notice displaces an unread plain occurrence and opens a batch // — in the audience that held the plain occurrence (§3.2). - firstView = state.occupant.firstView === true && this.host.isUnshown(state.occupant.placement); + if (!this.occupantLeaves(state, state.occupant)) firstView = false; this.host.audit({ kind: 'displaced', subject, eventId: state.occupant.eventId }); if (this.host.remove(state.occupant.placement)) { outcome = 'replaced'; @@ -736,15 +765,6 @@ export class PushCoalescer { // Later notices stay in the batch's audience. occurrence = { ...occurrence, deliverTo: state.batch.latest.deliverTo }; } - // A batch opened as the agent's first view stays one only while nothing - // may have shown the agent a version of the subject. A notice that - // displaced an occupant something may have shown, or that finds the - // subject read, ends that for the batch it joins and for one already - // rendering, whose render settles after this notice. - if (!firstView) { - if (state.batch) delete state.batch.firstView; - if (state.rendering) delete state.rendering.batch.firstView; - } const notice: DeferredNotice = { eventId: occurrence.eventId, timestamp: occurrence.timestamp, ...(occurrence.data !== undefined ? { data: structuredClone(occurrence.data) } : {}) }; if (state.batch) { state.batch.notices.push(notice); @@ -985,6 +1005,7 @@ export class PushCoalescer { consumedEventId: s.occupant?.eventId ?? s.consumedEventId, identity: s.identity, audienceAgent: s.audienceAgent ?? s.occupant?.placement.agent, + ...(s.firstViewEnded ? { firstViewEnded: true as const } : {}), touchedAt: now, }; if (s.batch) { @@ -1060,6 +1081,7 @@ export class PushCoalescer { subjects: [...this.subjects].map(([subject, s]) => ({ subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), + ...(s.firstViewEnded ? { firstViewEnded: true as const } : {}), ...(s.occupant ? { occupant: { eventId: s.occupant.eventId, placement: s.occupant.placement } } : {}), ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : s.batch.firstView ? { firstView: true as const } : {}) } } : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } diff --git a/src/mcpl/tags.ts b/src/mcpl/tags.ts index 156a41288..206ed7e33 100644 --- a/src/mcpl/tags.ts +++ b/src/mcpl/tags.ts @@ -24,8 +24,10 @@ const CORE_IMPLIES: Readonly> = { * of an existing message. These are markers, not turns — they must not clear * explicit-send suppression, count as a new conversational round, or * retarget the default publish channel. An edit can also be the first - * version of a message the agent sees; isConversationalInjection reads the - * host's mark for the case the coalescer knows (`replacesUnread`). Tags are + * version of a message the agent sees, and then counts as that message: + * isMarkerTagged reads the host's mark for the case the coalescer knows + * (`replacesUnread`), for isConversationalInjection and for the channel + * registry's publish channel and reply anchor. Tags are * still never authority (§16.6): this set only ever WITHHOLDS default * conversational treatment, it never grants anything. */ diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 2f2879e25..140363b8e 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -2311,13 +2311,15 @@ test("#157: a notice's render that lands while an earlier render of its subject assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'n2' }], 'only the first render is the first view'); }); -/** A coalescer whose every render lands in the deferred queue of 'other', an - * agent whose turn is alive, unless `stored` names the event, whose render is - * then stored and so materialized for the assembling turn. The renders of - * `held` wait for `release`. `state` answers the host's read and shown - * questions for every placement. (slimepriestess's probe for #168, with the - * held renders and the stored placement added.) */ -function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1'], stored = [] as string[] } = {}) { +/** A coalescer whose every delivery lands in the deferred queue of 'other', + * an agent whose turn is alive, unless `stored` names the event, whose + * delivery is then stored and so materialized for the assembling turn, or + * `unplaced` does, whose delivery is placed nowhere. The renders of `held` + * wait for `release`, and those of `blank` come back blank. `state` answers + * the host's read and shown questions for every placement. + * (slimepriestess's probe for #168, with the held renders and the other + * options added.) */ +function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1'], stored = [] as string[], unplaced = [] as string[], blank = [] as string[] } = {}) { const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; const releases = new Map void>(); const gates = new Map(held.map((id) => [id, new Promise((resolve) => { releases.set(id, resolve); })])); @@ -2327,6 +2329,7 @@ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1' isUnread: () => !state.read, isUnshown: () => !state.shown && !state.read, remove: () => true, deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); + if (unplaced.includes(occ.eventId)) return undefined; return stored.includes(occ.eventId) ? { agent: 'agent', messageId: `stored-${delivered.length}` } : { agent: 'other', deferredId: `deferred-${delivered.length}` }; @@ -2336,7 +2339,7 @@ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1' render: async (occ) => { started.get(occ.eventId)?.(); await gates.get(occ.eventId); - return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] }; + return { content: [{ type: 'text', text: blank.includes(occ.eventId) ? ' ' : `diff_${occ.eventId}` }] }; }, audit: () => {}, save: () => {}, }; @@ -2346,7 +2349,7 @@ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1' retract: false, deferred: false, initial: false, content: [{ type: 'text', text: eventId }], event: null, ...flags, }); return { - coalescer, delivered, occurrence, + coalescer, host, delivered, occurrence, release: (id: string) => releases.get(id)!(), renderStarted: (id: string) => renderStarted.get(id)!, }; @@ -2417,6 +2420,143 @@ test('#157: a plain edit that takes the place of a pending first-view batch afte assert.deepEqual(p.delivered.at(-1), { eventId: 'e1' }); }); +/** n1's render lands in 'other''s deferred queue as the agent's first view, + * and `shown` says whether the agent is then shown it. n2 displaces it, and + * n2's batch starts rendering for 'other', whose audience it took; n3 opens + * a new batch during that render. Returns the held assembly, wrapped, since + * an async function returning it would wait for it. */ +async function firstViewEndedDuringRender(p: ReturnType, state: { shown: boolean }, shown: boolean) { + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }], "n1's render is the agent's first view"); + state.shown = shown; + const n2 = await p.coalescer.accept(p.occurrence('n2', { deferred: true })); + assert.equal(n2.coalesce?.outcome, 'replaced', 'n2 displaced the queued render'); + const second = p.coalescer.assemble('other'); + await settles(p.renderStarted('n2'), "n2's render"); + const n3 = await p.coalescer.accept(p.occurrence('n3', { deferred: true })); + assert.equal(n3.coalesce?.outcome, 'first', 'n3 opened a new batch during the render'); + return { second }; +} + +for (const shown of [false, true]) test(`#157: a plain edit that takes the place of a batch opened during the render that followed a displaced first-view render ${shown ? 'the agent was shown is no first view' : 'nothing had shown is the first view'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: ['n2'] }); + const { second } = await firstViewEndedDuringRender(p, state, shown); + const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(e1.coalesce?.outcome, 'replaced', "e1 took the pending batch's place"); + p.release('n2'); + await second; + assert.equal(p.delivered.at(-1)?.eventId, 'e1'); + assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); +}); + +for (const shown of [false, true]) test(`#157: after a displaced first-view render ${shown ? 'the agent was shown, a batch opened while its successor rendered blank is no first view' : 'nothing had shown, a batch opened while its successor rendered blank is the first view'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: ['n2'], blank: ['n2'] }); + const { second } = await firstViewEndedDuringRender(p, state, shown); + p.release('n2'); + await second; + assert.equal(p.delivered.length, 1, "n2's blank render delivered nothing"); + await p.coalescer.assemble('agent'); + assert.equal(p.delivered.at(-1)?.eventId, 'n3'); + assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); +}); + +/** n1's render lands in 'other''s deferred queue as the agent's first view; + * `shown` says whether the agent is then shown it, and a retraction + * withdraws it. */ +async function firstViewRetracted(p: ReturnType, state: { shown: boolean }, shown: boolean) { + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }], "n1's render is the agent's first view"); + state.shown = shown; + const r1 = await p.coalescer.accept(p.occurrence('r1', { retract: true, content: [] })); + assert.equal(r1.coalesce?.outcome, 'retracted'); + // Later versions haven't been shown, whatever happened to n1. + state.shown = false; +} + +for (const shown of [false, true]) test(`#157: after a retraction withdraws a first-view render ${shown ? 'the agent was shown, the next batch is no first view' : 'nothing had shown, the next batch is the first view'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + await firstViewRetracted(p, state, shown); + await p.coalescer.accept(p.occurrence('n2', { deferred: true })); + await p.coalescer.assemble('agent'); + assert.equal(p.delivered.at(-1)?.eventId, 'n2'); + assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); +}); + +for (const shown of [false, true]) test(`#157: after a retraction withdraws a first-view render ${shown ? 'the agent was shown, a plain birth is no first view, nor its edit' : 'nothing had shown, a plain birth is the first view, and so is its edit'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + await firstViewRetracted(p, state, shown); + const p1 = await p.coalescer.accept(p.occurrence('p1', {})); + assert.equal(p1.coalesce?.outcome, 'first'); + const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(e1.coalesce?.outcome, 'replaced', 'e1 replaced the unread birth'); + assert.equal(p.delivered.at(-1)?.eventId, 'e1'); + assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); +}); + +for (const shown of [false, true]) test(`#157: a plain occurrence that displaces a first-view render ${shown ? 'the agent was shown and is placed nowhere leaves the next batch no first view' : 'nothing had shown and is placed nowhere leaves the next batch the first view'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [], unplaced: ['p1'] }); + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + await p.coalescer.assemble('agent'); + state.shown = shown; + const p1 = await p.coalescer.accept(p.occurrence('p1', { tags: ['chat:edited'] })); + assert.equal(p1.coalesce?.outcome, 'replaced'); + assert.equal(p.delivered.at(-1)?.eventId, 'p1'); + state.shown = false; + await p.coalescer.accept(p.occurrence('n2', { deferred: true })); + await p.coalescer.assemble('agent'); + assert.equal(p.delivered.at(-1)?.eventId, 'n2'); + assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); +}); + +test("#157: a snapshot keeps a subject's ended first view, so a restored coalescer's next batch is no first view", async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + await firstViewRetracted(p, state, true); + const snapshot = p.coalescer.snapshot(); + assert.equal((snapshot.subjects[0] as { firstViewEnded?: unknown }).firstViewEnded, true, 'the snapshot carries the end'); + const restored = new PushCoalescer(p.host); + restored.restore(snapshot); + await restored.accept(p.occurrence('n2', { deferred: true })); + await restored.assemble('agent'); + assert.deepEqual(p.delivered.at(-1), { eventId: 'n2' }); +}); + +test("#157: a receipt keeps an admission's first view in memory only, so a restored receipt has none", async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + await p.coalescer.assemble('agent'); + await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.deepEqual(p.delivered.at(-1), { eventId: 'e1', replacesUnread: true }); + assert.equal(p.coalescer.receiptFirstView('editor', 'binding', 'e1'), true, "the receipt records e1's first view"); + assert.equal(p.coalescer.receiptFirstView('editor', 'binding', 'n1'), false, "n1's admission delivered nothing"); + const snapshot = p.coalescer.snapshot(); + for (const [, entry] of snapshot.receipts) assert.deepEqual(Object.keys(entry).sort(), ['at', 'result'], "a snapshot's receipt is its declared shape"); + const restored = new PushCoalescer(p.host); + restored.restore(snapshot); + assert.equal(restored.receiptFirstView('editor', 'binding', 'e1'), false, 'a restored receipt has no first view'); +}); + +test('#157: a plain edit that arrives while a first-view batch renders takes its place as the first view', async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state); + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + const first = p.coalescer.assemble('agent'); + await settles(p.renderStarted('n1'), "n1's render"); + const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(e1.coalesce?.outcome, 'replaced', 'e1 cancelled the render and took its place'); + p.release('n1'); + await first; + assert.deepEqual(p.delivered, [{ eventId: 'e1', replacesUnread: true }]); +}); + for (const shown of [false, true]) test(`#157: a notice that displaces an unread first view ${shown ? 'that something may have shown the agent opens a batch whose render is no first view' : 'that nothing has shown the agent opens a batch whose render is the first view'}`, async () => { const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; const host: CoalescerHost = { @@ -2501,10 +2641,15 @@ test("#157: a channel edit that is the agent's first view moves context.incoming const message = { ...f.channel('a2', 'question_fixed', {}, 'other', 'ma'), tags: ['chat:edited'] }; const first = await f.send('channels/incoming', { messages: [message] }); assert.equal(first.result.results[0].accepted, false, 'the failed commit leaves the edit unacknowledged'); - assert(isMarked(storedWith(f, 'question_fixed')), "the edit was delivered as the agent's first view"); + const delivered = storedWith(f, 'question_fixed'); + assert(isMarked(delivered), "the edit was delivered as the agent's first view"); assert.equal(incomingAnchor(f)?.messageId, 'mb', 'an unacknowledged acceptance moves no anchor'); const retry = await f.send('channels/incoming', { messages: [message] }); - assert.equal(retry.result.results[0].coalesce.outcome, 'replaced', "the retry is answered from the first attempt's receipt"); + // A second admission would replace the first attempt's delivery of a2 + // itself, and say so. + assert.equal(retry.result.results[0].coalesce.outcome, 'replaced'); + assert.equal(retry.result.results[0].coalesce.priorEventId, 'a1', "the retry is answered from the first attempt's receipt"); + assert.equal(storedWith(f, 'question_fixed')?.id, delivered!.id, 'and delivers nothing again'); assert(!('firstView' in retry.result.results[0]), "the host's mark isn't in the answer to the server"); assert.equal(incomingAnchor(f)?.channelId, 'other', "the anchor moved to the edit's channel"); assert.equal(incomingAnchor(f)?.messageId, 'ma', "and to the edit's message"); From b8dc07acb1cf4d01dddcd5af80ebe968752c41a9 Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Sun, 18 Oct 2026 22:35:38 -0400 Subject: [PATCH 15/19] refactor(mcpl): name the coalescer's first-view mark `firstView` (R4-3) The mark the coalescer puts on a delivery that is the agent's first view of a message was named `replacesUnread`, for the narrower meaning it had when it was introduced. It now means "first view": a deferred notice's render that births its subject carries it although it replaces nothing. The key is written into stored message metadata and read back by isConversationalInjection, so after a release a rename would need a compatibility read. No release carries it yet, so it is renamed now: the metadata key, the occurrence and event fields, and the comments that name it. The tests that check whether an injection carried the mark now look for `"firstView":true` rather than the bare key. The framework's local spread that carries the mark is `firstViewMark`, beside `narrowing`. No behaviour changes. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 26 +++++++------- src/mcpl/channel-registry.ts | 6 ++-- src/mcpl/push-coalescer.ts | 20 +++++------ src/mcpl/push-handler.ts | 4 +-- src/mcpl/tags.ts | 9 ++--- test/mcpl-coalescing.test.ts | 66 ++++++++++++++++++------------------ 6 files changed, 66 insertions(+), 65 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index a9c571da3..1e81a288b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -292,7 +292,7 @@ const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send' * sees: when the coalescer delivers a version of a message of which no * version may have been shown to the agent (still queued, or stored above * every reader's shown watermark), the host marks the delivery - * (`replacesUnread`), and an edit so marked counts as the message it + * (`firstView`), and an edit so marked counts as the message it * replaced. A doubt counts as shown, so a message a scalar watermark covers * without having been shown gets no mark. A reaction or deletion so marked * is still a notice about that message and stays a marker. An edit of a @@ -304,7 +304,7 @@ const isConversationalInjection = (metadata?: MessageMetadata): boolean => { if (!metadata) return true; const m = metadata as Record; if (m.system === true) return false; - if (isMarkerTagged(m.tags, m.replacesUnread === true)) return false; + if (isMarkerTagged(m.tags, m.firstView === true)) return false; return true; }; @@ -587,8 +587,8 @@ interface CoalescedChannelEvent { /** RFC-006 §3.2: deliver into this agent only (replacement / notice audience). */ deliverTo?: string; /** The coalescer's mark: the agent's first view of the message (see - * CoalescedOccurrence.replacesUnread). */ - replacesUnread?: boolean; + * CoalescedOccurrence.firstView). */ + firstView?: boolean; /** Host acceptance time (epoch ms), stamped where the message was admitted. */ acceptedAt?: number; /** Framework-owned source envelope, frozen at a coalesced occurrence's @@ -11355,7 +11355,7 @@ export class AgentFramework { eventId?: string; assemblingFor?: string; deliverTo?: string; - replacesUnread?: boolean; + firstView?: boolean; acceptedAt?: number; inboundSource?: InboundSource; }): Promise { @@ -11382,8 +11382,8 @@ export class AgentFramework { if (event.threadId) metadata.threadId = event.threadId; if (event.tags) metadata.tags = event.tags; // The coalescer's mark is the host's alone: a server's own is dropped. - if (event.replacesUnread === true) metadata.replacesUnread = true; - else delete metadata.replacesUnread; + if (event.firstView === true) metadata.firstView = true; + else delete metadata.firstView; // 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 @@ -12621,7 +12621,7 @@ export class AgentFramework { ): Promise { const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; - const firstView = occ.replacesUnread ? { replacesUnread: true } : {}; + const firstViewMark = occ.firstView ? { firstView: true } : {}; // 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 } => @@ -12629,7 +12629,7 @@ export class AgentFramework { ? { inboundSource: materialized && source.kind !== 'surface' ? { ...source, materialized: true } : source } : {}; if (occ.event.lane === 'channel') { - const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...firstView, ...(assemblingFor ? { assemblingFor } : {}) }; + const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...firstViewMark, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); return placement; @@ -12659,7 +12659,7 @@ export class AgentFramework { coalescingSubject, eventId: occ.eventId, ...narrowing, - ...firstView, + ...firstViewMark, ...(assemblingFor ? { assemblingFor } : {}), ...frozenSource(push.inboundSource), }; @@ -12677,7 +12677,7 @@ export class AgentFramework { // assembled; the wake that started it is not repeated. ...(assemblingFor ? { assemblingFor, triggerInference: false } : {}), ...(occ.deliverTo ? { targetAgents: [occ.deliverTo] } : {}), - ...firstView, + ...firstViewMark, ...frozenSource(occ.event.event.inboundSource), }; const placement = this.handleMcplPushEvent(event); @@ -12861,8 +12861,8 @@ export class AgentFramework { ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }; // The coalescer's mark is the host's alone: a server's own is dropped. - if (event.replacesUnread === true) metadata.replacesUnread = true; - else delete metadata.replacesUnread; + if (event.firstView === true) metadata.firstView = true; + else delete metadata.firstView; // `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 diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index c1b9c6309..328b0dbc3 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -578,9 +578,9 @@ interface ChannelRegistryOptions { * the event the ordinary path would have queued. The handler decides * replace / append / withdraw and returns the per-message result. Throws a * `CoalesceError` for a malformed or unauthorized occurrence. `firstView` - * says the coalescer delivered the message as the agent's first view of it - * (`replacesUnread`); it is the host's own, and isn't in the answer to the - * server. + * says the coalescer delivered the message as the agent's first view of it, + * with the delivery's own `firstView` mark; it is the host's own, and isn't + * in the answer to the server. */ handleCoalescedIncoming?: ( serverId: string, diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 5bd18815d..8845445e4 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -102,7 +102,7 @@ export interface CoalescedOccurrence { * the agent may never have been shown (CoalescerHost.isUnshown), or the * first delivery of a batch that opened on a first view (its render, or * a plain occurrence that drops it). No other path sets it. */ - replacesUnread?: boolean; + firstView?: boolean; } export interface DeferredNotice { eventId: string; timestamp: string; data?: unknown } @@ -163,7 +163,7 @@ export interface AssemblyResult { interface SubjectState { history: 'none' | 'some' | 'unknown'; /** `firstView`: this occupant is the agent's first view of the subject, - * as `replacesUnread` defines it, so nothing of the subject has been read: + * as `firstView` defines it, so nothing of the subject has been read: * a read takes the occupant away. Unknown counts as not one. In memory * only: a snapshot's occupant comes back as read. */ occupant?: { eventId: string; placement: CoalescingPlacement; firstView?: true }; @@ -196,7 +196,7 @@ export interface CoalescingSnapshot { /** The subject's first view has ended (SubjectState.firstViewEnded). A * snapshot written before this field existed has none and reads as not * ended, so once after an upgrade a subject whose first view had - * already ended can give one delivery a spurious `replacesUnread`. */ + * already ended can give one delivery a spurious `firstView`. */ firstViewEnded?: true; /** `firstView` only on a batch that wasn't rendering: one that was may * have been shown. */ @@ -408,12 +408,12 @@ export class PushCoalescer { * written. An uncommitted receipt deduplicates effects but is not an * acknowledgement until a commit succeeds. `firstView`: the admission it * records handed its occurrence to the host's delivery marked as the - * agent's first view (`replacesUnread`), whether or not the host placed + * agent's first view (`firstView`), whether or not the host placed * it, so a retry answered from it says so too. Kept in memory only: a * receipt restored from a snapshot or the crash-window bridge reads as no * first view. */ private readonly receipts = new Map(); - /** Set by a delivery that carries `replacesUnread`. admit() clears it before + /** Set by a delivery that carries `firstView`. admit() clears it before * an admission's effects and reads it after them; every delivery runs * under the lock, so between the two it is that admission's alone. */ private deliveredFirstView = false; @@ -472,10 +472,10 @@ export class PushCoalescer { return (this.rendersInFlight.get(serverId) ?? 0) > 0; } - /** The host's delivery, noting one that carries `replacesUnread` for the + /** The host's delivery, noting one that carries `firstView` for the * receipt of the admission it belongs to (deliveredFirstView). */ private deliver(...args: Parameters['deliver']>): ReturnType['deliver']> { - if (args[0].replacesUnread) this.deliveredFirstView = true; + if (args[0].firstView) this.deliveredFirstView = true; return this.host.deliver(...args); } @@ -718,7 +718,7 @@ export class PushCoalescer { // The replacement follows the prior delivery's audience (§3.2). const placement = await this.deliver({ ...occurrence, deliverTo: prior.placement.agent, - ...(firstView ? { replacesUnread: true } : {}), + ...(firstView ? { firstView: true } : {}), }); state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; @@ -736,7 +736,7 @@ export class PushCoalescer { // that view has ended, and so is an occurrence that takes a first-view // batch's place (a batch's own first view ends with the subject's). const firstView = hadBatch ? batchFirstView && state.history === 'none' : outcome === 'first' && !state.firstViewEnded; - const placement = await this.deliver(hadBatch && firstView ? { ...occurrence, replacesUnread: true } : occurrence); + const placement = await this.deliver(hadBatch && firstView ? { ...occurrence, firstView: true } : occurrence); state.occupant = placement ? { eventId: occurrence.eventId, placement, ...(firstView ? { firstView: true as const } : {}) } : undefined; if (placement) state.audienceAgent = placement.agent; return outcome; @@ -961,7 +961,7 @@ export class PushCoalescer { // The batch's first delivery is the agent's first view while nothing // of the subject has been read and no other version of it is placed. const firstView = batch.firstView === true && state.history === 'none' && !state.occupant; - const placement = await this.deliver({ ...occurrence, timestamp, content, ...(firstView ? { replacesUnread: true } : {}) }, content, assemblingFor); + const placement = await this.deliver({ ...occurrence, timestamp, content, ...(firstView ? { firstView: true } : {}) }, content, assemblingFor); if (placement?.deferredId) { // Landed in another agent's deferred queue (its turn is alive): still // unread there, so it stays replaceable and withdrawable. The turn diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 04f053774..12ea00a81 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -51,8 +51,8 @@ export interface McplPushEvent { /** RFC-006 assembly: materialized for this agent's turn (store directly). */ assemblingFor?: string; /** The coalescer's mark: the agent's first view of the subject (see - * CoalescedOccurrence.replacesUnread). */ - replacesUnread?: boolean; + * CoalescedOccurrence.firstView). */ + firstView?: boolean; /** Host acceptance time (epoch ms), stamped where the push is admitted. */ acceptedAt?: number; /** Framework-owned source envelope, frozen at a coalesced occurrence's diff --git a/src/mcpl/tags.ts b/src/mcpl/tags.ts index 206ed7e33..579201003 100644 --- a/src/mcpl/tags.ts +++ b/src/mcpl/tags.ts @@ -26,7 +26,7 @@ const CORE_IMPLIES: Readonly> = { * retarget the default publish channel. An edit can also be the first * version of a message the agent sees, and then counts as that message: * isMarkerTagged reads the host's mark for the case the coalescer knows - * (`replacesUnread`), for isConversationalInjection and for the channel + * (`firstView`), for isConversationalInjection and for the channel * registry's publish channel and reply anchor. Tags are * still never authority (§16.6): this set only ever WITHHOLDS default * conversational treatment, it never grants anything. @@ -45,9 +45,10 @@ export function hasNonConversationalTag(tags: unknown): boolean { /** * True when a message is a marker by its tags. `firstView` is the host's own - * mark that the coalescer delivered the message as the agent's first view of - * it (`replacesUnread`), never a server's claim: an edit so delivered counts - * as the message it replaced, while a reaction or deletion stays a notice. + * mark (the message metadata's `firstView`) that the coalescer delivered the + * message as the agent's first view of it, never a server's claim: an edit so + * delivered counts as the message it replaced, while a reaction or deletion + * stays a notice. */ export function isMarkerTagged(tags: unknown, firstView: boolean): boolean { return hasNonConversationalTag(firstView && Array.isArray(tags) ? tags.filter((t) => t !== 'chat:edited') : tags); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 140363b8e..9dfd44242 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -1899,7 +1899,7 @@ test('#157: an edit of an edit of a follow-up the agent was shown at a tool boun await pushTagged(f, ['chat:edited'], 'e3', 'follow_up_fixed_again'); }); assert(r.injected[0].includes('follow_up_wiht_typo'), 'the first boundary showed the agent the follow-up'); - assert(r.injected[1].includes('follow_up_fixed_again') && !r.injected[1].includes('replacesUnread'), 'the second boundary showed it the last edit, unmarked'); + assert(r.injected[1].includes('follow_up_fixed_again') && !r.injected[1].includes('"firstView":true'), 'the second boundary showed it the last edit, unmarked'); assert(r.sent, 'both explicit sends posted'); assert(!r.replied, 'the prose after the second send stayed withheld'); }); @@ -1913,7 +1913,7 @@ test('#157: two edits of a follow-up the agent hasn\'t been shown leave the last outcomes.push(await pushTagged(f, ['chat:edited'], 'e3', 'follow_up_fixed_again')); }); assert.deepEqual(outcomes, ['replaced', 'replaced']); - assert(r.injected[0].includes('follow_up_fixed_again') && r.injected[0].includes('replacesUnread'), 'the boundary showed the agent the last edit, marked'); + assert(r.injected[0].includes('follow_up_fixed_again') && r.injected[0].includes('"firstView":true'), 'the boundary showed the agent the last edit, marked'); assert(r.sent, 'the explicit send posted'); assert(r.replied, 'the reply to the follow-up posted'); }); @@ -1930,13 +1930,13 @@ test('#157: an edit of a follow-up whose producer never marked it initial stays const serverMarked: Record Promise> = { 'channel message': async (f) => (await f.send('channels/incoming', { messages: [{ - ...f.channel('e9', 'edit_claiming_first_view', {}, 'chat', 'm9'), tags: ['chat:edited'], metadata: { replacesUnread: true }, + ...f.channel('e9', 'edit_claiming_first_view', {}, 'chat', 'm9'), tags: ['chat:edited'], metadata: { firstView: true }, }] })).result.results[0].coalesce.outcome, 'channel-scoped push': async (f) => (await f.send('push/event', { - ...f.params('e9', 'edit_claiming_first_view', { channelId: 'chat', key: 'message:m9' }), tags: ['chat:edited'], origin: { replacesUnread: true }, + ...f.params('e9', 'edit_claiming_first_view', { channelId: 'chat', key: 'message:m9' }), tags: ['chat:edited'], origin: { firstView: true }, })).result.coalesce.outcome, 'feature-set push': async (f) => (await f.send('push/event', { - ...f.params('e9', 'edit_claiming_first_view', { key: 'message:m9' }), tags: ['chat:edited'], origin: { replacesUnread: true }, + ...f.params('e9', 'edit_claiming_first_view', { key: 'message:m9' }), tags: ['chat:edited'], origin: { firstView: true }, })).result.coalesce.outcome, }; @@ -1946,7 +1946,7 @@ for (const [lane, deliver] of Object.entries(serverMarked)) test(`#157 (${lane}) const r = await sendThenSpeak(f, async () => { outcome = await deliver(f); }); assert.equal(outcome, 'appended'); assert(r.injected[0].includes('edit_claiming_first_view'), 'the edit reached the boundary'); - assert(!r.injected[0].includes('replacesUnread'), "the server's own mark was dropped"); + assert(!r.injected[0].includes('"firstView":true'), "the server's own mark was dropped"); assert(r.sent, 'the explicit send posted'); assert(!r.replied, 'the prose after the send stayed withheld'); }); @@ -2022,7 +2022,7 @@ test('#157: an edit of a follow-up stored at once, which a turn then read, is ap let outcome: string | undefined; const r = await sendThenSpeak(f, async () => { outcome = await pushTagged(f, ['chat:edited']); }); assert.equal(outcome, 'appended', 'the edit came after the agent read the follow-up, so it replaced nothing'); - assert(r.injected[0].includes('follow_up_fixed') && !r.injected[0].includes('replacesUnread'), 'the edit reached the boundary unmarked'); + assert(r.injected[0].includes('follow_up_fixed') && !r.injected[0].includes('"firstView":true'), 'the edit reached the boundary unmarked'); assert(r.sent, 'the explicit send posted'); assert(!r.replied, 'the prose after the send stayed withheld'); }); @@ -2047,7 +2047,7 @@ test('#157: an edit admitted while a compile is assembling a message stored at o }; const r = await sendThenSpeak(f, async () => {}); assert(shown, 'the compile assembled the question'); - assert(r.injected[0].includes('question_edited') && !r.injected[0].includes('replacesUnread'), 'the edit reached the boundary unmarked'); + assert(r.injected[0].includes('question_edited') && !r.injected[0].includes('"firstView":true'), 'the edit reached the boundary unmarked'); assert(r.sent, 'the explicit send posted'); assert(!r.replied, 'the prose after the send stayed withheld'); }); @@ -2055,7 +2055,7 @@ test('#157: an edit admitted while a compile is assembling a message stored at o /** A message in an agent's store whose text includes `text`. */ const storedWith = (f: Fixture, text: string, agent = 'agent') => f.framework.getAgent(agent)!.getContextManager().getAllMessages().find((m) => JSON.stringify(m.content).includes(text)); -const isMarked = (message: { metadata?: unknown } | undefined) => (message?.metadata as { replacesUnread?: unknown } | undefined)?.replacesUnread === true; +const isMarked = (message: { metadata?: unknown } | undefined) => (message?.metadata as { firstView?: unknown } | undefined)?.firstView === true; test("#157: a compile of an agent with a store of its own leaves another agent's message stored at once unseen, so its edit is still marked", async (t) => { const f = await fixture({ server: { shouldTriggerInference: () => false } }); t.after(f.close); await f.register(); @@ -2261,11 +2261,11 @@ test("#157: the render of a deferred notice that births its subject is the agent }); test("#157: a render that lands unread in another agent's deferred queue keeps its first view, so an edit that replaces it is marked", async () => { - const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const delivered: Array<{ eventId: string; firstView?: true }> = []; const host: CoalescerHost = { isUnread: () => true, isUnshown: () => true, remove: () => true, // Every delivery lands in the deferred queue of an agent whose turn is alive. - deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, + deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, audience: async () => ['agent', 'other'], render: async () => ({ content: [{ type: 'text', text: 'document_diff' }] }), @@ -2280,17 +2280,17 @@ test("#157: a render that lands unread in another agent's deferred queue keeps i await coalescer.assemble('agent'); const edit = await coalescer.accept(occurrence('e1', { tags: ['chat:edited'] })); assert.equal(edit.coalesce?.outcome, 'replaced', 'the edit replaced the unread render'); - assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'e1', replacesUnread: true }], 'the render and the edit that replaced it are each marked'); + assert.deepEqual(delivered, [{ eventId: 'n1', firstView: true }, { eventId: 'e1', firstView: true }], 'the render and the edit that replaced it are each marked'); }); test("#157: a notice's render that lands while an earlier render of its subject waits unread in a deferred queue is no first view", async () => { - const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const delivered: Array<{ eventId: string; firstView?: true }> = []; let releaseFirst!: () => void; const firstRender = new Promise((resolve) => { releaseFirst = resolve; }); const host: CoalescerHost = { isUnread: () => true, isUnshown: () => true, remove: () => true, // Every delivery lands in the deferred queue of an agent whose turn is alive. - deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, + deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, audience: async () => ['agent', 'other'], render: async (occ) => { if (occ.eventId === 'n1') await firstRender; return { content: [{ type: 'text', text: `diff_${occ.eventId}` }] }; }, @@ -2308,7 +2308,7 @@ test("#157: a notice's render that lands while an earlier render of its subject releaseFirst(); await first; await coalescer.assemble('agent'); - assert.deepEqual(delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'n2' }], 'only the first render is the first view'); + assert.deepEqual(delivered, [{ eventId: 'n1', firstView: true }, { eventId: 'n2' }], 'only the first render is the first view'); }); /** A coalescer whose every delivery lands in the deferred queue of 'other', @@ -2320,7 +2320,7 @@ test("#157: a notice's render that lands while an earlier render of its subject * (slimepriestess's probe for #168, with the held renders and the other * options added.) */ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1'], stored = [] as string[], unplaced = [] as string[], blank = [] as string[] } = {}) { - const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const delivered: Array<{ eventId: string; firstView?: true }> = []; const releases = new Map void>(); const gates = new Map(held.map((id) => [id, new Promise((resolve) => { releases.set(id, resolve); })])); const started = new Map void>(); @@ -2328,7 +2328,7 @@ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1' const host: CoalescerHost = { isUnread: () => !state.read, isUnshown: () => !state.shown && !state.read, remove: () => true, deliver: async (occ) => { - delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); + delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); if (unplaced.includes(occ.eventId)) return undefined; return stored.includes(occ.eventId) ? { agent: 'agent', messageId: `stored-${delivered.length}` } @@ -2363,7 +2363,7 @@ async function firstViewQueuedWithBatchPending(p: ReturnType { @@ -2377,7 +2377,7 @@ for (const shown of [false, true]) test(`#157: a notice that displaces a queued assert.equal(n3.coalesce?.outcome, 'replaced', 'n3 displaced the queued render and joined the pending batch'); await p.coalescer.assemble('agent'); assert.equal(p.delivered[1]?.eventId, 'n3'); - assert.equal(p.delivered[1]?.replacesUnread === true, !shown, shown ? 'the agent was shown n1, so the batch\'s render is no first view' : 'nothing showed n1, so the batch\'s render is still the first view'); + assert.equal(p.delivered[1]?.firstView === true, !shown, shown ? 'the agent was shown n1, so the batch\'s render is no first view' : 'nothing showed n1, so the batch\'s render is still the first view'); }); for (const shown of [false, true]) test(`#157: a notice that displaces a queued first-view render ${shown ? 'the agent was shown leaves a batch already rendering no first view' : 'nothing has shown leaves a batch already rendering its first view'}`, async () => { @@ -2393,7 +2393,7 @@ for (const shown of [false, true]) test(`#157: a notice that displaces a queued p.release('n2'); await second; assert.equal(p.delivered[1]?.eventId, 'n2'); - assert.equal(p.delivered[1]?.replacesUnread === true, !shown, shown ? 'the agent was shown n1, so the rendering batch\'s render is no first view' : 'nothing showed n1, so the rendering batch\'s render is still the first view'); + assert.equal(p.delivered[1]?.firstView === true, !shown, shown ? 'the agent was shown n1, so the rendering batch\'s render is no first view' : 'nothing showed n1, so the rendering batch\'s render is still the first view'); }); test("#157: a batch opened during a render whose content the turn then read has a render that is no first view", async () => { @@ -2407,7 +2407,7 @@ test("#157: a batch opened during a render whose content the turn then read has p.release('n1'); await first; await p.coalescer.assemble('agent'); - assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }, { eventId: 'n2' }], "n2's batch opened before the agent read n1, but its render comes after"); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }, { eventId: 'n2' }], "n2's batch opened before the agent read n1, but its render comes after"); }); test('#157: a plain edit that takes the place of a pending first-view batch after the agent read an earlier render is no first view', async () => { @@ -2428,7 +2428,7 @@ test('#157: a plain edit that takes the place of a pending first-view batch afte async function firstViewEndedDuringRender(p: ReturnType, state: { shown: boolean }, shown: boolean) { await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); await p.coalescer.assemble('agent'); - assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }], "n1's render is the agent's first view"); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }], "n1's render is the agent's first view"); state.shown = shown; const n2 = await p.coalescer.accept(p.occurrence('n2', { deferred: true })); assert.equal(n2.coalesce?.outcome, 'replaced', 'n2 displaced the queued render'); @@ -2448,7 +2448,7 @@ for (const shown of [false, true]) test(`#157: a plain edit that takes the place p.release('n2'); await second; assert.equal(p.delivered.at(-1)?.eventId, 'e1'); - assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); + assert.equal(p.delivered.at(-1)?.firstView === true, !shown); }); for (const shown of [false, true]) test(`#157: after a displaced first-view render ${shown ? 'the agent was shown, a batch opened while its successor rendered blank is no first view' : 'nothing had shown, a batch opened while its successor rendered blank is the first view'}`, async () => { @@ -2460,7 +2460,7 @@ for (const shown of [false, true]) test(`#157: after a displaced first-view rend assert.equal(p.delivered.length, 1, "n2's blank render delivered nothing"); await p.coalescer.assemble('agent'); assert.equal(p.delivered.at(-1)?.eventId, 'n3'); - assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); + assert.equal(p.delivered.at(-1)?.firstView === true, !shown); }); /** n1's render lands in 'other''s deferred queue as the agent's first view; @@ -2469,7 +2469,7 @@ for (const shown of [false, true]) test(`#157: after a displaced first-view rend async function firstViewRetracted(p: ReturnType, state: { shown: boolean }, shown: boolean) { await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); await p.coalescer.assemble('agent'); - assert.deepEqual(p.delivered, [{ eventId: 'n1', replacesUnread: true }], "n1's render is the agent's first view"); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }], "n1's render is the agent's first view"); state.shown = shown; const r1 = await p.coalescer.accept(p.occurrence('r1', { retract: true, content: [] })); assert.equal(r1.coalesce?.outcome, 'retracted'); @@ -2484,7 +2484,7 @@ for (const shown of [false, true]) test(`#157: after a retraction withdraws a fi await p.coalescer.accept(p.occurrence('n2', { deferred: true })); await p.coalescer.assemble('agent'); assert.equal(p.delivered.at(-1)?.eventId, 'n2'); - assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); + assert.equal(p.delivered.at(-1)?.firstView === true, !shown); }); for (const shown of [false, true]) test(`#157: after a retraction withdraws a first-view render ${shown ? 'the agent was shown, a plain birth is no first view, nor its edit' : 'nothing had shown, a plain birth is the first view, and so is its edit'}`, async () => { @@ -2496,7 +2496,7 @@ for (const shown of [false, true]) test(`#157: after a retraction withdraws a fi const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); assert.equal(e1.coalesce?.outcome, 'replaced', 'e1 replaced the unread birth'); assert.equal(p.delivered.at(-1)?.eventId, 'e1'); - assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); + assert.equal(p.delivered.at(-1)?.firstView === true, !shown); }); for (const shown of [false, true]) test(`#157: a plain occurrence that displaces a first-view render ${shown ? 'the agent was shown and is placed nowhere leaves the next batch no first view' : 'nothing had shown and is placed nowhere leaves the next batch the first view'}`, async () => { @@ -2512,7 +2512,7 @@ for (const shown of [false, true]) test(`#157: a plain occurrence that displaces await p.coalescer.accept(p.occurrence('n2', { deferred: true })); await p.coalescer.assemble('agent'); assert.equal(p.delivered.at(-1)?.eventId, 'n2'); - assert.equal(p.delivered.at(-1)?.replacesUnread === true, !shown); + assert.equal(p.delivered.at(-1)?.firstView === true, !shown); }); test("#157: a snapshot keeps a subject's ended first view, so a restored coalescer's next batch is no first view", async () => { @@ -2534,7 +2534,7 @@ test("#157: a receipt keeps an admission's first view in memory only, so a resto await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); await p.coalescer.assemble('agent'); await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); - assert.deepEqual(p.delivered.at(-1), { eventId: 'e1', replacesUnread: true }); + assert.deepEqual(p.delivered.at(-1), { eventId: 'e1', firstView: true }); assert.equal(p.coalescer.receiptFirstView('editor', 'binding', 'e1'), true, "the receipt records e1's first view"); assert.equal(p.coalescer.receiptFirstView('editor', 'binding', 'n1'), false, "n1's admission delivered nothing"); const snapshot = p.coalescer.snapshot(); @@ -2554,14 +2554,14 @@ test('#157: a plain edit that arrives while a first-view batch renders takes its assert.equal(e1.coalesce?.outcome, 'replaced', 'e1 cancelled the render and took its place'); p.release('n1'); await first; - assert.deepEqual(p.delivered, [{ eventId: 'e1', replacesUnread: true }]); + assert.deepEqual(p.delivered, [{ eventId: 'e1', firstView: true }]); }); for (const shown of [false, true]) test(`#157: a notice that displaces an unread first view ${shown ? 'that something may have shown the agent opens a batch whose render is no first view' : 'that nothing has shown the agent opens a batch whose render is the first view'}`, async () => { - const delivered: Array<{ eventId: string; replacesUnread?: true }> = []; + const delivered: Array<{ eventId: string; firstView?: true }> = []; const host: CoalescerHost = { isUnread: () => true, isUnshown: () => !shown, remove: () => true, - deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.replacesUnread ? { replacesUnread: true as const } : {}) }); return { agent: 'agent', messageId: `stored-${delivered.length}` }; }, + deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); return { agent: 'agent', messageId: `stored-${delivered.length}` }; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, audience: async () => ['agent'], render: async () => ({ content: [{ type: 'text', text: 'document_diff' }] }), @@ -2576,7 +2576,7 @@ for (const shown of [false, true]) test(`#157: a notice that displaces an unread const notice = await coalescer.accept(occurrence('n1', { deferred: true, tags: ['chat:edited'] })); assert.equal(notice.coalesce?.outcome, 'replaced', 'the notice displaced the unread birth'); await coalescer.assemble('agent'); - assert.deepEqual(delivered, [{ eventId: 'p1' }, { eventId: 'n1', ...(shown ? {} : { replacesUnread: true as const }) }]); + assert.deepEqual(delivered, [{ eventId: 'p1' }, { eventId: 'n1', ...(shown ? {} : { firstView: true as const }) }]); }); test("#157: a plain edit that takes the place of a pending notice that birthed its subject is the agent's first view, and so is an edit of that edit", async (t) => { From fb3389dee7899fe6a83fcf3a11ce933c751b5bce Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Sun, 18 Oct 2026 22:35:54 -0400 Subject: [PATCH 16/19] docs(mcpl): say what the default publish channel, a failed compile's give-back and an old snapshot do (R4-1, R4-2, R4-4) R4-1. channel_publish's description, and its channelId's, said an omitted channelId goes to the channel of "the most recent incoming message that isn't a reaction, reaction removal, edit or deletion". markAccepted is that default's only writer. It runs for every accepted `channels/incoming` message, on any channel, at acceptance (for a coalesced one, once the coalescer admits it), and the default is process-global, so a message the agent hasn't been shown yet, or one accepted for another agent, moves it. A push never reaches markAccepted, so a message that arrives by push never sets it. And an edit the coalescer delivered as the agent's first view does move it. Both strings now say so: the most recent incoming message this host received on any channel, whether or not you've been shown it, with reactions, reaction removals, deletions, and edits of a message you'd already seen aside; a message pushed to you rather than posted on a channel never sets it. The changelog says the same. R4-2. unshowFailedCompile gives a committing compile's move of the shown watermark back only while the watermark is still that compile's own claim, and gives it back to the watermark the compile found. Of two overlapping committing compiles, if the first to start throws first, it gives nothing back, and if the second then throws, it restores the first's failed claim, so messages up to that claim count as shown although no request held them. That errs toward a marker, the stated direction for a doubt, until the next compile or boundary moves the watermark. The comment now says so, and the shown watermark's doc says a compile gives its move back where it can. R4-4. A test comment in present-while-acting called the markers "ABOUT a message the agent has seen"; they are about an existing message. The changelog said a message counts as shown once "a compile of the agent's" has begun to assemble it; for residents sharing a message slot, a compile or tool boundary of any agent that can read it counts. The snapshot's `firstViewEnded` doc said that after an upgrade an old snapshot "can give one delivery a spurious" mark. A subject whose first view had ended without a read is granted a first view again, and until the agent is shown or reads a version of it, each delivery that would mark a first view (a render, or a plain occurrence that replaces an unread version or a pending notice) carries a spurious `firstView`. The doc now says that. Co-Authored-By: Claude Opus 5.5 --- changelog.d/nonconversational-markers.fixed.md | 2 +- src/agent.ts | 14 +++++++++++--- src/mcpl/channel-registry.ts | 4 ++-- src/mcpl/push-coalescer.ts | 7 +++++-- test/present-while-acting.test.ts | 4 ++-- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index b5c38b5a7..f2f525224 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of the agent's has begun to assemble it or a tool boundary may have injected it, and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of any agent that can read it has begun to assemble it or a tool boundary of one may have injected it (residents sharing a message slot can all read it), and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one, and otherwise to the channel of the most recent incoming message the host received on any channel, whether or not the agent has been shown it, which a message pushed to the agent never sets. diff --git a/src/agent.ts b/src/agent.ts index 2e09519b7..213518377 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -939,8 +939,9 @@ export class Agent { * awaits, and at every tool boundary that injects, whether or not the * stream says it carries injected messages. So nothing above it can have * been shown. A compile that throws assembled nothing, and gives its move - * back. A scalar counts a prefix, so a message stored below it without - * having been shown counts as shown, which errs toward a marker. + * back where it can (unshowFailedCompile). A scalar counts a prefix, so a + * message stored below it without having been shown counts as shown, which + * errs toward a marker. */ private shownWatermark: { branch: string; sequence: number } | null = null; @@ -973,7 +974,14 @@ export class Agent { /** A committing compile that threw assembled nothing, so its own move of * the shown watermark goes back, unless a boundary or another compile has - * moved the watermark since. */ + * moved the watermark since. It goes back to the watermark as the compile + * found it, which can be another compile's claim that has failed as well. + * Of two overlapping committing compiles, if the first to start throws + * first, it gives nothing back (the second's claim is current), and if + * the second then throws, it restores the first's failed claim. Messages + * up to that claim then count as shown, although no request held those + * above the watermark the first compile found, which errs toward a marker + * until the next compile or boundary moves the watermark. */ private unshowFailedCompile( watermark: { branch: string; sequence: number } | null, before: { branch: string; sequence: number } | null, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 328b0dbc3..580e19fcd 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -370,11 +370,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion.", + description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it (reactions, reaction removals, deletions, and edits of a message you'd already seen aside). A message pushed to you rather than posted on a channel never sets it.", inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message that isn't a reaction, reaction removal, edit or deletion)" }, + channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, reactions, reaction removals, deletions, and edits of a message you'd already seen aside; a message pushed to you rather than posted on a channel never sets it)" }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 8845445e4..8ce986179 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -195,8 +195,11 @@ export interface CoalescingSnapshot { audienceAgent?: string; /** The subject's first view has ended (SubjectState.firstViewEnded). A * snapshot written before this field existed has none and reads as not - * ended, so once after an upgrade a subject whose first view had - * already ended can give one delivery a spurious `firstView`. */ + * ended. So after an upgrade, a subject whose first view had ended + * without a read is granted a first view again. Until the agent is shown + * or reads a version of it, each delivery that would mark a first view (a + * render, or a plain occurrence that replaces an unread version or a + * pending notice) carries a spurious `firstView`. */ firstViewEnded?: true; /** `firstView` only on a batch that wasn't rendering: one that was may * have been shown. */ diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index dc4c8def3..cf01a33dd 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1871,8 +1871,8 @@ describe('present while acting', () => { it(`a ${tag} marker injected mid-turn does not clear send suppression (#157)`, async () => { // Edits, deletions and reaction removals (RFC-001 core tags, delivered // as synthetic incoming messages by e.g. zulip_mcp#23) are markers - // ABOUT a message the agent has seen — the same category as a - // reaction. Before #157 only `chat:reaction` was exempt, so an + // ABOUT an existing message — the same category as a reaction. + // Before #157 only `chat:reaction` was exempt, so an // "[edited] Ann: typo fix" arriving right after an explicit send // re-opened auto-routing and double-posted the same-round prose. membrane.pushResponse(createMockResponse([ From dcd047805006340408238d5701ae736a17a50418 Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Sun, 18 Oct 2026 23:27:33 -0400 Subject: [PATCH 17/19] fix(mcpl): keep a first view's end through a crash; say where channel_publish's default goes on each path From our internal review of the round. A crash before the throttled snapshot forgot an ended first view. The receipt record written at each acceptance, which bridges the snapshot's throttle, carried the subject's birth and its batch but not `firstViewEnded`, so a coalescer restored from those records alone gave the subject's next batch a first view although the agent may have been shown an earlier version: the direction a doubt must not take. Each record now carries the end when the subject's first view has ended, and restoreReceipts applies it. A record written before this reads as not ended, as a snapshot does. The stand-in hosts of the first-view tests now treat a removed placement as the framework does: no longer unread, and nothing in it unshown. occupantLeaves has to ask whether its occupant may have been shown before the removal takes the placement, and with the old stand-ins nothing pinned that order for a retraction or a notice's displacement. channel_publish's two descriptions said that only edits of a message the agent had already seen are set aside. An edit moves the default only when the coalescer delivers it as the agent's first view at its acceptance: on a server that doesn't coalesce, markAccepted runs with no first view, and a deferred edit's later first-view render never reaches it. The descriptions now say that reactions, reaction removals, deletions and edits don't set the default, though an edit of a message the agent hadn't been shown can count as that message, and the channelId description no longer runs its exclusions into the "whether or not" clause. The changelog says the same, and that a first-view edit moves the default for a channel message the coalescer delivers when it accepts it. Docs: isMarkerTagged's first-view mark comes from a stored message's metadata or, in the channel registry, from the admission's receipt; and a subject's ended first view holds while the coalescer keeps its record, since an idle subject's record can be evicted and an `initial` occurrence after that starts the subject afresh. Co-Authored-By: Claude Opus 5.5 --- .../nonconversational-markers.fixed.md | 2 +- src/mcpl/channel-registry.ts | 4 +- src/mcpl/push-coalescer.ts | 13 +++++- src/mcpl/tags.ts | 8 ++-- test/mcpl-coalescing.test.ts | 43 ++++++++++++++++--- 5 files changed, 56 insertions(+), 14 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index f2f525224..774811d1c 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of any agent that can read it has begun to assemble it or a tool boundary of one may have injected it (residents sharing a message slot can all read it), and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one, and otherwise to the channel of the most recent incoming message the host received on any channel, whether or not the agent has been shown it, which a message pushed to the agent never sets. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message the coalescer delivers when it accepts it, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of any agent that can read it has begun to assemble it or a tool boundary of one may have injected it (residents sharing a message slot can all read it), and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one, and otherwise to the channel of the most recent incoming message the host received on any channel, whether or not the agent has been shown it, apart from markers, and that a message pushed to the agent never sets it. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 580e19fcd..8b89d04ca 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -370,11 +370,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it (reactions, reaction removals, deletions, and edits of a message you'd already seen aside). A message pushed to you rather than posted on a channel never sets it.", + description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it.", inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, reactions, reaction removals, deletions, and edits of a message you'd already seen aside; a message pushed to you rather than posted on a channel never sets it)" }, + channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it)" }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 8ce986179..f612e113b 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -176,7 +176,9 @@ interface SubjectState { /** The agent's first view of the subject has ended: an occupant that may * have been shown left it without handing a first view on * (occupantLeaves). No later batch or birth of the subject is a first - * view. */ + * view while the coalescer keeps this record. An idle subject's record can + * be evicted (prune), and an `initial` occurrence after that starts the + * subject afresh. */ firstViewEnded?: true; touchedAt: number; } @@ -214,6 +216,11 @@ export interface CoalescingReceiptRecord { subject: string; /** History the subject was created with, for a subject first seen by this receipt. */ born?: SubjectState['history']; + /** The subject's first view had ended by this acceptance + * (SubjectState.firstViewEnded), so a crash before the snapshot flush + * can't forget the end. A record written before this field existed has + * none and reads as not ended, as a snapshot does. */ + firstViewEnded?: true; /** The subject's pending batch AFTER this acceptance: the accepted work * itself (so a crash before the snapshot flush cannot lose it while keeping * the receipt that suppresses its retry), or `null` when the acceptance @@ -567,6 +574,7 @@ export class PushCoalescer { try { this.host.recordReceipt?.({ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}), + ...(state.firstViewEnded ? { firstViewEnded: true as const } : {}), batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence, notices: state.batch.notices, dropped: state.batch.dropped }) : null, }); } catch (error) { @@ -1051,6 +1059,9 @@ export class PushCoalescer { if (now - r.at > this.retryWindowMs) continue; this.receipts.set(r.key, { result: r.result, at: r.at, committed: true }); if (r.born && !this.subjects.has(r.subject)) this.subjects.set(r.subject, { history: r.born, touchedAt: now }); + // Nothing undoes a first view's end, so any record that carries it ends + // the subject's first view. + if (r.firstViewEnded) { const ended = this.subjects.get(r.subject); if (ended) ended.firstViewEnded = true; } // A birth fact is not proof the subject is still unread: the plain // occurrence this record acknowledged is in context (durable before the // record) and counts as read from recovery on, like a snapshot occupant. diff --git a/src/mcpl/tags.ts b/src/mcpl/tags.ts index 579201003..823a33c3c 100644 --- a/src/mcpl/tags.ts +++ b/src/mcpl/tags.ts @@ -45,10 +45,10 @@ export function hasNonConversationalTag(tags: unknown): boolean { /** * True when a message is a marker by its tags. `firstView` is the host's own - * mark (the message metadata's `firstView`) that the coalescer delivered the - * message as the agent's first view of it, never a server's claim: an edit so - * delivered counts as the message it replaced, while a reaction or deletion - * stays a notice. + * mark that the coalescer delivered the message as the agent's first view of + * it (a stored message's metadata `firstView`, or in the channel registry the + * admission's receipt), never a server's claim: an edit so delivered counts + * as the message it replaced, while a reaction or deletion stays a notice. */ export function isMarkerTagged(tags: unknown, firstView: boolean): boolean { return hasNonConversationalTag(firstView && Array.isArray(tags) ? tags.filter((t) => t !== 'chat:edited') : tags); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 9dfd44242..b52a81af3 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -16,7 +16,7 @@ import type { ContentBlock, NormalizedRequest, StreamEvent, YieldingStream } fro import { MembraneError } from '@animalabs/membrane'; import { createMockResponse, MockYieldingStream } from './helpers/mock-membrane.js'; import { fixture, eventually, settles, crash, ok, TS, REPLY_DEADLINE_MS } from './helpers/coalescing-fixture.js'; -import { PushCoalescer, type CoalescerHost, type CoalescedOccurrence } from '../src/mcpl/push-coalescer.js'; +import { PushCoalescer, type CoalescerHost, type CoalescedOccurrence, type CoalescingReceiptRecord } from '../src/mcpl/push-coalescer.js'; // --------------------------------------------------------------------------- // Plain coalescing, feature-set scope (vectors 1–6, 35) @@ -2260,10 +2260,23 @@ test("#157: the render of a deferred notice that births its subject is the agent assert(isMarked(stored), "the render is the agent's first view"); }); +/** What a stand-in host has removed. As in the framework, a removed + * placement is no longer unread, and nothing can still be unshown in it, so + * a first-view decision has to be taken before the removal. */ +function removals() { + const removed = new Set(); + const key = (p: { deferredId?: string; messageId?: string }) => p.deferredId ?? p.messageId ?? ''; + return { + gone: (p: { deferredId?: string; messageId?: string }) => removed.has(key(p)), + remove: (p: { deferredId?: string; messageId?: string }) => { removed.add(key(p)); return true; }, + }; +} + test("#157: a render that lands unread in another agent's deferred queue keeps its first view, so an edit that replaces it is marked", async () => { const delivered: Array<{ eventId: string; firstView?: true }> = []; + const r = removals(); const host: CoalescerHost = { - isUnread: () => true, isUnshown: () => true, remove: () => true, + isUnread: (p) => !r.gone(p), isUnshown: (p) => !r.gone(p), remove: r.remove, // Every delivery lands in the deferred queue of an agent whose turn is alive. deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, @@ -2287,8 +2300,9 @@ test("#157: a notice's render that lands while an earlier render of its subject const delivered: Array<{ eventId: string; firstView?: true }> = []; let releaseFirst!: () => void; const firstRender = new Promise((resolve) => { releaseFirst = resolve; }); + const r = removals(); const host: CoalescerHost = { - isUnread: () => true, isUnshown: () => true, remove: () => true, + isUnread: (p) => !r.gone(p), isUnshown: (p) => !r.gone(p), remove: r.remove, // Every delivery lands in the deferred queue of an agent whose turn is alive. deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); return { agent: 'other', deferredId: `deferred-${delivered.length}` }; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, @@ -2316,7 +2330,8 @@ test("#157: a notice's render that lands while an earlier render of its subject * delivery is then stored and so materialized for the assembling turn, or * `unplaced` does, whose delivery is placed nowhere. The renders of `held` * wait for `release`, and those of `blank` come back blank. `state` answers - * the host's read and shown questions for every placement. + * the host's read and shown questions for every placement it hasn't removed + * (removals). * (slimepriestess's probe for #168, with the held renders and the other * options added.) */ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1'], stored = [] as string[], unplaced = [] as string[], blank = [] as string[] } = {}) { @@ -2325,8 +2340,9 @@ function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1' const gates = new Map(held.map((id) => [id, new Promise((resolve) => { releases.set(id, resolve); })])); const started = new Map void>(); const renderStarted = new Map(held.map((id) => [id, new Promise((resolve) => { started.set(id, resolve); })])); + const r = removals(); const host: CoalescerHost = { - isUnread: () => !state.read, isUnshown: () => !state.shown && !state.read, remove: () => true, + isUnread: (p) => !r.gone(p) && !state.read, isUnshown: (p) => !r.gone(p) && !state.shown && !state.read, remove: r.remove, deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); if (unplaced.includes(occ.eventId)) return undefined; @@ -2528,6 +2544,20 @@ test("#157: a snapshot keeps a subject's ended first view, so a restored coalesc assert.deepEqual(p.delivered.at(-1), { eventId: 'n2' }); }); +for (const shown of [false, true]) test(`#157: the crash-window receipt records keep a subject's ended first view, so a coalescer restored from them alone ${shown ? 'gives the next batch no first view' : 'still gives the next batch its first view when nothing had shown the agent the last'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + const records: CoalescingReceiptRecord[] = []; + p.host.recordReceipt = (record) => { records.push(structuredClone(record)); }; + await firstViewRetracted(p, state, shown); + // A crash before the throttled snapshot: only the records written at each acceptance survive. + const restored = new PushCoalescer(p.host); + restored.restoreReceipts(records); + await restored.accept(p.occurrence('n2', { deferred: true })); + await restored.assemble('agent'); + assert.deepEqual(p.delivered.at(-1), { eventId: 'n2', ...(shown ? {} : { firstView: true as const }) }); +}); + test("#157: a receipt keeps an admission's first view in memory only, so a restored receipt has none", async () => { const state = { shown: false, read: false }; const p = firstViewProbe(state, { held: [] }); @@ -2559,8 +2589,9 @@ test('#157: a plain edit that arrives while a first-view batch renders takes its for (const shown of [false, true]) test(`#157: a notice that displaces an unread first view ${shown ? 'that something may have shown the agent opens a batch whose render is no first view' : 'that nothing has shown the agent opens a batch whose render is the first view'}`, async () => { const delivered: Array<{ eventId: string; firstView?: true }> = []; + const r = removals(); const host: CoalescerHost = { - isUnread: () => true, isUnshown: () => !shown, remove: () => true, + isUnread: (p) => !r.gone(p), isUnshown: (p) => !r.gone(p) && !shown, remove: r.remove, deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); return { agent: 'agent', messageId: `stored-${delivered.length}` }; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, audience: async () => ['agent'], From a10eb51a37ae80e09be733b4ada87b9aeb4cf828 Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Mon, 19 Oct 2026 03:21:03 -0400 Subject: [PATCH 18/19] fix(coalescing): a crash-window record ends its subject's first view whatever its age restoreReceipts skipped every record older than the receipt retry window (an hour by default) before it read anything else, so a host that came back more than an hour after a crash in the snapshot throttle's window forgot a subject's ended first view, and the subject's next batch or birth could be granted one the agent may already have had. A record whose subject the restore didn't hold dropped the end the same way. The end is a fact about the subject, not about the receipt: each record that carries it now ends the subject's first view whatever its age, and a subject the restore doesn't hold yet comes back with that end and an unknown history. The retry window still bounds the receipt itself and the rest of the record, as before. Tests: the crash-window test now also ends the first view by a plain occurrence that displaces the shown render, beside the retraction; a snapshot taken before the end, with the records after it, restores the end onto the snapshot's subject; and records restored two hours later still end it, so the edit of a later initial plain occurrence is no first view. Text: five comments that name the default publish channel, the incoming anchor or the refused agent's locus as the latest message that isn't a reaction, reaction removal, edit or deletion marker now add "apart from an edit the agent sees first", which sets them like the message it replaced. The changelog says a compile that throws gives back its move of the agent's shown position unless something has moved it since. Co-Authored-By: Claude Opus 5.5 --- .../nonconversational-markers.fixed.md | 2 +- src/framework.ts | 7 ++- src/mcpl/channel-registry.ts | 14 ++--- src/mcpl/push-coalescer.ts | 23 +++++-- test/mcpl-coalescing.test.ts | 63 ++++++++++++++++++- 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/changelog.d/nonconversational-markers.fixed.md b/changelog.d/nonconversational-markers.fixed.md index 774811d1c..f3280e8aa 100644 --- a/changelog.d/nonconversational-markers.fixed.md +++ b/changelog.d/nonconversational-markers.fixed.md @@ -1 +1 @@ -- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message the coalescer delivers when it accepts it, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of any agent that can read it has begun to assemble it or a tool boundary of one may have injected it (residents sharing a message slot can all read it), and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one, and otherwise to the channel of the most recent incoming message the host received on any channel, whether or not the agent has been shown it, apart from markers, and that a message pushed to the agent never sets it. +- **Edit/delete/reaction-remove markers now preserve explicit-send suppression, like reaction markers** (#157). `isConversationalInjection` exempted only `chat:reaction`, so the RFC-001 marker tags `chat:edited`, `chat:deleted` and `chat:reaction-remove` (now delivered as synthetic incoming messages by e.g. zulip_mcp) cleared explicit-send suppression — an edit notice arriving right after a `send_message` re-opened auto-routing for the same round's prose. The four tags live in a shared `NON_CONVERSATIONAL_TAGS` set (`src/mcpl/tags.ts`). All four marker tags also leave ChannelRegistry's default publish channel and `context.incoming`'s message/thread anchor unchanged. An edit the agent sees first still counts as the message it replaced: it clears explicit-send suppression, can move the turn's speech channel at a tool boundary, and, for a channel message the coalescer delivers when it accepts it, moves the default publish channel and `context.incoming` as that message would. The host knows that case when RFC-006 coalescing delivers a version of a subject whose producer marked its first occurrence `initial` and of which no version may have been shown to the agent yet, whether the version is a plain occurrence that replaced an unread one, a deferred notice's render, or a plain occurrence that replaced a pending notice; an edit of such an edit is the agent's first view too. A doubt counts as shown, so the host errs toward a marker: a message counts as shown once a compile of any agent that can read it has begun to assemble it (a compile that throws gives that back, unless a tool boundary or another compile has moved the agent's position since) or a tool boundary of one may have injected it (residents sharing a message slot can all read it), and so does any message stored before one that was, since the host keeps one position per agent. `channel_publish`'s description now says that an omitted `channelId` goes to a conversation agent's own channel when it is bound to one, and otherwise to the channel of the most recent incoming message the host received on any channel, whether or not the agent has been shown it, apart from markers, and that a message pushed to the agent never sets it. diff --git a/src/framework.ts b/src/framework.ts index 33ecf2bf5..7e00821c6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8482,9 +8482,10 @@ export class AgentFramework { * Mark an inference refusal visibly: react on the message that holds the * refused agent's conversational locus (the most recent incoming channel * message that isn't a reaction, reaction removal, edit or deletion - * marker; an ephemeral run has none, so a subagent's refusal marks no - * message) with a category-specific emoji. Best-effort — failures are - * logged, never thrown, and non-Discord loci are silently skipped. + * marker, apart from an edit the agent sees first; an ephemeral run has + * none, so a subagent's refusal marks no message) with a category-specific + * emoji. Best-effort — failures are logged, never thrown, and non-Discord + * loci are silently skipped. */ private async reactToRefusal(agentName: string, category: string): Promise { try { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 8fe827d08..1dd5e27e7 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -776,8 +776,8 @@ export class ChannelRegistry { private dmClassified = new Map(); /** The channel of the most recent incoming message that isn't a reaction, - * reaction removal, edit or deletion marker: used for speech routing and - * the default publish. */ + * reaction removal, edit or deletion marker, apart from an edit the agent + * sees first: used for speech routing and the default publish. */ private defaultPublishChannel: string | null = null; /** The same message's id and thread, used for buildChannelContext. */ @@ -1550,7 +1550,7 @@ export class ChannelRegistry { /** * Get the default publish channel ID: the channel of the most recent * incoming message that isn't a reaction, reaction removal, edit or - * deletion marker. + * deletion marker, apart from an edit the agent sees first. */ getDefaultPublishChannel(): string | null { return this.defaultPublishChannel; @@ -1716,8 +1716,8 @@ export class ChannelRegistry { const context: ChannelContext = {}; // Incoming: the most recent inbound message that isn't a reaction, - // reaction removal, edit or deletion marker (what the agent is replying - // to). + // reaction removal, edit or deletion marker, apart from an edit the agent + // sees first (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 (fallback && this.defaultPublishMessageId) { @@ -3879,8 +3879,8 @@ export class ChannelRegistry { // Resolve channelId: default to the channel of the most recent incoming // message that isn't a reaction, reaction removal, edit or deletion - // marker, which an ephemeral run never takes (takesGlobalFallback), so it - // names one. + // marker, apart from an edit the agent sees first. An ephemeral run never + // takes that default (takesGlobalFallback), so it names one. const caller = origin?.kind === 'agent' ? origin.agentName : undefined; const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel); if (!channelId) { diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index f612e113b..0cd2b70b6 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -1056,12 +1056,23 @@ export class PushCoalescer { restoreReceipts(records: CoalescingReceiptRecord[]): void { const now = this.now(); for (const r of records) { - if (now - r.at > this.retryWindowMs) continue; - this.receipts.set(r.key, { result: r.result, at: r.at, committed: true }); - if (r.born && !this.subjects.has(r.subject)) this.subjects.set(r.subject, { history: r.born, touchedAt: now }); - // Nothing undoes a first view's end, so any record that carries it ends - // the subject's first view. - if (r.firstViewEnded) { const ended = this.subjects.get(r.subject); if (ended) ended.firstViewEnded = true; } + const fresh = now - r.at <= this.retryWindowMs; + if (fresh) { + this.receipts.set(r.key, { result: r.result, at: r.at, committed: true }); + if (r.born && !this.subjects.has(r.subject)) this.subjects.set(r.subject, { history: r.born, touchedAt: now }); + } + // A first view's end is a fact about the subject, which holds while the + // coalescer keeps it, not about the receipt, whose retry window says + // nothing about it. So a record that carries it ends the subject's + // first view whatever its age. A subject this restore doesn't hold yet + // existed when the record was written and may have been shown, so it + // comes back with that end and an unknown history. + if (r.firstViewEnded) { + const ended = this.subjects.get(r.subject); + if (ended) ended.firstViewEnded = true; + else this.subjects.set(r.subject, { history: 'unknown', touchedAt: now, firstViewEnded: true }); + } + if (!fresh) continue; // A birth fact is not proof the subject is still unread: the plain // occurrence this record acknowledged is in context (durable before the // record) and counts as read from recovery on, like a snapshot occupant. diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index b52a81af3..1d48d43a2 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -2544,12 +2544,28 @@ test("#157: a snapshot keeps a subject's ended first view, so a restored coalesc assert.deepEqual(p.delivered.at(-1), { eventId: 'n2' }); }); -for (const shown of [false, true]) test(`#157: the crash-window receipt records keep a subject's ended first view, so a coalescer restored from them alone ${shown ? 'gives the next batch no first view' : 'still gives the next batch its first view when nothing had shown the agent the last'}`, async () => { +/** n1's render lands in 'other''s deferred queue as the agent's first view; + * `shown` says whether the agent is then shown it, and a plain edit + * displaces it. */ +async function firstViewDisplacedByPlain(p: ReturnType, state: { shown: boolean }, shown: boolean) { + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }], "n1's render is the agent's first view"); + state.shown = shown; + const p1 = await p.coalescer.accept(p.occurrence('p1', { tags: ['chat:edited'] })); + assert.equal(p1.coalesce?.outcome, 'replaced', 'p1 displaced the queued render'); + // Later versions haven't been shown, whatever happened to n1. + state.shown = false; +} + +const firstViewEndings = { 'a retraction': firstViewRetracted, 'a plain occurrence that displaces it': firstViewDisplacedByPlain }; + +for (const [ending, end] of Object.entries(firstViewEndings)) for (const shown of [false, true]) test(`#157: the crash-window receipt records keep a subject's ended first view after ${ending}, so a coalescer restored from them alone ${shown ? 'gives the next batch no first view' : 'still gives the next batch its first view when nothing had shown the agent the last'}`, async () => { const state = { shown: false, read: false }; const p = firstViewProbe(state, { held: [] }); const records: CoalescingReceiptRecord[] = []; p.host.recordReceipt = (record) => { records.push(structuredClone(record)); }; - await firstViewRetracted(p, state, shown); + await end(p, state, shown); // A crash before the throttled snapshot: only the records written at each acceptance survive. const restored = new PushCoalescer(p.host); restored.restoreReceipts(records); @@ -2558,6 +2574,49 @@ for (const shown of [false, true]) test(`#157: the crash-window receipt records assert.deepEqual(p.delivered.at(-1), { eventId: 'n2', ...(shown ? {} : { firstView: true as const }) }); }); +for (const shown of [false, true]) test(`#157: a snapshot taken before a subject's first view ended, with the records written after it, ${shown ? 'gives the next batch no first view' : 'still gives the next batch its first view when nothing had shown the agent the last'}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + // The last snapshot holds the subject with its batch pending; the records after it are the crash window's. + const snapshot = p.coalescer.snapshot(); + const records: CoalescingReceiptRecord[] = []; + p.host.recordReceipt = (record) => { records.push(structuredClone(record)); }; + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }], "n1's render is the agent's first view"); + state.shown = shown; + const r1 = await p.coalescer.accept(p.occurrence('r1', { retract: true, content: [] })); + assert.equal(r1.coalesce?.outcome, 'retracted'); + state.shown = false; + assert.equal(records.length, 1, "the retraction's record is the only one after the snapshot"); + const restored = new PushCoalescer(p.host); + restored.restore(snapshot); + restored.restoreReceipts(records); + await restored.accept(p.occurrence('n2', { deferred: true })); + await restored.assemble('agent'); + assert.deepEqual(p.delivered.at(-1), { eventId: 'n2', ...(shown ? {} : { firstView: true as const }) }); +}); + +for (const shown of [false, true]) test(`#157: crash-window records restored after their retry window has passed ${shown ? "still end the first view, so the edit of a later initial plain occurrence is no first view" : "leave the edit of a later initial plain occurrence the first view when nothing had shown the agent the last"}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + const records: CoalescingReceiptRecord[] = []; + p.host.recordReceipt = (record) => { records.push(structuredClone(record)); }; + await firstViewRetracted(p, state, shown); + // The host comes back two hours after the crash: every record is past the default one-hour retry window. + const later = Date.now() + 2 * 3_600_000; + const restored = new PushCoalescer({ ...p.host, now: () => later }); + restored.restoreReceipts(records); + await restored.accept(p.occurrence('p1', { initial: true })); + const e1 = await restored.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(e1.coalesce?.outcome, 'replaced', 'e1 replaced the unread p1'); + // A birth carries no mark of its own (it isn't an edit); its edit carries the first view it hands on. + assert.deepEqual(p.delivered.slice(-2), [ + { eventId: 'p1' }, + { eventId: 'e1', ...(shown ? {} : { firstView: true as const }) }, + ]); +}); + test("#157: a receipt keeps an admission's first view in memory only, so a restored receipt has none", async () => { const state = { shown: false, read: false }; const p = firstViewProbe(state, { held: [] }); From 13d2bba63a2b660aa6c6fa177ae44b40f95bfddc Mon Sep 17 00:00:00 2001 From: Meg-2646 Date: Mon, 19 Oct 2026 06:29:11 -0400 Subject: [PATCH 19/19] fix(mcpl): a run's first view needs nothing of the subject read, and a late restore's end reaches the snapshot's batch occupantLeaves handed a run's first view on when the run's oldest render carried the mark and every render still in the run was unshown. But a render a request has read, or one that is gone, leaves the run before that question (refreshOccupant), so a newer render the agent had read could leave the oldest one to hand the view on. The subject's history still says something was read, so occupantLeaves now asks it too. Ending a first view is one step, endFirstView: the subject's end, and the first-view mark of a batch pending or rendering. A crash-window record that carries the end applies it whatever its age, and it now ends the view of a batch the snapshot brought back too. Past their retry window the records' batch facts aren't read, so a snapshot's pending batch can come back after the end; it kept its mark before. Text: the occupant's mark says a plain occurrence carries it too, and the comments on the default publish channel and the reply anchor say that only an edit the coalescer delivers on acceptance as the agent's first view sets them. Those on the conversational test say the first view is the coalescer's mark. Tests: the first-view probe answers read and shown per event as well, and new tests give a run's renders different views: a plain edit that displaces the run, or a retraction of it and the next batch, is no first view when either render may have been shown or the newer one was read. Another restores a snapshot holding a first-view batch with the records after it, past their retry window. Co-Authored-By: Claude Opus 5.5 --- src/framework.ts | 51 ++++++++++++----------- src/mcpl/channel-registry.ts | 25 ++++++----- src/mcpl/push-coalescer.ts | 52 +++++++++++++---------- test/mcpl-coalescing.test.ts | 80 ++++++++++++++++++++++++++++++++++-- 4 files changed, 149 insertions(+), 59 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 7e00821c6..4f8c3d6b3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2102,11 +2102,12 @@ export class AgentFramework { private streamBoundaryEnds = new WeakMap void>(); /** A tool boundary injected fresh CONVERSATIONAL input (a real message, - * or an edit the agent sees first; not a system marker or a reaction, - * reaction removal, edit or deletion marker) into the live stream. Tells - * driveStream to clear sticky explicit-send suppression before handling - * the next model round. The signal itself never moves routing: the - * boundary re-pins the turn's locus by its own rule (repinChannel). */ + * or an edit the coalescer marks as the agent's first view; not a system + * marker or a reaction, reaction removal, edit or deletion marker) into + * the live stream. Tells driveStream to clear sticky explicit-send + * suppression before handling the next model round. The signal itself + * never moves routing: the boundary re-pins the turn's locus by its own + * rule (repinChannel). */ 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 @@ -8482,10 +8483,11 @@ export class AgentFramework { * Mark an inference refusal visibly: react on the message that holds the * refused agent's conversational locus (the most recent incoming channel * message that isn't a reaction, reaction removal, edit or deletion - * marker, apart from an edit the agent sees first; an ephemeral run has - * none, so a subagent's refusal marks no message) with a category-specific - * emoji. Best-effort — failures are logged, never thrown, and non-Discord - * loci are silently skipped. + * marker, apart from an edit the coalescer delivers on acceptance as the + * agent's first view; an ephemeral run has none, so a subagent's refusal + * marks no message) with a category-specific emoji. Best-effort — + * failures are logged, never thrown, and non-Discord loci are silently + * skipped. */ private async reactToRefusal(agentName: string, category: string): Promise { try { @@ -10943,8 +10945,8 @@ export class AgentFramework { // Non-conversational injections (`system: true` markers, and // reaction, reaction removal, edit and deletion markers) don't even // clear the send suppression: nothing new was said to the agent. - // An edit the agent sees first is the message it replaced, so it - // does (isConversationalInjection). + // An edit the coalescer marks as the agent's first view is the + // message it replaced, so it does (isConversationalInjection). if (midTurnInjections.some((inj) => isConversationalInjection(inj.metadata))) { this.midTurnInputSignals.add(agent.name); } @@ -11012,11 +11014,11 @@ export class AgentFramework { // prose routed in earlier rounds already went to the old pin, // which was correct when it was delivered. Ambient chatter, system // markers, and reaction, reaction removal, edit and deletion - // markers (an edit the agent sees first aside, which counts as its - // message) 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). + // markers (an edit the coalescer marks as the agent's first view + // aside, which counts as its message) 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. // @@ -11047,9 +11049,10 @@ export class AgentFramework { // tracks nothing that matters here (antra, 2026-07-31). // System markers and reaction, reaction removal, edit and // deletion markers are already excluded by - // isConversationalInjection (apart from an edit the agent - // sees first), and the engaged-this-turn scope - // keeps unrelated channels from moving the pin. + // isConversationalInjection (apart from an edit the + // coalescer marks as the agent's first view), and the + // engaged-this-turn scope keeps unrelated channels from moving + // the pin. // The last injection that qualifies wins (repinChannel). const routing = this.routingOf(agent.name); let newLocus: string | undefined; @@ -14283,11 +14286,11 @@ export class AgentFramework { * The channel a message moves its turn's locus to, by the addressed * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (by * isConversationalInjection: not a system marker, nor a reaction, reaction - * removal, edit or deletion marker, apart from an edit the agent sees - * first) that addressed the agent, or that follows up in a channel the - * turn sent into (TurnRouting.engaged). Undefined for any other message. A - * tool boundary weighs the messages it injects by it, and noteArrival the - * ones no boundary of the turn weighs. + * removal, edit or deletion marker, apart from an edit the coalescer marks + * as the agent's first view) that addressed the agent, or that follows up + * in a channel the turn sent into (TurnRouting.engaged). Undefined for any + * other message. A tool boundary weighs the messages it injects by it, + * and noteArrival the ones no boundary of the turn weighs. */ private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { if (!isConversationalInjection(metadata)) return undefined; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 1dd5e27e7..c6dd2d1c9 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -636,9 +636,10 @@ interface ChannelRegistryOptions { * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their * plain-text speech must route THERE. The process-global `defaultPublishChannel` * tracks only the most recent inbound message across ALL channels (reaction, - * reaction-removal, edit and deletion markers aside), 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 + * reaction-removal, edit and deletion markers aside, apart from an edit the + * coalescer delivers on acceptance as the agent's first view), 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.). */ homeChannelResolver?: (agentName: string) => string | undefined; @@ -776,8 +777,9 @@ export class ChannelRegistry { private dmClassified = new Map(); /** The channel of the most recent incoming message that isn't a reaction, - * reaction removal, edit or deletion marker, apart from an edit the agent - * sees first: used for speech routing and the default publish. */ + * reaction removal, edit or deletion marker, apart from an edit the + * coalescer delivers on acceptance as the agent's first view: used for + * speech routing and the default publish. */ private defaultPublishChannel: string | null = null; /** The same message's id and thread, used for buildChannelContext. */ @@ -1550,7 +1552,8 @@ export class ChannelRegistry { /** * Get the default publish channel ID: the channel of the most recent * incoming message that isn't a reaction, reaction removal, edit or - * deletion marker, apart from an edit the agent sees first. + * deletion marker, apart from an edit the coalescer delivers on acceptance + * as the agent's first view. */ getDefaultPublishChannel(): string | null { return this.defaultPublishChannel; @@ -1716,8 +1719,9 @@ export class ChannelRegistry { const context: ChannelContext = {}; // Incoming: the most recent inbound message that isn't a reaction, - // reaction removal, edit or deletion marker, apart from an edit the agent - // sees first (what the agent is replying to). + // reaction removal, edit or deletion marker, apart from an edit the + // coalescer delivers on acceptance as the agent's first view (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 (fallback && this.defaultPublishMessageId) { @@ -3879,8 +3883,9 @@ export class ChannelRegistry { // Resolve channelId: default to the channel of the most recent incoming // message that isn't a reaction, reaction removal, edit or deletion - // marker, apart from an edit the agent sees first. An ephemeral run never - // takes that default (takesGlobalFallback), so it names one. + // marker, apart from an edit the coalescer delivers on acceptance as the + // agent's first view. An ephemeral run never takes that default + // (takesGlobalFallback), so it names one. const caller = origin?.kind === 'agent' ? origin.agentName : undefined; const channelId = input.channelId ?? (caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel); if (!channelId) { diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index dd2111551..aea9da0af 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -170,11 +170,13 @@ interface Occupant { placement: CoalescingPlacement; /** A materialized batch (its result or its fallback), which later renders build on. */ rendered?: true; - /** This occupant is the agent's first view of the subject, as `firstView` - * defines it, so nothing of the subject has been read: a read takes the - * occupant away. Unknown counts as not one. Only a run's oldest render can - * carry it, since a render that joins a run finds a version placed. In - * memory only: a snapshot's occupant comes back as read. */ + /** This occupant was the agent's first view of the subject when it was + * placed, as `firstView` defines it. It hands that view on only while + * nothing of the subject has been read and nothing of its run may have + * been shown (occupantLeaves). Unknown counts as not one. A plain + * occurrence can carry it, and of a run's renders only the oldest, since a + * render that joins a run finds a version placed. In memory only: a + * snapshot's occupant comes back as read. */ firstView?: true; /** The run's earlier renders, oldest first, each placed ahead of this one. */ before?: Array>; @@ -199,10 +201,10 @@ interface SubjectState { audienceAgent?: string; /** The agent's first view of the subject has ended: an occupant that may * have been shown left it without handing a first view on - * (occupantLeaves). No later batch or birth of the subject is a first - * view while the coalescer keeps this record. An idle subject's record can - * be evicted (prune), and an `initial` occurrence after that starts the - * subject afresh. */ + * (occupantLeaves, endFirstView). No later batch or birth of the subject + * is a first view while the coalescer keeps this record. An idle + * subject's record can be evicted (prune), and an `initial` occurrence + * after that starts the subject afresh. */ firstViewEnded?: true; touchedAt: number; } @@ -684,21 +686,28 @@ export class PushCoalescer { * displaces it, or a retraction withdraws it, with any run of renders it * carries. Returns whether it was a first view that nothing may have shown * (isUnshown), which a replacement carries on: for a run, its oldest render - * carries the mark, and no render of it may have been shown. Any other - * occupant may have been seen, and this is the last point where the host - * can say so, since the removal takes its placements: the subject's first - * view ends here, for a batch pending or rendering, whose first delivery - * would come after it, and for every later batch and birth - * (firstViewEnded). + * carries the mark, and no render of it may have been shown. Nor may + * anything of the subject have been read: a member a request has read, or + * that is gone, leaves the run before this (refreshOccupant), and only the + * subject's history still says so. Any other occupant may have been seen, + * and this is the last point where the host can say so, since the removal + * takes its placements: the subject's first view ends here (endFirstView). */ private occupantLeaves(state: SubjectState, occupant: Occupant): boolean { const { before = [], ...newest } = occupant; const members = [...before, newest]; - if (members[0].firstView === true && members.every((member) => this.host.isUnshown(member.placement))) return true; + if (state.history === 'none' && members[0].firstView === true && members.every((member) => this.host.isUnshown(member.placement))) return true; + this.endFirstView(state); + return false; + } + + /** The subject's first view ends: for a batch pending or rendering, whose + * first delivery would come after it, and for every later batch and + * birth (firstViewEnded). */ + private endFirstView(state: SubjectState): void { state.firstViewEnded = true; if (state.batch) delete state.batch.firstView; if (state.rendering) delete state.rendering.batch.firstView; - return false; } private scheduleRecovery(subject: string, state: SubjectState): void { @@ -1149,12 +1158,13 @@ export class PushCoalescer { // A first view's end is a fact about the subject, which holds while the // coalescer keeps it, not about the receipt, whose retry window says // nothing about it. So a record that carries it ends the subject's - // first view whatever its age. A subject this restore doesn't hold yet - // existed when the record was written and may have been shown, so it - // comes back with that end and an unknown history. + // first view whatever its age, for a batch the snapshot brought back + // too. A subject this restore doesn't hold yet existed when the record + // was written and may have been shown, so it comes back with that end + // and an unknown history. if (r.firstViewEnded) { const ended = this.subjects.get(r.subject); - if (ended) ended.firstViewEnded = true; + if (ended) this.endFirstView(ended); else this.subjects.set(r.subject, { history: 'unknown', touchedAt: now, firstViewEnded: true }); } if (!fresh) continue; diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index f45e549fb..58c88481b 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -2362,24 +2362,32 @@ test("#157: a notice's render that lands while an earlier render of its subject * `unplaced` does, whose delivery is placed nowhere. The renders of `held` * wait for `release`, and those of `blank` come back blank. `state` answers * the host's read and shown questions for every placement it hasn't removed - * (removals). + * (removals), and its `shownEvents` and `readEvents`, when given, for the + * placements of the events they name. * (slimepriestess's probe for #168, with the held renders and the other * options added.) */ -function firstViewProbe(state: { shown: boolean; read: boolean }, { held = ['n1'], stored = [] as string[], unplaced = [] as string[], blank = [] as string[] } = {}) { +function firstViewProbe(state: { shown: boolean; read: boolean; shownEvents?: Set; readEvents?: Set }, { held = ['n1'], stored = [] as string[], unplaced = [] as string[], blank = [] as string[] } = {}) { const delivered: Array<{ eventId: string; firstView?: true }> = []; + // Each placement's event, for the per-event read and shown answers. + const eventOf = new Map(); + const named = (p: { deferredId?: string; messageId?: string }, events?: Set) => events?.has(eventOf.get(p.deferredId ?? p.messageId ?? '') ?? '') === true; const releases = new Map void>(); const gates = new Map(held.map((id) => [id, new Promise((resolve) => { releases.set(id, resolve); })])); const started = new Map void>(); const renderStarted = new Map(held.map((id) => [id, new Promise((resolve) => { started.set(id, resolve); })])); const r = removals(); const host: CoalescerHost = { - isUnread: (p) => !r.gone(p) && !state.read, isUnshown: (p) => !r.gone(p) && !state.shown && !state.read, remove: r.remove, + isUnread: (p) => !r.gone(p) && !state.read && !named(p, state.readEvents), + isUnshown: (p) => !r.gone(p) && !state.shown && !state.read && !named(p, state.shownEvents) && !named(p, state.readEvents), + remove: r.remove, deliver: async (occ) => { delivered.push({ eventId: occ.eventId, ...(occ.firstView ? { firstView: true as const } : {}) }); if (unplaced.includes(occ.eventId)) return undefined; - return stored.includes(occ.eventId) + const placement = stored.includes(occ.eventId) ? { agent: 'agent', messageId: `stored-${delivered.length}` } : { agent: 'other', deferredId: `deferred-${delivered.length}` }; + eventOf.set(placement.messageId ?? placement.deferredId!, occ.eventId); + return placement; }, wakeForBatch: async () => {}, wakeToRead: async () => {}, cancelWake: () => {}, authorized: () => true, audience: async () => ['agent', 'other'], @@ -2527,6 +2535,46 @@ for (const shown of [false, true]) test(`#157: beside a queued first-view render /** n1's render lands in 'other''s deferred queue as the agent's first view; * `shown` says whether the agent is then shown it, and a retraction * withdraws it. */ +/** A run of n1, the agent's first view, and n3's render, which joined it + * unmarked, both queued for 'other'. */ +async function firstViewRunOfTwo(p: ReturnType): Promise { + await firstViewQueuedWithBatchPending(p); + await p.coalescer.accept(p.occurrence('n3', { deferred: true, tags: ['chat:edited'] })); + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }, { eventId: 'n3' }], "n3's render joined n1's run unmarked"); +} + +/** Runs whose renders the agent may have seen differently: only a run none + * of whose renders may have been shown, and none of whose renders has left + * it read, hands its first view on. */ +const runMixes: Record = { + 'its newest render shown and its oldest not': { shownEvents: ['n3'] }, + 'its oldest render shown and its newest not': { shownEvents: ['n1'] }, + 'its newest render read and its oldest not': { readEvents: ['n3'] }, +}; +const runEndings: Record) => Promise> = { + 'a plain edit that displaces it is': async (p) => { + const e1 = await p.coalescer.accept(p.occurrence('e1', { tags: ['chat:edited'] })); + assert.equal(e1.coalesce?.outcome, 'replaced', 'e1 displaced what is left of the run'); + return 'e1'; + }, + 'a retraction of it leaves the next batch': async (p) => { + await p.coalescer.accept(p.occurrence('r1', { retract: true, content: [] })); + await p.coalescer.accept(p.occurrence('n4', { deferred: true })); + await p.coalescer.assemble('agent'); + return 'n4'; + }, +}; +for (const [mix, seen] of Object.entries(runMixes)) for (const [ending, end] of Object.entries(runEndings)) test(`#157: with a run of renders that has ${mix}, ${ending} no first view`, async () => { + const state = { shown: false, read: false, shownEvents: new Set(), readEvents: new Set() }; + const p = firstViewProbe(state); + await firstViewRunOfTwo(p); + for (const id of seen.shownEvents ?? []) state.shownEvents.add(id); + for (const id of seen.readEvents ?? []) state.readEvents.add(id); + const next = await end(p); + assert.deepEqual(p.delivered.at(-1), { eventId: next }, 'something of the run may have been shown'); +}); + async function firstViewRetracted(p: ReturnType, state: { shown: boolean }, shown: boolean) { await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); await p.coalescer.assemble('agent'); @@ -2642,6 +2690,30 @@ for (const shown of [false, true]) test(`#157: a snapshot taken before a subject assert.deepEqual(p.delivered.at(-1), { eventId: 'n2', ...(shown ? {} : { firstView: true as const }) }); }); +for (const shown of [false, true]) test(`#157: a snapshot that holds a first-view batch, restored with the records after it once their retry window has passed, ${shown ? "gives that batch's render no first view when a record ends the first view" : "leaves that batch's render the first view when nothing had shown the agent the last"}`, async () => { + const state = { shown: false, read: false }; + const p = firstViewProbe(state, { held: [] }); + await p.coalescer.accept(p.occurrence('n1', { deferred: true, initial: true })); + // The last snapshot holds n1's batch pending, as the agent's first view. + const snapshot = p.coalescer.snapshot(); + const records: CoalescingReceiptRecord[] = []; + p.host.recordReceipt = (record) => { records.push(structuredClone(record)); }; + await p.coalescer.assemble('agent'); + assert.deepEqual(p.delivered, [{ eventId: 'n1', firstView: true }], "n1's render is the agent's first view"); + state.shown = shown; + await p.coalescer.accept(p.occurrence('r1', { retract: true, content: [] })); + state.shown = false; + // The host comes back two hours after the crash. Past their retry window + // the records' batch facts aren't read, so the snapshot's batch is back, + // and the end a record carries holds for it too. + const later = Date.now() + 2 * 3_600_000; + const restored = new PushCoalescer({ ...p.host, now: () => later }); + restored.restore(snapshot); + restored.restoreReceipts(records); + await restored.assemble('agent'); + assert.deepEqual(p.delivered.at(-1), { eventId: 'n1', ...(shown ? {} : { firstView: true as const }) }); +}); + for (const shown of [false, true]) test(`#157: crash-window records restored after their retry window has passed ${shown ? "still end the first view, so the edit of a later initial plain occurrence is no first view" : "leave the edit of a later initial plain occurrence the first view when nothing had shown the agent the last"}`, async () => { const state = { shown: false, read: false }; const p = firstViewProbe(state, { held: [] });