switchroom 0.19.42 → 0.19.44

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 (75) hide show
  1. package/dist/agent-scheduler/index.js +4 -1
  2. package/dist/auth-broker/index.js +284 -124
  3. package/dist/cli/notion-write-pretool.mjs +4 -1
  4. package/dist/cli/switchroom.js +4094 -2711
  5. package/dist/host-control/main.js +472 -155
  6. package/dist/vault/approvals/kernel-server.js +231 -71
  7. package/dist/vault/broker/server.js +293 -133
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +93 -8
  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 +1682 -936
  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/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
  73. package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
  75. package/vendor/hindsight-memory/settings.json +1 -1
@@ -27,6 +27,12 @@ import {
27
27
  import { richMessage, isParseEntitiesError } from './rich-send.js'
28
28
  import { renderOutboundChunks } from './render/rich-render.js'
29
29
  import { isSendGateShed, type SendGateOpts } from './send-gate.js'
30
+ import {
31
+ buildReplyParameters,
32
+ dropQuoteFromSendOpts,
33
+ isQuoteRejectionError,
34
+ sendOptsHaveQuote,
35
+ } from './reply-quote.js'
30
36
 
31
37
  /**
32
38
  * Minimal bot.api surface the controller needs. Real callers pass grammy's
@@ -59,8 +65,14 @@ export interface StreamSendOpts {
59
65
  * message. Only meaningful on the initial `sendMessage` — `editMessageText`
60
66
  * cannot add a quote reference to an existing message, so the controller
61
67
  * strips this from edit opts internally.
68
+ *
69
+ * `quote` is a Bot API **String** — the quoted substring itself. The optional
70
+ * offset is a SIBLING `quote_position` Integer, which this plugin
71
+ * deliberately never emits. Typing it as an object is what produced
72
+ * `400 field "quote" must be of type String` on every quoted reply; see
73
+ * `reply-quote.ts`.
62
74
  */
63
- reply_parameters?: { message_id: number; quote?: { text: string; position: number } }
75
+ reply_parameters?: { message_id: number; quote?: string }
64
76
  /**
65
77
  * Inline keyboard markup. Included in both sendMessage and editMessageText
66
78
  * so that inline buttons persist through text edits. Without this,
@@ -121,7 +133,10 @@ export interface StreamControllerConfig {
121
133
  /**
122
134
  * Optional quote text for surgical quoting. When set along with
123
135
  * `replyToMessageId`, the initial send includes
124
- * `reply_parameters: { message_id, quote: { text, position: 0 } }`.
136
+ * `reply_parameters: { message_id, quote }` `quote` is a Bot API String
137
+ * (see `reply-quote.ts`). A quote Telegram cannot find in the replied-to
138
+ * message is dropped and the send retried unquoted, so the answer still
139
+ * lands.
125
140
  */
126
141
  quoteText?: string
127
142
  /**
@@ -222,14 +237,16 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
222
237
  ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
223
238
  ...(replyMarkup != null ? { reply_markup: replyMarkup } : {}),
224
239
  }
225
- const sendOpts: StreamSendOpts = {
240
+ // `let`, not `const`: a quote Telegram cannot find in the replied-to message
241
+ // is stripped ONCE for the whole stream (see sendPieceWithQuoteFallback) —
242
+ // re-attaching it to the next piece would just 400 again.
243
+ let sendOpts: StreamSendOpts = {
226
244
  ...baseOpts,
227
245
  ...(replyToMessageId != null
228
246
  ? {
229
- reply_parameters: {
230
- message_id: replyToMessageId,
231
- ...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
232
- },
247
+ // `quote` is a Bot API String; `quote_position` is a separate sibling
248
+ // Integer we deliberately don't emit. See reply-quote.ts.
249
+ reply_parameters: buildReplyParameters(replyToMessageId, quoteText),
233
250
  }
234
251
  : {}),
235
252
  ...(protectContent === true ? { protect_content: true } : {}),
@@ -263,6 +280,24 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
263
280
  delete richOpts.link_preview_options
264
281
  return bot.api.sendRichMessage(chatId, richMessage(piece.text), richOpts)
265
282
  }
283
+ // Send a piece, degrading gracefully when the SURGICAL QUOTE is the thing
284
+ // Telegram rejected. `reply_parameters.quote` must be an exact substring of
285
+ // the replied-to message; when it isn't, the 400 kills a perfectly good
286
+ // answer. Drop the highlight (for this send and every later one on this
287
+ // stream) and re-send — the reply still lands, still threaded under the same
288
+ // message.
289
+ const sendPieceWithQuoteFallback = async (piece: { text: string; rich: boolean }) => {
290
+ try {
291
+ return await sendPiece(piece, sendOpts)
292
+ } catch (err) {
293
+ if (!isQuoteRejectionError(err) || !sendOptsHaveQuote(sendOpts)) throw err
294
+ warn?.(
295
+ `stream-controller: quote not found in the replied-to message — re-sending without the quote (${err instanceof Error ? err.message : String(err)})`,
296
+ )
297
+ sendOpts = dropQuoteFromSendOpts(sendOpts)
298
+ return await sendPiece(piece, sendOpts)
299
+ }
300
+ }
266
301
  const editPiece = (id: number, piece: { text: string; rich: boolean }, opts: StreamSendOpts) => {
267
302
  if (!piece.rich) return bot.api.editMessageText(chatId, id, piece.text, opts)
268
303
  return bot.api.editMessageText(chatId, id, richMessage(piece.text), opts)
@@ -395,7 +430,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
395
430
  }
396
431
  // First emission of this tail piece → a fresh follow-up message.
397
432
  try {
398
- const sent = await retry(() => sendPiece(piece, sendOpts), { threadId, chat_id: chatId })
433
+ const sent = await retry(() => sendPieceWithQuoteFallback(piece), { threadId, chat_id: chatId })
399
434
  tailIds[ti] = sent.message_id
400
435
  tailLastText[ti] = piece.text
401
436
  onSend?.(sent.message_id, piece.text.length)
@@ -435,7 +470,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
435
470
  let anchorId: number | undefined
436
471
  try {
437
472
  const sent = await retry(
438
- () => sendPiece(head, sendOpts),
473
+ () => sendPieceWithQuoteFallback(head),
439
474
  { threadId, chat_id: chatId },
440
475
  )
441
476
  anchorId = sent.message_id
@@ -109,9 +109,11 @@ export interface StreamReplyArgs {
109
109
  disable_notification?: boolean
110
110
  /**
111
111
  * Optional surgical quote text. When set along with `reply_to`, the initial
112
- * send includes `reply_parameters: { message_id, quote: { text, position: 0 } }`
113
- * so Telegram highlights the specific quoted sentence rather than the whole
114
- * referenced message. Ignored when `reply_to` is absent.
112
+ * send includes `reply_parameters: { message_id, quote }` `quote` is a Bot
113
+ * API String (see `reply-quote.ts`) so Telegram highlights the specific
114
+ * quoted sentence rather than the whole referenced message. It must be an
115
+ * exact substring of the referenced message; one Telegram cannot find is
116
+ * dropped and the send retried unquoted. Ignored when `reply_to` is absent.
115
117
  */
116
118
  quote_text?: string
117
119
  /**
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { describe, it, expect } from 'vitest'
16
16
  import { renderBootCard, resolvePersonaName } from '../gateway/boot-card.js'
17
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
17
18
 
18
19
  describe('renderBootCard — quiet by default', () => {
19
20
  it('returns one-line ack with default ✅ when no probes and no restart reason', () => {
@@ -153,7 +154,7 @@ describe('renderBootCard — degraded conditions', () => {
153
154
  },
154
155
  })
155
156
  expect(out).toContain('🟡 **Skills** 10/10 dangling')
156
- expect(out).toContain(' ↳ Run `switchroom agent reconcile lawgpt` to rebuild symlinks')
157
+ expect(out).toContain(`${NESTED_PREFIX}Run \`switchroom agent reconcile lawgpt\` to rebuild symlinks`)
157
158
  })
158
159
 
159
160
  it('crash row carries a tail-logs next-step', () => {
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { renderStatusCard, renderCombinedWorkerFeed } from '../tool-activity-summary.js'
3
+ import { fitCardToBudget, hardTruncateCard, renderCardSpec } from '../card-layout.js'
4
+ import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
5
+
6
+ /**
7
+ * #3833 (and its duplicate #3682) — a rendered card must NEVER exceed
8
+ * `STATUS_CARD_CHAR_BUDGET`. An over-budget card is not a cosmetic problem: it
9
+ * is rejected by the Bot API, so the user's progress card simply does not
10
+ * appear (or stops updating) for the whole turn.
11
+ *
12
+ * Two defects produced over-budget cards, and both are asserted here:
13
+ *
14
+ * 1. UNDER-COUNTED FIXED COST. The deepest shrink level charged its
15
+ * header/footer as `[...chrome, ...footer].join('\n')`, but cards are
16
+ * rendered by `stackCard`, which emits a collapse separator plus a
17
+ * two-space GFM hard break at every line boundary — 4 chars per boundary,
18
+ * not 1 — and it also charges the collapsed parent-marker line. The budget
19
+ * the fitter handed the body was therefore too generous and the card came
20
+ * out a few chars over.
21
+ *
22
+ * 2. NO RE-CHECK ON THE LAST RESORT. `fitCardToBudget` returned the deepest
23
+ * render unmeasured. When the FIXED chrome alone is over budget (the #3682
24
+ * repro: a 40k-char worker description), no shrink level can help — every
25
+ * level keeps the chrome — so a 40k-char card went to the wire.
26
+ */
27
+
28
+ const HEADER = { emoji: '🛠', label: 'Worker', elapsedMs: 61_000, toolCount: 7, state: 'running' as const }
29
+
30
+ function card(opts: { desc?: string; steps?: string[]; children?: string[]; final?: boolean; result?: string }): string {
31
+ return renderStatusCard({
32
+ header: { ...HEADER, description: opts.desc ?? 'a task', state: opts.final === true ? 'done' : 'running' },
33
+ steps: opts.steps ?? ['step one'],
34
+ childSteps: opts.children,
35
+ final: opts.final ?? false,
36
+ stepCount: 3,
37
+ result: opts.result != null ? { emoji: '✅', text: opts.result } : undefined,
38
+ })!
39
+ }
40
+
41
+ describe('a rendered card never exceeds the char budget (#3833 / #3682)', () => {
42
+ it('the #3682 repro — a 40k-char description — comes back under budget', () => {
43
+ // Fail-before: 40069 chars against a 32768 budget on origin/main.
44
+ const out = card({ desc: 'd'.repeat(40_000), steps: ['x'.repeat(4_000)], children: ['y'.repeat(4_000)] })
45
+ expect(out.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
46
+ // And it is still a card, not an empty string.
47
+ expect(out.length).toBeGreaterThan(100)
48
+ expect(out.startsWith('🛠')).toBe(true)
49
+ })
50
+
51
+ it('the join-cost under-count boundary — every description length near the budget fits', () => {
52
+ // Fail-before: on origin/main the lengths 32693…32800 each rendered 1…98
53
+ // chars OVER budget. That band is exactly the fixed-cost under-count: the
54
+ // renderer's per-boundary separator + hard break, which the arithmetic
55
+ // estimate charged as one newline.
56
+ const over: Array<{ n: number; by: number }> = []
57
+ for (let n = 32_600; n <= 32_900; n++) {
58
+ const out = card({ desc: 'd'.repeat(n), steps: ['a', 'b', 'c'], result: 'done' })
59
+ if (out.length > STATUS_CARD_CHAR_BUDGET) over.push({ n, by: out.length - STATUS_CARD_CHAR_BUDGET })
60
+ }
61
+ expect(over).toEqual([])
62
+ })
63
+
64
+ it('PROPERTY: no combination of description / steps / children / result overflows', () => {
65
+ // A deterministic sweep over the shape space rather than a handful of
66
+ // hand-picked cases: the guarantee is universal, so the test is too.
67
+ //
68
+ // Fillers matter as much as sizes — escaping EXPANDS text (`&` → `&amp;`
69
+ // is 5×, markdown specials are 2×), which is what the fitter's clip loop
70
+ // has to converge against, and `👍` is astral so a naive slice could split
71
+ // a surrogate pair.
72
+ //
73
+ // Every filler is swept at the small/medium sizes; the huge sizes (where a
74
+ // single render costs seconds purely in `escapeMarkdown`, unchanged by this
75
+ // PR) are swept with the cheap-but-still-expanding fillers.
76
+ const cases: Array<{ size: number; fill: string }> = []
77
+ for (const size of [0, 1, 7, 200, 4_000, 16_000]) {
78
+ for (const fill of ['x', '&', '_*[`', '👍', 'é']) cases.push({ size, fill })
79
+ }
80
+ for (const size of [32_700, 33_000, 90_000]) {
81
+ for (const fill of ['x', '&', '👍']) cases.push({ size, fill })
82
+ }
83
+ for (const { size, fill } of cases) {
84
+ const blob = fill.repeat(Math.ceil(size / fill.length)).slice(0, size)
85
+ for (const final of [false, true]) {
86
+ for (const withChildren of [false, true]) {
87
+ const out = renderStatusCard({
88
+ header: { ...HEADER, description: blob, state: final ? 'done' : 'running' },
89
+ steps: [blob, blob, 'plain step'],
90
+ childSteps: withChildren ? [blob, blob] : undefined,
91
+ final,
92
+ liveSuffix: ' · 22s',
93
+ stepCount: 12,
94
+ result: { emoji: '✅', text: blob },
95
+ })
96
+ if (out == null) continue
97
+ expect(
98
+ out.length,
99
+ `over budget: size=${size} fill=${JSON.stringify(fill)} final=${final} children=${withChildren}`,
100
+ ).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
101
+ // Never blanked, and never sliced through a surrogate pair.
102
+ expect(out.length).toBeGreaterThan(0)
103
+ expect(/[\uD800-\uDBFF]$/.test(out)).toBe(false)
104
+ }
105
+ }
106
+ }
107
+ }, 60_000)
108
+
109
+ it('PROPERTY: the combined worker card also stays under budget for any fan-out', () => {
110
+ for (const rows of [1, 2, 6, 25]) {
111
+ for (const size of [50, 5_000, 40_000]) {
112
+ const out = renderCombinedWorkerFeed(
113
+ Array.from({ length: rows }, (_, i) => ({
114
+ ordinal: i + 1,
115
+ description: 'w'.repeat(size),
116
+ currentStep: 's'.repeat(size),
117
+ historyLines: ['h'.repeat(size), 'h2'],
118
+ elapsedMs: 60_000,
119
+ toolCount: 3,
120
+ })),
121
+ { maxRows: 8 },
122
+ )!
123
+ expect(out.length, `rows=${rows} size=${size}`).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
124
+ }
125
+ }
126
+ })
127
+
128
+ it('fitCardToBudget itself guarantees the budget even when every level overflows', () => {
129
+ // The mechanism, isolated from any particular card: a build function whose
130
+ // deepest level is still hopeless. Fail-before: this returned the raw
131
+ // 50k-char render.
132
+ const huge = 'q'.repeat(50_000)
133
+ const out = fitCardToBudget(() => ({ spec: { chrome: [huge], sections: [], footer: [] } }), 3)
134
+ expect(out.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
135
+ expect(out.length).toBeGreaterThan(0)
136
+ })
137
+
138
+ it('fitCardToBudget still returns the FIRST fitting level untouched', () => {
139
+ // The backstop must not change the normal path: a level that fits is
140
+ // returned byte-for-byte, and deeper levels are never built.
141
+ const built: number[] = []
142
+ const spec = { chrome: ['🛠 **Worker**'], sections: [], footer: ['_✓ 3 steps_'] }
143
+ const out = fitCardToBudget((level) => {
144
+ built.push(level)
145
+ return { spec }
146
+ }, 5)
147
+ expect(built).toEqual([0])
148
+ expect(out).toBe(renderCardSpec(spec))
149
+ })
150
+
151
+ it('hardTruncateCard cuts at a line boundary and never returns invalid UTF-16', () => {
152
+ const lines = ['aaaa', 'bbbb', 'cccc'].join(' \n')
153
+ // Budget lands inside the third line → cut back to the second boundary.
154
+ const cut = hardTruncateCard(lines, 12)
155
+ expect(cut.length).toBeLessThanOrEqual(12)
156
+ expect(cut).toBe('aaaa \nbbbb')
157
+ // Under budget → identity.
158
+ expect(hardTruncateCard(lines, 1000)).toBe(lines)
159
+ // A single over-long line with an astral char at the cut → no lone surrogate.
160
+ const astral = '👍'.repeat(50)
161
+ const sliced = hardTruncateCard(astral, 11)
162
+ expect(sliced.length).toBeLessThanOrEqual(11)
163
+ expect(/[\uD800-\uDBFF]$/.test(sliced)).toBe(false)
164
+ })
165
+ })
@@ -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,