switchroom 0.19.45 → 0.19.47

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.
@@ -238,6 +238,18 @@ export interface HandbackPreturnSignalDeps {
238
238
  * is the GLOBAL busy signal because the handback queues behind whatever turn
239
239
  * is running, regardless of topic. Optional; defaults to "not busy". */
240
240
  isClaudeBusy?: () => boolean
241
+ /** True IFF the gateway observed the SESSION consume input after `sinceTs` —
242
+ * a turn minting at the enqueue seam, or a main-session MCP tool call over
243
+ * the IPC bridge (`session-consume-signal.ts`). Consulted at the moment the
244
+ * reap would otherwise declare a genuine orphan: claude holds ONE FIFO input
245
+ * queue, so post-delivery consumption evidence means the delivered handback
246
+ * entered the model's context even though no adopting turn was ever minted
247
+ * (the 2026-08-02 invisible-consumption incident — the session answered the
248
+ * handback via a `reply` from idle with zero session-tail events, then the
249
+ * reap re-injected the already-answered report twice). When true the reap
250
+ * CLEANS UP (card + typing + entry) without re-injecting and without
251
+ * escalating. Optional; defaults to "no evidence" (legacy re-inject). */
252
+ sessionActivitySince?: (sinceTs: number) => boolean
241
253
  now?: () => number
242
254
  /** Debounce before painting the pre-turn card (kills sub-second flicker). */
243
255
  debounceMs?: number
@@ -487,13 +499,27 @@ export function createHandbackPreturnSignal(
487
499
  entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
488
500
  return
489
501
  }
502
+ // INVISIBLE-CONSUMPTION GATE (2026-08-02 klanker incident). We are at the
503
+ // point the reap would declare a genuine orphan: claude is idle, the
504
+ // delivered TTL elapsed, and no adopting turn ever minted. But "no adopting
505
+ // turn" is NOT proof the handback went un-consumed — the session can
506
+ // consume injected input WITHOUT the gateway minting a turn (zero
507
+ // session-tail events; the only trace was a `reply` tool call from idle).
508
+ // If the gateway observed the session consume input after this handback
509
+ // was delivered (a later turn minting, or a main-session tool call —
510
+ // claude's single FIFO queue means either proves the earlier delivery
511
+ // entered the model's context), the handback was already processed:
512
+ // re-injecting it re-runs an answered report (observed: the same worker
513
+ // report processed three times). Clean up WITHOUT re-injection.
514
+ const consumedElsewhere = deps.sessionActivitySince?.(entry.deliveredAt) === true
490
515
  entry.consumed = true
491
- // GENUINE ORPHAN. Recover DETERMINISTICALLY — never ask the operator to
492
- // nudge. Stop the forever-running typing loop, DELETE the frozen card (do
493
- // not leave a stale "needs a nudge" message), then re-inject the handback so
494
- // the machine re-processes it itself. Cap the re-injections; past the cap
495
- // escalate to fleet-health telemetry (NOT a chat card).
496
- stopTypingUnlessTurnLive(entry, 'orphan reap')
516
+ // GENUINE ORPHAN (or consumed-elsewhere cleanup). Recover DETERMINISTICALLY
517
+ // — never ask the operator to nudge. Stop the forever-running typing loop,
518
+ // DELETE the frozen card (do not leave a stale "needs a nudge" message),
519
+ // then — for a genuine orphan only — re-inject the handback so the machine
520
+ // re-processes it itself. Cap the re-injections; past the cap escalate to
521
+ // fleet-health telemetry (NOT a chat card).
522
+ stopTypingUnlessTurnLive(entry, consumedElsewhere ? 'consumed reap' : 'orphan reap')
497
523
  if (entry.activityMessageId != null) {
498
524
  const record: PreTurnCardRecord = {
499
525
  turnKey: entry.syntheticTurnKey,
@@ -518,6 +544,18 @@ export function createHandbackPreturnSignal(
518
544
  // synchronously) is not swallowed by the per-topic dedupe guard. The entry
519
545
  // object stays valid for the captured re-inject below.
520
546
  dropEntry(entry)
547
+ if (consumedElsewhere) {
548
+ // Distinct structured line so fleet-health can track how often the
549
+ // session consumes a handback without a gateway-visible adopting turn
550
+ // (the underlying session-tail anomaly stays observable even though the
551
+ // false re-injection is gone).
552
+ log(
553
+ `handback-preturn-signal: reap resolved key=${entry.statusKey} ` +
554
+ `turnId=${entry.adoptTurnId} — session consumed input after delivery ` +
555
+ `(no adopting turn observed); cleaned up without re-injection\n`,
556
+ )
557
+ return
558
+ }
521
559
  if (entry.reinjectCount < maxReinjects) {
522
560
  const nextCount = entry.reinjectCount + 1
523
561
  // Stamp the counter on the inbound so it survives the buffer round trip
@@ -0,0 +1,72 @@
1
+ /**
2
+ * session-consume-signal.ts — "did the claude session consume input after T?"
3
+ *
4
+ * Built for the 2026-08-02 klanker false-orphan incident (handback turnId
5
+ * `#1785628912223`). A background worker's `subagent_handback` was released and
6
+ * DELIVERED to the bridge at 00:01:53Z; the session consumed it and answered it
7
+ * (a `reply` tool call at 00:02:58Z, IPC-observed, while the gateway's shadow
8
+ * state was `bridge_alive_idle`) — but NO gateway turn ever minted for it: the
9
+ * session-tail produced no `enqueue`/stream events for that consumption, so
10
+ * `handback-preturn-signal`'s entry was never adopted. After the delivered TTL
11
+ * the reap declared a genuine orphan and RE-INJECTED the already-answered
12
+ * handback — twice — re-processing the same worker report three times.
13
+ *
14
+ * The orphan reap's existing gates (`isClaudeBusy`, the delivered TTL) key off
15
+ * gateway-minted TURN state, which is exactly the signal that was absent. This
16
+ * module tracks the two consumption proxies the gateway CAN always observe,
17
+ * independent of the session-tail:
18
+ *
19
+ * - a TURN MINT (the `enqueue` seam in stream-render): claude holds a single
20
+ * FIFO input queue, so a turn minting for an inbound delivered AFTER the
21
+ * handback proves everything delivered before it entered the model's
22
+ * context (2026-08-02: real turn `#25821` minted 00:04:13Z and completed,
23
+ * while the reap still declared the 00:01:53Z handback orphaned);
24
+ * - a MAIN-SESSION MCP TOOL CALL over the IPC bridge (reply, progress_update,
25
+ * …): the session can only produce a tool call by processing input, so a
26
+ * tool call arriving after the delivery is direct evidence the session is
27
+ * alive and consuming — the 00:02:58Z reply was the ONLY observable trace
28
+ * of the invisible consumption. Cron/foreign sessions are excluded at the
29
+ * call site (their activity says nothing about the main session's queue).
30
+ *
31
+ * Consumers ask `activitySince(deliveredAt)`. The deliberate trade: when this
32
+ * returns true for a handback that was in fact DROPPED before reaching claude
33
+ * (bridge death in the release→write window) while unrelated session activity
34
+ * happened to occur, the reap now cleans up instead of re-injecting — a lost
35
+ * recovery for a rare double-fault, logged distinctly for fleet-health. The
36
+ * previous behaviour re-injected an already-answered handback on every
37
+ * invisible consumption, which is the incident that actually occurs.
38
+ *
39
+ * Pure state, no I/O; module singleton mirrors `subagentReplyAuthority`.
40
+ */
41
+
42
+ export class SessionConsumeSignal {
43
+ private lastTurnMintAt = 0
44
+ private lastMainToolCallAt = 0
45
+
46
+ /** A gateway turn minted at the `enqueue` seam (stream-render). */
47
+ noteTurnMint(now: number = Date.now()): void {
48
+ if (now > this.lastTurnMintAt) this.lastTurnMintAt = now
49
+ }
50
+
51
+ /** A NON-CRON client's MCP tool call arrived over the IPC bridge. The call
52
+ * site excludes cron identities — a Tier-1 cheap-cron session consuming its
53
+ * own input proves nothing about the main session's queue. */
54
+ noteMainToolCall(now: number = Date.now()): void {
55
+ if (now > this.lastMainToolCallAt) this.lastMainToolCallAt = now
56
+ }
57
+
58
+ /** True IFF either consumption proxy was observed strictly after `sinceTs`. */
59
+ activitySince(sinceTs: number): boolean {
60
+ return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs
61
+ }
62
+
63
+ /** Test-only: forget all observations. */
64
+ reset(): void {
65
+ this.lastTurnMintAt = 0
66
+ this.lastMainToolCallAt = 0
67
+ }
68
+ }
69
+
70
+ /** The ONE live instance (one CLI session per gateway process — the same
71
+ * module-singleton shape as `subagentReplyAuthority`). */
72
+ export const sessionConsumeSignal = new SessionConsumeSignal()
@@ -75,6 +75,7 @@ import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
75
75
  import { decideTurnFlush } from '../turn-flush-safety.js'
76
76
  import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
77
77
  import { subagentReplyAuthority } from './subagent-reply-authority.js'
78
+ import { sessionConsumeSignal } from './session-consume-signal.js'
78
79
  import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
79
80
  import { chatKey, chatKeyWithSuffix } from './chat-key.js'
80
81
  import { deriveTurnId } from './derive-turn-id.js'
@@ -747,6 +748,10 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
747
748
  process.stderr.write(
748
749
  `telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
749
750
  )
751
+ // Consumption proxy for the handback orphan reap (session-consume-signal):
752
+ // claude holds ONE FIFO input queue, so this turn minting proves every
753
+ // inbound delivered before its own entered the model's context.
754
+ sessionConsumeSignal.noteTurnMint(startedAt)
750
755
  // Component 3 — retain in the bounded recently-ended registry so a
751
756
  // LATE reply (landing after currentTurn flips to a successor) can
752
757
  // still resolve THIS turn's origin thread by its turnId.
@@ -30,11 +30,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
30
30
  import {
31
31
  shouldArmAnswerReadyFlush,
32
32
  resolveAnswerReadyFlushMs,
33
+ resolveAnswerStageMs,
33
34
  ANSWER_READY_FLUSH_MS,
35
+ ANSWER_STAGE_MS,
34
36
  AnswerReadyFlushController,
35
37
  type AnswerReadyArmInput,
36
38
  type FlushTimerHandle,
37
39
  } from '../answer-ready-flush.js'
40
+ import { decideTurnFlush } from '../turn-flush-safety.js'
38
41
  import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
39
42
 
40
43
  const CHAT = '12345'
@@ -174,9 +177,10 @@ class GatewayWorld {
174
177
  window = WINDOW
175
178
  readonly controller: AnswerReadyFlushController<FakeTurn>
176
179
 
177
- constructor() {
180
+ constructor(stageMs = 0) {
178
181
  this.currentTurn = { capturedText: [], replyCalled: false, answerReadyFlushTimeoutId: null }
179
182
  this.controller = new AnswerReadyFlushController<FakeTurn>({
183
+ stageWindowMs: stageMs,
180
184
  getCurrentTurn: () => this.currentTurn,
181
185
  getArmInput: (turn) => ({
182
186
  flush: { chatId: CHAT, replyCalled: turn.replyCalled, capturedText: turn.capturedText, flushEnabled: true },
@@ -203,14 +207,32 @@ class GatewayWorld {
203
207
  }
204
208
 
205
209
  /** = handleSessionEvent({turn_end}). A null atom short-circuits (no second
206
- * send); otherwise deliver once and tear the turn down. */
210
+ * send); otherwise the turn-flush branch runs the REAL `decideTurnFlush`
211
+ * classifier (exactly as stream-render does at turn_end): a reply-served
212
+ * turn tears down without a flush send; a captured-answer turn delivers
213
+ * once. Then tear the turn down. */
207
214
  dispatchTurnEnd(): void {
208
215
  const turn = this.currentTurn
209
216
  if (turn == null) return // atom already gone → exactly-once
210
- this.sends++
217
+ const d = decideTurnFlush({
218
+ chatId: CHAT,
219
+ replyCalled: turn.replyCalled,
220
+ capturedText: turn.capturedText,
221
+ flushEnabled: true,
222
+ })
223
+ if (d.kind === 'flush') this.sends++
211
224
  this.endCurrentTurnAtomic(turn)
212
225
  }
213
226
 
227
+ /** = the model's `reply` tool call landing on the live turn: the canonical
228
+ * answer message is sent on the normal reply path and the turn is marked
229
+ * reply-served (turn teardown waits for the real turn_end). */
230
+ onReply(): void {
231
+ if (this.currentTurn == null) return
232
+ this.currentTurn.replyCalled = true
233
+ this.sends++ // the ONE canonical user-visible message
234
+ }
235
+
214
236
  /** = case 'text': push the chunk, then (re)arm via the REAL controller. */
215
237
  onText(chunk: string): void {
216
238
  if (this.currentTurn != null) this.currentTurn.capturedText.push(chunk)
@@ -341,3 +363,125 @@ describe('answer-ready quiescence flush — real controller integration', () =>
341
363
  expect(vi.getTimerCount()).toBe(0)
342
364
  })
343
365
  })
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Stage-don't-send (2026-08-02 live repro — klanker DM, msgs 25843/25844).
369
+ // The quiescence flush posted the composed answer at 00:22:02Z; the model's
370
+ // `reply` tool call landed 4 s later (delivered 00:22:36Z as msg 25844, a
371
+ // REWORDING — not containment-equal) while a background sub-agent was live, so
372
+ // the #4176 content-gate hold shipped it FRESH: two near-identical messages.
373
+ // With staging, the quiescence expiry HOLDS the answer on the live turn and
374
+ // only promotes when the completion window closes with no reply — the
375
+ // provisional send (and everything downstream that had to claw it back) never
376
+ // happens. These timelines assert OUTCOMES: user-visible messages sent.
377
+ // ---------------------------------------------------------------------------
378
+
379
+ const STAGE = 30_000
380
+
381
+ describe('answer-ready stage-don\'t-send — completion-window staging', () => {
382
+ beforeEach(() => vi.useFakeTimers())
383
+ afterEach(() => vi.useRealTimers())
384
+
385
+ it('LIVE REPRO (msgs 25843/25844): a reply landing inside the stage window → exactly ONE message', () => {
386
+ const w = new GatewayWorld(STAGE)
387
+ // 00:21:30–00:22:00 — the model streams its terminal narration.
388
+ w.onText('Yes, that one\'s done, not pending. …the composed terminal answer…')
389
+ // 00:22:01 — quiescence fires. Pre-fix this SENT msg 25843; staged, it must
390
+ // send NOTHING (the turn stays live — typing hold — awaiting completion).
391
+ vi.advanceTimersByTime(WINDOW)
392
+ expect(w.sends).toBe(0) // ← RED on the immediate-flush behaviour
393
+ expect(w.currentTurn).not.toBeNull() // turn NOT ended by a provisional flush
394
+ // 00:22:06 — the model's `reply` lands (the reworded canonical answer).
395
+ vi.advanceTimersByTime(5_000)
396
+ w.onReply()
397
+ expect(w.sends).toBe(1) // the one canonical message (msg 25844)
398
+ // The stage promotion timer expires → fire-time re-verify sees replyCalled
399
+ // and DISCARDS the staged text. No second message, ever.
400
+ vi.advanceTimersByTime(STAGE)
401
+ expect(w.sends).toBe(1)
402
+ // The real turn_end finally lands: reply-served → teardown, no flush.
403
+ w.dispatchTurnEnd()
404
+ expect(w.sends).toBe(1)
405
+ expect(w.records).toBe(1)
406
+ vi.advanceTimersByTime(BACKSTOP_MS)
407
+ expect(w.sends).toBe(1)
408
+ expect(vi.getTimerCount()).toBe(0)
409
+ })
410
+
411
+ it('promotes the staged answer when the completion window closes with NO reply (delivery preserved)', () => {
412
+ const w = new GatewayWorld(STAGE)
413
+ w.onText('The composed final answer.')
414
+ vi.advanceTimersByTime(WINDOW)
415
+ expect(w.sends).toBe(0) // staged, not sent
416
+ // No reply, no real turn_end (the KNOWN-UNRELIABLE turn_end case PR A was
417
+ // built for): the stage window closes → promote through the same flush path.
418
+ vi.advanceTimersByTime(STAGE - 1)
419
+ expect(w.sends).toBe(0)
420
+ vi.advanceTimersByTime(1)
421
+ expect(w.sends).toBe(1)
422
+ expect(w.records).toBe(1)
423
+ // Exactly once: a late real turn_end short-circuits on the null atom.
424
+ w.dispatchTurnEnd()
425
+ expect(w.sends).toBe(1)
426
+ })
427
+
428
+ it('a REAL turn_end during the stage window delivers via the turn-flush branch — no double-send', () => {
429
+ const w = new GatewayWorld(STAGE)
430
+ w.onText('The composed final answer.')
431
+ vi.advanceTimersByTime(WINDOW) // staged
432
+ expect(w.sends).toBe(0)
433
+ vi.advanceTimersByTime(5_000)
434
+ w.dispatchTurnEnd() // the true completion signal lands first
435
+ expect(w.sends).toBe(1) // delivered by the turn-flush branch at turn_end
436
+ // endCurrentTurnAtomic's clear() cancelled the pending promotion timer.
437
+ expect(vi.getTimerCount()).toBe(0)
438
+ vi.advanceTimersByTime(BACKSTOP_MS)
439
+ expect(w.sends).toBe(1)
440
+ })
441
+
442
+ it('new text during the stage window re-stages (model still composing) and promotes once', () => {
443
+ const w = new GatewayWorld(STAGE)
444
+ w.onText('First part of the answer. ')
445
+ vi.advanceTimersByTime(WINDOW) // staged
446
+ expect(w.sends).toBe(0)
447
+ vi.advanceTimersByTime(10_000)
448
+ w.onText('Second part — the model was still composing.') // re-arms the debounce
449
+ vi.advanceTimersByTime(WINDOW) // re-staged with a fresh window
450
+ expect(w.sends).toBe(0)
451
+ vi.advanceTimersByTime(STAGE)
452
+ expect(w.sends).toBe(1) // promoted exactly once, with the full text captured
453
+ })
454
+
455
+ it('a tool starting during the stage window discards the stage (model resumed work)', () => {
456
+ const w = new GatewayWorld(STAGE)
457
+ w.onText('Interim narration that looked terminal.')
458
+ vi.advanceTimersByTime(WINDOW) // staged
459
+ w.onToolUse('bash_1') // clear() → unstaged + promotion timer cancelled
460
+ vi.advanceTimersByTime(BACKSTOP_MS)
461
+ expect(w.sends).toBe(0)
462
+ expect(vi.getTimerCount()).toBe(0)
463
+ })
464
+
465
+ it('stageMs=0 keeps the legacy immediate flush at quiescence', () => {
466
+ const w = new GatewayWorld(0)
467
+ w.onText('The composed final answer.')
468
+ vi.advanceTimersByTime(WINDOW)
469
+ expect(w.sends).toBe(1) // legacy: delivered at the ~1 s debounce
470
+ })
471
+ })
472
+
473
+ describe('resolveAnswerStageMs', () => {
474
+ it('defaults to ANSWER_STAGE_MS when unset', () => {
475
+ expect(resolveAnswerStageMs({})).toBe(ANSWER_STAGE_MS)
476
+ })
477
+ it('parses a positive override', () => {
478
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '15000' })).toBe(15_000)
479
+ })
480
+ it('treats 0 (and negatives) as the legacy immediate flush (0)', () => {
481
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '0' })).toBe(0)
482
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '-5' })).toBe(0)
483
+ })
484
+ it('fails safe to the default on an unparseable value', () => {
485
+ expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: 'soon' })).toBe(ANSWER_STAGE_MS)
486
+ })
487
+ })
@@ -22,6 +22,7 @@ import { describe, it, expect } from 'vitest'
22
22
  import { EventEmitter } from 'node:events'
23
23
 
24
24
  import {
25
+ buildRelayArgs,
25
26
  parseLoopbackRedirect,
26
27
  extractConsentUrl,
27
28
  parseConsentUrl,
@@ -492,6 +493,7 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
492
493
  email: 'user@example.com',
493
494
  replace: false,
494
495
  write: false,
496
+ calendar: false,
495
497
  orgMode: false,
496
498
  })
497
499
  })
@@ -501,6 +503,26 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
501
503
  expect(p).toMatchObject({ kind: 'provider-add', replace: true, write: true })
502
504
  })
503
505
 
506
+ it('parses google --calendar, and it does not imply --write', () => {
507
+ const p = parseAuthCommand('/auth google add user@example.com --replace --calendar')
508
+ expect(p).toMatchObject({
509
+ kind: 'provider-add',
510
+ replace: true,
511
+ calendar: true,
512
+ write: false,
513
+ })
514
+ })
515
+
516
+ it('parses --write and --calendar together', () => {
517
+ const p = parseAuthCommand('/auth google add user@example.com --write --calendar')
518
+ expect(p).toMatchObject({ kind: 'provider-add', write: true, calendar: true })
519
+ })
520
+
521
+ it('--calendar is google-only — microsoft rejects it as an unknown flag', () => {
522
+ const p = parseAuthCommand('/auth microsoft add user@corp.com --calendar')
523
+ expect(p).toMatchObject({ kind: 'help' })
524
+ })
525
+
504
526
  it('parses /auth microsoft add with --org-mode', () => {
505
527
  const p = parseAuthCommand('/auth microsoft add user@corp.com --org-mode')
506
528
  expect(p).toMatchObject({
@@ -531,3 +553,39 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
531
553
  expect(p).toMatchObject({ kind: 'help' })
532
554
  })
533
555
  })
556
+
557
+
558
+ describe('buildRelayArgs — chat flags actually reach the CLI', () => {
559
+ it('bare google add: no opt-in flags', () => {
560
+ expect(buildRelayArgs('google', 'a@b.com', {})).toEqual([
561
+ 'auth', 'google', 'account', 'add', 'a@b.com',
562
+ ])
563
+ })
564
+
565
+ it('forwards --calendar', () => {
566
+ expect(buildRelayArgs('google', 'a@b.com', { calendar: true })).toEqual([
567
+ 'auth', 'google', 'account', 'add', 'a@b.com', '--calendar',
568
+ ])
569
+ })
570
+
571
+ it('forwards --replace --write --calendar together, in order', () => {
572
+ expect(
573
+ buildRelayArgs('google', 'a@b.com', {
574
+ replace: true,
575
+ write: true,
576
+ calendar: true,
577
+ }),
578
+ ).toEqual([
579
+ 'auth', 'google', 'account', 'add', 'a@b.com',
580
+ '--replace', '--write', '--calendar',
581
+ ])
582
+ })
583
+
584
+ it('--calendar never leaks into the microsoft argv', () => {
585
+ expect(
586
+ buildRelayArgs('microsoft', 'a@corp.com', { calendar: true, orgMode: true }),
587
+ ).toEqual([
588
+ 'auth', 'microsoft', 'account', 'add', 'a@corp.com', '--org-mode',
589
+ ])
590
+ })
591
+ })
@@ -12,6 +12,7 @@ import {
12
12
  type PreTurnCardRecord,
13
13
  } from '../gateway/handback-preturn-signal.js'
14
14
  import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
15
+ import { SessionConsumeSignal } from '../gateway/session-consume-signal.js'
15
16
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
16
17
 
17
18
  /**
@@ -471,6 +472,82 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
471
472
  expect(h.signal.pendingCount()).toBe(0)
472
473
  })
473
474
 
475
+ // ── 2026-08-02 LIVE REPRO REPLAY (klanker, turnId #1785628912223) ──
476
+ // A handback was released+delivered at T0; the session CONSUMED it and
477
+ // answered (a reply tool call from idle at T0+65s — the only observable
478
+ // trace; no adopting turn ever minted), a later real turn minted and
479
+ // completed — yet at T0+TTL the reap declared a genuine orphan and
480
+ // RE-INJECTED the already-answered report (attempt 1/2 at 00:06:53Z,
481
+ // 2/2 at 00:11:58Z, escalation 00:17:34Z). The invisible-consumption gate
482
+ // must clean up WITHOUT re-injection. This test is RED on the pre-fix seam
483
+ // (it re-injects) and GREEN with `sessionActivitySince` consulted.
484
+ it('does NOT re-inject a handback the session already consumed (reply-from-idle after delivery)', async () => {
485
+ const consume = new SessionConsumeSignal()
486
+ const h = makeHarness({
487
+ deliveredTtlMs: 300_000,
488
+ sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
489
+ })
490
+ // T0: release (= delivery) — pre-turn entry armed, card painted at debounce.
491
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatConsumed', messageId: 900 }))
492
+ await h.sched.advance(700)
493
+ const cardId = await h.openCard.mock.results[0]!.value
494
+ expect(h.records.size).toBe(1)
495
+
496
+ // T0+65s: the session answers the handback — a main-session reply tool
497
+ // call observed over IPC while the gateway shows no live turn (the
498
+ // 00:02:58Z reply). NO gateway turn ever mints, so tryAdopt never fires.
499
+ await h.sched.advance(65_000)
500
+ consume.noteMainToolCall(h.sched.now())
501
+
502
+ // T0+TTL and beyond: the reap fires (idle, delivered TTL elapsed). The
503
+ // consumption evidence must resolve the entry WITHOUT re-injection and
504
+ // WITHOUT escalation — the frozen card is deleted, typing stops, entry
505
+ // dropped. OUTCOME: zero re-injected inbounds ever reach the buffer.
506
+ await h.sched.advance(600_000)
507
+ expect(h.reinjected).toEqual([])
508
+ expect(h.escalations).toEqual([])
509
+ expect(h.deletedIds).toEqual([cardId])
510
+ expect(h.records.size).toBe(0)
511
+ expect(h.signal.pendingCount()).toBe(0)
512
+ expect(h.typing.activeCount()).toBe(0)
513
+ })
514
+
515
+ it('a later turn MINT after delivery also counts as consumption (coalesced-turn case)', async () => {
516
+ const consume = new SessionConsumeSignal()
517
+ const h = makeHarness({
518
+ deliveredTtlMs: 300_000,
519
+ sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
520
+ })
521
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatCoalesced', messageId: 901 }))
522
+ await h.sched.advance(700)
523
+ // A real user turn mints AFTER the delivery (the 00:04:13Z turn #25821):
524
+ // FIFO queue ⇒ the earlier-delivered handback entered the model context.
525
+ await h.sched.advance(140_000)
526
+ consume.noteTurnMint(h.sched.now())
527
+ await h.sched.advance(600_000)
528
+ expect(h.reinjected).toEqual([])
529
+ expect(h.escalations).toEqual([])
530
+ expect(h.signal.pendingCount()).toBe(0)
531
+ })
532
+
533
+ it('still re-injects a GENUINE orphan (no session activity since delivery)', async () => {
534
+ const consume = new SessionConsumeSignal()
535
+ const h = makeHarness({
536
+ deliveredTtlMs: 300_000,
537
+ sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
538
+ })
539
+ // Activity BEFORE the delivery must not mask a genuine orphan.
540
+ consume.noteMainToolCall(h.sched.now())
541
+ await h.sched.advance(10)
542
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatDead', messageId: 902 }))
543
+ await h.sched.advance(700)
544
+ // Nothing consumes anything; past the TTL the deterministic recovery must
545
+ // still fire exactly as before the gate (delete + re-inject, no nudge).
546
+ await h.sched.advance(600_000)
547
+ expect(h.reinjected).toHaveLength(1)
548
+ expect(h.deletedIds).toHaveLength(1)
549
+ })
550
+
474
551
  // ── deterministic-recovery retry cap: re-inject up to 2×, THEN escalate ──
475
552
  // A genuine orphan whose re-injections keep coming back orphaned must not loop
476
553
  // forever, and must NEVER fall back to a chat nudge. After `maxReinjects` it