switchroom 0.19.41 → 0.19.43

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 (90) hide show
  1. package/dist/agent-scheduler/index.js +23 -3
  2. package/dist/auth-broker/index.js +54 -9
  3. package/dist/cli/notion-write-pretool.mjs +23 -3
  4. package/dist/cli/switchroom.js +1956 -900
  5. package/dist/host-control/main.js +227 -30
  6. package/dist/vault/approvals/kernel-server.js +54 -9
  7. package/dist/vault/broker/server.js +54 -9
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1318 -693
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  19. package/telegram-plugin/gateway/boot-card.ts +7 -2
  20. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  21. package/telegram-plugin/gateway/gateway.ts +56 -58
  22. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  23. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  24. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  25. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  26. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  27. package/telegram-plugin/gateway/model-command.ts +21 -109
  28. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  29. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  30. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  31. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  32. package/telegram-plugin/gateway/stream-render.ts +62 -0
  33. package/telegram-plugin/gateway/update-announce.ts +11 -4
  34. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  35. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  36. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  37. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  38. package/telegram-plugin/outbox.ts +103 -2
  39. package/telegram-plugin/render/rich-render.ts +10 -2
  40. package/telegram-plugin/reply-quote.ts +150 -0
  41. package/telegram-plugin/retry-api-call.ts +254 -11
  42. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  43. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  44. package/telegram-plugin/silent-end.ts +34 -2
  45. package/telegram-plugin/status-no-truncate.ts +31 -16
  46. package/telegram-plugin/stream-controller.ts +44 -9
  47. package/telegram-plugin/stream-reply-handler.ts +5 -3
  48. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  49. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  50. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  51. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  52. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  53. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  54. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  55. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  56. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  57. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  58. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  59. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  60. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  61. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  62. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  63. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  64. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  65. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  66. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  67. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  68. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  69. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  70. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  71. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  72. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  73. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  74. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  75. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  76. package/telegram-plugin/tool-activity-summary.ts +58 -30
  77. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  78. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  79. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  80. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  81. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  82. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  83. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  84. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  85. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  88. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  90. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { hardTruncateCard } from '../card-layout.js'
