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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +227 -59
- package/telegram-plugin/flushed-turn-supersede.ts +230 -0
- package/telegram-plugin/gateway/gateway.ts +69 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
- package/telegram-plugin/turn-flush-safety.ts +74 -1
- package/telegram-plugin/worker-activity-feed.ts +155 -45
|
@@ -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('
|
|
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 (
|
|
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
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
|
|
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
|
-
//
|
|
1541
|
-
// finalizes.
|
|
1545
|
+
// A heartbeat with the gate clear re-drives the staged recap → it lands.
|
|
1542
1546
|
clock = 30_000
|
|
1543
|
-
|
|
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)
|