switchroom 0.19.4 → 0.19.6

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.
Files changed (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -46,6 +46,8 @@ import {
46
46
  } from '../gateway/outbound-send-path.js'
47
47
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
48
  import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
49
+ import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
50
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
49
51
  import { redact } from '../secret-detect/redact.js'
50
52
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
51
53
 
@@ -209,7 +211,8 @@ function makeHarness(opts?: {
209
211
  assertSendable: () => {},
210
212
  statusKey: key,
211
213
  streamKey: key,
212
- resolveReplyOwnerTurn: () => null,
214
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
215
+ getLastSubagentHandbackAt: () => null,
213
216
  findTurnByOriginId: () => null,
214
217
  findTurnByQuotedMessageId: () => null,
215
218
  resolveAnswerThreadWithLog: (_c, explicit) => explicit,
@@ -633,8 +636,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
633
636
  Date.now(),
634
637
  )
635
638
  // The late reply resolves the ENDED flush-delivered turn as its owner
636
- // (latest-ended tier — no live turn, no origin echo, no quote in a DM).
637
- h.deps.resolveReplyOwnerTurn = () => owner
639
+ // (latest-ended tier — no live turn, no origin echo, no quote in a DM). This
640
+ // is the AMBIGUOUS fallback tier: the content gate applies here, so a
641
+ // genuinely-new handback sends fresh while the turn's own contained answer
642
+ // still supersedes.
643
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
638
644
  }
639
645
 
640
646
  it('CORE REGRESSION (red pre-fix): a handback with genuinely NEW content sends a ' +
@@ -642,6 +648,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
642
648
  const h = makeHarness()
643
649
  const owner = makeFlushDeliveredEndedTurn()
644
650
  seedFlushRecord(h, owner)
651
+ // A background sub-agent handback WAS enqueued for this chat after the
652
+ // flushed turn ended and within the supersede TTL — so this late reply might
653
+ // BE that handback. The marker keeps the #3429 content gate. (owner.endedAt
654
+ // = now-30s; handback at now-15s is after it and within the 60s TTL.)
655
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
645
656
 
646
657
  // The handback lands LATE: req.turn = null (no live gateway turn — a
647
658
  // sub-agent completion is not a new inbound).
@@ -700,8 +711,8 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
700
711
  const h = makeHarness()
701
712
  const owner = makeFlushDeliveredEndedTurn()
702
713
  // NO registry record (the post-fire pre-record window); owner resolution
703
- // still recovers the ended flush-armed turn.
704
- h.deps.resolveReplyOwnerTurn = () => owner
714
+ // still recovers the ended flush-armed turn via the latest-ended tier.
715
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
705
716
 
706
717
  const res = await sendReply(h.deps, req(HANDBACK))
707
718
 
@@ -715,11 +726,215 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
715
726
  'window is still suppressed (the #2996 Part 2 backstop holds)', async () => {
716
727
  const h = makeHarness()
717
728
  const owner = makeFlushDeliveredEndedTurn()
718
- h.deps.resolveReplyOwnerTurn = () => owner
729
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
719
730
 
720
731
  const res = await sendReply(h.deps, req(FLUSHED_TEXT))
721
732
 
722
733
  expect(h.calls).toHaveLength(0) // nothing sent, nothing edited
723
734
  expect(res.content[0]!.text).toContain('deduped')
724
735
  })
736
+
737
+ // ─── The REAL marko DM incident (fix/backstop-duplicate-reply) ───
738
+ //
739
+ // The dominant real duplicate: the model narrates its answer as prose (flushed
740
+ // as message A), then fires the `reply` tool with a RE-WORDED version of the
741
+ // same answer. On a DM this late reply has NO live turn, NO `origin_turn_id`
742
+ // echo, and NO explicit `reply_to`, so it resolves its owner via the ambiguous
743
+ // `latest-ended` tier — the #3429 content gate then declines (reworded text ⊄
744
+ // flushed blob) and a fresh SECOND bubble ships = the visible duplicate
745
+ // (agent:marko 2026-07-20: turns #1177/#1182/#1201 double-sent; 11/11 declines
746
+ // were own-replies with NO handback in flight).
747
+ //
748
+ // The two cases are indistinguishable by tier (both latest-ended) and by
749
+ // content (rewording). The deterministic discriminator is the gateway-
750
+ // synthesized `subagent_handback` marker: case A has NONE enqueued after the
751
+ // flushed turn ended; a genuine background handback (case B) does. These two
752
+ // tests drive the REAL send path with the REAL supersede registry over the
753
+ // SAME reworded text and the SAME latest-ended tier, differing ONLY in the
754
+ // handback marker — proving it is the marker that flips the outcome.
755
+ const REWORDED_SAME_TURN_ANSWER =
756
+ 'Good news on the fleet: every one of the twelve agents is running fine right now. ' +
757
+ 'The gateway, the vault broker and the approval kernel are all green, and nothing has ' +
758
+ 'restarted in the past day — so there is nothing you need to do.'
759
+
760
+ function seedRecord(h: ReturnType<typeof makeHarness>, owner: CurrentTurn): void {
761
+ h.deps.flushedTurnSupersede.record(
762
+ CHAT,
763
+ undefined,
764
+ { turnId: owner.turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
765
+ Date.now(),
766
+ )
767
+ }
768
+
769
+ it('CASE A — REAL marko DM incident: reworded own reply, latest-ended tier, ' +
770
+ 'NO handback in flight → supersedes the flushed draft, collapses to ONE message', async () => {
771
+ const h = makeHarness()
772
+ const owner = makeFlushDeliveredEndedTurn()
773
+ seedRecord(h, owner)
774
+ // Late DM reply: latest-ended tier (the path the tier discriminator MISSES),
775
+ // and crucially NO subagent_handback was enqueued for this chat after the
776
+ // turn ended — so the reply is the flushed turn's OWN answer.
777
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
778
+ h.deps.getLastSubagentHandbackAt = () => null
779
+
780
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
781
+
782
+ // Exactly ONE client-visible message: the flushed message edited in place
783
+ // into the reworded reply — NO fresh second bubble (duplicate closed).
784
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
785
+ expect(edits).toHaveLength(1)
786
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
787
+ expect(edits[0]!.text).toContain('every one of the twelve agents')
788
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
789
+ expect(res.content[0]!.text).toMatch(/^sent/)
790
+ // Record consumed.
791
+ expect(
792
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
793
+ liveTurnId: OWNER_TURN_ID,
794
+ now: Date.now(),
795
+ }).reason,
796
+ ).toBe('no-record')
797
+ })
798
+
799
+ it('CASE B — genuine background handback: SAME reworded text, SAME latest-ended tier, ' +
800
+ 'but a handback WAS enqueued in-window → keeps the gate, sends FRESH (TWO messages)', async () => {
801
+ const h = makeHarness()
802
+ const owner = makeFlushDeliveredEndedTurn()
803
+ seedRecord(h, owner)
804
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
805
+ // A background sub-agent handback was enqueued for this chat AFTER the turn
806
+ // ended (now-30s) and within the 60s TTL (now-15s) — this reply might BE it.
807
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
808
+
809
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
810
+
811
+ // A fresh notifying bubble ships; the flushed message is NEITHER edited nor
812
+ // deleted — flush (A) + fresh (B) = two surfaced messages (#3429 preserved).
813
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
814
+ expect(fresh).toHaveLength(1)
815
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
816
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
817
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
818
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
819
+ // Record NOT consumed.
820
+ expect(
821
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
822
+ liveTurnId: OWNER_TURN_ID,
823
+ replyText: CANONICAL_REPLY,
824
+ now: Date.now(),
825
+ }).supersede,
826
+ ).toBe(true)
827
+ })
828
+
829
+ it('MUST-FIX 1 (silent-data-loss): a handback reply model-STEERED to the quoted ' +
830
+ 'tier (reply_to = the flushed turn\'s source msg) must NOT edit/delete the flushed ' +
831
+ 'answer while a handback is in flight — sends FRESH (two messages)', async () => {
832
+ const h = makeHarness()
833
+ const owner = makeFlushDeliveredEndedTurn()
834
+ seedRecord(h, owner)
835
+ // The `quoted` tier is derived from the MODEL-SUPPLIED `args.reply_to`, so a
836
+ // background handback turn can point reply_to at the user's original message
837
+ // (which the prior turn's flush is recorded against) to resolve THAT turn via
838
+ // `quoted`. A handback IS in flight, so a positive tier must NOT override the
839
+ // #3429 content gate — else the reworded handback text silently edits over
840
+ // the flushed turn's DELIVERED answer (Telegram edits don't re-notify).
841
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
842
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
843
+
844
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
845
+
846
+ // Fresh notifying send; the flushed answer is NEITHER edited nor deleted.
847
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
848
+ expect(fresh).toHaveLength(1)
849
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
850
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
851
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
852
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
853
+ // Flush record NOT consumed — the flushed answer stands.
854
+ expect(
855
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
856
+ liveTurnId: OWNER_TURN_ID,
857
+ replyText: CANONICAL_REPLY,
858
+ now: Date.now(),
859
+ }).supersede,
860
+ ).toBe(true)
861
+ })
862
+
863
+ it('the framework-owned `live` tier MAY still bypass a handback window (a live ' +
864
+ 'currentTurn is not model-derived and cannot collide with an ended turn\'s record)', async () => {
865
+ const h = makeHarness()
866
+ const owner = makeFlushDeliveredEndedTurn()
867
+ seedRecord(h, owner)
868
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'live' })
869
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
870
+
871
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
872
+
873
+ // Live tier bypasses → supersede via edit-in-place (one message).
874
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
875
+ expect(edits).toHaveLength(1)
876
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
877
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
878
+ expect(res.content[0]!.text).toMatch(/^sent/)
879
+ })
880
+
881
+ it('MUST-FIX 2 (boot-replay): a handback re-pushed through the buffer chokepoint ' +
882
+ 'stamps the marker (with the envelope ts) → gate preserved → FRESH send, no silent edit', async () => {
883
+ // Real marker + real buffer wired exactly as the gateway wires them. The
884
+ // boot-replay loop re-pushes un-acked spooled inbounds — including handback
885
+ // envelopes — through this SAME push(); the chokepoint stamp is what makes a
886
+ // replayed handback populate the (post-restart empty) marker.
887
+ const marker = new SubagentHandbackMarker()
888
+ const buffer = createPendingInboundBuffer({
889
+ log: () => {},
890
+ onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
891
+ })
892
+
893
+ // Simulate the boot-replay re-push of an un-acked spooled handback envelope.
894
+ // The envelope carries its own ms `ts` (after the flushed turn ended, within
895
+ // TTL) — owner.endedAt below is now-30s, this handback is now-15s.
896
+ const handbackTs = Date.now() - 15_000
897
+ buffer.push('marko', {
898
+ type: 'inbound',
899
+ chatId: CHAT,
900
+ messageId: handbackTs,
901
+ user: 'subagent-watcher',
902
+ userId: 0,
903
+ ts: handbackTs,
904
+ text: 'handback result',
905
+ meta: { source: 'subagent_handback' },
906
+ })
907
+ // The chokepoint stamped the marker from the replay push (pre-fix: empty).
908
+ expect(marker.lastAt(CHAT)).toBe(handbackTs)
909
+
910
+ const h = makeHarness()
911
+ const owner = makeFlushDeliveredEndedTurn()
912
+ seedRecord(h, owner)
913
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
914
+ // The gateway reads the marker the boot-replay stamped.
915
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAt(chatId)
916
+
917
+ const res = await sendReply(h.deps, req(HANDBACK))
918
+
919
+ // Gate preserved → the replayed handback delivers FRESH; the post-boot
920
+ // flushed answer is NEITHER edited nor deleted (no silent #3429 on restart).
921
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
922
+ expect(fresh).toHaveLength(1)
923
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
924
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
925
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
926
+ })
927
+
928
+ it('the buffer chokepoint stamps ONLY handback envelopes (a normal inbound does not)', () => {
929
+ const marker = new SubagentHandbackMarker()
930
+ const buffer = createPendingInboundBuffer({
931
+ log: () => {},
932
+ onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
933
+ })
934
+ buffer.push('marko', {
935
+ type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
936
+ ts: Date.now(), text: 'hi', meta: {},
937
+ })
938
+ expect(marker.lastAt(CHAT)).toBe(null)
939
+ })
725
940
  })