3
+ import { truncateMarkdownSafe, safeMarkdownCut } from '../format.js'
4
+ import { validateRichMarkdown } from './rich-markdown-oracle.js'
5
+
6
+ /**
7
+ * #4116 — the LAST-RESORT truncation must not be able to fail the send.
8
+ *
9
+ * `hardTruncateCard` is the path `fitCardToBudget` falls to when no shrink
10
+ * level can get a card under `STATUS_CARD_CHAR_BUDGET` (the #3682 repro: a
11
+ * 40k-char worker description, where every level keeps the same over-budget
12
+ * chrome). Its single-over-budget-line branch used to slice the raw markdown
13
+ * by character count. A cut landing between a `**` pair — or inside a
14
+ * `` `code` `` span, a `[label](href)`, an open ``` fence, a `~~strike~~`, a
15
+ * `||spoiler||` — emits a body Telegram parse-REJECTS with "can't find end of
16
+ * the entity". So the code path whose whole purpose is to guarantee delivery
17
+ * within budget could itself make the send fail.
18
+ *
19
+ * The fix routes the cut through `truncateMarkdownSafe`, which reuses the
20
+ * chunker's existing entity-boundary reasoning (`safeMarkdownCut` =
21
+ * fence → table row → inline span back-offs, plus surrogate-pair safety)
22
+ * rather than adding a second markdown parser.
23
+ *
24
+ * These tests assert the OUTCOME — "the emitted text parses" — using the
25
+ * repo's own parse-accept oracle (`rich-markdown-oracle.ts`, the same one the
26
+ * formatting-regression suite trusts), at EVERY cut offset rather than at a
27
+ * handful of hand-picked ones.
28
+ */
29
+
30
+ /** A card body carrying one of every entity kind that can straddle a cut. */
31
+ const MIXED_ENTITY_CARD = [
32
+ '🛠 **Worker · fix/telegram-w0-de** · 2m05s · 12 tools',
33
+ '~~_✓ Read `telegram-plugin/card-layout.ts` and `format.ts`_~~',
34
+ '**→ Rewriting the last-resort clip** with a [linked issue](https://github.com/switchroom/switchroom/issues/4116) inline',
35
+ '⠀⠀⠀↳ _nested_ step with a ||spoiler|| and ~~a struck phrase~~ plus 👍🏽 astral text',
36
+ '```ts',
37
+ 'const out = hardTruncateCard(text, budget)',
38
+ 'const safe = safeMarkdownCut(text, cut)',
39
+ '```',
40
+ 'A trailing prose line with **bold**, `code`, _italic_ and an emoji 🧵 at the end.',
41
+ ].join('\n')
42
+
43
+ describe('the source fixture is itself valid markdown', () => {
44
+ it('guards the property test against a vacuous baseline', () => {
45
+ // If the fixture were already invalid, "every truncation is valid" could
46
+ // only ever fail — or, worse, a broken oracle would make it vacuously pass.
47
+ expect(validateRichMarkdown(MIXED_ENTITY_CARD)).toEqual([])
48
+ })
49
+ })
50
+
51
+ describe('PROPERTY: hardTruncateCard emits parseable markdown at every offset (#4116)', () => {
52
+ it('every budget from 1 to the full length yields a card Telegram can parse', () => {
53
+ const failures: Array<{ budget: number; kinds: string[]; tail: string }> = []
54
+ for (let budget = 1; budget <= MIXED_ENTITY_CARD.length; budget++) {
55
+ const out = hardTruncateCard(MIXED_ENTITY_CARD, budget)
56
+ const issues = validateRichMarkdown(out)
57
+ if (issues.length > 0) {
58
+ failures.push({
59
+ budget,
60
+ kinds: [...new Set(issues.map((i) => i.kind))],
61
+ tail: out.slice(-24),
62
+ })
63
+ }
64
+ }
65
+ // Fail-before on origin/main: every offset whose cut lands inside a `**`
66
+ // pair / code span / link / fence produces an unsendable card.
67
+ expect(failures).toEqual([])
68
+ })
69
+
70
+ it('every truncation stays within its budget and is valid UTF-16', () => {
71
+ for (let budget = 1; budget <= MIXED_ENTITY_CARD.length; budget++) {
72
+ const out = hardTruncateCard(MIXED_ENTITY_CARD, budget)
73
+ expect(out.length, `budget ${budget}`).toBeLessThanOrEqual(budget)
74
+ // No lone surrogate — a split astral char is invalid UTF-16 on the wire.
75
+ expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out), `budget ${budget}`).toBe(false)
76
+ expect(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(out), `budget ${budget}`).toBe(false)
77
+ }
78
+ })
79
+
80
+ it('a clipped card is still a card — it never collapses to (nearly) nothing', () => {
81
+ // The clip exists because "a clipped card beats no card". A back-off that
82
+ // retreated past a huge entity could return an empty string, which is the
83
+ // dropped card all over again. From a budget with room for real content,
84
+ // at least half of it survives — minus the handful of delimiter characters
85
+ // an opener-drop repair removes (measured worst case on this fixture: 8).
86
+ const REPAIR_SLACK = 12
87
+ for (let budget = 24; budget <= MIXED_ENTITY_CARD.length; budget++) {
88
+ const out = hardTruncateCard(MIXED_ENTITY_CARD, budget)
89
+ expect(out.length * 2 + REPAIR_SLACK, `budget ${budget}`).toBeGreaterThanOrEqual(budget)
90
+ }
91
+ })
92
+ })
93
+
94
+ describe('the specific entity kinds that can straddle a cut (#4116)', () => {
95
+ const cases: Array<[string, string]> = [
96
+ ['bold', 'lead in **a bold run that is long enough to straddle** tail'],
97
+ ['italic-underscore', 'lead in _an italic run long enough to straddle_ tail'],
98
+ ['bold-italic', 'lead in ***a bold-italic run long enough to straddle*** tail'],
99
+ ['inline code', 'lead in `a code span long enough to straddle the cut` tail'],
100
+ ['link', 'lead in [a label](https://example.com/a/long/path/here) tail'],
101
+ ['strikethrough', 'lead in ~~a struck run long enough to straddle~~ tail'],
102
+ ['spoiler', 'lead in ||a spoiler run long enough to straddle|| tail'],
103
+ ['table row', 'lead in\n| col a | col b | col c | col d |\ntail'],
104
+ ['fenced block', 'lead in\n```ts\nconst x = 1\nconst y = 2\n```\ntail'],
105
+ ]
106
+
107
+ for (const [kind, body] of cases) {
108
+ it(`never bisects a ${kind}`, () => {
109
+ expect(validateRichMarkdown(body)).toEqual([])
110
+ const bad: number[] = []
111
+ for (let budget = 1; budget <= body.length; budget++) {
112
+ if (validateRichMarkdown(hardTruncateCard(body, budget)).length > 0) bad.push(budget)
113
+ }
114
+ expect(bad).toEqual([])
115
+ })
116
+ }
117
+ })
118
+
119
+ describe('truncateMarkdownSafe — the shared mechanism', () => {
120
+ it('backs off rather than cutting inside a span, keeping formatting intact', () => {
121
+ const text = 'abcdefghij **bold text here** klmno'
122
+ // Budget 20 lands inside the bold span (which opens at 11).
123
+ const out = truncateMarkdownSafe(text, 20)
124
+ expect(out).toBe('abcdefghij ')
125
+ expect(validateRichMarkdown(out)).toEqual([])
126
+ })
127
+
128
+ it('drops the opener instead of returning a near-empty result when one entity swallows the budget', () => {
129
+ // The #3682 shape: a single span longer than the whole window. Backing off
130
+ // would return `🛠 ` — a card with no content. Dropping the (unrenderable,
131
+ // because its closer is far past the budget) opener keeps the text.
132
+ const text = `🛠 **${'d'.repeat(500)}**`
133
+ const out = truncateMarkdownSafe(text, 64)
134
+ // Within budget, and only the dropped `**` short of it — the window is
135
+ // held fixed in SOURCE terms so the repair can never pull the span's
136
+ // closing delimiter into the output.
137
+ expect(out.length).toBe(62)
138
+ expect(out.startsWith('🛠 d')).toBe(true)
139
+ expect(out).not.toContain('*')
140
+ expect(validateRichMarkdown(out)).toEqual([])
141
+ })
142
+
143
+ it('is a no-op when the text already fits', () => {
144
+ expect(truncateMarkdownSafe('**short**', 64)).toBe('**short**')
145
+ })
146
+
147
+ it('safeMarkdownCut never splits a surrogate pair', () => {
148
+ const text = 'ab👍cd'
149
+ // Index 3 is between the two halves of the astral char.
150
+ expect(safeMarkdownCut(text, 3)).toBe(2)
151
+ expect(safeMarkdownCut(text, 4)).toBe(4)
152
+ })
153
+ })
@@ -29,9 +29,9 @@ _1m35s · 12 tools · 41.2k tok · opus 4.8_ 
29
29
  ~~_✓ Searching memory for card render paths_~~ 
