switchroom 0.19.44 → 0.19.45
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 +940 -1006
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +486 -314
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +66 -65
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
|
@@ -437,10 +437,18 @@ function endCurrentTurnAtomic(
|
|
|
437
437
|
const turnEndedAt = Date.now()
|
|
438
438
|
// 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
|
|
439
439
|
// `latest-ended` supersede tier can be recency-bounded to the
|
|
440
|
-
//
|
|
440
|
+
// turn-completion window (a stale latest-ended turn must not inherit deletion
|
|
441
441
|
// authority over a newer turn's flush record). Set once; idempotent on the
|
|
442
442
|
// deferRecord flush path (which calls this synchronously before its send).
|
|
443
443
|
turn.endedAt = turnEndedAt
|
|
444
|
+
// #4173 — default completion stamp: for every ordinary turn-end path, the
|
|
445
|
+
// turn ending IS the session's real stop, so the completion window is born
|
|
446
|
+
// closed (the latest-ended tier keeps its tight ~60 s replay grace). The ONE
|
|
447
|
+
// path where that is false — the answer-ready quiescence flush, which ends
|
|
448
|
+
// the turn synthetically while the session is still composing — re-opens the
|
|
449
|
+
// window right after this call (`flushCompletionTracker.open`, stream-render
|
|
450
|
+
// turn-flush branch) and holds it until the REAL turn_end is observed.
|
|
451
|
+
turn.realEndObservedAt = turnEndedAt
|
|
444
452
|
process.stderr.write(
|
|
445
453
|
`telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
|
|
446
454
|
)
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
* feeds their resolved turnIds here; the precedence lives in one place.
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
|
+
// The one cross-module import this pure module takes — a sibling PURE module's
|
|
45
|
+
// constant, so the two consumers of the turn-completion window (#4173) can
|
|
46
|
+
// never disagree on the default replay grace.
|
|
47
|
+
import { SUPERSEDE_COMPLETED_GRACE_MS } from './flushed-turn-supersede.js'
|
|
48
|
+
|
|
44
49
|
/**
|
|
45
50
|
* The four owner-turn candidate ids, in the gateway's resolution precedence.
|
|
46
51
|
* Each is the `turnId` of the turn a given lookup resolved, or null when that
|
|
@@ -63,38 +68,75 @@ export interface ReplyOwnerCandidates {
|
|
|
63
68
|
latestEndedTurnId: string | null
|
|
64
69
|
/** Age (ms) of the latest-ended turn — `now - turn.endedAt`. The latest-ended
|
|
65
70
|
* tier carries DESTRUCTIVE authority (it drives supersede deletion), so it is
|
|
66
|
-
* honoured ONLY
|
|
67
|
-
*
|
|
68
|
-
* resolve its owner to a NEWER turn now sitting at the
|
|
69
|
-
* delete THAT turn's legit answer.
|
|
71
|
+
* honoured ONLY within the turn-completion window (#4173 — see
|
|
72
|
+
* `latestEndedRealEndAgeMs`). Without a bound, a late reply belonging to an
|
|
73
|
+
* OLDER turn could resolve its owner to a NEWER turn now sitting at the
|
|
74
|
+
* registry tail and delete THAT turn's legit answer.
|
|
70
75
|
*
|
|
71
76
|
* Two distinct absences (#3725):
|
|
72
|
-
* - `undefined` (property omitted) ⇒
|
|
73
|
-
*
|
|
77
|
+
* - `undefined` (property omitted) ⇒ no age supplied at all — fails
|
|
78
|
+
* CLOSED since #4175 (the pre-F2 "unbounded" escape granted unbounded
|
|
79
|
+
* destructive authority exactly when the wiring was DROPPED, the
|
|
80
|
+
* dangerous direction);
|
|
74
81
|
* - explicit `null` ⇒ the caller COMPUTED no age, i.e. its candidate turn
|
|
75
|
-
* has no `endedAt` and has NOT ended. That cannot be
|
|
82
|
+
* has no `endedAt` and has NOT ended. That cannot be bounded, so it
|
|
76
83
|
* fails CLOSED (not accepted) rather than granting unbounded authority
|
|
77
84
|
* to a turn that is still running. */
|
|
78
85
|
latestEndedAgeMs?: number | null
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
86
|
+
/** Bound applied to `latestEndedAgeMs` while the turn's completion window is
|
|
87
|
+
* OPEN (#4173): for a flush-ended turn whose REAL turn_end has not been
|
|
88
|
+
* observed yet, this is the crash-backstop cap
|
|
89
|
+
* (`SUPERSEDE_OPEN_WINDOW_CAP_MS`). Undefined ⇒ fails closed (#4175). */
|
|
81
90
|
latestEndedTtlMs?: number
|
|
91
|
+
/** #4173 — the turn-completion signal, ms since the candidate turn's REAL
|
|
92
|
+
* turn_end was observed (`turn.realEndObservedAt`):
|
|
93
|
+
* - `null` ⇒ the real turn_end has NOT been observed (a flush ended this
|
|
94
|
+
* turn synthetically and the session is still composing) — the OPEN
|
|
95
|
+
* phase; acceptance falls to `latestEndedAgeMs <= latestEndedTtlMs`
|
|
96
|
+
* (the crash backstop), so a 20-minute compaction still resolves.
|
|
97
|
+
* - a number ⇒ the session stopped that long ago — the COMPLETED phase;
|
|
98
|
+
* accepted only within `latestEndedCompletedGraceMs` (the bounded
|
|
99
|
+
* tool-call-replay window). For a normally-ended turn the real end IS
|
|
100
|
+
* `endedAt`, so this equals `latestEndedAgeMs` and the tier keeps its
|
|
101
|
+
* original tight ~60 s bound.
|
|
102
|
+
* - `undefined` ⇒ legacy caller shape (no completion signal wired):
|
|
103
|
+
* falls back to the plain `age <= ttl` rule. */
|
|
104
|
+
latestEndedRealEndAgeMs?: number | null
|
|
105
|
+
/** COMPLETED-phase bound for `latestEndedRealEndAgeMs`. Undefined ⇒ the
|
|
106
|
+
* module default (`SUPERSEDE_COMPLETED_GRACE_MS`). */
|
|
107
|
+
latestEndedCompletedGraceMs?: number
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/**
|
|
85
111
|
* Whether the latest-ended candidate is fresh enough to carry supersede
|
|
86
|
-
* (deletion) authority
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
112
|
+
* (deletion) authority — the turn-completion-window rule (#4173, three phases
|
|
113
|
+
* documented on `latestEndedRealEndAgeMs` above).
|
|
114
|
+
*
|
|
115
|
+
* An EXPLICIT null age fails closed (#3725 — the caller computed no age
|
|
116
|
+
* because its candidate turn has not ended, and an un-ended turn must be
|
|
117
|
+
* resolved by the `live` tier, never by this destructive fallback). An OMITTED
|
|
118
|
+
* age or TTL also fails closed (#4175): the old "omitted ⇒ unbounded"
|
|
119
|
+
* back-compat escape meant DROPPING the gateway's bound wiring silently
|
|
120
|
+
* granted unbounded destructive authority — failing toward silent edit-over.
|
|
121
|
+
* Now dropped wiring degrades to "tier never accepted" — a visible duplicate,
|
|
122
|
+
* the safe direction.
|
|
90
123
|
*/
|
|
91
124
|
function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
|
|
92
125
|
if (candidates.latestEndedTurnId == null) return false
|
|
93
126
|
const age = candidates.latestEndedAgeMs
|
|
94
127
|
const ttl = candidates.latestEndedTtlMs
|
|
95
|
-
if (age
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
if (age == null || ttl == null) return false
|
|
129
|
+
const realEndAge = candidates.latestEndedRealEndAgeMs
|
|
130
|
+
if (realEndAge === undefined || realEndAge === null) {
|
|
131
|
+
// Legacy caller (no completion signal) or OPEN window (real turn_end not
|
|
132
|
+
// yet observed): the plain age-vs-cap rule. For an open window `ttl` is
|
|
133
|
+
// the generous crash backstop, so a long compaction still resolves.
|
|
134
|
+
return age <= ttl
|
|
135
|
+
}
|
|
136
|
+
// COMPLETED: the session stopped `realEndAge` ago — only the bounded
|
|
137
|
+
// tool-call-replay grace remains.
|
|
138
|
+
const grace = candidates.latestEndedCompletedGraceMs ?? SUPERSEDE_COMPLETED_GRACE_MS
|
|
139
|
+
return realEndAge <= grace
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
/**
|
|
@@ -207,6 +249,21 @@ export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): strin
|
|
|
207
249
|
* `live` keeps its unconditional bypass: `currentTurn` is framework-owned, and
|
|
208
250
|
* `decideSupersede`'s same-turnId requirement already bars it from reaching a
|
|
209
251
|
* DIFFERENT ended turn's record. `none` never bypasses (nothing to attribute).
|
|
252
|
+
*
|
|
253
|
+
* ## The second ambiguity: a live sub-agent (#4176)
|
|
254
|
+
*
|
|
255
|
+
* `handbackCouldOwnReply` covers the sub-agent completion that the gateway
|
|
256
|
+
* SYNTHESIZED as an inbound. It does not cover a background sub-agent calling
|
|
257
|
+
* `reply` DIRECTLY, mid-run: that call rides the same main bridge as the parent
|
|
258
|
+
* loop (so the #4172 caller-identity gate cannot see it) and never traverses
|
|
259
|
+
* `pendingInboundBuffer.push` (so no marker is stamped). Marker-absence is only
|
|
260
|
+
* evidence of "the turn's own answer" if nothing else on the session could have
|
|
261
|
+
* emitted this reply — which is exactly what `subagentCouldOwnReply` reports,
|
|
262
|
+
* from gateway-observed `sub_agent_*` liveness. When it is true the content gate
|
|
263
|
+
* is KEPT on every non-`live` tier: a reply that IS the flushed answer still
|
|
264
|
+
* collapses, a sub-agent's foreign content sends fresh. See
|
|
265
|
+
* `gateway/subagent-reply-authority.ts` for the ordering argument and the
|
|
266
|
+
* failure directions.
|
|
210
267
|
*/
|
|
211
268
|
export function decideContentGateBypass(input: {
|
|
212
269
|
/** The winning owner tier (`resolveReplyOwnerTier`). */
|
|
@@ -216,18 +273,36 @@ export function decideContentGateBypass(input: {
|
|
|
216
273
|
/** The SAME candidate set both of the above were derived from — supplies the
|
|
217
274
|
* framework-derived `latestEndedTurnId` plus its freshness bound, so the
|
|
218
275
|
* corroboration reuses the EXACT rule `resolveReplyOwnerTier` applies
|
|
219
|
-
* (`latestEndedAccepted`) instead of duplicating it: within the
|
|
220
|
-
* since #3725 — not a turn that is
|
|
276
|
+
* (`latestEndedAccepted`) instead of duplicating it: within the
|
|
277
|
+
* turn-completion window (#4173), and — since #3725 — not a turn that is
|
|
278
|
+
* still running. */
|
|
221
279
|
candidates: ReplyOwnerCandidates
|
|
222
280
|
/** True when a decoupled-completion inbound (`subagent_handback`) was enqueued
|
|
223
|
-
* in this chat AFTER the owner turn ended and within the
|
|
224
|
-
*
|
|
225
|
-
* the
|
|
281
|
+
* in this chat AFTER the owner turn ended and within the handback recency
|
|
282
|
+
* window (`HANDBACK_RECENCY_WINDOW_MS`, #4174) — the ambiguous window where
|
|
283
|
+
* the late reply might BE that handback rather than the turn's own answer.
|
|
284
|
+
* Keeps the content gate on every non-`live` tier. */
|
|
226
285
|
handbackCouldOwnReply: boolean
|
|
286
|
+
/** #4176 — true when a sub-agent is LIVE on this session
|
|
287
|
+
* (`subagentReplyAuthority`, gateway/subagent-reply-authority.ts). A
|
|
288
|
+
* background `Task` sub-agent calls `reply` over the SAME main bridge as the
|
|
289
|
+
* parent loop, so the #4172 caller-identity gate cannot see it and — because
|
|
290
|
+
* a DIRECT tool call never traverses `pendingInboundBuffer.push` — it stamps
|
|
291
|
+
* no handback marker either. Marker-absence therefore does NOT prove "the
|
|
292
|
+
* turn's own answer" while a sub-agent is live: the reply might be the
|
|
293
|
+
* sub-agent's, and bypassing the content gate would silently EDIT OVER the
|
|
294
|
+
* flushed answer. Typed REQUIRED and evaluated FAIL-CLOSED (anything but an
|
|
295
|
+
* explicit `false` keeps the gate): `telegram-plugin/` is not covered by
|
|
296
|
+
* `tsc --noEmit`, so the type alone cannot stop a caller from dropping the
|
|
297
|
+
* wiring — and the #4175 precedent is that a dropped bound fails closed,
|
|
298
|
+
* never open. */
|
|
299
|
+
subagentCouldOwnReply: boolean
|
|
227
300
|
}): boolean {
|
|
228
301
|
if (input.tier === 'live') return true
|
|
229
302
|
if (input.tier === 'none') return false
|
|
230
303
|
if (input.handbackCouldOwnReply) return false
|
|
304
|
+
// Fail-CLOSED on an omitted flag (see the field doc): `!== false`, not truthiness.
|
|
305
|
+
if (input.subagentCouldOwnReply !== false) return false
|
|
231
306
|
// Total over degenerate input (#3726). TypeScript makes `candidates` mandatory
|
|
232
307
|
// and the one production caller (`resolveReplyOwnerTurn`) always builds it, so
|
|
233
308
|
// this cannot fire today — but this module is exported precisely so the
|
|
@@ -260,16 +335,17 @@ export function decideContentGateBypass(input: {
|
|
|
260
335
|
* handback pattern (#3426): the parent turn's interim ack (a substantive
|
|
261
336
|
* `reply`) armed the latch, the turn ended, and the sub-agent completion
|
|
262
337
|
* handback — a genuinely NEW answer arriving with no live gateway turn —
|
|
263
|
-
* resolved the ended turn as owner (latest-ended tier, inside the 60 s
|
|
264
|
-
* supersede
|
|
338
|
+
* resolved the ended turn as owner (latest-ended tier, inside the — then 60 s —
|
|
339
|
+
* supersede window), saw the stale latch, and was silently dropped with a false
|
|
265
340
|
* "deduped" success. Tagging the source lets the suppression fire ONLY for the
|
|
266
341
|
* flush races it exists for; byte-identical replays of a reply-delivered
|
|
267
342
|
* answer remain covered by the content-keyed outbound dedup (#546).
|
|
268
343
|
*
|
|
269
344
|
* Honest bound on that dedup cover: the #546 TTL (60 s) is anchored at reply
|
|
270
|
-
* RECORD time, while the latest-ended owner tier's
|
|
271
|
-
* turn's
|
|
272
|
-
*
|
|
345
|
+
* RECORD time, while the latest-ended owner tier's bound is the turn-completion
|
|
346
|
+
* window (#4173) anchored at the turn's OBSERVED real end — later by the
|
|
347
|
+
* reply→turn_end gap. A byte-identical replay landing >60 s after record but
|
|
348
|
+
* still inside the completion window is evicted from dedup yet
|
|
273
349
|
* still resolves the ended turn, so it DELIVERS as a duplicate message. That
|
|
274
350
|
* is a conscious trade: this fix also drops the weak "reworded/bridge-replayed
|
|
275
351
|
* duplicate" suppression the reply-armed boolean latch used to provide —
|
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
decideSupersedeCorrection,
|
|
24
24
|
flushedAnswerMatchesReply,
|
|
25
25
|
FlushedTurnSupersedeRegistry,
|
|
26
|
-
|
|
26
|
+
FlushCompletionTracker,
|
|
27
|
+
SUPERSEDE_OPEN_WINDOW_CAP_MS,
|
|
28
|
+
SUPERSEDE_COMPLETED_GRACE_MS,
|
|
27
29
|
SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
|
|
28
30
|
type FlushedTurnRecord,
|
|
29
31
|
} from '../flushed-turn-supersede.js'
|
|
@@ -33,6 +35,8 @@ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
|
|
|
33
35
|
messageIds: [101, 102],
|
|
34
36
|
text: 'narration\n\nthe real answer',
|
|
35
37
|
ts: 1_000_000,
|
|
38
|
+
// #4173 — default fixture is an OPEN window (real turn_end not yet observed).
|
|
39
|
+
completedAt: null,
|
|
36
40
|
...over,
|
|
37
41
|
})
|
|
38
42
|
|
|
@@ -133,10 +137,60 @@ describe('decideSupersede — the duplicate-reply decision core', () => {
|
|
|
133
137
|
expect(d.reason).toBe('different-turn')
|
|
134
138
|
})
|
|
135
139
|
|
|
136
|
-
it('
|
|
140
|
+
it('2026-08-01 incident gap: an OPEN record is still live 143 s after the flush ' +
|
|
141
|
+
'(Stop-hook nudge + proactive /compact delayed the canonical reply)', () => {
|
|
142
|
+
// klanker msgs 25680/25682: under the old 60 s TTL this returned 'expired'
|
|
143
|
+
// and the reply shipped as a second bubble. With the turn-completion window
|
|
144
|
+
// (#4173) the record stays claimable while the session is still composing
|
|
145
|
+
// (real turn_end not yet observed — completedAt null).
|
|
137
146
|
const d = decideSupersede(rec(), {
|
|
138
147
|
liveTurnId: 'turn-A',
|
|
139
|
-
now: 1_000_000 +
|
|
148
|
+
now: 1_000_000 + 143_000,
|
|
149
|
+
})
|
|
150
|
+
expect(d.supersede).toBe(true)
|
|
151
|
+
expect(d.reason).toBe('supersede')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('#4173 outcome: an OPEN record survives a 20-minute /compact — any fixed ' +
|
|
155
|
+
'sub-20-min TTL turns this red', () => {
|
|
156
|
+
// The point of the completion signal over a widened constant: a compaction
|
|
157
|
+
// longer than any tuned TTL still collapses to ONE bubble, because the
|
|
158
|
+
// claude session has not stopped (no real turn_end observed).
|
|
159
|
+
const d = decideSupersede(rec(), {
|
|
160
|
+
liveTurnId: 'turn-A',
|
|
161
|
+
now: 1_000_000 + 20 * 60_000,
|
|
162
|
+
})
|
|
163
|
+
expect(d.supersede).toBe(true)
|
|
164
|
+
expect(d.reason).toBe('supersede')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('an OPEN record past the crash-backstop cap does NOT supersede', () => {
|
|
168
|
+
const d = decideSupersede(rec(), {
|
|
169
|
+
liveTurnId: 'turn-A',
|
|
170
|
+
now: 1_000_000 + SUPERSEDE_OPEN_WINDOW_CAP_MS + 1,
|
|
171
|
+
})
|
|
172
|
+
expect(d.supersede).toBe(false)
|
|
173
|
+
expect(d.reason).toBe('expired')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('#4173: a COMPLETED record stays claimable through the replay grace ' +
|
|
177
|
+
'(the bridge-reconnect tool_call replay class) …', () => {
|
|
178
|
+
const d = decideSupersede(rec({ completedAt: 1_010_000 }), {
|
|
179
|
+
liveTurnId: 'turn-A',
|
|
180
|
+
now: 1_010_000 + SUPERSEDE_COMPLETED_GRACE_MS - 1,
|
|
181
|
+
})
|
|
182
|
+
expect(d.supersede).toBe(true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('… and EXPIRES past the grace — a late claimant (the Task-sub-agent-after-' +
|
|
186
|
+
'turn-end shape) can no longer reach the record at all', () => {
|
|
187
|
+
// Once the session stopped and the grace lapsed, nothing may edit/delete
|
|
188
|
+
// the flushed answer — a genuinely late duplicate ships fresh (safe).
|
|
189
|
+
const d = decideSupersede(rec({ completedAt: 1_010_000 }), {
|
|
190
|
+
liveTurnId: 'turn-A',
|
|
191
|
+
replyText: 'unrelated worker handback content, definitely not the answer',
|
|
192
|
+
positiveAttribution: true,
|
|
193
|
+
now: 1_010_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1,
|
|
140
194
|
})
|
|
141
195
|
expect(d.supersede).toBe(false)
|
|
142
196
|
expect(d.reason).toBe('expired')
|
|
@@ -249,7 +303,7 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
|
|
|
249
303
|
})
|
|
250
304
|
|
|
251
305
|
it('size() evicts expired records and prunes emptied lanes', () => {
|
|
252
|
-
const reg = new FlushedTurnSupersedeRegistry({
|
|
306
|
+
const reg = new FlushedTurnSupersedeRegistry({ openCapMs: 1000 })
|
|
253
307
|
reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
|
|
254
308
|
reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 1000)
|
|
255
309
|
expect(reg.size(1500)).toBe(2)
|
|
@@ -257,7 +311,7 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
|
|
|
257
311
|
})
|
|
258
312
|
|
|
259
313
|
it('record() actively sweeps expired records (LOW-2 GC — no orphan accumulation)', () => {
|
|
260
|
-
const reg = new FlushedTurnSupersedeRegistry({
|
|
314
|
+
const reg = new FlushedTurnSupersedeRegistry({ openCapMs: 1000 })
|
|
261
315
|
reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
|
|
262
316
|
// A much later flush on a DIFFERENT lane sweeps the now-expired turn-A record.
|
|
263
317
|
reg.record('chat2', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 5000)
|
|
@@ -434,12 +488,12 @@ describe('positiveAttribution — tier-gated content check', () => {
|
|
|
434
488
|
expect(d.reason).toBe('different-turn')
|
|
435
489
|
})
|
|
436
490
|
|
|
437
|
-
it('positiveAttribution=true still respects the
|
|
438
|
-
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
491
|
+
it('positiveAttribution=true still respects the window (expired record never supersedes)', () => {
|
|
492
|
+
const d = decideSupersede(rec({ text: FLUSHED, completedAt: 1_000_000 }), {
|
|
439
493
|
liveTurnId: 'turn-A',
|
|
440
494
|
replyText: REWORDED,
|
|
441
495
|
positiveAttribution: true,
|
|
442
|
-
now: 1_000_000 +
|
|
496
|
+
now: 1_000_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1,
|
|
443
497
|
})
|
|
444
498
|
expect(d.supersede).toBe(false)
|
|
445
499
|
expect(d.reason).toBe('expired')
|
|
@@ -470,3 +524,51 @@ describe('positiveAttribution — tier-gated content check', () => {
|
|
|
470
524
|
expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
|
|
471
525
|
})
|
|
472
526
|
})
|
|
527
|
+
|
|
528
|
+
describe('#4173 — turn-completion window plumbing (completeAll / FlushCompletionTracker)', () => {
|
|
529
|
+
it('completeAll(now) flips OPEN records to grace-bounded; already-completed keep their earlier stamp', () => {
|
|
530
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
531
|
+
reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1_000)
|
|
532
|
+
reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b', completedAt: 2_000 }, 1_000)
|
|
533
|
+
reg.completeAll(50_000)
|
|
534
|
+
// turn-A: completed at 50s → claimable until 50s+grace, expired after.
|
|
535
|
+
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 50_000 + SUPERSEDE_COMPLETED_GRACE_MS - 1 }).supersede).toBe(true)
|
|
536
|
+
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 50_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
|
|
537
|
+
// turn-B kept its ORIGINAL earlier completion (2s) — completeAll never widens.
|
|
538
|
+
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-B', now: 2_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('a record born completed (flush send finished AFTER the close) is grace-bounded, never open', () => {
|
|
542
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
543
|
+
// The close ran at t=5s while the flush send was in flight; record lands at
|
|
544
|
+
// t=6s carrying the atom's stamped completion.
|
|
545
|
+
reg.record('chatX', undefined, { turnId: 'turn-C', messageIds: [9], text: 'c', completedAt: 5_000 }, 6_000)
|
|
546
|
+
expect(reg.peek('chatX', undefined, { liveTurnId: 'turn-C', now: 5_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('FlushCompletionTracker: open() nulls realEndObservedAt; closeAll() stamps every pending atom once', () => {
|
|
550
|
+
const t = new FlushCompletionTracker()
|
|
551
|
+
const turnA = { realEndObservedAt: 111 as number | null }
|
|
552
|
+
const turnB = { realEndObservedAt: 222 as number | null }
|
|
553
|
+
t.open(turnA)
|
|
554
|
+
t.open(turnB)
|
|
555
|
+
expect(turnA.realEndObservedAt).toBeNull()
|
|
556
|
+
expect(turnB.realEndObservedAt).toBeNull()
|
|
557
|
+
expect(t.pendingCount()).toBe(2)
|
|
558
|
+
expect(t.closeAll(9_000)).toBe(2)
|
|
559
|
+
expect(turnA.realEndObservedAt).toBe(9_000)
|
|
560
|
+
expect(turnB.realEndObservedAt).toBe(9_000)
|
|
561
|
+
expect(t.pendingCount()).toBe(0)
|
|
562
|
+
// Idempotent: nothing pending → nothing closed, stamps untouched.
|
|
563
|
+
expect(t.closeAll(10_000)).toBe(0)
|
|
564
|
+
expect(turnA.realEndObservedAt).toBe(9_000)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it('FlushCompletionTracker: re-open of the same atom does not double-register', () => {
|
|
568
|
+
const t = new FlushCompletionTracker()
|
|
569
|
+
const turn = { realEndObservedAt: null as number | null }
|
|
570
|
+
t.open(turn)
|
|
571
|
+
t.open(turn)
|
|
572
|
+
expect(t.pendingCount()).toBe(1)
|
|
573
|
+
})
|
|
574
|
+
})
|
|
@@ -423,6 +423,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
423
423
|
streamKey: key,
|
|
424
424
|
resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
|
|
425
425
|
getLastSubagentHandbackAt: () => null,
|
|
426
|
+
// #4176 — no sub-agent live on this session (see subagent-reply-authority.ts).
|
|
427
|
+
subagentReplyAuthority: { subagentCouldOwnReply: () => false },
|
|
426
428
|
findTurnByOriginId: () => null,
|
|
427
429
|
findTurnByQuotedMessageId: () => null,
|
|
428
430
|
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|