@@ -296,4 +296,67 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
296
296
  expect(r.status).toBe(0)
297
297
  expect(r.stdout.trim()).toBe('')
298
298
  })
299
+
300
+ // ── Single-writer election (duplicate-message fix) ─────────────────
301
+ describe('single-writer election end-to-end', () => {
302
+ function writeFreshHeartbeat() {
303
+ writeFileSync(join(stateDir, 'gateway-heartbeat'), String(Date.now()), 'utf8')
304
+ }
305
+
306
+ it('zero-reply ≥200 + FRESH gateway heartbeat → ALLOW (no block re-prompt), state file still written for the flush', () => {
307
+ // The duplicate repro: today this BLOCKS *and* the gateway flush fires →
308
+ // two messages. With a fresh heartbeat the election ALLOWS the stop so
309
+ // the gateway flush is the single writer.
310
+ writeFreshHeartbeat()
311
+ const transcript = writeTranscript(tmp, [
312
+ ENQUEUE,
313
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
314
+ ])
315
+ const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
316
+ expect(r.status).toBe(0)
317
+ // ALLOW → no block JSON on stdout.
318
+ expect(r.stdout.trim()).toBe('')
319
+ expect(r.stderr).toMatch(/single-writer election ALLOWED/)
320
+ // State file IS written so the gateway's captured-prose bridge has its
321
+ // input (turnKey/turnId/pendingText); retryCount stays 0 (not a re-prompt).
322
+ const statePath = join(stateDir, 'silent-end-pending.json')
323
+ expect(existsSync(statePath)).toBe(true)
324
+ const state = JSON.parse(readFileSync(statePath, 'utf8'))
325
+ expect(state.retryCount).toBe(0)
326
+ expect(state.turnKey).toBe('111:_')
327
+ expect(state.pendingText).toBe('A'.repeat(300))
328
+ })
329
+
330
+ it('zero-reply ≥200 + STALE/missing heartbeat → BLOCK (never allow into a possibly-dead gateway)', () => {
331
+ // No heartbeat file → the liveness gate forces today's BLOCK behaviour.
332
+ const transcript = writeTranscript(tmp, [
333
+ ENQUEUE,
334
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
335
+ ])
336
+ const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
337
+ expect(r.status).toBe(0)
338
+ expect(JSON.parse(r.stdout).decision).toBe('block')
339
+ const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
340
+ expect(state.retryCount).toBe(1)
341
+ })
342
+
343
+ it('retryCount>0 (prior failed delivery) + fresh heartbeat → BLOCK (preserve #3228 send-failure net)', () => {
344
+ writeFreshHeartbeat()
345
+ // Seed a prior state file with retryCount=1 (a delivery already failed).
346
+ writeFileSync(
347
+ join(stateDir, 'silent-end-pending.json'),
348
+ JSON.stringify({ chatId: '111', threadId: null, turnKey: '111:_', retryCount: 1, timestamp: Date.now() }),
349
+ 'utf8',
350
+ )
351
+ const transcript = writeTranscript(tmp, [
352
+ ENQUEUE,
353
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
354
+ ])
355
+ const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
356
+ expect(r.status).toBe(0)
357
+ // retryCount was 1 → not the exhaustion boundary (MAX=2) → still blocks,
358
+ // and the election does NOT allow (retry-ladder-in-flight).
359
+ expect(JSON.parse(r.stdout).decision).toBe('block')
360
+ })
361
+ })
299
362
  })
