switchroom 0.19.44 → 0.19.46

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.
@@ -23,7 +23,9 @@ import {
23
23
  decideSupersedeCorrection,
24
24
  flushedAnswerMatchesReply,
25
25
  FlushedTurnSupersedeRegistry,
26
- DEFAULT_SUPERSEDE_TTL_MS,
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('does NOT supersede once the record is past its TTL', () => {
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 + DEFAULT_SUPERSEDE_TTL_MS + 1,
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({ ttlMs: 1000 })
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({ ttlMs: 1000 })
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 TTL (expired record never supersedes)', () => {
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 + DEFAULT_SUPERSEDE_TTL_MS + 1,
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,