switchroom 0.19.5 → 0.19.7
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 +156 -13
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/gateway.ts +36 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +91 -12
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +40 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +221 -0
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +435 -6
- package/telegram-plugin/tests/stream-render-golden.test.ts +142 -5
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +165 -0
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
33
|
import type { InboundSpool } from './inbound-spool.js'
|
|
34
|
+
import { stampsHandbackMarker } from './subagent-handback-marker.js'
|
|
34
35
|
|
|
35
36
|
/** Default cap per agent. Tuned for `should fit a reasonable backlog of
|
|
36
37
|
* approval cards stacked while bridge is offline` but no more. */
|
|
@@ -79,6 +80,20 @@ export interface PendingInboundBufferOptions {
|
|
|
79
80
|
* never breaks the push hot path.
|
|
80
81
|
*/
|
|
81
82
|
onEvict?: (agent: string, evicted: InboundMessage) => void
|
|
83
|
+
/**
|
|
84
|
+
* fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
|
|
85
|
+
* `subagent_handback` envelope (live synthesis AND boot-replay re-push),
|
|
86
|
+
* carrying the envelope's `chatId`, its `threadId` (the originating forum
|
|
87
|
+
* topic, or undefined for a DM), and its own `ts` (ms). The gateway wires this
|
|
88
|
+
* to the per-chat/thread subagent-handback marker so the supersede path can
|
|
89
|
+
* tell a flushed turn's own late reply from a background handback attributed to
|
|
90
|
+
* it — INCLUDING after a restart, where the only handback push is the replay.
|
|
91
|
+
* The `threadId` is passed so the marker keys on the SAME `chatId|threadId`
|
|
92
|
+
* lane the supersede registry uses (dup-audit F2): a handback in one topic must
|
|
93
|
+
* not hold the content gate open in another. Best-effort: a throw here never
|
|
94
|
+
* breaks the push hot path.
|
|
95
|
+
*/
|
|
96
|
+
onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
/**
|
|
@@ -342,6 +357,31 @@ export function createPendingInboundBuffer(
|
|
|
342
357
|
}
|
|
343
358
|
}
|
|
344
359
|
q.push(msg)
|
|
360
|
+
// fix/backstop-duplicate-reply MUST-FIX 2 — stamp the subagent-handback
|
|
361
|
+
// marker at THIS chokepoint, not at the live onFinish enqueue site alone.
|
|
362
|
+
// Every handback enqueue funnels through here — the live synthesis push
|
|
363
|
+
// AND the boot-replay re-push of un-acked spooled inbounds — so stamping
|
|
364
|
+
// here (rather than only at the live site) means a handback replayed after
|
|
365
|
+
// a restart still populates the marker. Otherwise the Map is empty
|
|
366
|
+
// post-boot and a replayed handback's late reply bypasses the #3429
|
|
367
|
+
// content gate → silent edit-over-answer. Uses the envelope's own `ts`
|
|
368
|
+
// (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
|
|
369
|
+
// handback actually happened, not the replay moment. Best-effort.
|
|
370
|
+
// F1 (dup-audit) — the ONE chokepoint that decides which sources stamp the
|
|
371
|
+
// decoupled-completion marker, delegated to the single `stampsHandbackMarker`
|
|
372
|
+
// predicate (its membership is the invariant's only extension point). Every
|
|
373
|
+
// inbound — live synthesis AND boot-replay — funnels through this push(), so
|
|
374
|
+
// routing the decision here makes "a decoupled late-reply source stamps the
|
|
375
|
+
// marker" true BY CONSTRUCTION rather than by per-feature discipline.
|
|
376
|
+
if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
|
|
377
|
+
try {
|
|
378
|
+
// F2 (dup-audit): pass the envelope's originating topic so the marker
|
|
379
|
+
// keys on the same `chatId|threadId` lane as the supersede registry.
|
|
380
|
+
opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts)
|
|
381
|
+
} catch {
|
|
382
|
+
/* marker stamp is best-effort; never break the push hot path */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
345
385
|
// Durable record FIRST-class to the in-memory queue: spool BEFORE
|
|
346
386
|
// returning, regardless of the cap eviction above — an entry the
|
|
347
387
|
// in-memory cap drops still survives in the spool (boot-replayed /
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chat-and-thread marker of the most recent gateway-synthesized
|
|
3
|
+
* `subagent_handback` enqueue (fix/backstop-duplicate-reply).
|
|
4
|
+
*
|
|
5
|
+
* A BACKGROUND sub-agent completion is a GATEWAY-SYNTHESIZED event, not model
|
|
6
|
+
* output: when a background worker terminates the gateway wakes the agent with a
|
|
7
|
+
* `subagent_handback` inbound. Recording WHEN one was enqueued, per chat/thread,
|
|
8
|
+
* is the ONE deterministic signal that distinguishes the two late-reply cases
|
|
9
|
+
* that both resolve a flush-delivered ENDED turn via the latest-ended tier — the
|
|
10
|
+
* case the owner-resolution tier alone cannot separate (a DM late reply has no
|
|
11
|
+
* live/origin/quoted attribution, so both land on latest-ended):
|
|
12
|
+
*
|
|
13
|
+
* - CASE A — the flushed turn's OWN reworded reply landing late. NO
|
|
14
|
+
* `subagent_handback` was enqueued for this chat/thread after the turn ended,
|
|
15
|
+
* so the reply is that turn's own answer → the supersede path collapses the
|
|
16
|
+
* provisional flush REGARDLESS of the model's rewording (closes the #3429
|
|
17
|
+
* reworded-duplicate regression: agent:marko 2026-07-20, turns
|
|
18
|
+
* #1177/#1182/#1201 double-sent).
|
|
19
|
+
* - CASE B — a background handback attributed to that ended turn. A
|
|
20
|
+
* `subagent_handback` WAS enqueued after the turn ended and within the
|
|
21
|
+
* supersede TTL, so the late reply might BE it → keep the #3429 content gate
|
|
22
|
+
* and send fresh (two messages), never silently edit/delete the flushed
|
|
23
|
+
* answer.
|
|
24
|
+
*
|
|
25
|
+
* ## Why thread-keyed (F2, dup-audit 2026-07-21)
|
|
26
|
+
*
|
|
27
|
+
* The supersede registry this marker gates is keyed on `chatId|threadId`
|
|
28
|
+
* (`flushed-turn-supersede.ts` `makeKey`). Keying the marker on `chatId` ALONE
|
|
29
|
+
* was inconsistent: in a forum supergroup a background handback in topic A
|
|
30
|
+
* stamped the chat-wide marker, and a genuine CASE-A reworded own-reply in ANY
|
|
31
|
+
* other topic within the 60 s TTL then computed `handbackCouldOwnReply = true` →
|
|
32
|
+
* kept the content gate → shipped a second visible bubble instead of collapsing.
|
|
33
|
+
* The blast radius was every topic in the chat for 60 s per handback. Keying on
|
|
34
|
+
* `chatId + threadId` — the SAME lane the supersede registry uses — confines a
|
|
35
|
+
* handback's gate-hold to the topic it actually landed in, so an unrelated
|
|
36
|
+
* topic's CASE-A collapse is untouched. A DM (no thread) collapses to the
|
|
37
|
+
* chat-only key, so single-lane behaviour is unchanged.
|
|
38
|
+
*
|
|
39
|
+
* One entry per chat/thread (overwritten on each enqueue), so bounded by
|
|
40
|
+
* chat×topic count. No clock reads beyond the caller-supplied `now`; the gateway
|
|
41
|
+
* wires the actual enqueue site and the supersede-path read. Deterministic —
|
|
42
|
+
* keyed on a gateway-emitted event, never on model discipline (Ken's
|
|
43
|
+
* controls-in-code rule).
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* ## The enforced invariant (F1, dup-audit 2026-07-21)
|
|
47
|
+
*
|
|
48
|
+
* The whole content-gate bypass rests on this premise: *a decoupled late reply
|
|
49
|
+
* (turn == null) carrying content that is NOT the flushed turn's own answer,
|
|
50
|
+
* resolving an ENDED flushed turn as its owner, can ONLY be a gateway-synthesized
|
|
51
|
+
* completion — and every such completion stamps this marker.* If that premise
|
|
52
|
+
* holds, then marker-ABSENCE in the window proves the late reply IS the flushed
|
|
53
|
+
* turn's own (possibly reworded) answer, so bypassing the content gate is safe
|
|
54
|
+
* (closes the marko duplicate). If a NEW synthesized-inbound source were ever
|
|
55
|
+
* able to land as a decoupled late reply with foreign content WITHOUT stamping,
|
|
56
|
+
* it would silently edit over a delivered answer (the #3429 failure).
|
|
57
|
+
*
|
|
58
|
+
* We make the premise an ENFORCED invariant rather than an architectural
|
|
59
|
+
* coincidence by routing the stamp decision through this ONE predicate at the ONE
|
|
60
|
+
* chokepoint every inbound funnels through (`pendingInboundBuffer.push` — live
|
|
61
|
+
* synthesis, boot-replay, and every future source alike). The membership of the
|
|
62
|
+
* decoupled-completion class is defined HERE and nowhere else:
|
|
63
|
+
*
|
|
64
|
+
* - `subagent_handback` — the only source today that wakes the agent to emit a
|
|
65
|
+
* reply with NO live gateway turn of its own (a background worker completion),
|
|
66
|
+
* so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
|
|
67
|
+
* tier. It is the F1 vector.
|
|
68
|
+
* - Every OTHER synthesized source (cron, resume_*, reaction, vault_*, …) lands
|
|
69
|
+
* as its OWN live inbound turn, so its reply resolves the `live` tier for its
|
|
70
|
+
* own turnId and structurally cannot supersede a different ended turn's flush
|
|
71
|
+
* record (`decideSupersede` requires `record.turnId === liveTurnId`). Those
|
|
72
|
+
* must NOT stamp — stamping them would needlessly hold the content gate open
|
|
73
|
+
* for an unrelated own-reply in the window (a safe but avoidable visible dup).
|
|
74
|
+
*
|
|
75
|
+
* This predicate is therefore the SINGLE point of extension: any future feature
|
|
76
|
+
* that synthesizes an inbound which can land as a DECOUPLED late reply (no live
|
|
77
|
+
* turn) MUST add its `meta.source` here — and because the chokepoint consults
|
|
78
|
+
* this predicate, doing so is the whole wiring. A source that forgets to opt in
|
|
79
|
+
* cannot reach the bypass unnoticed: the negative outcome guard
|
|
80
|
+
* (`send-reply-golden.test.ts`) pins that a decoupled foreign-content reply on a
|
|
81
|
+
* non-live tier never silently edits over the flushed answer.
|
|
82
|
+
*
|
|
83
|
+
* ## Inbound meta.source classification registry (F1 durability — dup-audit
|
|
84
|
+
* MUST-FIX 3, Fable 2026-07-21)
|
|
85
|
+
*
|
|
86
|
+
* The old predicate was a bare `=== 'subagent_handback'` with NO tripwire and an
|
|
87
|
+
* UNSAFE default: adding a new decoupled-completion source tripped nothing — no
|
|
88
|
+
* stamp → content-gate bypass eligible → silent edit-over of a delivered answer.
|
|
89
|
+
* This registry makes the classification EXPLICIT, EXHAUSTIVE and FAIL-SAFE:
|
|
90
|
+
*
|
|
91
|
+
* - Every known `meta.source` is listed with `decoupledCompletion`. Today ONLY
|
|
92
|
+
* `subagent_handback` is true; every other synthesized source lands as its
|
|
93
|
+
* OWN live inbound turn (live tier → cannot supersede a different ended
|
|
94
|
+
* turn's record), so it must NOT stamp.
|
|
95
|
+
* - An UNKNOWN / unclassified source FAILS SAFE: `stampsHandbackMarker` returns
|
|
96
|
+
* `true`, so it STAMPS → the content gate is KEPT → the worst case is a
|
|
97
|
+
* visible duplicate, NEVER a silent edit-over (inverted from the old
|
|
98
|
+
* deny-default, which failed toward silent loss).
|
|
99
|
+
* - The exhaustiveness test (`subagent-handback-marker.test.ts`) scans the
|
|
100
|
+
* gateway for `source:` / `meta.source ===` literals and FAILS when a new one
|
|
101
|
+
* is added without a registry entry — forcing a conscious classification.
|
|
102
|
+
*
|
|
103
|
+
* Defense-in-depth with the tier restriction (`outbound-send-path.ts`): the
|
|
104
|
+
* content-gate bypass is now limited to the `live` + `latest-ended` tiers, so
|
|
105
|
+
* model-steerable `quoted`/`origin` attributions can never bypass regardless of
|
|
106
|
+
* the marker. This registry closes the residual `latest-ended` vector for a
|
|
107
|
+
* FUTURE decoupled source, fail-safe.
|
|
108
|
+
*/
|
|
109
|
+
export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion: boolean }> = {
|
|
110
|
+
// The ONE decoupled-completion source today: a background worker termination
|
|
111
|
+
// wakes the agent with no live turn of its own → its reply resolves a
|
|
112
|
+
// different, already-ended turn via the latest-ended tier. THE F1 vector.
|
|
113
|
+
subagent_handback: { decoupledCompletion: true },
|
|
114
|
+
// Everything below lands as its OWN live inbound turn (live tier), so its reply
|
|
115
|
+
// resolves the live tier for its own turnId and structurally cannot supersede a
|
|
116
|
+
// different ended turn's record → not a decoupled-completion vector, must not stamp.
|
|
117
|
+
cron: { decoupledCompletion: false },
|
|
118
|
+
reaction: { decoupledCompletion: false },
|
|
119
|
+
subagent_progress: { decoupledCompletion: false },
|
|
120
|
+
resume_interrupted: { decoupledCompletion: false },
|
|
121
|
+
resume_deferred: { decoupledCompletion: false },
|
|
122
|
+
resume_watchdog_timeout: { decoupledCompletion: false },
|
|
123
|
+
vault_grant_approved: { decoupledCompletion: false },
|
|
124
|
+
vault_grant_denied: { decoupledCompletion: false },
|
|
125
|
+
vault_grant_timeout: { decoupledCompletion: false },
|
|
126
|
+
vault_save_completed: { decoupledCompletion: false },
|
|
127
|
+
vault_save_discarded: { decoupledCompletion: false },
|
|
128
|
+
vault_save_failed: { decoupledCompletion: false },
|
|
129
|
+
vault_save_timeout: { decoupledCompletion: false },
|
|
130
|
+
secret_provided: { decoupledCompletion: false },
|
|
131
|
+
secret_declined: { decoupledCompletion: false },
|
|
132
|
+
secret_provide_failed: { decoupledCompletion: false },
|
|
133
|
+
secret_request_timeout: { decoupledCompletion: false },
|
|
134
|
+
mental_model_propose_timeout: { decoupledCompletion: false },
|
|
135
|
+
bridge_dead_restart: { decoupledCompletion: false },
|
|
136
|
+
obligation_represent: { decoupledCompletion: false },
|
|
137
|
+
missed_approval_retry: { decoupledCompletion: false },
|
|
138
|
+
skill_proposal_apply: { decoupledCompletion: false },
|
|
139
|
+
warmup: { decoupledCompletion: false },
|
|
140
|
+
// dup-audit pass-2 (Fable) — sources the widened exhaustiveness scanner now
|
|
141
|
+
// sees. Each lands as its OWN live inbound turn (not a decoupled completion
|
|
142
|
+
// resolving a DIFFERENT ended turn), so it must NOT stamp — else its fail-safe
|
|
143
|
+
// stamp would hold the content gate chat-wide for 60 s and re-open the
|
|
144
|
+
// reworded-own-answer visible dup in that window.
|
|
145
|
+
// - mental_model_proposal_{applied,denied,failed}: resume-synthetic inbounds
|
|
146
|
+
// injected via `deliverResumeSyntheticOrBuffer` as their own live turn.
|
|
147
|
+
// - webhook / linear: built in `src/web/webhook-dispatch.ts`, delivered via
|
|
148
|
+
// the gateway's `webhookInject` (`sendToAgent`, buffer on miss) as their
|
|
149
|
+
// own live turn.
|
|
150
|
+
mental_model_proposal_applied: { decoupledCompletion: false },
|
|
151
|
+
mental_model_proposal_denied: { decoupledCompletion: false },
|
|
152
|
+
mental_model_proposal_failed: { decoupledCompletion: false },
|
|
153
|
+
webhook: { decoupledCompletion: false },
|
|
154
|
+
linear: { decoupledCompletion: false },
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether an inbound of this `meta.source` must stamp the decoupled-completion
|
|
159
|
+
* marker. Null/undefined (a normal user inbound — not synthesized) → false.
|
|
160
|
+
* A known source → its registry classification. An UNKNOWN source → true
|
|
161
|
+
* (fail-safe: stamp, so an unclassified future decoupled source can only cause a
|
|
162
|
+
* visible dup, never a silent edit-over).
|
|
163
|
+
*/
|
|
164
|
+
export function stampsHandbackMarker(source: string | null | undefined): boolean {
|
|
165
|
+
if (source == null) return false
|
|
166
|
+
const known = INBOUND_SOURCE_CLASSIFICATION[source]
|
|
167
|
+
if (known == null) return true // fail-safe: unknown synthesized source stamps
|
|
168
|
+
return known.decoupledCompletion
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
|
|
172
|
+
const MAIN_THREAD_KEY = '<main>'
|
|
173
|
+
|
|
174
|
+
export class SubagentHandbackMarker {
|
|
175
|
+
// chatId → (threadKey → last enqueue ms). Nested so the CONTENT-GATE read can
|
|
176
|
+
// query chat-wide (`lastAtInChat`) while the record stays thread-resolved.
|
|
177
|
+
private readonly byChat = new Map<string, Map<string, number>>()
|
|
178
|
+
|
|
179
|
+
private threadKey(threadId: number | undefined): string {
|
|
180
|
+
return threadId == null ? MAIN_THREAD_KEY : String(threadId)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Record that a `subagent_handback` was enqueued for `chatId`/`threadId` at
|
|
184
|
+
* `now` (ms). Thread-resolved so the record retains the originating topic. */
|
|
185
|
+
record(chatId: string, threadId: number | undefined, now: number): void {
|
|
186
|
+
let inner = this.byChat.get(chatId)
|
|
187
|
+
if (inner == null) {
|
|
188
|
+
inner = new Map<string, number>()
|
|
189
|
+
this.byChat.set(chatId, inner)
|
|
190
|
+
}
|
|
191
|
+
inner.set(this.threadKey(threadId), now)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Wall-clock ms of the most recent handback enqueue for a SPECIFIC
|
|
195
|
+
* `chatId`/`threadId` lane, or null. (Diagnostics / unit tests.) */
|
|
196
|
+
lastAt(chatId: string, threadId: number | undefined): number | null {
|
|
197
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Wall-clock ms of the most recent handback enqueue ANYWHERE in `chatId`
|
|
202
|
+
* (across every topic lane), or null. This is what the content-gate read uses
|
|
203
|
+
* (dup-audit MUST-FIX 2, Fable 2026-07-21): the owner-resolution latest-ended
|
|
204
|
+
* tier is CHAT-WIDE (`findLatestEndedTurnForChat` ignores thread), so a
|
|
205
|
+
* background handback in topic A can resolve — and supersede — topic B's
|
|
206
|
+
* ended turn. A thread-SPECIFIC gate read (the F2 regression) let a reply
|
|
207
|
+
* dodge that handback by carrying a different `message_thread_id`, silently
|
|
208
|
+
* editing over the answer. Querying chat-wide makes the gate un-steerable by
|
|
209
|
+
* the reply's own thread arg: any in-window handback in the chat keeps the
|
|
210
|
+
* content gate. The cost is the F2 visible-dup (a handback in one topic keeps
|
|
211
|
+
* the gate for a coinciding own-reply in another for ≤TTL) — a self-healing
|
|
212
|
+
* visible duplicate, which is strictly better than a silent edit-over.
|
|
213
|
+
*/
|
|
214
|
+
lastAtInChat(chatId: string): number | null {
|
|
215
|
+
const inner = this.byChat.get(chatId)
|
|
216
|
+
if (inner == null || inner.size === 0) return null
|
|
217
|
+
let max = -Infinity
|
|
218
|
+
for (const ts of inner.values()) if (ts > max) max = ts
|
|
219
|
+
return max === -Infinity ? null : max
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -84,6 +84,37 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
|
84
84
|
return age <= ttl
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Which precedence tier resolved a reply's owner turn.
|
|
89
|
+
*
|
|
90
|
+
* - `'live'` / `'origin'` / `'quoted'` are POSITIVE attributions: the reply
|
|
91
|
+
* is tied to a specific turn by a live atom, the model's own echo, or the
|
|
92
|
+
* framework-owned quoted message id — it IS that turn's answer.
|
|
93
|
+
* - `'latest-ended'` is the ambiguous FALLBACK: no positive link exists, so
|
|
94
|
+
* the reply is merely attributed to the chat's most-recently-ended turn.
|
|
95
|
+
* This tier cannot tell the turn's OWN late reply from an async sub-agent
|
|
96
|
+
* handback that has no live turn and no quote linkage — both land here with
|
|
97
|
+
* the same resolved turnId (#3429). The supersede content gate
|
|
98
|
+
* (`flushedAnswerMatchesReply`) is therefore applied ONLY on this tier.
|
|
99
|
+
* - `'none'` — every lookup missed (a genuinely unattributable reply).
|
|
100
|
+
*/
|
|
101
|
+
export type ReplyOwnerTier = 'live' | 'origin' | 'quoted' | 'latest-ended' | 'none'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The tier that WINS owner resolution for these candidates — the single source
|
|
105
|
+
* of the precedence order, consumed by both `resolveReplyOwnerTurnId` (which id)
|
|
106
|
+
* and the gateway (which discrimination the supersede applies). Same precedence,
|
|
107
|
+
* first non-null wins; the destructive latest-ended tier is honoured only when
|
|
108
|
+
* fresh (`latestEndedAccepted`).
|
|
109
|
+
*/
|
|
110
|
+
export function resolveReplyOwnerTier(candidates: ReplyOwnerCandidates): ReplyOwnerTier {
|
|
111
|
+
if (candidates.liveTurnId != null) return 'live'
|
|
112
|
+
if (candidates.originTurnId != null) return 'origin'
|
|
113
|
+
if (candidates.quotedTurnId != null) return 'quoted'
|
|
114
|
+
if (latestEndedAccepted(candidates)) return 'latest-ended'
|
|
115
|
+
return 'none'
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
/**
|
|
88
119
|
* Resolve the turnId that OWNS a landing reply, using the SAME full chain the
|
|
89
120
|
* thread-router uses. Precedence, first non-null wins:
|
|
@@ -98,13 +129,18 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
|
98
129
|
* to delete any turnId-bearing flush record.
|
|
99
130
|
*/
|
|
100
131
|
export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): string | null {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
132
|
+
switch (resolveReplyOwnerTier(candidates)) {
|
|
133
|
+
case 'live':
|
|
134
|
+
return candidates.liveTurnId
|
|
135
|
+
case 'origin':
|
|
136
|
+
return candidates.originTurnId
|
|
137
|
+
case 'quoted':
|
|
138
|
+
return candidates.quotedTurnId
|
|
139
|
+
case 'latest-ended':
|
|
140
|
+
return candidates.latestEndedTurnId
|
|
141
|
+
case 'none':
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
108
144
|
}
|
|
109
145
|
|
|
110
146
|
/**
|
|
@@ -381,3 +381,92 @@ describe('#3429 — decideSupersede new-content gate', () => {
|
|
|
381
381
|
expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
|
|
382
382
|
})
|
|
383
383
|
})
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The tier discriminator (fix/backstop-duplicate-reply). `positiveAttribution`
|
|
387
|
+
* lets the gateway bypass the #3429 content gate when the reply's owner turn was
|
|
388
|
+
* resolved by POSITIVE attribution (live / origin echo / quoted) rather than the
|
|
389
|
+
* ambiguous latest-ended fallback. On a positive tier a genuinely-DIFFERENT
|
|
390
|
+
* (reworded) reply for the same turn still supersedes — closing the reworded
|
|
391
|
+
* same-turn duplicate — while the latest-ended path keeps the content gate so a
|
|
392
|
+
* true async handback still sends fresh.
|
|
393
|
+
*/
|
|
394
|
+
describe('positiveAttribution — tier-gated content check', () => {
|
|
395
|
+
const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
|
|
396
|
+
const REWORDED =
|
|
397
|
+
'So, to wrap up: the incident boiled down to a stale cache entry, and it is fully resolved now.'
|
|
398
|
+
|
|
399
|
+
it('positiveAttribution=true: same turn + DIFFERENT (reworded) content STILL supersedes', () => {
|
|
400
|
+
// Guard: the reworded text is genuinely new vs the flushed blob (would be
|
|
401
|
+
// declined new-content without the tier bypass).
|
|
402
|
+
expect(flushedAnswerMatchesReply(FLUSHED, REWORDED)).toBe(false)
|
|
403
|
+
|
|
404
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
405
|
+
liveTurnId: 'turn-A',
|
|
406
|
+
replyText: REWORDED,
|
|
407
|
+
positiveAttribution: true,
|
|
408
|
+
now: 1_000_010,
|
|
409
|
+
})
|
|
410
|
+
expect(d.supersede).toBe(true)
|
|
411
|
+
expect(d.reason).toBe('supersede')
|
|
412
|
+
expect(d.deleteMessageIds).toEqual([101, 102])
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('positiveAttribution=false (latest-ended): the SAME reworded content declines as new-content', () => {
|
|
416
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
417
|
+
liveTurnId: 'turn-A',
|
|
418
|
+
replyText: REWORDED,
|
|
419
|
+
positiveAttribution: false,
|
|
420
|
+
now: 1_000_010,
|
|
421
|
+
})
|
|
422
|
+
expect(d.supersede).toBe(false)
|
|
423
|
+
expect(d.reason).toBe('new-content')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('positiveAttribution=true still requires turn IDENTITY (different turn never supersedes)', () => {
|
|
427
|
+
const d = decideSupersede(rec({ turnId: 'turn-A', text: FLUSHED }), {
|
|
428
|
+
liveTurnId: 'turn-B',
|
|
429
|
+
replyText: REWORDED,
|
|
430
|
+
positiveAttribution: true,
|
|
431
|
+
now: 1_000_010,
|
|
432
|
+
})
|
|
433
|
+
expect(d.supersede).toBe(false)
|
|
434
|
+
expect(d.reason).toBe('different-turn')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('positiveAttribution=true still respects the TTL (expired record never supersedes)', () => {
|
|
438
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
439
|
+
liveTurnId: 'turn-A',
|
|
440
|
+
replyText: REWORDED,
|
|
441
|
+
positiveAttribution: true,
|
|
442
|
+
now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
|
|
443
|
+
})
|
|
444
|
+
expect(d.supersede).toBe(false)
|
|
445
|
+
expect(d.reason).toBe('expired')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('registry take() threads positiveAttribution through to the decision', () => {
|
|
449
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
450
|
+
const now = 1_000_000
|
|
451
|
+
reg.record('chatT', undefined, { turnId: 'turn-Q', messageIds: [8001], text: FLUSHED }, now)
|
|
452
|
+
|
|
453
|
+
// latest-ended (positiveAttribution falsey): reworded → new-content, kept.
|
|
454
|
+
const ambiguous = reg.peek('chatT', undefined, {
|
|
455
|
+
liveTurnId: 'turn-Q',
|
|
456
|
+
replyText: REWORDED,
|
|
457
|
+
now: now + 5_000,
|
|
458
|
+
})
|
|
459
|
+
expect(ambiguous.reason).toBe('new-content')
|
|
460
|
+
|
|
461
|
+
// positive tier: same reworded reply supersedes and consumes the record.
|
|
462
|
+
const positive = reg.take('chatT', undefined, {
|
|
463
|
+
liveTurnId: 'turn-Q',
|
|
464
|
+
replyText: REWORDED,
|
|
465
|
+
positiveAttribution: true,
|
|
466
|
+
now: now + 6_000,
|
|
467
|
+
})
|
|
468
|
+
expect(positive.supersede).toBe(true)
|
|
469
|
+
expect(positive.deleteMessageIds).toEqual([8001])
|
|
470
|
+
expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
|
|
471
|
+
})
|
|
472
|
+
})
|
|
@@ -371,7 +371,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
371
371
|
assertSendable: () => {},
|
|
372
372
|
statusKey: key,
|
|
373
373
|
streamKey: key,
|
|
374
|
-
resolveReplyOwnerTurn: () => null,
|
|
374
|
+
resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
|
|
375
|
+
getLastSubagentHandbackAt: () => null,
|
|
375
376
|
findTurnByOriginId: () => null,
|
|
376
377
|
findTurnByQuotedMessageId: () => null,
|
|
377
378
|
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { describe, it, expect } from 'vitest'
|
|
33
33
|
import {
|
|
34
34
|
resolveReplyOwnerTurnId,
|
|
35
|
+
resolveReplyOwnerTier,
|
|
35
36
|
decideAnswerLatchSuppression,
|
|
36
37
|
type ReplyOwnerCandidates,
|
|
37
38
|
type AnswerDeliveredLatch,
|
|
@@ -611,3 +612,76 @@ describe('#3429 — flush-armed latch with content evidence', () => {
|
|
|
611
612
|
).toBe(false)
|
|
612
613
|
})
|
|
613
614
|
})
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* `resolveReplyOwnerTier` (fix/backstop-duplicate-reply) exposes WHICH precedence
|
|
618
|
+
* tier won, so the gateway can apply the #3429 content gate ONLY on the ambiguous
|
|
619
|
+
* `latest-ended` fallback. It shares one precedence with `resolveReplyOwnerTurnId`
|
|
620
|
+
* (asserted equivalent below), so the winning id and the winning tier can never
|
|
621
|
+
* disagree.
|
|
622
|
+
*/
|
|
623
|
+
describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () => {
|
|
624
|
+
const base: ReplyOwnerCandidates = {
|
|
625
|
+
liveTurnId: null,
|
|
626
|
+
originTurnId: null,
|
|
627
|
+
quotedTurnId: null,
|
|
628
|
+
latestEndedTurnId: null,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
it('live wins over every lower tier', () => {
|
|
632
|
+
expect(
|
|
633
|
+
resolveReplyOwnerTier({ ...base, liveTurnId: 'L', originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' }),
|
|
634
|
+
).toBe('live')
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('origin wins when no live turn', () => {
|
|
638
|
+
expect(resolveReplyOwnerTier({ ...base, originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('origin')
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('quoted wins when no live/origin turn (the positive DM recovery tier)', () => {
|
|
642
|
+
expect(resolveReplyOwnerTier({ ...base, quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('quoted')
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('latest-ended is the ambiguous FALLBACK when only it resolves', () => {
|
|
646
|
+
expect(resolveReplyOwnerTier({ ...base, latestEndedTurnId: 'E' })).toBe('latest-ended')
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('none when every lookup missed', () => {
|
|
650
|
+
expect(resolveReplyOwnerTier(base)).toBe('none')
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('a STALE latest-ended (age > TTL) is NOT accepted → none, never latest-ended', () => {
|
|
654
|
+
const stale: ReplyOwnerCandidates = {
|
|
655
|
+
...base,
|
|
656
|
+
latestEndedTurnId: 'E',
|
|
657
|
+
latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
|
|
658
|
+
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
659
|
+
}
|
|
660
|
+
expect(resolveReplyOwnerTier(stale)).toBe('none')
|
|
661
|
+
// And the id resolver agrees (no destructive authority granted to a stale turn).
|
|
662
|
+
expect(resolveReplyOwnerTurnId(stale)).toBe(null)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('tier and id resolver share one precedence for every candidate shape', () => {
|
|
666
|
+
const shapes: ReplyOwnerCandidates[] = [
|
|
667
|
+
base,
|
|
668
|
+
{ ...base, liveTurnId: 'L', quotedTurnId: 'Q', latestEndedTurnId: 'E' },
|
|
669
|
+
{ ...base, originTurnId: 'O', latestEndedTurnId: 'E' },
|
|
670
|
+
{ ...base, quotedTurnId: 'Q' },
|
|
671
|
+
{ ...base, latestEndedTurnId: 'E' },
|
|
672
|
+
]
|
|
673
|
+
const idForTier = (s: ReplyOwnerCandidates): string | null => {
|
|
674
|
+
switch (resolveReplyOwnerTier(s)) {
|
|
675
|
+
case 'live': return s.liveTurnId
|
|
676
|
+
case 'origin': return s.originTurnId
|
|
677
|
+
case 'quoted': return s.quotedTurnId
|
|
678
|
+
case 'latest-ended': return s.latestEndedTurnId
|
|
679
|
+
case 'none': return null
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
for (const s of shapes) {
|
|
683
|
+
// The id the winning tier points at equals what the id resolver returns.
|
|
684
|
+
expect(resolveReplyOwnerTurnId(s)).toBe(idForTier(s))
|
|
685
|
+
}
|
|
686
|
+
})
|
|
687
|
+
})
|