@@ -184,13 +184,20 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
184
184
  expect(r.pendingText).toBe(trailing)
185
185
  })
186
186
 
187
- it('Option A: a SHORT trailing/only fragment does NOT set pendingText (substance floor)', () => {
188
- // A genuinely empty-ish turn: a short plain-text closer under the 200-char
189
- // floor must not be re-delivered as if it were the answer.
190
- const text = jsonl(ENQUEUE, assistantText('ok done, let me know if you need anything else'))
187
+ it('Option A: a SHORT single trailing fragment (zero-reply) IS persisted for the lowered-floor corner', () => {
188
+ // Post duplicate-message fix: a single short plain-text block in the
189
+ // ZERO-reply case is the real short-answer shape, so the scan persists it
190
+ // as pendingText for the capture-divergence corner (gateway captured empty
191
+ // the bridge delivers with a lowered floor). Under the gateway's DEFAULT
192
+ // 200-char floor it is still NOT delivered, so the substance guard holds at
193
+ // the delivery layer.
194
+ const short = 'ok done, let me know if you need anything else'
195
+ const text = jsonl(ENQUEUE, assistantText(short))
191
196
  const r = scanTurnForFinalReply(text)
192
197
  expect(r.decided).toBe('block')
193
- expect(r.pendingText).toBeUndefined()
198
+ expect(r.reason).toBe('no-final-reply')
199
+ expect(r.pendingText).toBe(short)
200
+ expect(r.hasTrailingProse).toBe(true)
194
201
  })
195
202
 
196
203
  it('notification-bearing reply → allow', () => {
@@ -388,14 +395,19 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
388
395
  // "Let me check…" / "Still querying…" narration crossed 200 and was delivered
389
396
  // as if it were the answer. Post-fix only a single block that clears the floor
390
397
  // ON ITS OWN becomes pendingText.
391
- const NARRATION_A = 'Let me check the first data source now — pulling the records and scanning for the relevant rows.' // ~95
392
- const NARRATION_B = 'Still querying; the second source is slower than expected, so hang tight while it finishes loading.' // ~98
393
- const NARRATION_C = 'Almost there, cross-referencing the last set of figures against the ledger before I summarise it.' // ~96
394
-
395
- it('zero-delivery turn of only short narration blocks block WITHOUT pendingText (fails on the old joined-floor code)', () => {
396
- // Combined length of the three blocks is ≥ 200, so the OLD code would join
397
- // them and set pendingText (masquerade). The NEW code sets nothing because
398
- // no single block clears the floor.
398
+ // Genuinely-narration blocks: each matches the opener/trailer heuristic
399
+ // (`isNarrationBlock`) the flush's `selectFlushDeliveryText` uses, so a pure
400
+ // run of them is delivered as NOTHING by the flush and the scan must agree
401
+ // (no pendingText) so the capture-divergence bridge never masquerades a
402
+ // narration run as an answer (#3228 Finding 2).
403
+ const NARRATION_A = 'Let me check the first data source now, pulling the records and scanning the rows…' // opener + …
404
+ const NARRATION_B = "Now let me query the second source; it is slower than expected, hang tight…" // opener + …
405
+ const NARRATION_C = "I'll cross-reference the last set of figures against the ledger before I summarise…" // opener + …
406
+
407
+ it('zero-delivery turn of only NARRATION blocks → block WITHOUT pendingText (#3228 Finding 2 / flush parity)', () => {
408
+ // Combined length of the three blocks is ≥ 200, so the OLD joined-floor code
409
+ // would join them and set pendingText (masquerade). The NEW code mirrors the
410
+ // flush's narration strip: every block is narration → nothing to deliver.
399
411
  expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
400
412
  .toBeGreaterThanOrEqual(200)
401
413
  const text = jsonl(
@@ -413,6 +425,35 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
413
425
  expect(r.pendingText).toBeUndefined()
414
426
  })
415
427
 
428
+ it('zero-delivery turn of MULTIPLE sub-200 REAL-CONTENT blocks → JOINED pendingText (review item 3 drop fix)', () => {
429
+ // A real answer split as two ~150-char paragraphs, each individually under
430
+ // the 200-char substance floor. Pre-fix the scan set NO pendingText — and in
431
+ // the capture-divergence-empty corner (gateway captured nothing → flush
432
+ // skips 'empty-text') the bridge then had nothing to deliver and the hook
433
+ // had already allowed the stop → DROPPED ANSWER. Post-fix the scan mirrors
434
+ // the flush's join so the bridge delivers the joined prose.
435
+ const PARA_1 = 'The root cause is a stale cache entry that survives the reload because the invalidation key is derived from the wrong field.' // ~150, real content
436
+ const PARA_2 = 'The fix is to key invalidation off the canonical id so the entry is dropped on every write; I have verified it against the repro.' // ~150, real content
437
+ expect(PARA_1.length).toBeLessThan(200)
438
+ expect(PARA_2.length).toBeLessThan(200)
439
+ const text = jsonl(ENQUEUE, assistantText(PARA_1), assistantText(PARA_2))
440
+ const r = scanTurnForFinalReply(text)
441
+ expect(r.decided).toBe('block')
442
+ expect(r.reason).toBe('no-final-reply')
443
+ // Joined prose, mirroring the flush's selectFlushDeliveryText.
444
+ expect(r.pendingText).toBe(`${PARA_1}\n\n${PARA_2}`)
445
+ expect(r.hasTrailingProse).toBe(true)
446
+ })
447
+
448
+ it('leading narration + a real sub-200 answer block → only the answer is delivered (narration stripped)', () => {
449
+ const NARR = 'Let me pull the numbers first…' // narration opener + …
450
+ const ANSWER = 'Revenue was up 12% quarter-over-quarter, driven mostly by the new enterprise tier.' // ~85, real
451
+ const text = jsonl(ENQUEUE, assistantText(NARR), assistantText(ANSWER))
452
+ const r = scanTurnForFinalReply(text)
453
+ expect(r.decided).toBe('block')
454
+ expect(r.pendingText).toBe(ANSWER)
455
+ })
456
+
416
457
  it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
417
458
  // Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
418
459
  // false → allow. (The old joined-floor logic never affected the block
@@ -447,9 +488,12 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
447
488
  const text = jsonl(ENQUEUE, assistantText('X'.repeat(n)))
448
489
  return scanTurnForFinalReply(text)
449
490
  }
450
- // 199 → under floor, no pendingText (block still fires for the zero-delivery
451
- // turn, but there is nothing substantive to deliver).
452
- expect(at(199).pendingText).toBeUndefined()
491
+ // 199 → under the 200-char SUBSTANCE floor, but a SINGLE trailing block is
492
+ // the real short-answer shape: post duplicate-message fix the scan persists
493
+ // it as pendingText so the capture-divergence corner can deliver it with a
494
+ // lowered floor. The gateway's default-floor decide still won't deliver it,
495
+ // so the #3228 masquerade guard is unchanged at the delivery layer.
496
+ expect(at(199).pendingText).toBe('X'.repeat(199))
453
497
  // 200 → exactly the floor, delivered.
454
498
  expect(at(200).pendingText).toBe('X'.repeat(200))
455
499
  // 201 → over floor, delivered.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Regression tests for the single-writer election (duplicate-message fix).
3
+ *
4
+ * RCA: when a turn ends with its final answer as PLAIN TRANSCRIPT TEXT (no
5
+ * `reply` tool call), two uncoordinated recovery paths both fired — (A) the
6
+ * gateway turn-end flush (`decideTurnFlush`) delivered the captured prose, and
7
+ * (B) this Stop hook blocked and re-prompted, regenerating a REWORDED reply
8
+ * that defeated the exact-match dedup. The user got two near-identical
9
+ * messages.
10
+ *
11
+ * Fix: the Stop hook is the single elector (`decideStopHookDisposition`). On a
12
+ * would-BLOCK scan it ALLOWS the stop — handing delivery to the gateway's
13
+ * flush / captured-prose bridge — IFF four never-drop gates all hold; otherwise
14
+ * it BLOCKS exactly as today. These tests pin the whole verdict matrix and
15
+ * prove every allow path has a delivery machine and every guard forces BLOCK.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import {
20
+ decideStopHookDisposition,
21
+ isTurnFlushSafetyEnabledEnv,
22
+ isCapturedProseDeliveryEnabledEnv,
23
+ isGatewayHeartbeatFresh,
24
+ GATEWAY_HEARTBEAT_FRESH_MS,
25
+ } from '../hooks/silent-end-scan.mjs'
26
+ import {
27
+ mkdtempSync,
28
+ writeFileSync,
29
+ utimesSync,
30
+ rmSync,
31
+ } from 'node:fs'
32
+ import { join } from 'node:path'
33
+ import { tmpdir } from 'node:os'
34
+
35
+ // Scan fixtures — the shapes `scanTurnForFinalReply` returns on a would-block.
36
+ const ZERO_REPLY_LONG = {
37
+ decided: 'block' as const,
38
+ reason: 'no-final-reply',
39
+ turnKey: 'c:_',
40
+ hasTrailingProse: true,
41
+ pendingText: 'A'.repeat(300),
42
+ }
43
+ const ZERO_REPLY_SHORT = {
44
+ decided: 'block' as const,
45
+ reason: 'no-final-reply',
46
+ turnKey: 'c:_',
47
+ hasTrailingProse: true,
48
+ pendingText: 'ok done',
49
+ }
50
+ const ZERO_REPLY_NO_PROSE = {
51
+ decided: 'block' as const,
52
+ reason: 'no-final-reply',
53
+ turnKey: 'c:_',
54
+ // hasTrailingProse absent — tool-calls-only turn, nothing for a machine to send.
55
+ }
56
+ const INTERIM_ACK_LONG = {
57
+ decided: 'block' as const,
58
+ reason: 'trailing-text-after-reply',
59
+ turnKey: 'c:_',
60
+ hasTrailingProse: true,
61
+ pendingText: 'B'.repeat(300),
62
+ }
63
+ const INTERIM_ACK_SHORT = {
64
+ decided: 'block' as const,
65
+ reason: 'trailing-text-after-reply',
66
+ turnKey: 'c:_',
67
+ hasTrailingProse: true,
68
+ pendingText: 'thanks!',
69
+ }
70
+
71
+ const ALL_ON = {
72
+ retryCount: 0,
73
+ turnFlushSafetyEnabled: true,
74
+ capturedProseDeliveryEnabled: true,
75
+ gatewayLive: true,
76
+ }
77
+
78
+ describe('decideStopHookDisposition — duplicate repro (#1: zero-reply ≥200)', () => {
79
+ it('zero-reply long answer, flags on, retry 0, live → ALLOW-elected (today it blocks → duplicate)', () => {
80
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON })
81
+ expect(d.action).toBe('allow-elected')
82
+ expect(d.reason).toBe('flush-will-deliver')
83
+ })
84
+ })
85
+
86
+ describe('decideStopHookDisposition — short-answer repro (#2: zero-reply <200)', () => {
87
+ it('zero-reply SHORT answer, flags on, retry 0, live → ALLOW-elected (flush has no length floor)', () => {
88
+ // Today this BLOCKS *and* the flush fires → duplicate. Exactly one machine
89
+ // must win: the flush. The election allows the stop so the hook does not
90
+ // also re-prompt.
91
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON })
92
+ expect(d.action).toBe('allow-elected')
93
+ })
94
+ })
95
+
96
+ describe('decideStopHookDisposition — never-drop matrix (#3)', () => {
97
+ it('every ALLOW verdict is backed by a delivery machine', () => {
98
+ // Zero-reply → the turn-flush delivers (no length floor); interim-ack ≥200 →
99
+ // the captured-prose bridge delivers. Both are allow.
100
+ expect(decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON }).action).toBe('allow-elected')
101
+ expect(decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON }).action).toBe('allow-elected')
102
+ expect(decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON }).action).toBe('allow-elected')
103
+ })
104
+
105
+ it('turn-flush flag OFF (zero-reply) → BLOCK (the flush will not fire)', () => {
106
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, turnFlushSafetyEnabled: false })
107
+ expect(d.action).toBe('block')
108
+ expect(d.reason).toBe('turn-flush-flag-disabled')
109
+ })
110
+
111
+ it('captured-prose flag OFF (interim-ack) → BLOCK (the bridge will not fire)', () => {
112
+ const d = decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
113
+ expect(d.action).toBe('block')
114
+ expect(d.reason).toBe('captured-prose-flag-disabled')
115
+ })
116
+
117
+ it('captured-prose flag OFF (zero-reply) → BLOCK (bridge is the capture-divergence backstop)', () => {
118
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
119
+ expect(d.action).toBe('block')
120
+ expect(d.reason).toBe('captured-prose-flag-disabled')
121
+ })
122
+
123
+ it('retryCount > 0 → BLOCK (a prior failed delivery keeps the #3228 recovery net)', () => {
124
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, retryCount: 1 })
125
+ expect(d.action).toBe('block')
126
+ expect(d.reason).toBe('retry-ladder-in-flight')
127
+ })
128
+
129
+ it('gateway NOT live → BLOCK (never allow into a possibly-dead gateway)', () => {
130
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false })
131
+ expect(d.action).toBe('block')
132
+ expect(d.reason).toBe('gateway-liveness-not-fresh')
133
+ })
134
+
135
+ it('interim-ack SHORT (<200) → BLOCK (bridge floor is 200; no duplicate exists today)', () => {
136
+ const d = decideStopHookDisposition({ scan: INTERIM_ACK_SHORT, ...ALL_ON })
137
+ expect(d.action).toBe('block')
138
+ expect(d.reason).toBe('short-trailing-after-reply')
139
+ })
140
+
141
+ it('zero-reply with NO trailing prose (tool-calls only) → BLOCK (nothing to deliver)', () => {
142
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_NO_PROSE, ...ALL_ON })
143
+ expect(d.action).toBe('block')
144
+ expect(d.reason).toBe('no-trailing-prose')
145
+ })
146
+
147
+ it('a non-block scan (allow/unknown) is passed straight through as allow-scan', () => {
148
+ expect(decideStopHookDisposition({ scan: { decided: 'allow', reason: 'final-reply' }, ...ALL_ON }).action).toBe('allow-scan')
149
+ expect(decideStopHookDisposition({ scan: { decided: 'unknown', reason: 'no-turn-start' }, ...ALL_ON }).action).toBe('allow-scan')
150
+ })
151
+
152
+ it('gateway-liveness gate is checked BEFORE the flag/prose gates (dead gateway always blocks)', () => {
153
+ // Even with an otherwise-perfect zero-reply allow, a dead gateway forces block.
154
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false, retryCount: 0 })
155
+ expect(d.action).toBe('block')
156
+ expect(d.reason).toBe('gateway-liveness-not-fresh')
157
+ })
158
+ })
159
+
160
+ describe('env-flag mirrors stay in sync with the TS source', () => {
161
+ it('turn-flush-safety: default ON; 0/false/off/no disable', () => {
162
+ expect(isTurnFlushSafetyEnabledEnv({})).toBe(true)
163
+ for (const v of ['0', 'false', 'off', 'no', 'FALSE', 'Off']) {
164
+ expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: v })).toBe(false)
165
+ }
166
+ expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: '1' })).toBe(true)
167
+ })
168
+ it('captured-prose: default ON; only exact "0" disables', () => {
169
+ expect(isCapturedProseDeliveryEnabledEnv({})).toBe(true)
170
+ expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '0' })).toBe(false)
171
+ expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '1' })).toBe(true)
172
+ })
173
+ })
174
+
175
+ describe('isGatewayHeartbeatFresh — liveness gate IO', () => {
176
+ it('fresh heartbeat → true; stale → false; missing → false', () => {
177
+ const dir = mkdtempSync(join(tmpdir(), 'gw-hb-'))
178
+ try {
179
+ // Missing file → not fresh.
180
+ expect(isGatewayHeartbeatFresh(dir)).toBe(false)
181
+ const path = join(dir, 'gateway-heartbeat')
182
+ writeFileSync(path, 'x')
183
+ // Just-written → fresh.
184
+ expect(isGatewayHeartbeatFresh(dir)).toBe(true)
185
+ // Age it past the freshness bound → stale.
186
+ const old = new Date(Date.now() - GATEWAY_HEARTBEAT_FRESH_MS - 10_000)
187
+ utimesSync(path, old, old)
188
+ expect(isGatewayHeartbeatFresh(dir)).toBe(false)
189
+ } finally {
190
+ rmSync(dir, { recursive: true, force: true })
191
+ }
192
+ })
193
+ })