switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest'
3
3
  import {
4
4
  ackDelivery,
5
5
  createDeliveryQueue,
6
+ extractEnqueueMessageIds,
6
7
  forgetDelivery,
8
+ isRedeliverySuspended,
7
9
  isTrackableResumeSynthetic,
8
10
  shouldTrackDelivery,
9
11
  sweep,
10
12
  trackDelivery,
11
13
  type DeliveryQueue,
14
+ type SuspendedTargets,
12
15
  } from '../gateway/inbound-delivery-confirm.js'
13
16
 
14
17
  /**
@@ -110,6 +113,52 @@ describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () =>
110
113
  })
111
114
  })
112
115
 
116
+ // Regression for #2787 Mechanism A — a permission / ask_user card must suspend
117
+ // the confirm sweep ONLY for its own chat/topic, never globally. Before the fix
118
+ // the gateway early-returned the whole sweep whenever ANY card was pending
119
+ // anywhere, so one card parked in a single topic (or the operator DM) froze
120
+ // re-delivery of every stranded inbound across every topic until it resolved —
121
+ // the ~5-minute all-topics stall of #1922. isRedeliverySuspended encodes the
122
+ // scoped decision the sweep now makes per swept entry.
123
+ describe('isRedeliverySuspended — per-topic sweep suspension (#2787 Mechanism A)', () => {
124
+ const none: SuspendedTargets = { keys: new Set(), chats: new Set() }
125
+
126
+ it('no cards open → nothing is suspended (sweep runs for every topic)', () => {
127
+ expect(isRedeliverySuspended('-100:4', none)).toBe(false)
128
+ expect(isRedeliverySuspended('555:_', none)).toBe(false)
129
+ })
130
+
131
+ it('reproduces the global-stall fix: a card in topic A does NOT suspend topic B', () => {
132
+ // A permission card is open in supergroup topic 4; a DIFFERENT topic (7) and
133
+ // an unrelated DM both have inbounds stranded in the composer.
134
+ const suspended: SuspendedTargets = { keys: new Set(['-100:4']), chats: new Set() }
135
+ expect(isRedeliverySuspended('-100:4', suspended)).toBe(true) // the card's own topic — deferred
136
+ expect(isRedeliverySuspended('-100:7', suspended)).toBe(false) // sibling topic keeps flowing
137
+ expect(isRedeliverySuspended('555:_', suspended)).toBe(false) // unrelated DM keeps flowing
138
+ })
139
+
140
+ it('a card fanned to the operator DM (chatId only, topic unknown) suspends just that chat', () => {
141
+ const suspended: SuspendedTargets = { keys: new Set(), chats: new Set(['999']) }
142
+ expect(isRedeliverySuspended('999:_', suspended)).toBe(true) // operator DM — deferred
143
+ expect(isRedeliverySuspended('-100:4', suspended)).toBe(false) // every topic elsewhere flows
144
+ expect(isRedeliverySuspended('555:_', suspended)).toBe(false)
145
+ })
146
+
147
+ it('drives the actual sweep: stranded siblings still re-deliver while one topic holds a card', () => {
148
+ const q = createDeliveryQueue<{ text: string }>()
149
+ trackDelivery(q, '-100:4', { text: 'msg in the card topic' }, 0)
150
+ trackDelivery(q, '-100:7', { text: 'msg in a sibling topic' }, 0)
151
+ trackDelivery(q, '555:_', { text: 'dm msg' }, 0)
152
+ const suspended: SuspendedTargets = { keys: new Set(['-100:4']), chats: new Set() }
153
+ // The gateway loop: sweep, then skip only entries suspended for their target.
154
+ const redelivered = sweep(q, 15_000, TIMEOUT).filter(
155
+ (p) => !isRedeliverySuspended(p.key, suspended),
156
+ )
157
+ const keys = redelivered.map((p) => p.key).sort()
158
+ expect(keys).toEqual(['-100:7', '555:_']) // the card's topic held; the rest flowed
159
+ })
160
+ })
161
+
113
162
  // Regression for the cross-source ACK collision (silent drop): `enqueue` fires
114
163
  // for EVERY turn start regardless of source. A synthetic-source turn (cron /
115
164
  // resume / vault / reaction) that shares the chatKey of a real user message
@@ -151,6 +200,103 @@ describe('ackDelivery — message-id-matched (cross-source false-ack guard)', ()
151
200
  })
152
201
  })
153
202
 
203
+ // Regression for #2786 — the enqueue-ack mismatch that re-delivers a RUNNING
204
+ // turn (a duplicate turn / double-actioned user message). Delivery-confirm
205
+ // re-parses the `<channel message_id="…">` envelope back out of the transcript
206
+ // to match the ack. When the composer merges/reorders inbound envelopes, the
207
+ // single re-parsed id can belong to a sibling, so strict equality mis-concludes
208
+ // "never delivered" and the sweep re-delivers a turn already running. Passing
209
+ // the raw enqueue content lets the ack tolerate that by scanning every id.
210
+ describe('ackDelivery — composer-tolerant match (#2786 duplicate-turn guard)', () => {
211
+ const envelope = (id: string, text: string) =>
212
+ `<channel source="telegram" chat_id="555" message_id="${id}" user="ken">${text}</channel>`
213
+
214
+ it('reproduces the bug: strict re-parsed id mismatch would NOT ack (pre-fix)', () => {
215
+ const q = fresh()
216
+ trackDelivery(q, 'chat:_', { text: 'run the deploy' }, 0, '5002')
217
+ // Composer merged an earlier sibling first, so parseChannelMeta re-parsed
218
+ // the FIRST id (5001), not our tracked 5002. Without the content, no ack →
219
+ // the sweep would re-deliver the already-running turn (the duplicate).
220
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(false)
221
+ expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1) // would double-action
222
+ })
223
+
224
+ it('acks when the tracked id appears anywhere in the merged enqueue content', () => {
225
+ const q = fresh()
226
+ trackDelivery(q, 'chat:_', { text: 'run the deploy' }, 0, '5002')
227
+ const merged = `${envelope('5001', 'hi')}\n${envelope('5002', 'run the deploy')}`
228
+ // First-parsed id is the sibling's 5001, but our 5002 is present in content.
229
+ expect(ackDelivery(q, 'chat:_', '5001', merged)).toBe(true)
230
+ expect(q.pending.size).toBe(0)
231
+ expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(0) // no duplicate turn
232
+ })
233
+
234
+ it('acks on a reformatted single envelope where content still carries the id', () => {
235
+ const q = fresh()
236
+ trackDelivery(q, 'chat:_', { text: 'x' }, 0, '5002')
237
+ // Re-parse yielded null (wrapper reformatted) but the id survives in content.
238
+ expect(ackDelivery(q, 'chat:_', null, envelope('5002', 'x'))).toBe(true)
239
+ expect(q.pending.size).toBe(0)
240
+ })
241
+
242
+ it('preserves the cross-source false-ack guard: a synthetic turn whose content lacks our id does NOT ack', () => {
243
+ const q = fresh()
244
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5002')
245
+ // A cron/resume turn under the same key — its envelope carries a fabricated
246
+ // id, never the user's 5002. Even with content scanning, it must not ack.
247
+ const cron = envelope('1716123456789', 'scheduled digest')
248
+ expect(ackDelivery(q, 'chat:_', '1716123456789', cron)).toBe(false)
249
+ expect(q.pending.size).toBe(1)
250
+ expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1) // user msg re-delivered, not dropped
251
+ })
252
+
253
+ it('extractEnqueueMessageIds pulls every id from a merged envelope', () => {
254
+ const merged = `${envelope('5001', 'a')}\n${envelope('5002', 'b')}`
255
+ expect(extractEnqueueMessageIds(merged)).toEqual(['5001', '5002'])
256
+ expect(extractEnqueueMessageIds('no envelope here')).toEqual([])
257
+ })
258
+
259
+ // Regression for the unanchored-regex substring collision (silent-drop
260
+ // hazard on this never-drop path). `target_message_id`, `reply_to_message_id`,
261
+ // `original_message_id`, `card_message_id` etc. all END in `message_id="…"`.
262
+ // An unanchored /message_id="([^"]+)"/ grabs those siblings' values as if they
263
+ // were the real `message_id`, so a synthetic-source turn that merely REFERENCES
264
+ // message 5002 (e.g. `reply_to_message_id="5002"`) — without actually being
265
+ // message 5002 — would false-ack and silently drop the still-pending real
266
+ // inbound 5002. The regex must match ONLY the real `message_id` attribute.
267
+ it('does NOT extract same-suffix sibling attributes (target_/reply_to_/original_/card_message_id)', () => {
268
+ const content =
269
+ 'target_message_id="5002" reply_to_message_id="5003" ' +
270
+ 'original_message_id="5004" card_message_id="5005"'
271
+ expect(extractEnqueueMessageIds(content)).toEqual([])
272
+ })
273
+
274
+ it('extracts the real message_id even when a sibling *_message_id sits alongside it', () => {
275
+ const content =
276
+ '<channel source="reaction" chat_id="555" target_message_id="5002" ' +
277
+ 'message_id="9999" user="ken">reacted</channel>'
278
+ // Only the real attribute (9999) is pulled — never the referenced 5002.
279
+ expect(extractEnqueueMessageIds(content)).toEqual(['9999'])
280
+ })
281
+
282
+ it('a sibling *_message_id="5002" reference does NOT false-ack a pending entry keyed on 5002 (substring-collision guard)', () => {
283
+ const q = fresh()
284
+ trackDelivery(q, 'chat:_', { text: 'real user msg 5002' }, 0, '5002')
285
+ // A synthetic-source turn (its own id 8000) merely REFERENCES 5002 via
286
+ // sibling attributes — it is not message 5002 itself.
287
+ const synthetic =
288
+ '<channel source="reaction" chat_id="555" message_id="8000" ' +
289
+ 'target_message_id="5002" reply_to_message_id="5002" user="ken">👍</channel>'
290
+ expect(ackDelivery(q, 'chat:_', '8000', synthetic)).toBe(false)
291
+ // The real inbound 5002 is still pending — it strands and re-delivers, not dropped.
292
+ expect(q.pending.size).toBe(1)
293
+ expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1)
294
+ // Its own genuine envelope (real message_id="5002") later acks it cleanly.
295
+ expect(ackDelivery(q, 'chat:_', '5002', envelope('5002', 'real user msg 5002'))).toBe(true)
296
+ expect(q.pending.size).toBe(0)
297
+ })
298
+ })
299
+
154
300
  // Regression for the steer/interrupt re-delivery loop: steering and `!`
155
301
  // interrupt inbounds amend the running turn and never emit `enqueue`, so they
156
302
  // must NOT be tracked (else the sweep re-delivers them forever). Only
@@ -435,3 +435,146 @@ describe('inbound-spool — robustness', () => {
435
435
  ])
436
436
  })
437
437
  })
438
+
439
+ describe('inbound-spool — #2789 C: multi-message drop notice reports the real count', () => {
440
+ it('passes the true per-chat dropped count to the coalesced notice', () => {
441
+ const fs = fakeFs()
442
+ let t = 0
443
+ const s = createInboundSpool({
444
+ path: PATH,
445
+ fs,
446
+ now: () => t,
447
+ escalateAfterMs: 100,
448
+ escalateNoticeCooldownMs: 10_000,
449
+ })
450
+ // Four undeliverable synthetics in ONE chat — the historical bug
451
+ // coalesced these into a notice that read as a SINGLE drop.
452
+ s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
453
+ s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
454
+ s.put('marko', msg({ messageId: 0, ts: 3, meta: { source: 'cron' } }))
455
+ s.put('marko', msg({ messageId: 0, ts: 4, meta: { source: 'cron' } }))
456
+ t = 1000 // all older than the 100ms bound
457
+ const posted: number[] = []
458
+ const seen: number[] = []
459
+ const dropped = s.sweepEscalations((_e, { postNotice, droppedCount }) => {
460
+ seen.push(droppedCount)
461
+ if (postNotice) posted.push(droppedCount)
462
+ })
463
+ expect(dropped).toBe(4) // all four retracted
464
+ // Every callback for this chat sees the true sweep count (4), and the
465
+ // ONE posted notice reports 4 — not 1.
466
+ expect(seen).toEqual([4, 4, 4, 4])
467
+ expect(posted).toEqual([4])
468
+ })
469
+
470
+ it('reports per-chat counts independently in a mixed sweep', () => {
471
+ const fs = fakeFs()
472
+ let t = 0
473
+ const s = createInboundSpool({
474
+ path: PATH,
475
+ fs,
476
+ now: () => t,
477
+ escalateAfterMs: 100,
478
+ escalateNoticeCooldownMs: 10_000,
479
+ })
480
+ // 3 in chat A, 1 in chat B.
481
+ s.put('m', msg({ chatId: 'A', messageId: 0, ts: 1, meta: { source: 'cron' } }))
482
+ s.put('m', msg({ chatId: 'A', messageId: 0, ts: 2, meta: { source: 'cron' } }))
483
+ s.put('m', msg({ chatId: 'A', messageId: 0, ts: 3, meta: { source: 'cron' } }))
484
+ s.put('m', msg({ chatId: 'B', messageId: 0, ts: 4, meta: { source: 'cron' } }))
485
+ t = 1000
486
+ const posted = new Map<string, number>()
487
+ s.sweepEscalations((e, { postNotice, droppedCount }) => {
488
+ if (postNotice) posted.set(String(e.msg.chatId), droppedCount)
489
+ })
490
+ expect(posted.get('A')).toBe(3)
491
+ expect(posted.get('B')).toBe(1)
492
+ })
493
+
494
+ it('a single drop still reports a count of 1', () => {
495
+ const fs = fakeFs()
496
+ let t = 0
497
+ const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
498
+ s.put('m', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
499
+ t = 1000
500
+ const counts: number[] = []
501
+ s.sweepEscalations((_e, { droppedCount }) => counts.push(droppedCount))
502
+ expect(counts).toEqual([1])
503
+ })
504
+ })
505
+
506
+ describe('inbound-spool — #2789 B: spool write failure surfaces a health signal', () => {
507
+ // An fs whose appendFileSync can be toggled to fail, modelling a full /
508
+ // unwritable persistent volume. The append swallows the error (delivery
509
+ // must not break) but the degradation must be SURFACED, not silent.
510
+ function toggleableFs(): InboundSpoolFsSeam & { fail: boolean } {
511
+ const files = new Map<string, string>()
512
+ const seam = {
513
+ fail: false,
514
+ appendFileSync(p: string, d: string) {
515
+ if (seam.fail) throw new Error('ENOSPC: no space left on device')
516
+ files.set(p, (files.get(p) ?? '') + d)
517
+ },
518
+ readFileSync: (p: string) => files.get(p) ?? '',
519
+ writeFileSync: (p: string, d: string) => files.set(p, d),
520
+ renameSync: (from: string, to: string) => {
521
+ files.set(to, files.get(from) ?? '')
522
+ files.delete(from)
523
+ },
524
+ existsSync: (p: string) => files.has(p),
525
+ statSizeSync: (p: string) => Buffer.byteLength(files.get(p) ?? ''),
526
+ }
527
+ return seam
528
+ }
529
+
530
+ it('raises a latched onDegraded signal when an append fails, and isDegraded() reflects it', () => {
531
+ const fs = toggleableFs()
532
+ const events: { degraded: boolean; consecutiveFailures: number }[] = []
533
+ const s = createInboundSpool({
534
+ path: PATH,
535
+ fs,
536
+ log: () => {},
537
+ onDegraded: (info) => events.push({ degraded: info.degraded, consecutiveFailures: info.consecutiveFailures }),
538
+ })
539
+ expect(s.isDegraded()).toBe(false)
540
+ fs.fail = true
541
+ // put() must NOT throw — live delivery keeps working, durability degrades.
542
+ expect(() => s.put('a', msg({ messageId: 1, ts: 1 }))).not.toThrow()
543
+ expect(s.isDegraded()).toBe(true)
544
+ expect(s.appendFailureCount()).toBe(1)
545
+ // Latched: exactly one transition event on entering degraded.
546
+ expect(events).toEqual([{ degraded: true, consecutiveFailures: 1 }])
547
+ })
548
+
549
+ it('does not re-fire the degraded signal on every failing append (latched)', () => {
550
+ const fs = toggleableFs()
551
+ const events: boolean[] = []
552
+ const s = createInboundSpool({
553
+ path: PATH, fs, log: () => {},
554
+ onDegraded: (info) => events.push(info.degraded),
555
+ })
556
+ fs.fail = true
557
+ s.put('a', msg({ messageId: 1, ts: 1 }))
558
+ s.put('a', msg({ messageId: 2, ts: 2 }))
559
+ s.put('a', msg({ messageId: 3, ts: 3 }))
560
+ expect(s.appendFailureCount()).toBe(3)
561
+ expect(events).toEqual([true]) // one transition, not three
562
+ })
563
+
564
+ it('surfaces recovery when appends succeed again (health signal un-latches)', () => {
565
+ const fs = toggleableFs()
566
+ const events: boolean[] = []
567
+ const s = createInboundSpool({
568
+ path: PATH, fs, log: () => {},
569
+ onDegraded: (info) => events.push(info.degraded),
570
+ })
571
+ fs.fail = true
572
+ s.put('a', msg({ messageId: 1, ts: 1 }))
573
+ expect(s.isDegraded()).toBe(true)
574
+ fs.fail = false
575
+ s.put('a', msg({ messageId: 2, ts: 2 }))
576
+ expect(s.isDegraded()).toBe(false)
577
+ expect(s.appendFailureCount()).toBe(0)
578
+ expect(events).toEqual([true, false]) // degraded → recovered
579
+ })
580
+ })
@@ -184,7 +184,7 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
184
184
  });
185
185
 
186
186
  describe("handleModelCommand — set", () => {
187
- it("injects exactly `/model <name>` once and relays output + persistence note", async () => {
187
+ it("injects exactly `/model <name>` once and relays a genuine confirmation + persistence note", async () => {
188
188
  const { deps, calls } = makeDeps();
189
189
  const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
190
190
  expect(calls).toEqual([{ agent: "klanker", command: "/model opus" }]);
@@ -193,6 +193,59 @@ describe("handleModelCommand — set", () => {
193
193
  expect(reply.html).toBe(true);
194
194
  });
195
195
 
196
+ it("SILENT switch: suppresses raw pane scrollback instead of dumping it as a code block", async () => {
197
+ // claude switches models silently, so the pane capture below the command
198
+ // echo is just the agent's previous prose answer. It must NOT be relayed.
199
+ const scrollback = [
200
+ "Here's the summary you asked for earlier:",
201
+ "- point one about the deploy",
202
+ "- point two about the rollback plan",
203
+ ].join("\n");
204
+ const { deps } = makeDeps({ inject: async () => okResult(scrollback) });
205
+ const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
206
+ // The leak: none of the scrollback prose reaches the reply, and there is
207
+ // no <pre> code block echoing the capture.
208
+ expect(reply.text).not.toContain("summary you asked for");
209
+ expect(reply.text).not.toContain("rollback plan");
210
+ expect(reply.text).not.toContain("<pre>");
211
+ // A clean, session-scoped confirmation is sent instead.
212
+ expect(reply.text).toContain("/model fable");
213
+ expect(reply.text).toContain("switched (session)");
214
+ expect(reply.text).toContain("Session-only");
215
+ expect(reply.html).toBe(true);
216
+ });
217
+
218
+ it("relays only the confirmation line when the capture also carries scrollback", async () => {
219
+ const mixed = [
220
+ "Some earlier prose that must not leak",
221
+ "⏺ Set model to Fable 5 for this session",
222
+ ].join("\n");
223
+ const { deps } = makeDeps({ inject: async () => okResult(mixed) });
224
+ const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
225
+ expect(reply.text).toContain("<pre>⏺ Set model to Fable 5 for this session</pre>");
226
+ expect(reply.text).not.toContain("earlier prose that must not leak");
227
+ });
228
+
229
+ it("does NOT relay scrollback prose that merely contains 'switched'/'set model' as ordinary words", async () => {
230
+ // No line begins with claude's real confirmation phrasing — these are just
231
+ // English sentences that happen to use the words. None must be relayed.
232
+ const prose = [
233
+ "I switched the deploy to blue-green as we discussed.",
234
+ "Then I set model behaviour aside and moved on to the tests.",
235
+ "The team kept model changes out of this release entirely.",
236
+ ].join("\n");
237
+ const { deps } = makeDeps({ inject: async () => okResult(prose) });
238
+ const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
239
+ // The anchored regex rejects all three lines, so nothing leaks and there is
240
+ // no <pre> block. A clean session confirmation is sent instead.
241
+ expect(reply.text).not.toContain("<pre>");
242
+ expect(reply.text).not.toContain("switched the deploy");
243
+ expect(reply.text).not.toContain("set model behaviour");
244
+ expect(reply.text).not.toContain("kept model changes");
245
+ expect(reply.text).toContain("switched (session)");
246
+ expect(reply.html).toBe(true);
247
+ });
248
+
196
249
  it("re-gates the model arg at the seam (caller bypassing the parser)", async () => {
197
250
  const { deps, calls } = makeDeps();
198
251
  const reply = await handleModelCommand({ kind: "set", model: "a b; reboot" }, deps);
@@ -50,15 +50,9 @@ describe('component 3 — turn-origin reply routing', () => {
50
50
  expect(fn).toMatch(/resolveAnswerThread\w*\(/)
51
51
  })
52
52
 
53
- it('executeStreamReply resolves the answer thread via the origin turn too', () => {
54
- const fn = gatewaySrc.split('async function executeStreamReply')[1]?.split('\nasync function ')[0] ?? ''
55
- expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
56
- expect(fn).toMatch(/resolveAnswerThread\w*\(/)
57
- })
58
-
59
- it('the reply + stream_reply tool schemas expose origin_turn_id to the model', () => {
53
+ it('the reply tool schema exposes origin_turn_id to the model', () => {
60
54
  const occurrences = bridgeSrc.match(/origin_turn_id: \{ type: 'string'/g) ?? []
61
- expect(occurrences.length).toBe(2) // reply + stream_reply
55
+ expect(occurrences.length).toBe(1) // reply
62
56
  })
63
57
 
64
58
  it('recentTurnsById is a BOUNDED registry (cannot grow unbounded)', () => {
@@ -78,8 +72,8 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
78
72
  expect(fn).toMatch(/recentTurnIdBySourceMessageId\.delete\(evicted\.sourceMessageId\)/)
79
73
  })
80
74
 
81
- it('both reply paths recover origin from the quoted message_id when the model omits the echo', () => {
82
- for (const name of ['executeReply', 'executeStreamReply']) {
75
+ it('the reply path recovers origin from the quoted message_id when the model omits the echo', () => {
76
+ for (const name of ['executeReply']) {
83
77
  const fn = gatewaySrc.split(new RegExp(`async function ${name}`))[1]?.split('\nasync function ')[0] ?? ''
84
78
  // Echo first (authoritative), quoted message_id as the framework fallback.
85
79
  expect(fn).toMatch(/const echoedTurn = findTurnByOriginId\(args\.origin_turn_id/)
@@ -144,7 +138,7 @@ describe('component 5 — queued-status UX (delete-on-answer)', () => {
144
138
 
145
139
  it('Hook C reaps the placeholder on the answer (executeReply / stream)', () => {
146
140
  const reapCalls = gatewaySrc.match(/reapQueuedStatus\(/g) ?? []
147
- // definition + executeReply + executeStreamReply + purge cleanup (2 branches)
141
+ // definition + executeReply + purge cleanup (2 branches)
148
142
  expect(reapCalls.length).toBeGreaterThanOrEqual(4)
149
143
  })
150
144