switchroom 0.18.19 → 0.18.21

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 (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -137,6 +137,62 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
137
137
  expect(r.reason).toBe('no-final-reply')
138
138
  })
139
139
 
140
+ it('Option A: block result carries pendingText = the undelivered answer prose (#3227)', () => {
141
+ // The transcript-prose bridge: a turn that ends with a substantive answer
142
+ // written as plain text (no reply tool) must surface that prose so the
143
+ // gateway can deliver it directly on the first silent-end.
144
+ const answer = 'That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)
145
+ const text = jsonl(
146
+ ENQUEUE,
147
+ assistantToolUse('mcp__switchroom-telegram__reply', {
148
+ text: "On it — checking…",
149
+ disable_notification: true,
150
+ }),
151
+ assistantToolUse('Bash', { command: 'ls' }),
152
+ assistantText(answer),
153
+ )
154
+ const r = scanTurnForFinalReply(text)
155
+ expect(r.decided).toBe('block')
156
+ expect(r.reason).toBe('no-final-reply')
157
+ expect(r.pendingText).toBe(answer)
158
+ })
159
+
160
+ it('Option A: zero-outbound turn with a long plain-text answer → pendingText set', () => {
161
+ const answer = 'Here is the whole answer the model forgot to send. ' + 'Z'.repeat(400)
162
+ const text = jsonl(ENQUEUE, assistantText(answer))
163
+ const r = scanTurnForFinalReply(text)
164
+ expect(r.decided).toBe('block')
165
+ expect(r.reason).toBe('no-final-reply')
166
+ expect(r.pendingText).toBe(answer)
167
+ })
168
+
169
+ it('Option A: trailing-text-after-reply block carries only the trailing prose (#3227)', () => {
170
+ const trailing = 'The real verdict the model wrote but never re-sent. ' + 'T'.repeat(400)
171
+ const text = jsonl(
172
+ ENQUEUE,
173
+ assistantText('some narration before the delivered answer'),
174
+ assistantToolUse('mcp__switchroom-telegram__reply', {
175
+ text: 'delivered answer, notification-bearing',
176
+ disable_notification: false,
177
+ }),
178
+ assistantText(trailing),
179
+ )
180
+ const r = scanTurnForFinalReply(text)
181
+ expect(r.decided).toBe('block')
182
+ expect(r.reason).toBe('trailing-text-after-reply')
183
+ // Only the prose AFTER the last delivery event — not the pre-reply narration.
184
+ expect(r.pendingText).toBe(trailing)
185
+ })
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'))
191
+ const r = scanTurnForFinalReply(text)
192
+ expect(r.decided).toBe('block')
193
+ expect(r.pendingText).toBeUndefined()
194
+ })
195
+
140
196
  it('notification-bearing reply → allow', () => {
141
197
  const text = jsonl(
142
198
  ENQUEUE,
@@ -322,6 +378,144 @@ it('early qualifying reply + trailing SUBSTANTIVE undelivered text (≥ floor)
322
378
  })
323
379
  })
324
380
 
