switchroom 0.18.21 → 0.18.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Turn-flush supersede registry (2026-07 duplicate-reply fix).
3
+ *
4
+ * The duplicate-reply class this closes:
5
+ *
6
+ * 1. A turn-flush (the answer-ready quiescence flush OR the older turn-end
7
+ * backstop) posts the model's terminal text as a Telegram message, because
8
+ * the turn appeared to end without a `reply` tool call.
9
+ * 2. ~10 s later the model's REAL `reply` tool call lands (it was still
10
+ * composing it when the flush fired, or claude-code replayed an un-acked
11
+ * tool_call after a bridge reconnect).
12
+ * 3. The gateway sends that as a SECOND message → the user sees a duplicate.
13
+ *
14
+ * The pre-existing `OutboundDedupCache` did NOT catch this because it matches on
15
+ * EXACT normalised-text equality: a flush that dumped `narration\n\nanswer`
16
+ * never equals the clean `answer`-only reply, so the containment case slipped
17
+ * through (216 occurrences in older logs via the turn-end backstop alone).
18
+ *
19
+ * This registry SUBSTANTIALLY REDUCES the class, keyed on the turn IDENTITY
20
+ * (the per-turn `turnId` nonce) rather than on text. When a flush sends, it
21
+ * records `{ turnId, messageIds }` for the chat/thread. When a `reply` for the
22
+ * SAME turn later lands, the gateway SUPERSEDES the flushed message(s) — deletes
23
+ * them and lets the canonical reply send path deliver exactly one clean message
24
+ * (delete+resend; the caller may also edit-in-place). A reply for a DIFFERENT
25
+ * (newer) turn never supersedes — that turn owns its own message.
26
+ *
27
+ * NOT fully deterministic — a residual race remains. The flush→reply direction
28
+ * this registry covers, plus the controller's fire-time recount for the
29
+ * reply→flush direction, close the COMMON ~10 s replay-gap case; but if a reply
30
+ * and a flush interleave so the reply's `take()` runs BEFORE the flush's
31
+ * `record()` (a much smaller window), `take` finds no record and the duplicate
32
+ * can still slip through. We deliberately trade that residual window for the
33
+ * safety guarantee that we NEVER delete a message we cannot positively attribute
34
+ * to the reply's own turn (identity-only supersede — see `decideSupersede`).
35
+ *
36
+ * Pure module: no I/O, no globals, no clock reads beyond the caller-supplied
37
+ * `now`. Fully unit-testable; the gateway wires the actual delete/send.
38
+ */
39
+
40
+ /** TTL after which a recorded flush is forgotten. 60 s comfortably spans the
41
+ * observed ~10 s flush→reply replay gap with margin, and matches the outbound
42
+ * dedup window so the two mechanisms age out together. */
43
+ export const DEFAULT_SUPERSEDE_TTL_MS = 60_000
44
+
45
+ export interface FlushedTurnRecord {
46
+ /** The per-turn `turnId` nonce (`deriveTurnId` shape) of the flushed turn.
47
+ * Keying on this — NOT the stable `chatId:threadId` statusKey — is what makes
48
+ * the supersede turn-identity-scoped: a later, unrelated turn on the same
49
+ * chat has a different `turnId` and must NOT clobber this record's message. */
50
+ turnId: string | null
51
+ /** The Telegram message id(s) the flush posted (edit target + any extra
52
+ * chunk messages). Superseding deletes all of them. */
53
+ messageIds: number[]
54
+ /** The text the flush delivered — retained for diagnostics/logging only. The
55
+ * supersede decision NEVER compares text (that is the whole point: it fires
56
+ * even when the flushed text differs from the reply text). */
57
+ text: string
58
+ /** Wall-clock ms when recorded. */
59
+ ts: number
60
+ }
61
+
62
+ /**
63
+ * The supersede decision for a landing reply. Pure so the gateway runs the
64
+ * exact code the regression tests exercise (`gateway.ts` is not importable in
65
+ * tests — the repo's `decideTurnFlush` / `decideCapturedProseDelivery` pattern).
66
+ */
67
+ export interface SupersedeDecision {
68
+ /** True → the reply belongs to an already-flushed turn; the gateway must
69
+ * delete `deleteMessageIds` and deliver the reply as the single message. */
70
+ supersede: boolean
71
+ /** Message ids the gateway must delete before/instead of the fresh send.
72
+ * Empty when `supersede` is false. */
73
+ deleteMessageIds: number[]
74
+ /** Machine-readable reason (for logs / tests). */
75
+ reason: 'supersede' | 'no-record' | 'expired' | 'different-turn'
76
+ }
77
+
78
+ /**
79
+ * Decide whether a landing reply supersedes a recorded flush.
80
+ *
81
+ * Supersede IFF the record is present, fresh (within TTL), AND positively
82
+ * attributable to the reply's turn by IDENTITY:
83
+ * - a turnId-bearing record is superseded ONLY by a reply whose resolved
84
+ * `liveTurnId` equals `record.turnId`;
85
+ * - a null-turnId record (a synthetic/no-nonce flush) is superseded ONLY by a
86
+ * reply that ALSO has no resolvable turn (`liveTurnId == null`).
87
+ *
88
+ * Crucially, a reply with `liveTurnId == null` does NOT supersede a
89
+ * turnId-bearing record: we never delete a message we cannot positively
90
+ * attribute to the reply's own turn. (The earlier revision superseded ANY
91
+ * record on a null live turn, which — combined with a single overwriting lane
92
+ * slot — could late-delete a DIFFERENT turn's legitimate message. The gateway
93
+ * now resolves a last-known turnId for the reply before calling in, so the
94
+ * common late-replay case still matches by identity rather than relying on the
95
+ * promiscuous null branch.)
96
+ */
97
+ export function decideSupersede(
98
+ record: FlushedTurnRecord | undefined,
99
+ args: { liveTurnId: string | null; now: number; ttlMs?: number },
100
+ ): SupersedeDecision {
101
+ const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
102
+ if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
103
+ if (args.now - record.ts > ttlMs) {
104
+ return { supersede: false, deleteMessageIds: [], reason: 'expired' }
105
+ }
106
+ const sameTurn =
107
+ record.turnId != null
108
+ ? record.turnId === args.liveTurnId
109
+ : args.liveTurnId == null
110
+ if (!sameTurn) {
111
+ return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
112
+ }
113
+ return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
114
+ }
115
+
116
+ /** Sentinel key for records whose flush carried no turnId nonce. */
117
+ const NULL_TURN_KEY = '<<null-turn>>'
118
+
119
+ function turnKey(turnId: string | null): string {
120
+ return turnId == null ? NULL_TURN_KEY : turnId
121
+ }
122
+
123
+ /**
124
+ * In-memory registry of recently-flushed turns, keyed by `chatId|threadId` and
125
+ * then by the flush's `turnId` nonce WITHIN that lane. Holding a per-turnId map
126
+ * (rather than a single overwriting slot) is what makes the identity guarantee
127
+ * airtight: two turns can each flush a message on the same lane, and each turn's
128
+ * later reply supersedes ONLY its own flushed message — turn B's reply can never
129
+ * reach turn A's record, and a late turn-A reply can never reach turn B's.
130
+ *
131
+ * Bounded by TTL eviction (swept on every `record`); chat count per gateway is
132
+ * small and a lane holds at most a handful of concurrent-turn records.
133
+ */
134
+ export class FlushedTurnSupersedeRegistry {
135
+ private readonly entries = new Map<string, Map<string, FlushedTurnRecord>>()
136
+ private readonly ttlMs: number
137
+
138
+ constructor(opts: { ttlMs?: number } = {}) {
139
+ this.ttlMs = opts.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
140
+ }
141
+
142
+ /** Record a flush's posted message(s) so a later same-turn reply supersedes
143
+ * them, keyed on the flush's `turnId` within the chat/thread lane. No record
144
+ * is kept when the flush posted zero messages. Sweeps expired records first
145
+ * (lightweight active GC — LOW-2) so orphan records never accumulate. */
146
+ record(
147
+ chatId: string,
148
+ threadId: number | undefined,
149
+ rec: { turnId: string | null; messageIds: number[]; text: string },
150
+ now: number,
151
+ ): void {
152
+ if (rec.messageIds.length === 0) return
153
+ this.sweep(now)
154
+ const lane = makeKey(chatId, threadId)
155
+ let laneMap = this.entries.get(lane)
156
+ if (laneMap == null) {
157
+ laneMap = new Map<string, FlushedTurnRecord>()
158
+ this.entries.set(lane, laneMap)
159
+ }
160
+ laneMap.set(turnKey(rec.turnId), {
161
+ turnId: rec.turnId,
162
+ messageIds: [...rec.messageIds],
163
+ text: rec.text,
164
+ ts: now,
165
+ })
166
+ }
167
+
168
+ /** Decide supersede for a landing reply WITHOUT consuming the record. Selects
169
+ * the record whose turnId matches the reply's resolved `liveTurnId` (or the
170
+ * null-turnId record when `liveTurnId == null`). */
171
+ peek(
172
+ chatId: string,
173
+ threadId: number | undefined,
174
+ args: { liveTurnId: string | null; now: number },
175
+ ): SupersedeDecision {
176
+ const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
177
+ return decideSupersede(rec, { liveTurnId: args.liveTurnId, now: args.now, ttlMs: this.ttlMs })
178
+ }
179
+
180
+ /** Decide supersede AND, on a supersede, consume the matched record (so a
181
+ * second replay of the same reply doesn't try to delete the same — now gone —
182
+ * messages again). Returns the same decision `peek` would. */
183
+ take(
184
+ chatId: string,
185
+ threadId: number | undefined,
186
+ args: { liveTurnId: string | null; now: number },
187
+ ): SupersedeDecision {
188
+ const lane = makeKey(chatId, threadId)
189
+ const decision = this.peek(chatId, threadId, args)
190
+ if (decision.supersede) {
191
+ const laneMap = this.entries.get(lane)
192
+ laneMap?.delete(turnKey(args.liveTurnId))
193
+ if (laneMap != null && laneMap.size === 0) this.entries.delete(lane)
194
+ }
195
+ return decision
196
+ }
197
+
198
+ /** Drop all records for a chat/thread (e.g. after the flushed message was
199
+ * deleted through another path). Idempotent. */
200
+ forget(chatId: string, threadId: number | undefined): void {
201
+ this.entries.delete(makeKey(chatId, threadId))
202
+ }
203
+
204
+ /** Test-only: clear all entries. */
205
+ clear(): void {
206
+ this.entries.clear()
207
+ }
208
+
209
+ /** Evict every record past its TTL and prune emptied lanes. */
210
+ private sweep(now: number): void {
211
+ for (const [lane, laneMap] of this.entries) {
212
+ for (const [tk, rec] of laneMap) {
213
+ if (now - rec.ts > this.ttlMs) laneMap.delete(tk)
214
+ }
215
+ if (laneMap.size === 0) this.entries.delete(lane)
216
+ }
217
+ }
218
+
219
+ /** Test-only: live record count after TTL eviction. */
220
+ size(now: number): number {
221
+ this.sweep(now)
222
+ let total = 0
223
+ for (const laneMap of this.entries.values()) total += laneMap.size
224
+ return total
225
+ }
226
+ }
227
+
228
+ function makeKey(chatId: string, threadId: number | undefined): string {
229
+ return threadId == null ? chatId : `${chatId}|${threadId}`
230
+ }
@@ -88,6 +88,7 @@ import {
88
88
  type TelegraphAccount,
89
89
  } from '../telegraph.js'