30
30
  ~~_✓ Reading telegram-plugin/tool-activity-summary.ts_~~ 
31
31
  ~~_✓ Reading telegram-plugin/worker-activity-feed.ts_~~ 
32
- ~~_Reading the repo conventions_~~ 
33
- ~~_Running the scoped test suite_~~ 
34
- **→ Summarising findings · 24s**
32
+ ⠀⠀⠀↳ ~~_Reading the repo conventions_~~ 
33
+ ⠀⠀⠀↳ ~~_Running the scoped test suite_~~ 
34
+ ⠀⠀⠀↳ **→ Summarising findings · 24s**
35
35
 
36
36
  ===== CARD ===== Agent card — liveness early-open (no tools yet)
37
37
  🤖 **Agent** 
@@ -203,9 +203,9 @@ _✓ +9 earlier…_ 
203
203
  _1m35s · 12 tools · 41.2k tok · opus 4.8_ 
204
204
  ~~_✓ Searching memory for card render paths_~~ 
205
205
  ~~_✓ Reading telegram-plugin/tool-activity-summary.ts_~~ 
206
- _+6 earlier…_ 
207
- ~~_Child step 7_~~ 
208
- ~~_Child step 8_~~ 
209
- ~~_Child step 9_~~ 
210
- ~~_Child step 10_~~ 
211
- **→ Child step 11**
206
+ ⠀⠀⠀↳ _+6 earlier…_ 
207
+ ⠀⠀⠀↳ ~~_Child step 7_~~ 
208
+ ⠀⠀⠀↳ ~~_Child step 8_~~ 
209
+ ⠀⠀⠀↳ ~~_Child step 9_~~ 
210
+ ⠀⠀⠀↳ ~~_Child step 10_~~ 
211
+ ⠀⠀⠀↳ **→ Child step 11**
@@ -36,7 +36,7 @@ import {
36
36
  floodQueueNonce,
37
37
  FLOOD_QUEUED_SOURCE,
38
38
  } from '../gateway/flood-reply-queue.js'
39
- import { sweepOutbox } from '../gateway/outbox-sweep.js'
39
+ import { sweepOutbox, type OutboxSendResult } from '../gateway/outbox-sweep.js'
40
40
  import { listPendingRecords, resolveOutboxDir, type OutboxRecord } from '../outbox.js'