381
+ // ── Finding 2 (#3228): concatenated inter-tool narration must NOT masquerade
382
+ // as a final answer via the joined-prose floor ───────────────────────────
383
+
384
+ describe('scanTurnForFinalReply — pendingText is a single substantive block, not joined narration (#3228 Finding 2)', () => {
385
+ // Each narration block is well under FINAL_ANSWER_MIN_CHARS (200) on its own.
386
+ // Pre-fix the scan JOINED every post-delivery text block and surfaced the
387
+ // join whenever the COMBINED length hit the floor — so a run of short
388
+ // "Let me check…" / "Still querying…" narration crossed 200 and was delivered
389
+ // as if it were the answer. Post-fix only a single block that clears the floor
390
+ // 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.
399
+ expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
400
+ .toBeGreaterThanOrEqual(200)
401
+ const text = jsonl(
402
+ ENQUEUE,
403
+ assistantText(NARRATION_A),
404
+ assistantToolUse('Bash', { command: 'ls' }),
405
+ assistantText(NARRATION_B),
406
+ assistantToolUse('Read', { file_path: '/tmp/x' }),
407
+ assistantText(NARRATION_C),
408
+ )
409
+ const r = scanTurnForFinalReply(text)
410
+ expect(r.decided).toBe('block')
411
+ expect(r.reason).toBe('no-final-reply')
412
+ // The masquerade must NOT happen — no answer to deliver, take the re-prompt.
413
+ expect(r.pendingText).toBeUndefined()
414
+ })
415
+
416
+ it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
417
+ // Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
418
+ // false → allow. (The old joined-floor logic never affected the block
419
+ // decision here, only pendingText; this pins the two stay consistent.)
420
+ const text = jsonl(
421
+ ENQUEUE,
422
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ok', disable_notification: false }),
423
+ assistantText(NARRATION_A),
424
+ assistantText(NARRATION_B),
425
+ )
426
+ const r = scanTurnForFinalReply(text)
427
+ expect(r.decided).toBe('allow')
428
+ expect(r.pendingText).toBeUndefined()
429
+ })
430
+
431
+ it('a genuine ≥floor answer followed by a SHORT closer → pendingText is the answer, not the closer', () => {
432
+ // "big answer then short closer" — the LAST substantive (≥floor) block is
433
+ // the answer; the trailing short closer must not displace it.
434
+ const answer = 'Here is the real answer you were waiting for: ' + 'A'.repeat(300)
435
+ const text = jsonl(
436
+ ENQUEUE,
437
+ assistantText(answer),
438
+ assistantText('Let me know if you need anything else.'),
439
+ )
440
+ const r = scanTurnForFinalReply(text)
441
+ expect(r.decided).toBe('block')
442
+ expect(r.pendingText).toBe(answer)
443
+ })
444
+
445
+ it('substance-floor boundary: a single trailing block at 199/200/201 chars', () => {
446
+ const at = (n: number) => {
447
+ const text = jsonl(ENQUEUE, assistantText('X'.repeat(n)))
448
+ return scanTurnForFinalReply(text)
449
+ }
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()
453
+ // 200 → exactly the floor, delivered.
454
+ expect(at(200).pendingText).toBe('X'.repeat(200))
455
+ // 201 → over floor, delivered.
456
+ expect(at(201).pendingText).toBe('X'.repeat(201))
457
+ })
458
+ })
459
+
460
+ // ── Finding 3 (#3228): the block result carries a per-turn nonce (turnId) ────
461
+
462
+ describe('scanTurnForFinalReply — per-turn nonce in block result (#3228 Finding 3)', () => {
463
+ it('block carries turnId = ${chatKey}#${messageId} derived from the enqueue envelope', () => {
464
+ const enq = JSON.stringify({
465
+ type: 'queue-operation',
466
+ operation: 'enqueue',
467
+ content: '<channel source="switchroom-telegram" chat_id="abc" message_thread_id="42" message_id="9">hi</channel>',
468
+ })
469
+ const text = jsonl(
470
+ enq,
471
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }),
472
+ )
473
+ const r = scanTurnForFinalReply(text)
474
+ expect(r.decided).toBe('block')
475
+ expect(r.turnKey).toBe('abc:42')
476
+ // Matches gateway deriveTurnId: `${chatKey(chatId, threadId)}#${messageId}`.
477
+ expect(r.turnId).toBe('abc:42#9')
478
+ })
479
+
480
+ it("DM (no thread) still derives the turnId with the '_' sentinel", () => {
481
+ // ENQUEUE has chat_id="111" message_id="42", no thread.
482
+ const text = jsonl(
483
+ ENQUEUE,
484
+ assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }),
485
+ )
486
+ const r = scanTurnForFinalReply(text)
487
+ expect(r.turnKey).toBe('111:_')
488
+ expect(r.turnId).toBe('111:_#42')
489
+ })
490
+
491
+ it('no derivable message_id → turnId omitted (gateway falls back to turnKey match)', () => {
492
+ const enq = JSON.stringify({
493
+ type: 'queue-operation',
494
+ operation: 'enqueue',
495
+ content: '<channel source="subagent_handback" chat_id="c7">work is done</channel>',
496
+ })
497
+ const text = jsonl(enq, assistantText('a plain-text answer never sent ' + 'Q'.repeat(300)))
498
+ const r = scanTurnForFinalReply(text)
499
+ expect(r.decided).toBe('block')
500
+ expect(r.turnKey).toBe('c7:_')
501
+ expect(r.turnId).toBeUndefined()
502
+ })
503
+
504
+ it('a same-suffix sibling attribute (target_message_id) does NOT leak into turnId', () => {
505
+ // Left-anchored match: `target_message_id` must not be mistaken for the
506
+ // real `message_id`. Envelope carries the sibling FIRST to prove ordering
507
+ // does not confuse the parser.
508
+ const enq = JSON.stringify({
509
+ type: 'queue-operation',
510
+ operation: 'enqueue',
511
+ content: '<channel source="reaction" target_message_id="777" chat_id="c" message_id="5">x</channel>',
512
+ })
513
+ const text = jsonl(enq, assistantToolUse('mcp__switchroom-telegram__reply', { text: 'ack', disable_notification: true }))
514
+ const r = scanTurnForFinalReply(text)
515
+ expect(r.turnId).toBe('c:_#5')
516
+ })
517
+ })
518
+
325
519
  describe('scanTurnForFinalReply — silent-marker carve-out', () => {
326
520
  it('NO_REPLY → allow', () => {
327
521
  const text = jsonl(
@@ -9,10 +9,14 @@ import {
9
9
  readSilentEndState,
10
10
  recordSilentTurnEnd,
11
11
  recordUndeliveredTurnEnd,
12
+ decideCapturedProseDelivery,
13
+ settleCapturedProseDelivery,
14
+ CAPTURED_PROSE_MIN_CHARS,
12
15
  SILENT_END_MAX_RETRIES,
13
16
  SILENT_END_STALE_RECORD_MAX_AGE_MS,
14
17
  } from '../silent-end.js'
15
18
  import { isFinalAnswerReply } from '../final-answer-detect.js'
19
+ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
16
20
 
17
21
  let stateDir: string
18
22
  const ORIG_ENV = process.env.TELEGRAM_STATE_DIR
@@ -683,4 +687,296 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
683
687
  const r = runHook({}) // serialised as `{}` — but the hook also tolerates empty
684
688
  expect(r.exit).toBe(0)
685
689
  })
690
+
691
+ // ── Option A transcript-prose bridge (#3227) — end-to-end scan → hook →
692
+ // state → gateway decision. Proves the gateway would deliver the model's
693
+ // real answer directly on the FIRST silent-end.
694
+ describe('captured-prose delivery bridge (#3227)', () => {
695
+ const ANSWER = 'That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)
696
+
697
+ it('FIRST silent-end with a genuine plain-text answer → hook persists pendingText and decide → deliver', () => {
698
+ // A turn that ended with a substantive answer written as plain text
699
+ // (never sent via the reply tool). The Stop hook scans, blocks, and now
700
+ // persists the answer prose into silent-end-pending.json.
701
+ const transcript = writeTranscript([
702
+ ENQUEUE,
703
+ replyToolUse('on it — checking now', { disable_notification: true }),
704
+ { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] } },
705
+ { type: 'assistant', message: { content: [{ type: 'text', text: ANSWER }] } },
706
+ ])
707
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
708
+ expect(r.exit).toBe(0)
709
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
710
+
711
+ // The bridge: the hook wrote the answer prose into the state file...
712
+ const state = readSilentEndState()!
713
+ expect(state.pendingText).toBe(ANSWER)
714
+ expect(state.turnKey).toBe('c:_')
715
+
716
+ // ...and the gateway's pure decision core reads it back and decides to
717
+ // deliver directly on this FIRST silent-end (retryCount just went to 1).
718
+ const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
719
+ expect(decision.deliver).toBe(true)
720
+ expect(decision.text).toBe(ANSWER)
721
+ expect(decision.reason).toBe('captured-prose')
722
+ // FIRST silent-end: this is the retry-budget's first block, not exhaustion.
723
+ expect(state.retryCount).toBe(1)
724
+ })
725
+
726
+ it('a turn that DID call a final reply → no state, no pendingText, decide → no synthetic send', () => {
727
+ const transcript = writeTranscript([
728
+ ENQUEUE,
729
+ replyToolUse('here is the answer, notification-bearing', { disable_notification: false }),
730
+ ])
731
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
732
+ expect(r.stdout.trim()).toBe('')
733
+ expect(readSilentEndState()).toBeNull()
734
+ const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
735
+ expect(decision.deliver).toBe(false)
736
+ expect(decision.reason).toBe('no-state')
737
+ })
738
+
739
+ it('a genuinely empty turn (no substantive prose) → hook blocks WITHOUT pendingText → decide falls through', () => {
740
+ // Short closer under the substance floor: not a dropped answer. The hook
741
+ // still blocks (re-prompt), but writes NO pendingText, so the gateway
742
+ // takes the existing recordUndeliveredTurnEnd / represent path unchanged.
743
+ const transcript = writeTranscript([
744
+ ENQUEUE,
745
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'ok done' }] } },
746
+ ])
747
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
748
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
749
+ const state = readSilentEndState()!
750
+ expect(state.pendingText).toBeUndefined()
751
+ const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
752
+ expect(decision.deliver).toBe(false)
753
+ expect(decision.reason).toBe('no-substantive-prose')
754
+ })
755
+
756
+ it('a stale pendingText for a DIFFERENT turn is never delivered (turnKey guard)', () => {
757
+ const path = join(stateDir, 'silent-end-pending.json')
758
+ mkdirSync(stateDir, { recursive: true })
759
+ writeFileSync(path, JSON.stringify({
760
+ chatId: 'c', threadId: null, turnKey: 'OTHER:_',
761
+ retryCount: 1, timestamp: Date.now(), pendingText: ANSWER,
762
+ }))
763
+ const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
764
+ expect(decision.deliver).toBe(false)
765
+ expect(decision.reason).toBe('turnkey-mismatch')
766
+ })
767
+
768
+ it('double-send guard: once the captured prose is recorded in the dedup cache, a late reply-tool retry is suppressed', () => {
769
+ // The gateway records what it delivered in the #546 dedup cache. This is
770
+ // the airtight guard: the re-prompted model's reply (same answer) hits
771
+ // the same cache at its send site and is dropped — the represent + the
772
+ // captured-prose delivery are mutually exclusive by construction.
773
+ const dedup = new OutboundDedupCache()
774
+ const t0 = 1_000_000
775
+ // Gateway captured-prose delivery records the answer.
776
+ dedup.record('c', undefined, ANSWER, t0, 'reg-1')
777
+ // Model re-prompt then calls reply with the same answer moments later.
778
+ const hit = dedup.check('c', undefined, ANSWER, t0 + 5_000, 'reg-1')
779
+ expect(hit).not.toBeNull()
780
+ // A genuinely different later answer is NOT suppressed.
781
+ const miss = dedup.check('c', undefined, 'a completely different answer ' + 'X'.repeat(50), t0 + 5_000, 'reg-1')
782
+ expect(miss).toBeNull()
783
+ })
784
+
785
+ it('CAPTURED_PROSE_MIN_CHARS matches the scan/final-answer substance floor', () => {
786
+ expect(CAPTURED_PROSE_MIN_CHARS).toBe(200)
787
+ })
788
+ })
789
+ })
790
+
791
+ // ── Finding 3 (#3228): per-turn nonce guards decideCapturedProseDelivery ─────
792
+
793
+ describe('decideCapturedProseDelivery — per-turn nonce (turnId) guard (#3228 Finding 3)', () => {
794
+ const ANSWER = 'A recovered final answer written as plain text. ' + 'A'.repeat(300)
795
+ const statePath = () => join(stateDir, 'silent-end-pending.json')
796
+ function writeState(partial: Record<string, unknown>) {
797
+ mkdirSync(stateDir, { recursive: true })
798
+ writeFileSync(statePath(), JSON.stringify({
799
+ chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: Date.now(),
800
+ pendingText: ANSWER, ...partial,
801
+ }))
802
+ }
803
+
804
+ it('turnKey matches AND turnId matches → deliver', () => {
805
+ writeState({ turnId: 'c:_#100' })
806
+ const d = decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#100' })
807
+ expect(d.deliver).toBe(true)
808
+ expect(d.text).toBe(ANSWER)
809
+ expect(d.reason).toBe('captured-prose')
810
+ })
811
+
812
+ it('STALE record: same chat turnKey but a DIFFERENT turnId → NOT delivered (the carryover the finding describes)', () => {
813
+ // Turn A wrote pendingText+turnId=A. Turn B (same chat → same turnKey)
814
+ // ends in reprompt with its OWN turnId=B. The stale answer must not deliver.
815
+ writeState({ turnId: 'c:_#AAA' }) // turn A's record survived (hook overwrite bypassed)
816
+ const d = decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#BBB' })
817
+ expect(d.deliver).toBe(false)
818
+ expect(d.reason).toBe('turnid-mismatch')
819
+ })
820
+
821
+ it('record has NO turnId (synthetic inbound) → falls back to turnKey match, delivers', () => {
822
+ writeState({}) // no turnId field
823
+ const d = decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#123' })
824
+ expect(d.deliver).toBe(true)
825
+ expect(d.reason).toBe('captured-prose')
826
+ })
827
+
828
+ it('caller passes no turnId → falls back to turnKey match, delivers (never suppress on doubt)', () => {
829
+ writeState({ turnId: 'c:_#100' })
830
+ const d = decideCapturedProseDelivery({ turnKey: 'c:_' })
831
+ expect(d.deliver).toBe(true)
832
+ expect(d.reason).toBe('captured-prose')
833
+ })
834
+
835
+ it('substance-floor boundary at 199/200/201 (min-chars gate is unchanged by the nonce)', () => {
836
+ const check = (n: number) => {
837
+ writeState({ turnId: 'c:_#100', pendingText: 'X'.repeat(n) })
838
+ return decideCapturedProseDelivery({ turnKey: 'c:_', turnId: 'c:_#100' })
839
+ }
840
+ expect(check(199).deliver).toBe(false)
841
+ expect(check(199).reason).toBe('no-substantive-prose')
842
+ expect(check(200).deliver).toBe(true)
843
+ expect(check(201).deliver).toBe(true)
844
+ })
845
+ })
846
+
847
+ // ── Finding 1 (#3228): captured-prose send-failure arms the recovery net ─────
848
+
849
+ describe('settleCapturedProseDelivery — send-failure posture (#3228 Finding 1)', () => {
850
+ function spies(exhausted = false) {
851
+ const calls = { close: 0, clear: 0, undelivered: 0 }
852
+ const effects = {
853
+ closeObligation: () => { calls.close++ },
854
+ clearState: () => { calls.clear++ },
855
+ recordUndelivered: () => { calls.undelivered++; return { exhausted } },
856
+ }
857
+ return { calls, effects }
858
+ }
859
+
860
+ it("outcome 'failed' → arms the Stop-hook re-prompt net; does NOT close obligation or clear state", () => {
861
+ // THIS is the regression fix: in the interim-ack case the shared teardown
862
+ // has already closed the obligation (replyCalled=true), so "leave it open"
863
+ // is a myth — the net MUST be recordUndelivered. A pre-fix deliverCapturedProse
864
+ // catch that merely `return`ed (no recordUndelivered) would leave calls
865
+ // all-zero here → the answer is permanently lost.
866
+ const { calls, effects } = spies()
867
+ const r = settleCapturedProseDelivery('failed', effects)
868
+ expect(calls.undelivered).toBe(1)
869
+ expect(calls.close).toBe(0)
870
+ expect(calls.clear).toBe(0)
871
+ expect(r.exhausted).toBe(false)
872
+ })
873
+
874
+ it("outcome 'failed' with budget SPENT → threads exhausted:true out so the caller can fire the fallback (#3228 exhaustion-boundary)", () => {
875
+ // The exhaustion-boundary gap: recordUndeliveredTurnEnd took its exhausted
876
+ // branch (cleared state, returned exhausted:true). The settlement MUST
877
+ // surface that so the gateway delivers the plain-text/apology fallback —
878
+ // the obligation was already closed by the interim-ack teardown, so the
879
+ // re-prompt is gone AND the represent is gone. Pre-fix the return type was
880
+ // void, so this signal could not exist and the fallback never fired.
881
+ const { calls, effects } = spies(true)
882
+ const r = settleCapturedProseDelivery('failed', effects)
883
+ expect(calls.undelivered).toBe(1)
884
+ expect(r.exhausted).toBe(true)
885
+ })
886
+
887
+ it("outcome 'sent' → closes obligation + clears state; does NOT re-arm the re-prompt; exhausted:false", () => {
888
+ const { calls, effects } = spies()
889
+ const r = settleCapturedProseDelivery('sent', effects)
890
+ expect(calls.close).toBe(1)
891
+ expect(calls.clear).toBe(1)
892
+ expect(calls.undelivered).toBe(0)
893
+ expect(r.exhausted).toBe(false)
894
+ })
895
+
896
+ it("outcome 'skipped-dedup' → closes obligation + clears state (answer already went out); exhausted:false", () => {
897
+ const { calls, effects } = spies()
898
+ const r = settleCapturedProseDelivery('skipped-dedup', effects)
899
+ expect(calls.close).toBe(1)
900
+ expect(calls.clear).toBe(1)
901
+ expect(calls.undelivered).toBe(0)
902
+ expect(r.exhausted).toBe(false)
903
+ })
904
+
905
+ it('end-to-end failure net (budget remaining): a failed send leaves an undelivered-turn record armed, exhausted:false', () => {
906
+ // Simulate the gateway's `failed` settlement wiring against the REAL
907
+ // recordUndeliveredTurnEnd effect. The interim-ack teardown already closed
908
+ // the obligation; only the silent-end state can recover the turn now.
909
+ expect(readSilentEndState()).toBeNull()
910
+ const r = settleCapturedProseDelivery('failed', {
911
+ closeObligation: () => { throw new Error('must not be called on failure') },
912
+ clearState: () => { throw new Error('must not be called on failure') },
913
+ recordUndelivered: () =>
914
+ recordUndeliveredTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }),
915
+ })
916
+ // The recovery net is armed: the Stop hook will find this and re-prompt.
917
+ expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_' })
918
+ expect(r.exhausted).toBe(false)
919
+ })
920
+
921
+ it('end-to-end exhaustion boundary: a failed send AT the retry cap returns exhausted:true and clears state (the gateway must then fire the fallback)', () => {
922
+ // On-disk record already at the cap (the Stop hook blocked twice; the
923
+ // re-prompted turn's captured-prose send is now THROWING). The REAL
924
+ // recordUndeliveredTurnEnd takes its exhausted branch: clears state,
925
+ // returns exhausted:true. Pin that the settlement surfaces it — this is the
926
+ // signal the gateway uses to fire the plain-text/apology fallback so the
927
+ // user is never left with neither the answer nor the apology.
928
+ const path = join(stateDir, 'silent-end-pending.json')
929
+ mkdirSync(stateDir, { recursive: true })
930
+ writeFileSync(path, JSON.stringify({
931
+ chatId: 'c', threadId: null, turnKey: 'c:_',
932
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: Date.now(),
933
+ }))
934
+ const r = settleCapturedProseDelivery('failed', {
935
+ closeObligation: () => { throw new Error('must not be called on failure') },
936
+ clearState: () => { throw new Error('must not be called on failure') },
937
+ recordUndelivered: () =>
938
+ recordUndeliveredTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }),
939
+ })
940
+ expect(r.exhausted).toBe(true)
941
+ // recordUndeliveredTurnEnd's exhausted branch cleared the state — the
942
+ // Stop-hook re-prompt can no longer recover, so the caller owns delivery.
943
+ expect(readSilentEndState()).toBeNull()
944
+ })
945
+ })
946
+
947
+ // ── Finding 4/5 (#3228): honest documentation of the dedup divergence boundary ─
948
+
949
+ describe('recent-outbound-dedup — divergent-content double-send boundary (#3228 Finding 4/5)', () => {
950
+ it('divergent reply text vs captured prose under a different registryKey → NOT deduped (a real double-send vector)', () => {
951
+ // Documents the boundary the "airtight by construction" claim overstated:
952
+ // captured-prose records content C under registryKey K1. A re-present that
953
+ // lands REWORDED content C' under a DIFFERENT registryKey K2 has both a
954
+ // different hash AND a different turnKey — the cross-turn carve-out treats
955
+ // it as a miss, so it is NOT suppressed. Content-hash dedup cannot catch a
956
+ // divergent-content re-send; the obligation close + state clear are what
957
+ // make them mutually exclusive for the common case.
958
+ const dedup = new OutboundDedupCache()
959
+ const t0 = 1_000_000
960
+ const proseAnswer = 'The answer is 42, computed from the ledger. ' + 'A'.repeat(60)
961
+ dedup.record('c', undefined, proseAnswer, t0, 'reg-1')
962
+
963
+ // Same content, same key → suppressed (the common double-send).
964
+ expect(dedup.check('c', undefined, proseAnswer, t0 + 1_000, 'reg-1')).not.toBeNull()
965
+
966
+ // Reworded content under a different registryKey → NOT suppressed.
967
+ const reworded = 'To answer your question: it is 42 (per the ledger). ' + 'B'.repeat(60)
968
+ expect(dedup.check('c', undefined, reworded, t0 + 1_000, 'reg-2')).toBeNull()
969
+ })
970
+
971
+ it('identical content under two DIFFERENT non-null turnKeys → miss (cross-turn carve-out, documented boundary)', () => {
972
+ const dedup = new OutboundDedupCache()
973
+ const t0 = 2_000_000
974
+ const content = 'Identical answer text delivered in two separate turns. ' + 'C'.repeat(60)
975
+ dedup.record('c', undefined, content, t0, 'turnA')
976
+ // A later, genuinely different turn re-sending the SAME text is NOT a #546
977
+ // within-turn retry → not suppressed.
978
+ expect(dedup.check('c', undefined, content, t0 + 1_000, 'turnB')).toBeNull()
979
+ // But the SAME turnKey (a true within-turn retry) IS suppressed.
980
+ expect(dedup.check('c', undefined, content, t0 + 1_000, 'turnA')).not.toBeNull()
981
+ })
686
982
  })