90
90
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
91
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
91
92
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
92
93
  import {
93
94
  splitCoalescedAttachments,
@@ -2220,6 +2221,14 @@ const deferredDoneReactions = new DeferredDoneReactions<StatusReactionController
2220
2221
  // path threw `outboundDedup is not defined` at runtime, blocking ALL outbound
2221
2222
  // from the agent. Restore the module-level singleton here.
2222
2223
  const outboundDedup = new OutboundDedupCache()
2224
+ // 2026-07 duplicate-reply fix — turnId-keyed supersede. When a turn-flush
2225
+ // (answer-ready quiescence OR the turn-end backstop) posts the model's terminal
2226
+ // text and the model's REAL `reply` for the SAME turn lands later, the reply
2227
+ // SUPERSEDES the flushed message (delete + canonical resend) instead of shipping
2228
+ // a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
2229
+ // catches the containment case the exact-text `outboundDedup` misses (a
2230
+ // `narration\n\nanswer` flush never equals the clean `answer`-only reply).
2231
+ const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
2223
2232
  /**
2224
2233
  * Per-chat cache of `available_reactions` from `getChat`. Populated lazily —
2225
2234
  * the FIRST message in a chat creates a controller without the filter (null
@@ -12902,6 +12911,50 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12902
12911
  }
12903
12912
  }
12904
12913
 
12914
+ // 2026-07 duplicate-reply fix — turnId-keyed supersede (consumption side).
12915
+ // If an earlier turn-flush (answer-ready quiescence OR the turn-end backstop)
12916
+ // already posted THIS turn's terminal text and the model's REAL `reply` for
12917
+ // the same turn is landing now (it was still composing the tool call when the
12918
+ // flush fired, or claude-code replayed the tool_call after a bridge
12919
+ // reconnect), delete the flushed message(s) so the canonical reply below
12920
+ // delivers exactly one clean message instead of a second one. Keyed on the
12921
+ // per-turn `turnId` nonce, so it fires even when the flushed narration+answer
12922
+ // blob differs from the clean answer-only reply — the containment case the
12923
+ // exact-text `outboundDedup` above structurally cannot catch. A reply for a
12924
+ // DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
12925
+ // so a fresh turn's answer is never clobbered.
12926
+ {
12927
+ const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
12928
+ // Resolve the turnId this reply belongs to by IDENTITY, not just the live
12929
+ // `currentTurn`. A late reply lands with `currentTurn == null` (silence poke
12930
+ // cleared it — Bug D) or with a transiently-cleared currentTurn while its own
12931
+ // turn is really still the owner (LOW-1); in both cases the turn is still
12932
+ // resolvable from the `origin_turn_id` nonce the model echoes back (Tier 2 —
12933
+ // the same last-known-turn resolver the chat-routing / obligation code uses).
12934
+ // Passing this resolved turnId means supersede matches the flushed record by
12935
+ // identity instead of falling back to a null-liveTurnId branch that would
12936
+ // otherwise be free to delete a DIFFERENT turn's legitimate message.
12937
+ const resolvedTurnId =
12938
+ turn?.turnId ?? findTurnByOriginId(args.origin_turn_id as string | undefined)?.turnId ?? null
12939
+ const decision = flushedTurnSupersede.take(
12940
+ chat_id,
12941
+ replyThreadId,
12942
+ { liveTurnId: resolvedTurnId, now: Date.now() },
12943
+ )
12944
+ if (decision.supersede) {
12945
+ process.stderr.write(
12946
+ `telegram gateway: reply: superseding flushed turn message(s) ` +
12947
+ `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
12948
+ )
12949
+ for (const id of decision.deleteMessageIds) {
12950
+ await swallowingApiCall(
12951
+ () => lockedBot.api.deleteMessage(chat_id, id),
12952
+ { chat_id, verb: 'reply.supersedeFlushed' },
12953
+ )
12954
+ }
12955
+ }
12956
+ }
12957
+
12905
12958
  const files = (args.files as string[] | undefined) ?? []
12906
12959
  const quoteOptIn = args.quote !== false
12907
12960
  let reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
@@ -18264,6 +18317,22 @@ function handleSessionEvent(ev: SessionEvent): void {
18264
18317
  Date.now(),
18265
18318
  currentTurn?.registryKey ?? null,
18266
18319
  )
18320
+ // 2026-07 duplicate-reply fix — record the flushed message id(s)
18321
+ // keyed on THIS turn's `turnId` nonce so a late `reply` for the same
18322
+ // turn supersedes them (delete + canonical resend) instead of
18323
+ // shipping a duplicate. `turn` is the ending turn atom captured at
18324
+ // the top of this branch (endCurrentTurnAtomic nulled currentTurn,
18325
+ // but the captured `turn` still carries the honest turnId). Covers
18326
+ // BOTH flush paths — answer-ready quiescence and the turn-end
18327
+ // backstop both funnel through this single IIFE.
18328
+ if (sentIds.length > 0) {
18329
+ flushedTurnSupersede.record(
18330
+ backstopChatId,
18331
+ backstopThreadId,
18332
+ { turnId: turn.turnId, messageIds: sentIds, text: capturedText },
18333
+ Date.now(),
18334
+ )
18335
+ }
18267
18336
  // #1713: route the backstop terminal through finalize() —
18268
18337
  // single terminal path keeps the controller contract clean.
18269
18338
  if (backstopCtrl) backstopCtrl.finalize('done')
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unit coverage for the turnId-keyed flushed-turn supersede registry
3
+ * (2026-07 duplicate-reply fix).
4
+ *
5
+ * The regression these tests pin: the answer-ready quiescence flush (or the
6
+ * turn-end backstop) posts a turn's terminal text as a Telegram message, then
7
+ * the model's REAL `reply` tool call for the SAME turn lands ~10 s later and
8
+ * ships a SECOND message. The pre-existing `OutboundDedupCache` misses this
9
+ * because it matches on EXACT text equality — a `narration\n\nanswer` flush
10
+ * never equals the clean `answer`-only reply.
11
+ *
12
+ * Identity-only supersede (adversarial-review HIGH fix): supersede fires ONLY
13
+ * when the landing reply is positively attributable to the flushed turn by
14
+ * turnId. A reply with an UNRESOLVED turn (`liveTurnId == null`) never deletes a
15
+ * turnId-bearing record — we never delete a message we can't attribute to the
16
+ * reply's own turn. The lane holds a per-turnId map, so two concurrent turns can
17
+ * each flush a message and each turn's reply supersedes ONLY its own.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ decideSupersede,
23
+ FlushedTurnSupersedeRegistry,
24
+ DEFAULT_SUPERSEDE_TTL_MS,
25
+ type FlushedTurnRecord,
26
+ } from '../flushed-turn-supersede.js'
27
+
28
+ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
29
+ turnId: 'turn-A',
30
+ messageIds: [101, 102],
31
+ text: 'narration\n\nthe real answer',
32
+ ts: 1_000_000,
33
+ ...over,
34
+ })
35
+
36
+ describe('decideSupersede — the duplicate-reply decision core', () => {
37
+ it('supersedes when the reply is attributed to the SAME turn as the flush', () => {
38
+ // The common late-replay dup: the gateway resolves the reply's turnId (from
39
+ // origin_turn_id) even after currentTurn cleared, so it matches by identity.
40
+ const d = decideSupersede(rec({ turnId: 'turn-A' }), {
41
+ liveTurnId: 'turn-A',
42
+ now: 1_000_000 + 10_000,
43
+ })
44
+ expect(d.supersede).toBe(true)
45
+ expect(d.deleteMessageIds).toEqual([101, 102])
46
+ expect(d.reason).toBe('supersede')
47
+ })
48
+
49
+ it('does NOT supersede a reply belonging to a DIFFERENT turn', () => {
50
+ // A different turn is the resolved owner — it must never delete this turn's
51
+ // message. This is the guard that keeps the fix from eating a legit answer.
52
+ const d = decideSupersede(rec({ turnId: 'turn-A' }), {
53
+ liveTurnId: 'turn-B',
54
+ now: 1_000_000 + 500,
55
+ })
56
+ expect(d.supersede).toBe(false)
57
+ expect(d.deleteMessageIds).toEqual([])
58
+ expect(d.reason).toBe('different-turn')
59
+ })
60
+
61
+ it('does NOT supersede a turnId-bearing record when the reply turn is UNRESOLVED (liveTurnId == null)', () => {
62
+ // HIGH-finding core guard: a late reply we cannot attribute to a turn must
63
+ // NEVER delete a message that positively belongs to some turn. Pre-fix the
64
+ // null branch superseded ANY record — deleting a possibly-different turn's
65
+ // legitimate message.
66
+ const d = decideSupersede(rec({ turnId: 'turn-A' }), {
67
+ liveTurnId: null,
68
+ now: 1_000_000 + 10_000,
69
+ })
70
+ expect(d.supersede).toBe(false)
71
+ expect(d.reason).toBe('different-turn')
72
+ })
73
+
74
+ it('does NOT supersede once the record is past its TTL', () => {
75
+ const d = decideSupersede(rec(), {
76
+ liveTurnId: 'turn-A',
77
+ now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
78
+ })
79
+ expect(d.supersede).toBe(false)
80
+ expect(d.reason).toBe('expired')
81
+ })
82
+
83
+ it('returns no-record when there is nothing to supersede', () => {
84
+ const d = decideSupersede(undefined, { liveTurnId: null, now: 1_000_000 })
85
+ expect(d.supersede).toBe(false)
86
+ expect(d.reason).toBe('no-record')
87
+ })
88
+
89
+ it('a null-turnId record is superseded ONLY by an equally-unresolved (null) reply', () => {
90
+ // A synthetic/no-nonce flush: only a reply that ALSO has no resolvable turn
91
+ // matches it — it never clobbers a turn we CAN identify.
92
+ const live = decideSupersede(rec({ turnId: null }), {
93
+ liveTurnId: 'turn-Z',
94
+ now: 1_000_000,
95
+ })
96
+ expect(live.supersede).toBe(false)
97
+ expect(live.reason).toBe('different-turn')
98
+
99
+ const unresolved = decideSupersede(rec({ turnId: null }), {
100
+ liveTurnId: null,
101
+ now: 1_000_000,
102
+ })
103
+ expect(unresolved.supersede).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () => {
108
+ it('records a flush and supersedes the same turn`s later reply end to end', () => {
109
+ const reg = new FlushedTurnSupersedeRegistry()
110
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [7, 8], text: 'x' }, 1000)
111
+ const d = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 })
112
+ expect(d.supersede).toBe(true)
113
+ expect(d.deleteMessageIds).toEqual([7, 8])
114
+ })
115
+
116
+ it('take() CONSUMES the matched record so a replayed reply does not double-delete', () => {
117
+ const reg = new FlushedTurnSupersedeRegistry()
118
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [7], text: 'x' }, 1000)
119
+ const first = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 2000 })
120
+ expect(first.supersede).toBe(true)
121
+ // Replay of the same reply — the flushed message is already gone.
122
+ const second = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 2001 })
123
+ expect(second.supersede).toBe(false)
124
+ expect(second.reason).toBe('no-record')
125
+ })
126
+
127
+ it('peek() does NOT consume — repeated peeks keep returning supersede', () => {
128
+ const reg = new FlushedTurnSupersedeRegistry()
129
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [7], text: 'x' }, 1000)
130
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(true)
131
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 2001 }).supersede).toBe(true)
132
+ })
133
+
134
+ it('does not record a flush that posted zero messages', () => {
135
+ const reg = new FlushedTurnSupersedeRegistry()
136
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [], text: 'x' }, 1000)
137
+ expect(reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(false)
138
+ })
139
+
140
+ it('keys per chat|thread — a flush in one thread never supersedes a reply in another', () => {
141
+ const reg = new FlushedTurnSupersedeRegistry()
142
+ reg.record('chat1', 42, { turnId: 'turn-A', messageIds: [7], text: 'x' }, 1000)
143
+ // different thread, same chat
144
+ expect(reg.take('chat1', 99, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(false)
145
+ // correct thread supersedes
146
+ expect(reg.take('chat1', 42, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(true)
147
+ })
148
+
149
+ // ---- HIGH finding regression: wrong-delete via a null-turn late reply. ----
150
+ describe('HIGH regression — two concurrent turns on one lane', () => {
151
+ it('turn A`s late reply supersedes ONLY A`s flush, never turn B`s legitimate message', () => {
152
+ const reg = new FlushedTurnSupersedeRegistry()
153
+ // Turn A flushes msg 100.
154
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [100], text: 'a' }, 1000)
155
+ // Turn B flushes msg 200 (B's real answer message).
156
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [200], text: 'b' }, 1100)
157
+
158
+ // Turn A's real reply lands late, resolved (via origin_turn_id) to turn A.
159
+ const dA = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 1200 })
160
+ expect(dA.supersede).toBe(true)
161
+ expect(dA.deleteMessageIds).toEqual([100]) // NOT [200] — B's message is untouched.
162
+
163
+ // B's own message is still independently supersedable by B's reply — proof
164
+ // A's reply did not consume or clobber B's record.
165
+ const dB = reg.take('chat1', undefined, { liveTurnId: 'turn-B', now: 1300 })
166
+ expect(dB.supersede).toBe(true)
167
+ expect(dB.deleteMessageIds).toEqual([200])
168
+ })
169
+
170
+ it('an UNRESOLVED (null-turn) late reply does NOT delete a different turn`s legitimate message', () => {
171
+ // This is the exact wrong-delete the review flagged. Pre-fix: single lane
172
+ // slot overwritten to {turn-B,[200]} + promiscuous null branch → the null
173
+ // reply superseded and DELETED msg 200 (B's good answer). Post-fix: a null
174
+ // liveTurnId matches no turnId-bearing record, so nothing is deleted.
175
+ const reg = new FlushedTurnSupersedeRegistry()
176
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [100], text: 'a' }, 1000)
177
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [200], text: 'b' }, 1100)
178
+
179
+ const d = reg.take('chat1', undefined, { liveTurnId: null, now: 1200 })
180
+ expect(d.supersede).toBe(false)
181
+ expect(d.deleteMessageIds).toEqual([])
182
+
183
+ // Both records survive — neither turn's message was wrongly deleted.
184
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 1300 }).supersede).toBe(true)
185
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-B', now: 1300 }).supersede).toBe(true)
186
+ })
187
+ })
188
+
189
+ it('size() evicts expired records and prunes emptied lanes', () => {
190
+ const reg = new FlushedTurnSupersedeRegistry({ ttlMs: 1000 })
191
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
192
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 1000)
193
+ expect(reg.size(1500)).toBe(2)
194
+ expect(reg.size(3000)).toBe(0)
195
+ })
196
+
197
+ it('record() actively sweeps expired records (LOW-2 GC — no orphan accumulation)', () => {
198
+ const reg = new FlushedTurnSupersedeRegistry({ ttlMs: 1000 })
199
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
200
+ // A much later flush on a DIFFERENT lane sweeps the now-expired turn-A record.
201
+ reg.record('chat2', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 5000)
202
+ // Only the fresh record remains; the orphan was swept, not left to leak.
203
+ expect(reg.size(5000)).toBe(1)
204
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 }).reason).toBe('no-record')
205
+ })
206
+ })
@@ -21,6 +21,8 @@ import {
21
21
  isCompositeSilentNoise,
22
22
  endsWithSilentMarker,
23
23
  isTurnFlushSafetyEnabled,
24
+ selectFlushDeliveryText,
25
+ FLUSH_SUBSTANTIVE_MIN_CHARS,
24
26
  } from '../turn-flush-safety.js'
25
27
  // Rich-message send-path primitives (Bot API 10.1, #2669/#2692). The #2798
26
28
  // regression suite below reconstructs the exact gateway turn-flush render
@@ -571,3 +573,72 @@ describe('isTurnFlushSafetyEnabled', () => {
571
573
  }
572
574
  })
573
575
  })
576
+
577
+ describe('selectFlushDeliveryText — deliver the terminal answer, strip only narration', () => {
578
+ const answer = 'A'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 20)
579
+
580
+ it('delivers ONLY the terminal answer when narration precedes the answer', () => {
581
+ // The duplicate-reply root cause: the flush fired while the model was still
582
+ // composing its reply, so capturedText held intent-narration blocks THEN
583
+ // the composed answer. Pre-fix the flush dumped the whole blob; post-fix it
584
+ // delivers only the answer. This test FAILS on the pre-fix `join('\n\n')`.
585
+ const blocks = ["Let me check that.", "I'll look it up now.", answer]
586
+ const out = selectFlushDeliveryText(blocks)
587
+ expect(out).toBe(answer)
588
+ expect(out).not.toContain('Let me check')
589
+ expect(out).not.toContain("I'll look it up")
590
+ })
591
+
592
+ // MEDIUM finding (adversarial review): a LONG (>=200) narration block FOLLOWED
593
+ // by a SHORT (<200) real answer. The pre-fix `find last block >= 200` returned
594
+ // the narration and DROPPED the short real answer. The terminal block is the
595
+ // answer regardless of length. FAILS on the pre-fix threshold scan.
596
+ it('delivers a SHORT real answer that follows a LONG (>=200) narration block', () => {
597
+ const verboseNarration =
598
+ 'Let me pull the numbers together before I answer — ' +
599
+ 'X'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS)
600
+ const realAnswer = 'Revenue was 4.2M, up 12% year over year.' // < 200 chars
601
+ expect(verboseNarration.length).toBeGreaterThanOrEqual(FLUSH_SUBSTANTIVE_MIN_CHARS)
602
+ expect(realAnswer.length).toBeLessThan(FLUSH_SUBSTANTIVE_MIN_CHARS)
603
+ const out = selectFlushDeliveryText([verboseNarration, realAnswer])
604
+ expect(out).toBe(realAnswer)
605
+ expect(out).not.toContain('Let me pull the numbers')
606
+ })
607
+
608
+ it('keeps the FULL joined text when an earlier block is real content, never truncating to the last paragraph', () => {
609
+ // Two substantive blocks, neither an intent-narration opener: this is a
610
+ // genuine multi-paragraph answer written as several blocks. Delivering only
611
+ // the last paragraph would drop real content — keep the whole thing.
612
+ const first = 'B'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
613
+ const last = 'C'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
614
+ expect(selectFlushDeliveryText([first, last])).toBe(`${first}\n\n${last}`)
615
+ })
616
+
617
+ it('keeps short two-paragraph answers joined (short legit answer preserved)', () => {
618
+ const blocks = ['First short paragraph.', 'Second short paragraph.']
619
+ expect(selectFlushDeliveryText(blocks)).toBe('First short paragraph.\n\nSecond short paragraph.')
620
+ })
621
+
622
+ it('a single block is delivered verbatim regardless of length', () => {
623
+ expect(selectFlushDeliveryText(['short answer'])).toBe('short answer')
624
+ expect(selectFlushDeliveryText([answer])).toBe(answer)
625
+ })
626
+
627
+ it('trims and drops empty blocks', () => {
628
+ expect(selectFlushDeliveryText([' ', '', ' hi '])).toBe('hi')
629
+ expect(selectFlushDeliveryText([])).toBe('')
630
+ })
631
+
632
+ it('decideTurnFlush delivers the narrowed answer, not the whole blob', () => {
633
+ const decision = decideTurnFlush({
634
+ chatId: 'chat1',
635
+ replyCalled: false,
636
+ capturedText: ['Let me check.', 'Now let me compose the reply.', answer],
637
+ })
638
+ expect(decision.kind).toBe('flush')
639
+ if (decision.kind === 'flush') {
640
+ expect(decision.text).toBe(answer)
641
+ expect(decision.text).not.toContain('Let me check')
642
+ }
643
+ })
644
+ })
@@ -1513,7 +1513,7 @@ describe('worker-feed send-gate shed contract', () => {
1513
1513
  expect(bot.edits[0].text).toContain('2 tools')
1514
1514
  })
1515
1515
 
1516
- it('does not falsely finalize on a shed terminal edit re-drives the finalize once the gate clears', async () => {
1516
+ it('a shed terminal edit drops the finished row immediately but stages the recap for a heartbeat re-drive (#3207)', async () => {
1517
1517
  let clock = 10_000
1518
1518
  const bot = makeGateBot(() => 0)
1519
1519
  const feed = createWorkerActivityFeed({
@@ -1522,25 +1522,30 @@ describe('worker-feed send-gate shed contract', () => {
1522
1522
  firstPaintMinMs: 0,
1523
1523
  minEditIntervalMs: 0,
1524
1524
  floodWaitRemainingMs: () => 0,
1525
+ setInterval: () => 1,
1526
+ clearInterval: () => {},
1525
1527
  })
1526
1528
 
1527
1529
  await feed.update('w1', 'chat', view({ toolCount: 1 }))
1528
1530
  expect(bot.sent).toHaveLength(1)
1529
1531
 
1530
- // Gate sheds the terminal edit (undefined).
1532
+ // Gate sheds the terminal edit (SEND_GATE_SHED sentinel).
1531
1533
  clock = 20_000
1532
1534
  bot.shedNextEdit = true
1533
1535
  await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
1534
1536
  expect(bot.editCalls).toBe(1)
1535
1537
  expect(bot.edits).toHaveLength(0)
1536
- // NOT finalized: the handle survives (pendingFinish staged) rather than
1537
- // being torn down with the card frozen on its last running render.
1538
- expect(feed.has('w1')).toBe(true)
1538
+ // #3207: the finished row is dropped RIGHT AWAY — it is NOT kept alive to
1539
+ // leak the group/pin when the terminal edit fails. The recap is staged for
1540
+ // the heartbeat re-drive instead (a second finish() would be a no-op: the
1541
+ // agent is already finalized + removed).
1542
+ expect(feed.has('w1')).toBe(false)
1543
+ expect(feed.size).toBe(0)
1539
1544
 
1540
- // Re-drive with the gate clear: the terminal recap lands and the handle
1541
- // finalizes.
1545
+ // A heartbeat with the gate clear re-drives the staged recap it lands.
1542
1546
  clock = 30_000
1543
- await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
1547
+ feed.heartbeatTick()
1548
+ await new Promise((r) => setTimeout(r, 0))
1544
1549
  expect(bot.edits).toHaveLength(1)
1545
1550
  expect(bot.edits[0].text).toContain('_done · 5 tools')
1546
1551
  expect(feed.has('w1')).toBe(false)