41
41
  import {
42
42
  floodStatePath,
@@ -60,6 +60,32 @@ function wrappedReplyFailure(): Error {
60
60
  return new Error('reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE')
61
61
  }
62
62
 
63
+ /**
64
+ * A sweep `send` stub with the REAL {@link OutboxSendResult} shape (#4044).
65
+ *
66
+ * These tests used to stub `send` as `async () => 4242` — a bare number, the
67
+ * pre-#3510 contract. `sweepOutbox` has read `.messageId` / `.chunks` off the
68
+ * result since, so the stub was silently lying: it journalled
69
+ * `tgMessageId: undefined` and would have THROWN on `.chunks.length` had the
70
+ * test wired `recordOutbound` — which the sweep's catch turns into a
71
+ * `sendFailure` and a re-send on the next tick, i.e. a duplicate delivery of
72
+ * the very answer this file exists to protect. The `journals the landed message id`
73
+ * test below asserts that outcome; this helper is what makes it representable.
74
+ */
75
+ function makeSendStub(firstMessageId = 4242) {
76
+ let next = firstMessageId
77
+ return vi.fn(
78
+ async (
79
+ _chatId: string,
80
+ _threadId: number | null,
81
+ text: string,
82
+ ): Promise<OutboxSendResult> => {
83
+ const messageId = next++
84
+ return { messageId, chunks: [{ messageId, text }] }
85
+ },
86
+ )
87
+ }
88
+
63
89
  function readRecords(dir: string): OutboxRecord[] {
64
90
  const outbox = resolveOutboxDir(dir)
65
91
  return readdirSync(outbox)
@@ -280,7 +306,7 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
280
306
 
281
307
  // 4. Sweeping WHILE the window is open must not touch the wire, and must
282
308
  // not lose the record.
283
- const send = vi.fn(async () => 4242)
309
+ const send = makeSendStub(4242)
284
310
  const during = await sweepOutbox({
285
311
  stateDir: dir,
286
312
  send,
@@ -318,6 +344,65 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
318
344
  expect(listPendingRecords(dir)).toHaveLength(0)
319
345
  })
320
346
 
347
+ it('journals the landed message id and records history parity — not a send failure (#4044)', async () => {
348
+ // Regression for the stale `send` stub. `sweepOutbox` reads `.messageId`
349
+ // and `.chunks` off its `send` result (`OutboxSendResult`, outbox-sweep.ts
350
+ // :63-80). While these tests stubbed `send` as a bare number the contract
351
+ // was unguarded, and a stub-shaped production sender would (a) journal
352
+ // `tgMessageId: undefined`, losing the pointer to the delivered message,
353
+ // and (b) throw on `sendResult.chunks.length` — which the sweep's catch
354
+ // converts into `sendFailures++` and a claim RELEASE, so the next tick
355
+ // re-sends the operator's answer a second time. This asserts the outcome:
356
+ // delivered once, journalled with the REAL id, mirrored to history.
357
+ const answer = 'the answer that must land exactly once'
358
+ const t0 = 1_000_000
359
+ queueFloodBlockedReply({
360
+ err: floodWaitActiveError(30),
361
+ chatId: '777',
362
+ threadId: null,
363
+ text: answer,
364
+ priorityClass: 'critical',
365
+ stateDir: dir,
366
+ createdAt: t0,
367
+ })
368
+
369
+ const send = makeSendStub(9001)
370
+ const recorded: Array<[string, number | null, number[], string[]]> = []
371
+ const summary = await sweepOutbox({
372
+ stateDir: dir,
373
+ send,
374
+ recordOutbound: (chatId, threadId, messageIds, texts) => {
375
+ recorded.push([chatId, threadId, messageIds, texts])
376
+ },
377
+ textAlreadyDelivered: () => false,
378
+ now: () => t0 + 10_000,
379
+ })
380
+
381
+ // Delivered — and NOT counted as a send failure.
382
+ expect(summary.delivered).toBe(1)
383
+ expect(summary.sendFailures).toBe(0)
384
+ expect(send).toHaveBeenCalledTimes(1)
385
+
386
+ // The journal carries the id the wire actually returned, so a duplicate is
387
+ // provable from the journal alone (#3510).
388
+ const journal = readFileSync(join(resolveOutboxDir(dir), 'delivered.jsonl'), 'utf8')
389
+ .split('\n')
390
+ .filter((l) => l.trim() !== '')
391
+ .map((l) => JSON.parse(l) as { tgMessageId?: number; deliverySource?: string })
392
+ expect(journal).toHaveLength(1)
393
+ expect(journal[0]!.tgMessageId).toBe(9001)
394
+ expect(journal[0]!.deliverySource).toBe('sweep')
395
+
396
+ // And history got the landed ids + their delivered texts.
397
+ expect(recorded).toHaveLength(1)
398
+ expect(recorded[0]![0]).toBe('777')
399
+ expect(recorded[0]![2]).toEqual([9001])
400
+ expect(recorded[0]![3]!.join('')).toContain(answer)
401
+
402
+ // Nothing left to re-send.
403
+ expect(listPendingRecords(dir)).toHaveLength(0)
404
+ })
405
+
321
406
  it('does not double-deliver when the caller retries after the window closed', async () => {
322
407
  const answer = 'the one answer'
323
408
  const t0 = 1_000_000
@@ -331,7 +416,7 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
331
416
  createdAt: t0,
332
417
  })
333
418
 
334
- const send = vi.fn(async () => 1)
419
+ const send = makeSendStub(1)
335
420
  await sweepOutbox({
336
421
  stateDir: dir,
337
422
  send,
@@ -381,7 +466,7 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
381
466
  })
382
467
  })