@@ -120,12 +120,13 @@ describe('handleStreamReply', () => {
120
120
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
121
121
  })
122
122
 
123
- it('rich path sends the multi-paragraph `\\n\\n` gap byte-exact (no NBSP spacer)', async () => {
124
- // The NBSP paragraph-spacer pass was removed in the #2669 follow-up — the
125
- // rich renderer shows a plain `\n\n` gap as one blank line, so the handler
126
- // passes the text through unchanged.
123
+ it('applies addParagraphSpacers on the rich path (multi-paragraph gap spaced)', async () => {
127
124
  const state = makeState()
128
- const deps = makeDeps(bot)
125
+ // Spacer dep replaces every `\n\n` gap with a visible marker so we can
126
+ // assert the rich path ran it (mirrors the real gateway wiring).
127
+ const deps = makeDeps(bot, {
128
+ addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
129
+ })
129
130
 
130
131
  const pending = handleStreamReply(
131
132
  { chat_id: '1', text: 'Para one.\n\nPara two.', done: true },
@@ -136,13 +137,14 @@ describe('handleStreamReply', () => {
136
137
  await pending
137
138
 
138
139
  expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
139
- expect(richSendMarkdown(bot)).toBe('Para one.\n\nPara two.')
140
- expect(richSendMarkdown(bot)).not.toContain(String.fromCharCode(0xa0))
140
+ expect(richSendMarkdown(bot)).toBe('Para one.\n\nSPACER\n\nPara two.')
141
141
  })
142
142
 
143
- it('literal format=text path is byte-exact too', async () => {
143
+ it('does NOT apply addParagraphSpacers on the literal format=text path', async () => {
144
144
  const state = makeState()
145
- const deps = makeDeps(bot)
145
+ const deps = makeDeps(bot, {
146
+ addParagraphSpacers: (t) => t.replace(/\n\n/g, '\n\nSPACER\n\n'),
147
+ })
146
148
 
147
149
  const pending = handleStreamReply(
148
150
  { chat_id: '1', text: 'Para one.\n\nPara two.', format: 'text', done: true },
@@ -153,6 +155,7 @@ describe('handleStreamReply', () => {
153
155
  await pending
154
156
 
155
157
  expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
158
+ // Literal path is byte-exact — no spacer injected.
156
159
  expect(bot.api.sendMessage.mock.calls[0][1]).toBe('Para one.\n\nPara two.')
157
160
  })
158
161
 
@@ -264,6 +264,36 @@ describe('decideSubagentProgress', () => {
264
264
  if (!d.deliver) expect(d.reason).toBe('foreground')
265
265
  })
266
266
 
267
+ // #3233 — worker-feed-DISABLED legacy path: a contentless skeleton liveness
268
+ // cue must NOT be relayed as a synthesized "still working" inbound (that
269
+ // would be a blank card). It degrades to a no-op, deterministically, BEFORE
270
+ // bucketing so it can never advance the bucket tracker.
271
+ it('skeleton liveness cue is dropped (no blank card) even when every other gate would pass', () => {
272
+ // Same input that DELIVERS in the happy-path test above (bucket 1, chat
273
+ // resolves) — only `skeleton` flips it off. Empty summary mirrors the real
274
+ // skeleton cue.
275
+ const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '' }))
276
+ expect(d.deliver).toBe(false)
277
+ if (!d.deliver) expect(d.reason).toBe('skeleton-liveness')
278
+ })
279
+
280
+ it('skeleton suppression fires before bucketing — a background skeleton at bucket>=1 never delivers', () => {
281
+ // ≤1 relay per interval is trivially satisfied: skeleton cues deliver ZERO
282
+ // inbounds regardless of how many no-growth polls fire within a bucket.
283
+ for (const elapsedMs of [7 * 60 * 1000, 8 * 60 * 1000, 9 * 60 * 1000]) {
284
+ const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '', elapsedMs }))
285
+ expect(d.deliver, `elapsedMs=${elapsedMs}`).toBe(false)
286
+ }
287
+ })
288
+
289
+ it('a NON-skeleton cue with identical inputs still delivers (guard is skeleton-scoped, not summary-scoped)', () => {
290
+ // Red-on-regression companion: proves the drop keys on `skeleton`, not on
291
+ // the empty summary — a real tool-only cue (empty prose summary) still
292
+ // delivers, so the guard cannot silently swallow genuine progress.
293
+ const d = decideSubagentProgress(baseInput({ skeleton: false, latestSummary: '' }))
294
+ expect(d.deliver).toBe(true)
295
+ })
296
+
267
297
  it('falls back to owner chat when fleet chat is empty', () => {
268
298
  const d = decideSubagentProgress(baseInput({ fleetChatId: '' }))
269
299
  expect(d.deliver).toBe(true)