383
468
 
384
- const send = vi.fn(async () => 1)
469
+ const send = makeSendStub(1)
385
470
  await sweepOutbox({
386
471
  stateDir: dir,
387
472
  send,
@@ -397,20 +397,166 @@ describe('stripExcessBold', () => {
397
397
  expect(stripExcessBold(input)).toBe(input)
398
398
  })
399
399
 
400
- test('48/49-char pseudo-heading boundary honoured under global strip', () => {
401
- // Heading length is measured WITH the `**` markers. 44 inner chars → 48
402
- // total (exempt); 45 inner chars → 49 total (stripped).
403
- const heading48 = '**' + 'H'.repeat(44) + '**' // length 48
404
- const heading49 = '**' + 'H'.repeat(45) + '**' // length 49
405
- expect(heading48.length).toBe(48)
406
- expect(heading49.length).toBe(49)
407
-
408
- const out48 = stripExcessBold(`${heading48}\n\n${boldDenseBody}`)
409
- expect(out48).toContain(heading48)
410
-
411
- const out49 = stripExcessBold(`${heading49}\n\n${boldDenseBody}`)
412
- expect(out49).not.toContain(heading49)
413
- expect(out49).toContain('H'.repeat(45))
400
+ test('64/65-char pseudo-heading boundary honoured under global strip', () => {
401
+ // Heading length is measured WITH the `**` markers. 60 inner chars → 64
402
+ // total (exempt); 61 inner chars → 65 total (stripped).
403
+ const heading64 = '**' + 'H'.repeat(60) + '**' // length 64
404
+ const heading65 = '**' + 'H'.repeat(61) + '**' // length 65
405
+ expect(heading64.length).toBe(64)
406
+ expect(heading65.length).toBe(65)
407
+
408
+ const out64 = stripExcessBold(`${heading64}\n\n${boldDenseBody}`)
409
+ expect(out64).toContain(heading64)
410
+
411
+ const out65 = stripExcessBold(`${heading65}\n\n${boldDenseBody}`)
412
+ expect(out65).not.toContain(heading65)
413
+ expect(out65).toContain('H'.repeat(61))
414
+ })
415
+
416
+ // ── #4021: long-but-legitimate bold section labels survive ───────────────
417
+ // At PSEUDO_HEADING_MAX_CHARS=48 a real section label of 50-63 chars was
418
+ // read as an over-bolded paragraph and flattened. The cap is 64.
419
+
420
+ test.each([
421
+ // [label, survives?]
422
+ ['**Deployment status across all three regions:**', true], // 46
423
+ ['**Deployment status across all three AWS regions:**', true], // 50
424
+ ['**What changed in the delivery path since last Tuesday:**', true], // 56
425
+ ['**What actually changed in the delivery path since Tue:**', true], // 56
426
+ ['**Everything that changed across the delivery path this week:**', true], // 62
427
+ ['**Everything that changed across the whole delivery path today:**', false], // 65
428
+ ])('bold section label %s survives=%s under global strip', (label, survives) => {
429
+ // Sanity: the fixtures must straddle the 64-char boundary.
430
+ expect(label.length <= 64).toBe(survives)
431
+
432
+ const out = stripExcessBold(`${label}\n\n${boldDenseBody}`)
433
+ const inner = label.slice(2, -2)
434
+ if (survives) {
435
+ expect(out).toContain(label)
436
+ } else {
437
+ expect(out).not.toContain(label)
438
+ expect(out).toContain(inner)
439
+ }
440
+ // Either way the body is still flattened — the ratio guard is intact.
441
+ expect(out).not.toContain('**alpha**')
442
+ })
443
+
444
+ // ── #4017: a message that is ALL pseudo-headings still strips ────────────
445
+ // Every block exempted meant `out === masked`: a 100%-bold digest stayed
446
+ // fully bold and onStrip never fired.
447
+
448
+ test('all-pseudo-heading message strips (no body to contrast against)', () => {
449
+ const input = [
450
+ '**Deploy status**',
451
+ '**Open incidents**',
452
+ '**Merged today**',
453
+ '**Blocked on review**',
454
+ '**Rollout window tomorrow**',
455
+ '**Next steps:**',
456
+ ].join('\n\n')
457
+ expect(input.length).toBeGreaterThan(100)
458
+
459
+ const calls: Array<{ rule: string; ratio: number }> = []
460
+ const out = stripExcessBold(input, (d) => calls.push(d))
461
+
462
+ expect(out).not.toContain('**')
463
+ expect(out).toContain('Deploy status')
464
+ expect(out).toContain('Next steps:')
465
+ // Blank-line gaps between the headings are preserved.
466
+ expect(out).toBe(input.replace(/\*\*/g, ''))
467
+ // …and the strip is logged.
468
+ expect(calls).toHaveLength(1)
469
+ expect(calls[0].rule).toBe('global')
470
+ expect(calls[0].ratio).toBeGreaterThan(0.3)
471
+ })
472
+
473
+ test.each([
474
+ [
475
+ 'all headings, no body → strip',
476
+ `**Deploy status**\n\n**Open incidents this morning**\n\n**Merged today since the last release**\n\n**Next steps:**`,
477
+ false,
478
+ ],
479
+ [
480
+ 'headings + bold-dense body → headings survive',
481
+ `**Deploy status**\n\n${boldDenseBody}`,
482
+ true,
483
+ ],
484
+ [
485
+ 'headings around a bold-dense body → headings survive',
486
+ `**Deploy status**\n\n**Open incidents**\n\n${boldDenseBody}\n\n**Next steps:**`,
487
+ true,
488
+ ],
489
+ ])('all-heading guard: %s', (_name, input, headingSurvives) => {
490
+ expect(input.length).toBeGreaterThan(100)
491
+ const out = stripExcessBold(input)
492
+ expect(out.includes('**Deploy status**')).toBe(headingSurvives)
493
+ expect(out).toContain('Deploy status')
494
+ })
495
+
496
+ // ── #4114: a code-only block is NOT body for the all-heading guard ───────
497
+ // #4108's short-circuit tested `blocks.every(isPseudoHeadingBlock)` on every
498
+ // non-blank block. A masked code block satisfies neither side, so ONE code
499
+ // fence in an all-headings digest flipped `every(...)` to false, re-exempted
500
+ // every heading, and reproduced #4017 exactly: 100% bold, onStrip silent.
501
+ // Decision pinned here: masked code is not body — it is invisible to every
502
+ // other measurement in stripExcessBold (it is stripped before the ratio is
503
+ // taken), so it does not buy the headings an exemption either.
504
+
505
+ const HEADINGS = ['**Deploy status**', '**Open incidents**', '**Merged today**']
506
+ const TAIL = ['**Blocked on review**', '**Rollout window tomorrow**', '**Next steps:**']
507
+
508
+ test.each([
509
+ ['fenced code block', '```\nswitchroom agent restart klanker\n```'],
510
+ ['inline code span', '`switchroom agent restart klanker`'],
511
+ ['fenced block with a language tag', '```bash\nswitchroom agent restart klanker\n```'],
512
+ ['several inline spans on one line', '`alpha` `beta` `gamma` `delta` `epsilon`'],
513
+ ])('all-heading guard: headings + a code-only block (%s) still strips', (_name, code) => {
514
+ const input = [...HEADINGS, code, ...TAIL].join('\n\n')
515
+ expect(input.length).toBeGreaterThan(100)
516
+
517
+ const calls: Array<{ rule: string; ratio: number }> = []
518
+ const out = stripExcessBold(input, (d) => calls.push(d))
519
+
520
+ // Outcome 1: no bold survives — this is the assertion that fails on the
521
+ // real bug, where `out` was the input verbatim.
522
+ expect(out).not.toContain('**')
523
+ expect(out).toContain('Deploy status')
524
+ expect(out).toContain('Next steps:')
525
+ // Outcome 2: the code block itself is preserved byte-for-byte.
526
+ expect(out).toContain(code)
527
+ expect(out).toBe(input.replace(/\*\*/g, ''))
528
+ // Outcome 3: the strip is logged (the #4017 silence is what made this
529
+ // class of bug invisible in the first place).
530
+ expect(calls).toHaveLength(1)
531
+ expect(calls[0].rule).toBe('global')
532
+ expect(calls[0].ratio).toBeGreaterThan(0.3)
533
+ })
534
+
535
+ test('all-heading guard: real prose body CONTAINING inline code keeps headings', () => {
536
+ // The counterpart that stops the #4114 fix from over-correcting: a block
537
+ // with visible text around its code span IS body, so the headings keep
538
+ // their bold. Only blocks that are *nothing but* code are skipped.
539
+ const input = [
540
+ ...HEADINGS,
541
+ 'Run `switchroom agent restart klanker` now, then check the gateway log for errors.',
542
+ ...TAIL,
543
+ ].join('\n\n')
544
+
545
+ const calls: Array<{ rule: string; ratio: number }> = []
546
+ const out = stripExcessBold(input, (d) => calls.push(d))
547
+
548
+ expect(out).toContain('**Deploy status**')
549
+ expect(out).toContain('**Next steps:**')
550
+ expect(calls).toHaveLength(0)
551
+ })
552
+
553
+ test('all-heading guard: a code-only block does not rescue a headings-only digest', () => {
554
+ // Degenerate shape: the code block is the ONLY non-heading block and sits
555
+ // last. Same verdict, so the rule does not depend on block position.
556
+ const input = [...HEADINGS, ...TAIL, '```\nok\n```'].join('\n\n')
557
+ const out = stripExcessBold(input)
558
+ expect(out).not.toContain('**')
559
+ expect(out).toContain('```\nok\n```')
414
560
  })
415
561
 
416
562
  test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Two-state desync wedge — the silence-fallback must drain `parkedTurnStarts`.
3
+ *
4
+ * The gateway carries TWO independent "turn in flight" states. The park gate in
5
+ * `stream-render.ts` (`case 'enqueue'`) treats the session as busy if EITHER a
6
+ * live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
7
+ * framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
8
+ * hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
9
+ * before the fix it NEVER touched `parkedTurnStarts`. That store's only real
10
+ * drains are the CLI's own `dequeue` / `remove` stream events, so when the
11
+ * claude REPL hangs mid-turn those never arrive, a leftover parked envelope
12
+ * latches the busy gate permanently, and every later inbound parks unseen until
13
+ * a manual container restart (gymbro parked msgs 4502→4504 behind a dead lock
14
+ * until SIGTERM). #3927 introduced the parked store but never wired it into
15
+ * fallback recovery.
16
+ *
17
+ * This drives the REAL `handleSessionEvent` (the extracted-module golden
18
+ * harness, same standard as `turn-mint-defers-until-dequeue.test.ts`) and the
19
+ * REAL `onFrameworkFallback` (via `buildSilencePokeOptions`, same standard as
20
+ * `silence-poke-teardown-notice.test.ts`), sharing ONE world: the fallback's
21
+ * `drainParkedTurnStarts` dep routes to the real store + `beginTurn` over the
22
+ * harness deps. It asserts the OUTCOME, not a code path: after the fallback the
23
+ * parked store is emptied AND the parked message was handed back into turn
24
+ * processing (its turn begun, its card opened) rather than silently dropped.
25
+ */
26
+ import { describe, it, expect, beforeEach } from 'vitest'
27
+ import {
28
+ handleSessionEvent,
29
+ drainParkedTurnStartsForChat,
30
+ __resetParkedTurnStartsForTest,
31
+ __parkedTurnStartCountForTest,
32
+ } from '../gateway/stream-render.js'
33
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
34
+ import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
35
+ import { CHAT, enqueue, makeHarness } from './turn-mint-harness.js'
36
+
37
+ beforeEach(() => {
38
+ __resetParkedTurnStartsForTest()
39
+ })
40
+
41
+ describe('silence-fallback drains parkedTurnStarts so a hung REPL cannot wedge the park gate', () => {
42
+ it('empties the parked store AND redelivers the parked message into turn processing', async () => {
43
+ const h = makeHarness()
44
+
45
+ // ── turn A mints on the idle enqueue and its ms-later dequeue ─────────────
46
+ handleSessionEvent(h.deps, enqueue('501'))
47
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
48
+ const turnA = h.current()!
49
+ expect(turnA.sourceMessageId).toBe(501)
50
+
51
+ // ── the operator sends B mid-turn; the CLI queues it (enqueue) but the
52
+ // REPL hangs, so the paired `dequeue` never comes and B stays parked ──
53
+ handleSessionEvent(h.deps, enqueue('502', 'still there?'))
54
+ expect(__parkedTurnStartCountForTest()).toBe(1)
55
+
56
+ // ── the REAL 300 s framework-fallback fires, wired to the REAL parked
57
+ // drain over the harness deps so both states share one world ──────────
58
+ const KEY = statusKeyForTests(CHAT, null)
59
+ const fx = makeLivenessFixture({
60
+ drainParkedTurnStarts: (chatId: string, threadId: number | null) =>
61
+ drainParkedTurnStartsForChat(
62
+ h.deps,
63
+ chatId,
64
+ threadId != null ? String(threadId) : null,
65
+ ).length,
66
+ })
67
+ // The wedged turn the fallback tears down — same chat/thread as parked B.
68
+ fx.activeTurnStartedAt.set(KEY, 0)
69
+ fx.setCurrentTurn(makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }))
70
+
71
+ const opts = buildSilencePokeOptions(fx.deps)
72
+ await opts.onFrameworkFallback({
73
+ key: KEY,
74
+ chatId: CHAT,
75
+ threadId: null,
76
+ fallbackKind: 'working',
77
+ silenceMs: 302_000,
78
+ inFlightTools: [],
79
+ })
80
+
81
+ // The fallback nulled the wedged turn (its one load-bearing job).
82
+ expect(fx.endedKeys).toContain(KEY)
83
+
84
+ // OUTCOME 1 — the parked store is emptied, so the busy park gate unlatches
85
+ // and the NEXT inbound can mint its own turn instead of parking unseen.
86
+ expect(__parkedTurnStartCountForTest()).toBe(0)
87
+
88
+ // OUTCOME 2 — B was actually dispatched back into turn processing, not
89
+ // dropped: its turn was begun (it is now the live turn) and its progress
90
+ // card was opened. Pre-fix, B stayed parked and this never happened.
91
+ expect(h.current()!.sourceMessageId).toBe(502)
92
+ expect(h.cardsOpened.some((c) => c.sourceMessageId === 502)).toBe(true)
93
+ })
94
+
95
+ it('is scoped to the wedged chat — a fallback for chat A leaves chat B parked', () => {
96
+ const h = makeHarness()
97
+
98
+ // A live turn on CHAT, plus a parked entry on CHAT.
99
+ handleSessionEvent(h.deps, enqueue('601'))
100
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
101
+ handleSessionEvent(h.deps, enqueue('602'))
102
+ expect(__parkedTurnStartCountForTest()).toBe(1)
103
+
104
+ // A fallback for a DIFFERENT chat must not touch CHAT's parked entry.
105
+ const drainedOther = drainParkedTurnStartsForChat(h.deps, '9999', null)
106
+ expect(drainedOther).toHaveLength(0)
107
+ expect(__parkedTurnStartCountForTest()).toBe(1)
108
+
109
+ // The correctly-scoped drain releases it.
110
+ const drainedHere = drainParkedTurnStartsForChat(h.deps, CHAT, null)
111
+ expect(drainedHere).toHaveLength(1)
112
+ expect(drainedHere[0]!.messageId).toBe('602')
113
+ expect(__parkedTurnStartCountForTest()).toBe(0)
114
+ })
115
+
116
+ it('is idempotent w.r.t. a late CLI dequeue — no double-start', () => {
117
+ const h = makeHarness()
118
+ handleSessionEvent(h.deps, enqueue('701'))
119
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
120
+ handleSessionEvent(h.deps, enqueue('702'))
121
+ expect(__parkedTurnStartCountForTest()).toBe(1)
122
+
123
+ // Fallback drains + begins 702.
124
+ expect(drainParkedTurnStartsForChat(h.deps, CHAT, null)).toHaveLength(1)
125
+ expect(h.current()!.sourceMessageId).toBe(702)
126
+ const cardsAfterDrain = h.cardsOpened.length
127
+
128
+ // The REPL finally un-hangs and emits its late `dequeue`. The store is empty
129
+ // so `takeParkedTurnStart` returns null and no second turn/card is minted.
130
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
131
+ expect(h.cardsOpened).toHaveLength(cardsAfterDrain)
132
+ expect(h.current()!.sourceMessageId).toBe(702)
133
+ })
134
+ })