switchroom 0.16.38 → 0.16.46

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 (61) hide show
  1. package/dist/agent-scheduler/index.js +88 -82
  2. package/dist/auth-broker/index.js +87 -81
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +89 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1621 -737
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +271 -239
  10. package/dist/vault/approvals/kernel-server.js +90 -84
  11. package/dist/vault/broker/server.js +91 -85
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +2 -2
  15. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  16. package/skills/switchroom-status/SKILL.md +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -1
  18. package/telegram-plugin/card-format.ts +7 -1
  19. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  20. package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
  21. package/telegram-plugin/dist/server.js +180 -162
  22. package/telegram-plugin/format.ts +305 -31
  23. package/telegram-plugin/gateway/gateway.ts +262 -63
  24. package/telegram-plugin/gateway/model-command.ts +173 -19
  25. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  26. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  27. package/telegram-plugin/package.json +1 -1
  28. package/telegram-plugin/session-tail.ts +47 -1
  29. package/telegram-plugin/stream-reply-handler.ts +19 -1
  30. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  31. package/telegram-plugin/tests/card-format.test.ts +28 -0
  32. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  33. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  34. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  35. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  36. package/telegram-plugin/tests/model-command.test.ts +213 -47
  37. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  38. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  40. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  41. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  42. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  43. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  44. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  45. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  46. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  47. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  48. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  49. package/telegram-plugin/text-voice-scrub.ts +68 -18
  50. package/telegram-plugin/tool-activity-summary.ts +20 -108
  51. package/telegram-plugin/tts-normalize.ts +377 -0
  52. package/telegram-plugin/uat/driver.ts +472 -22
  53. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  54. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  56. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  57. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  58. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  59. package/telegram-plugin/voice-ondemand.ts +161 -10
  60. package/telegram-plugin/voice-presynth.ts +242 -0
  61. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -0,0 +1,469 @@
1
+ /**
2
+ * rich-markdown-oracle — an INDEPENDENT CommonMark/GFM validator + entity
3
+ * extractor for the Bot API 10.1 rich-message path (`sendRichMessage`,
4
+ * `editMessageText({ markdown })`).
5
+ *
6
+ * ── Why this exists ────────────────────────────────────────────────────────
7
+ * Every outbound message now ships as raw GFM markdown through the rich path
8
+ * (see `format.ts` / `rich-send.ts`). A malformed body makes Telegram throw a
9
+ * 400 ("can't parse entities" / "can't find end of the entity" — see
10
+ * `isParseEntitiesError` in `rich-send.ts`), which drops or corrupts the
11
+ * message. Our existing format tests string-compare the text we *emit*; they
12
+ * do NOT verify that the emitted markdown is well-formed enough that Telegram
13
+ * won't reject it, nor that it parses into the entity structure we intended.
14
+ *
15
+ * This oracle closes that gap with TWO signals:
16
+ * (a) PARSE-ACCEPT: `validateRichMarkdown(md)` returns [] when the body is
17
+ * well-formed on every axis whose violation actually produces a rich-path
18
+ * 400 or a corrupt render; otherwise it returns a list of concrete
19
+ * problems. A test asserting `[]` fails loudly on output Telegram would
20
+ * reject.
21
+ * (b) STRUCTURE: `parseRichEntities(md)` extracts the entity spans (bold,
22
+ * italic, code, pre/fence, link, strikethrough) so a test can assert the
23
+ * body parses into the intended shape.
24
+ *
25
+ * ── The non-circularity guarantee (READ THIS) ──────────────────────────────
26
+ * The oracle is a HAND-WRITTEN tokenizer that follows the CommonMark/GFM
27
+ * grammar and Telegram's documented entity-closure rules. It shares NO code
28
+ * with `format.ts` — it does not import, echo, or re-derive the formatter's
29
+ * output. It is a second, independent implementation, so asserting the
30
+ * formatter's output against it is a genuine cross-check, not a formatter
31
+ * comparing itself to itself.
32
+ *
33
+ * ── Scope boundary (documented, deliberate) ────────────────────────────────
34
+ * A byte-exact port of the full CommonMark reference parser is ~3000 lines and
35
+ * not worth hand-rolling reliably. So:
36
+ * - Signal (a) is RIGOROUS for the constructs whose malformedness is what
37
+ * actually 400s / corrupts on the rich path: unbalanced fenced-code
38
+ * delimiters, unterminated inline code spans, malformed / unclosed link
39
+ * syntax, and structurally-incomplete table rows. These are precisely the
40
+ * failure modes `format.ts`'s chunker + block-boundary passes are built to
41
+ * avoid, so they are the ones a regression test must guard.
42
+ * - Signal (b) covers the COMMON entity types (bold `**`/`__`, italic
43
+ * `*`/`_`, strikethrough `~~`, inline code, fenced code, links). It uses a
44
+ * simplified-but-faithful delimiter model. It deliberately does NOT model
45
+ * every CommonMark emphasis edge case (intraword `_`, triple-run
46
+ * `***bold italic***` nesting split points) — those are called out inline
47
+ * and the fixtures avoid depending on them. When in doubt the extractor is
48
+ * CONSERVATIVE: it reports a span only when the open/close is unambiguous.
49
+ *
50
+ * CommonMark itself never "fails to parse" (every string is valid CommonMark),
51
+ * so a naive "does it parse" check would be vacuous. That is exactly why signal
52
+ * (a) is defined as *structural well-formedness of the emitted constructs*
53
+ * rather than "did a CommonMark parser throw" — it is the real predictor of a
54
+ * rich-path 400.
55
+ */
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Entity model
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export type EntityType =
62
+ | 'bold'
63
+ | 'italic'
64
+ | 'strikethrough'
65
+ | 'code' // inline code span
66
+ | 'pre' // fenced code block
67
+ | 'link'
68
+
69
+ export interface RichEntity {
70
+ readonly type: EntityType
71
+ /** The rendered (delimiter-stripped) inner text of the entity. */
72
+ readonly text: string
73
+ /** For a link, the destination URL. */
74
+ readonly url?: string
75
+ /** For a fenced block, the info string (language), if any. */
76
+ readonly lang?: string
77
+ }
78
+
79
+ export interface ParseIssue {
80
+ readonly kind:
81
+ | 'unbalanced-fence'
82
+ | 'unterminated-code-span'
83
+ | 'malformed-link'
84
+ | 'incomplete-table-row'
85
+ | 'unbalanced-emphasis'
86
+ readonly detail: string
87
+ /** 1-based line number where the problem was detected, when known. */
88
+ readonly line?: number
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Fence / code-region masking (independent re-implementation — NOT imported
93
+ // from format.ts, on purpose). We mask fenced blocks and inline code so the
94
+ // emphasis/link scanners never see their interior, matching how a CommonMark
95
+ // parser treats code as verbatim.
96
+ // ---------------------------------------------------------------------------
97
+
98
+ interface Fence {
99
+ readonly lang: string
100
+ readonly body: string
101
+ readonly startLine: number
102
+ readonly closed: boolean
103
+ }
104
+
105
+ /**
106
+ * Split the document into fenced-code regions and everything else, tracking
107
+ * whether each fence is CLOSED. A fence opens on a line whose first non-space
108
+ * run is ``` (or longer) or ~~~; it closes on a later line whose fence marker
109
+ * is the same char and at least as long, with no trailing info string.
110
+ *
111
+ * This is the CommonMark fenced-code rule, scoped to the two markers Telegram
112
+ * emits (``` predominantly). An unclosed fence is the single most common cause
113
+ * of a corrupt rich render (it swallows the rest of the message), so we track
114
+ * `closed` explicitly for signal (a).
115
+ */
116
+ export function scanFences(md: string): {
117
+ fences: Fence[]
118
+ /** Line indices (0-based) that belong to a fence (open, body, close). */
119
+ fenceLineSet: Set<number>
120
+ } {
121
+ const lines = md.split('\n')
122
+ const fences: Fence[] = []
123
+ const fenceLineSet = new Set<number>()
124
+ let i = 0
125
+ const openRe = /^(\s*)(`{3,}|~{3,})\s*([^\n`]*)$/
126
+ while (i < lines.length) {
127
+ const m = lines[i].match(openRe)
128
+ if (m == null) {
129
+ i++
130
+ continue
131
+ }
132
+ const indent = m[1].length
133
+ const marker = m[2]
134
+ const fenceChar = marker[0]
135
+ const lang = m[3].trim()
136
+ const startLine = i
137
+ fenceLineSet.add(i)
138
+ const bodyLines: string[] = []
139
+ let j = i + 1
140
+ let closed = false
141
+ // Close marker: same char, length >= open marker, only whitespace after.
142
+ const closeRe = new RegExp(`^\\s*${fenceChar === '`' ? '`' : '~'}{${marker.length},}\\s*$`)
143
+ for (; j < lines.length; j++) {
144
+ if (closeRe.test(lines[j])) {
145
+ fenceLineSet.add(j)
146
+ closed = true
147
+ break
148
+ }
149
+ bodyLines.push(lines[j].slice(indent))
150
+ fenceLineSet.add(j)
151
+ }
152
+ fences.push({ lang, body: bodyLines.join('\n'), startLine, closed })
153
+ i = closed ? j + 1 : j
154
+ }
155
+ return { fences, fenceLineSet }
156
+ }
157
+
158
+ /**
159
+ * Replace fenced regions with blank lines (preserving line count) so the
160
+ * inline scanners never see fence interiors. Returns the masked doc plus the
161
+ * fence list.
162
+ */
163
+ function maskFences(md: string): { masked: string; fences: Fence[]; fenceLineSet: Set<number> } {
164
+ const { fences, fenceLineSet } = scanFences(md)
165
+ const lines = md.split('\n')
166
+ const masked = lines.map((l, idx) => (fenceLineSet.has(idx) ? '' : l)).join('\n')
167
+ return { masked, fences, fenceLineSet }
168
+ }
169
+
170
+ /**
171
+ * Extract inline code spans from a single line (fence interiors already
172
+ * masked). CommonMark inline code is a run of N backticks, closed by the next
173
+ * run of exactly N backticks. We support the common single- and double-tick
174
+ * forms our formatter emits. Returns the spans and the line with each span
175
+ * blanked (same length) so downstream emphasis scanning skips code content.
176
+ */
177
+ function extractInlineCode(line: string): { spans: string[]; blanked: string; unterminated: boolean } {
178
+ const spans: string[] = []
179
+ let out = ''
180
+ let i = 0
181
+ let unterminated = false
182
+ while (i < line.length) {
183
+ if (line[i] === '`') {
184
+ // Measure opening run length.
185
+ let n = 0
186
+ while (line[i + n] === '`') n++
187
+ const openEnd = i + n
188
+ // Find a closing run of EXACTLY n backticks.
189
+ let k = openEnd
190
+ let closeStart = -1
191
+ while (k < line.length) {
192
+ if (line[k] === '`') {
193
+ let m = 0
194
+ while (line[k + m] === '`') m++
195
+ if (m === n) {
196
+ closeStart = k
197
+ break
198
+ }
199
+ k += m
200
+ } else {
201
+ k++
202
+ }
203
+ }
204
+ if (closeStart === -1) {
205
+ // No matching close on this line → unterminated inline code span.
206
+ unterminated = true
207
+ out += line.slice(i)
208
+ break
209
+ }
210
+ const inner = line.slice(openEnd, closeStart)
211
+ spans.push(inner)
212
+ // Blank out the whole span (ticks + inner) with spaces of equal length.
213
+ out += ' '.repeat(closeStart + n - i)
214
+ i = closeStart + n
215
+ } else {
216
+ out += line[i]
217
+ i++
218
+ }
219
+ }
220
+ return { spans, blanked: out, unterminated }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Signal (a): PARSE-ACCEPT validation
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Validate that `md` is well-formed enough that Telegram's rich-message path
229
+ * would NOT 400 on it. Returns an empty array on success, else a list of the
230
+ * concrete problems found. See the module doc for the exact scope of "well
231
+ * formed" and why it is the right predictor of a rich-path reject.
232
+ */
233
+ export function validateRichMarkdown(md: string): ParseIssue[] {
234
+ const issues: ParseIssue[] = []
235
+ const { masked, fences } = maskFences(md)
236
+
237
+ // --- Unbalanced fence: an unclosed fenced block corrupts the render. -----
238
+ for (const f of fences) {
239
+ if (!f.closed) {
240
+ issues.push({
241
+ kind: 'unbalanced-fence',
242
+ detail: `fenced code block opened at line ${f.startLine + 1} is never closed`,
243
+ line: f.startLine + 1,
244
+ })
245
+ }
246
+ }
247
+ // Defensive: an ODD number of lone ``` fence lines (that scanFences somehow
248
+ // did not pair) is also an imbalance. scanFences already pairs greedily, so
249
+ // this catches only truly stray markers.
250
+ const strayFenceLines = (md.match(/^\s*(`{3,}|~{3,})\s*$/gm) ?? []).length
251
+ const openWithInfo = (md.match(/^\s*(`{3,}|~{3,})[^\n`~]+$/gm) ?? []).length
252
+ if ((strayFenceLines + openWithInfo) % 2 !== 0 && fences.every((f) => f.closed)) {
253
+ issues.push({
254
+ kind: 'unbalanced-fence',
255
+ detail: `odd number of fence delimiter lines (${strayFenceLines + openWithInfo})`,
256
+ })
257
+ }
258
+
259
+ const maskedLines = masked.split('\n')
260
+ maskedLines.forEach((rawLine, idx) => {
261
+ const { blanked, unterminated } = extractInlineCode(rawLine)
262
+ if (unterminated) {
263
+ issues.push({
264
+ kind: 'unterminated-code-span',
265
+ detail: `inline code span opened but not closed on line ${idx + 1}`,
266
+ line: idx + 1,
267
+ })
268
+ }
269
+ // --- Malformed link: a `[label](` that never closes its `)` ------------
270
+ // Only flag an OPENED inline-link that fails to close — a lone `[` used as
271
+ // literal text (no following `(`) is valid CommonMark and renders fine.
272
+ checkLinks(blanked, idx + 1, issues)
273
+ })
274
+
275
+ // --- Incomplete table row: a GFM table row must be a complete `| … |`. ---
276
+ checkTableRows(md, issues)
277
+
278
+ // --- Emphasis balance (common-case): odd count of unescaped `**` runs etc.
279
+ checkEmphasisBalance(masked, issues)
280
+
281
+ return issues
282
+ }
283
+
284
+ /**
285
+ * Flag an inline-link open `[...](` whose `(...)` destination never closes on
286
+ * the same line. A closed `[a](b)` is fine; a bare `[a]` with no `(` is fine
287
+ * (literal text). Nested `()` inside the destination are balanced-counted.
288
+ */
289
+ function checkLinks(line: string, lineNo: number, issues: ParseIssue[]): void {
290
+ const re = /\[[^\]]*\]\(/g
291
+ let m: RegExpExecArray | null
292
+ while ((m = re.exec(line)) != null) {
293
+ // Start scanning the destination just after `](`.
294
+ let depth = 1
295
+ let k = m.index + m[0].length
296
+ let closed = false
297
+ for (; k < line.length; k++) {
298
+ if (line[k] === '(') depth++
299
+ else if (line[k] === ')') {
300
+ depth--
301
+ if (depth === 0) {
302
+ closed = true
303
+ break
304
+ }
305
+ }
306
+ }
307
+ if (!closed) {
308
+ issues.push({
309
+ kind: 'malformed-link',
310
+ detail: `inline link opened at line ${lineNo} col ${m.index + 1} has an unclosed destination`,
311
+ line: lineNo,
312
+ })
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * A GFM table is a header row, a delimiter row (`| --- | --- |`), then body
319
+ * rows. Every row that participates must be a complete pipe row. We flag a
320
+ * delimiter row that is NOT flanked by a complete header above and at least a
321
+ * well-formed body/structure — i.e. the specific corruption `format.ts`'s
322
+ * chunker guards against (a bisected half-row). A row is "complete" when, after
323
+ * trimming, it starts and ends with `|` OR is a headerless GFM row with a
324
+ * matching delimiter. We keep this conservative: only flag a row that opens
325
+ * with `|` but does not close with `|` (a clearly bisected row).
326
+ */
327
+ function checkTableRows(md: string, issues: ParseIssue[]): void {
328
+ const { fenceLineSet } = maskFences(md)
329
+ const lines = md.split('\n')
330
+ lines.forEach((line, idx) => {
331
+ if (fenceLineSet.has(idx)) return
332
+ const t = line.trim()
333
+ if (t.length === 0) return
334
+ const looksLikeRow = t.startsWith('|')
335
+ if (!looksLikeRow) return
336
+ // A leading-pipe row must also close with a pipe to be a complete row.
337
+ if (!t.endsWith('|')) {
338
+ issues.push({
339
+ kind: 'incomplete-table-row',
340
+ detail: `table row opens with '|' but does not close with '|' on line ${idx + 1}`,
341
+ line: idx + 1,
342
+ })
343
+ }
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Common-case emphasis balance. Counts unescaped `**` and `__` (bold) runs and
349
+ * standalone `*`/`_` (italic) and `~~` (strikethrough) on the fence+code-masked
350
+ * text; an odd count means an unterminated emphasis entity — the thing that
351
+ * yields "can't find end of the entity" on the rich path.
352
+ *
353
+ * NOTE (scope): this is a COUNT check, not a full delimiter-run resolver. It
354
+ * intentionally does not model CommonMark's left/right-flanking rules, so it
355
+ * can miss an exotic imbalance or (rarely) over-flag a legitimately
356
+ * asymmetric-but-valid construct. The torture-set fixtures avoid such exotic
357
+ * cases so this check stays a reliable regression signal. Escaped markers
358
+ * (`\*`) and code (already masked) are excluded.
359
+ */
360
+ function checkEmphasisBalance(masked: string, issues: ParseIssue[]): void {
361
+ // Strip inline code per-line first (reuse extractInlineCode's blanking).
362
+ const codeStripped = masked
363
+ .split('\n')
364
+ .map((l) => extractInlineCode(l).blanked)
365
+ .join('\n')
366
+ // Remove escaped markers so `\*` doesn't count.
367
+ const noEsc = codeStripped.replace(/\\[*_~`\\]/g, '')
368
+
369
+ // Bold `**` and `__`: count runs.
370
+ const doubleStar = (noEsc.match(/\*\*/g) ?? []).length
371
+ if (doubleStar % 2 !== 0) {
372
+ issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of '**' bold delimiters (${doubleStar})` })
373
+ }
374
+ const strike = (noEsc.match(/~~/g) ?? []).length
375
+ if (strike % 2 !== 0) {
376
+ issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of '~~' strikethrough delimiters (${strike})` })
377
+ }
378
+ // Single `*` italic: count SINGLE stars that are not part of a `**` pair.
379
+ // Remove `**` pairs first, then count leftover lone `*`.
380
+ const noBold = noEsc.replace(/\*\*/g, '')
381
+ const loneStar = (noBold.match(/\*/g) ?? []).length
382
+ if (loneStar % 2 !== 0) {
383
+ issues.push({ kind: 'unbalanced-emphasis', detail: `odd number of lone '*' italic delimiters (${loneStar})` })
384
+ }
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Signal (b): STRUCTURE extraction
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Parse the common entity structure out of a rich-markdown body. Fenced blocks
393
+ * are extracted first (as `pre`), then inline code, links, bold, italic and
394
+ * strikethrough from the remaining text. See the module doc for the scope of
395
+ * "common entities". Order of returned entities is document order per type is
396
+ * NOT guaranteed across types; assert on membership, not absolute index, in
397
+ * tests (helpers below make that easy).
398
+ */
399
+ export function parseRichEntities(md: string): RichEntity[] {
400
+ const entities: RichEntity[] = []
401
+ const { masked, fences } = maskFences(md)
402
+
403
+ for (const f of fences) {
404
+ entities.push({ type: 'pre', text: f.body, lang: f.lang.length > 0 ? f.lang : undefined })
405
+ }
406
+
407
+ // Inline code + links + emphasis on the masked (fence-free) text, line-wise.
408
+ for (const rawLine of masked.split('\n')) {
409
+ const { spans, blanked } = extractInlineCode(rawLine)
410
+ for (const s of spans) entities.push({ type: 'code', text: s })
411
+
412
+ // Links: [label](url). Extract before emphasis so a bracketed label with
413
+ // emphasis inside is handled as a link (label text preserved verbatim).
414
+ let line = blanked
415
+ const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g
416
+ let lm: RegExpExecArray | null
417
+ const linkBlank: string[] = []
418
+ while ((lm = linkRe.exec(line)) != null) {
419
+ entities.push({ type: 'link', text: lm[1], url: lm[2] })
420
+ linkBlank.push(lm[0])
421
+ }
422
+ for (const lb of linkBlank) line = line.replace(lb, ' '.repeat(lb.length))
423
+
424
+ // Strikethrough ~~...~~
425
+ line = collect(line, /~~([^~]+)~~/g, (mtext) => entities.push({ type: 'strikethrough', text: mtext }))
426
+ // Bold **...** (and __...__)
427
+ line = collect(line, /\*\*([^*]+)\*\*/g, (mtext) => entities.push({ type: 'bold', text: mtext }))
428
+ line = collect(line, /__([^_]+)__/g, (mtext) => entities.push({ type: 'bold', text: mtext }))
429
+ // Italic *...* / _..._ (bold already removed above)
430
+ line = collect(line, /\*([^*]+)\*/g, (mtext) => entities.push({ type: 'italic', text: mtext }))
431
+ line = collect(line, /(?<![A-Za-z0-9])_([^_]+)_(?![A-Za-z0-9])/g, (mtext) =>
432
+ entities.push({ type: 'italic', text: mtext }),
433
+ )
434
+ }
435
+
436
+ return entities
437
+ }
438
+
439
+ /** Apply `re` globally, invoke `cb` with capture group 1, blank the match. */
440
+ function collect(line: string, re: RegExp, cb: (inner: string) => void): string {
441
+ let out = line
442
+ let m: RegExpExecArray | null
443
+ const toBlank: string[] = []
444
+ while ((m = re.exec(line)) != null) {
445
+ cb(m[1])
446
+ toBlank.push(m[0])
447
+ }
448
+ for (const b of toBlank) out = out.replace(b, ' '.repeat(b.length))
449
+ return out
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Test-facing convenience helpers
454
+ // ---------------------------------------------------------------------------
455
+
456
+ /** True when the body is parse-accept clean (signal a). */
457
+ export function isRichMarkdownValid(md: string): boolean {
458
+ return validateRichMarkdown(md).length === 0
459
+ }
460
+
461
+ /** All entities of a given type, in document order. */
462
+ export function entitiesOfType(md: string, type: EntityType): RichEntity[] {
463
+ return parseRichEntities(md).filter((e) => e.type === type)
464
+ }
465
+
466
+ /** The set of rendered inner texts for a given entity type. */
467
+ export function entityTexts(md: string, type: EntityType): string[] {
468
+ return entitiesOfType(md, type).map((e) => e.text)
469
+ }
@@ -577,6 +577,97 @@ describe('projectSubagentLine', () => {
577
577
  ])
578
578
  })
579
579
 
580
+ // ── Upstream-shape regression (Claude Code ≥2.1.x split-message writes) ──
581
+ // One logical assistant message is now persisted as MULTIPLE JSONL lines
582
+ // sharing one message.id, one content-block per line, and the terminal
583
+ // stop_reason (end_turn) is stamped on EVERY split line — including the
584
+ // leading [thinking] line that precedes the [text: final answer] line.
585
+ // Regression: firing the sub-agent terminal on the thinking-only line marks
586
+ // the sub-agent done and hands back stale/empty text BEFORE the real handback
587
+ // [text] line is projected. These pin the fix: the terminal must ride the
588
+ // content-bearing line, not the thinking-only split preamble.
589
+ it('does NOT emit sub_agent_turn_end on a thinking-only end_turn split line', () => {
590
+ // The FIRST line of a split terminal message: thinking only, but carries
591
+ // stop_reason end_turn (observed verbatim in 2.1.199 transcripts).
592
+ const st = { hasEmittedStart: true }
593
+ const events = projectSubagentLine(
594
+ JSON.stringify({
595
+ type: 'assistant',
596
+ message: {
597
+ id: 'msg_split',
598
+ stop_reason: 'end_turn',
599
+ content: [{ type: 'thinking', thinking: 'deciding how to summarise', signature: 'sig' }],
600
+ },
601
+ }),
602
+ 'X',
603
+ st,
604
+ )
605
+ // Thinking is a projection no-op for sub-agents; crucially, NO premature
606
+ // terminal — the handback text has not been seen yet.
607
+ expect(events.some((e) => e.kind === 'sub_agent_turn_end')).toBe(false)
608
+ })
609
+
610
+ it('emits the handback text THEN one turn_end across a split end_turn message', () => {
611
+ // The full split terminal message, as two consecutive JSONL lines sharing
612
+ // message.id — the shape that dropped background sub-agent handbacks.
613
+ const st = { hasEmittedStart: true }
614
+ const thinkingLine = JSON.stringify({
615
+ type: 'assistant',
616
+ message: {
617
+ id: 'msg_split2',
618
+ stop_reason: 'end_turn',
619
+ content: [{ type: 'thinking', thinking: '...', signature: 'sig' }],
620
+ },
621
+ })
622
+ const textLine = JSON.stringify({
623
+ type: 'assistant',
624
+ message: {
625
+ id: 'msg_split2',
626
+ stop_reason: 'end_turn',
627
+ content: [{ type: 'text', text: 'Handback: the fix is in session-tail.' }],
628
+ },
629
+ })
630
+ const all = [
631
+ ...projectSubagentLine(thinkingLine, 'X', st),
632
+ ...projectSubagentLine(textLine, 'X', st),
633
+ ]
634
+ // The handback text must be emitted, and exactly one turn_end, and the
635
+ // text must come BEFORE the turn_end so the watcher captures the real
636
+ // result before it marks the entry done.
637
+ const textEvents = all.filter((e) => e.kind === 'sub_agent_text')
638
+ const endEvents = all.filter((e) => e.kind === 'sub_agent_turn_end')
639
+ expect(textEvents.length).toBe(1)
640
+ expect((textEvents[0] as { text: string }).text).toBe('Handback: the fix is in session-tail.')
641
+ expect(endEvents.length).toBe(1)
642
+ const textIdx = all.findIndex((e) => e.kind === 'sub_agent_text')
643
+ const endIdx = all.findIndex((e) => e.kind === 'sub_agent_turn_end')
644
+ expect(textIdx).toBeLessThan(endIdx)
645
+ })
646
+
647
+ it('still fires terminal on the legacy single-line [thinking, text](end_turn) shape', () => {
648
+ // Graceful degradation: the OLD one-line-all-blocks shape has a text block
649
+ // on the same line, so the terminal fires exactly as before.
650
+ const st = { hasEmittedStart: true }
651
+ const events = projectSubagentLine(
652
+ JSON.stringify({
653
+ type: 'assistant',
654
+ message: {
655
+ stop_reason: 'end_turn',
656
+ content: [
657
+ { type: 'thinking', thinking: '...', signature: 'sig' },
658
+ { type: 'text', text: 'Legacy handback.' },
659
+ ],
660
+ },
661
+ }),
662
+ 'X',
663
+ st,
664
+ )
665
+ const textIdx = events.findIndex((e) => e.kind === 'sub_agent_text')
666
+ const endIdx = events.findIndex((e) => e.kind === 'sub_agent_turn_end')
667
+ expect(textIdx).toBeGreaterThanOrEqual(0)
668
+ expect(endIdx).toBeGreaterThan(textIdx)
669
+ })
670
+
580
671
  it('does NOT emit sub_agent_turn_end for a tool-using assistant message (stop_reason tool_use)', () => {
581
672
  // A mid-run assistant message that calls a tool has stop_reason 'tool_use'
582
673
  // and keeps going — it must not be mistaken for completion.
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { describeToolUse } from '../tool-activity-summary.js'
3
+ import { computeLabel } from '../hooks/tool-label-pretool.mjs'
4
+
5
+ /**
6
+ * SINGLE STATUS-VOCABULARY GUARANTEE (know-what-my-agent-is-doing).
7
+ *
8
+ * Every activity surface renders per-tool status wording from ONE composer:
9
+ * `computeLabel` (hooks/tool-label-pretool.mjs). The consumers:
10
+ *
11
+ * - real-time live feed — the PreToolUse sidecar runs computeLabel at
12
+ * tool-call time (`tool_label` events → appendActivityLabel);
13
+ * - flush-time feed — appendActivityLine → describeToolUse;
14
+ * - nested sub-agent / worker-card steps — subagent-watcher →
15
+ * describeToolUse (whose lines the 🛠 Worker card renders via
16
+ * renderStatusCard).
17
+ *
18
+ * `describeToolUse` is a thin delegating wrapper over computeLabel. Before
19
+ * the delegation the two tables had drifted (same action, different copy on
20
+ * different surfaces — the wording drift the spec bars). This test is the
21
+ * anti-drift lock: it sweeps a corpus of every tool shape both composers
22
+ * handle and asserts they emit IDENTICAL labels. If someone re-forks the
23
+ * vocabulary — a second wording table in describeToolUse, a hook-only
24
+ * rewording — this fails CI.
25
+ */
26
+
27
+ // Corpus: (toolName, input) pairs spanning every branch of the composer.
28
+ const CORPUS: Array<[string, Record<string, unknown>]> = [
29
+ ['Bash', { command: 'ls -la', description: 'List repo files' }],
30
+ ['Bash', { command: 'grep -r foo .' }],
31
+ ['BashOutput', {}],
32
+ ['KillShell', {}],
33
+ ['Read', { file_path: '/home/u/code/gateway.ts' }],
34
+ ['Read', {}],
35
+ ['Edit', { file_path: '/a/b/CLAUDE.md' }],
36
+ ['MultiEdit', { file_path: '/a/b/x.ts' }],
37
+ ['Edit', {}],
38
+ ['Write', { file_path: 'notes.txt' }],
39
+ ['Write', {}],
40
+ ['NotebookEdit', { notebook_path: '/n/analysis.ipynb' }],
41
+ ['NotebookEdit', {}],
42
+ ['Grep', { pattern: 'TODO' }],
43
+ ['Grep', { pattern: 'TODO', path: 'src/' }],
44
+ ['Grep', {}],
45
+ ['Glob', { pattern: '**/*.ts' }],
46
+ ['Glob', {}],
47
+ ['WebFetch', { url: 'https://www.example.com/path?q=1' }],
48
+ ['WebFetch', {}],
49
+ ['WebSearch', { query: 'best running shoes' }],
50
+ ['WebSearch', {}],
51
+ ['Task', { description: 'Review the migration' }],
52
+ ['Agent', {}],
53
+ ['TodoWrite', {}],
54
+ ['TaskCreate', {}],
55
+ ['TaskUpdate', {}],
56
+ ['TaskList', {}],
57
+ ['ToolSearch', {}],
58
+ ['Skill', { skill: 'switchroom' }],
59
+ ['SomeFutureBuiltin', {}],
60
+ ['mcp__hindsight__recall', { query: 'x' }],
61
+ ['mcp__hindsight__reflect', { query: 'x' }],
62
+ ['mcp__hindsight__retain', {}],
63
+ ['mcp__hindsight__update_memory', {}],
64
+ ['mcp__hindsight__list_memories', {}],
65
+ ['mcp__claude_ai_Google_Calendar__list_events', {}],
66
+ ['mcp__claude_ai_Gmail__search', {}],
67
+ ['mcp__claude_ai_Google_Drive__search_files', {}],
68
+ ['mcp__claude_ai_Notion__notion-search', {}],
69
+ ['mcp__perplexity__perplexity_search', { query: 'weather sydney' }],
70
+ ['mcp__webkite__read', { url: 'https://example.org/docs' }],
71
+ ['mcp__acme__do_thing', { description: 'Fetched the report' }],
72
+ ['mcp__acme__do_thing', {}],
73
+ ['mcp__switchroom-telegram__get_recent_messages', {}],
74
+ ]
75
+
76
+ // Suppressed-on-both corpus: surfaces must agree these render NOTHING.
77
+ const SUPPRESSED: Array<[string, Record<string, unknown>]> = [
78
+ ['mcp__switchroom-telegram__reply', { text: 'hi' }],
79
+ ['mcp__switchroom-telegram__stream_reply', {}],
80
+ ['mcp__switchroom-telegram__edit_message', {}],
81
+ ['mcp__switchroom-telegram__react', {}],
82
+ ['mcp__clerk-telegram__reply', {}],
83
+ ['mcp__hindsight__sync_retain', {}],
84
+ ['Skill', {}], // empty-slug Skill stays suppressed (#2111 sidecar contract)
85
+ ]
86
+
87
+ describe('single status-vocabulary composer (drift fails CI)', () => {
88
+ it('describeToolUse and computeLabel emit identical labels across the corpus', () => {
89
+ for (const [tool, input] of CORPUS) {
90
+ expect(describeToolUse(tool, input), `label drift for ${tool}`).toBe(
91
+ computeLabel(tool, input),
92
+ )
93
+ }
94
+ })
95
+
96
+ it('both composers agree on suppression (never a surfaced reply/control tool)', () => {
97
+ for (const [tool, input] of SUPPRESSED) {
98
+ expect(describeToolUse(tool, input), `describeToolUse must suppress ${tool}`).toBeNull()
99
+ expect(computeLabel(tool, input), `computeLabel must suppress ${tool}`).toBeNull()
100
+ }
101
+ })
102
+
103
+ it('the previously-drifted wordings are unified (the Ken-observed drift)', () => {
104
+ // These exact pairs rendered DIFFERENT copy per surface before the
105
+ // unification. Pin the unified form on both composers.
106
+ expect(computeLabel('Grep', { pattern: 'TODO' })).toBe('Searching for TODO')
107
+ expect(describeToolUse('Grep', { pattern: 'TODO' })).toBe('Searching for TODO')
108
+ expect(computeLabel('WebFetch', { url: 'https://www.example.com/path?q=1' })).toBe(
109
+ 'Reading example.com',
110
+ )
111
+ expect(describeToolUse('WebFetch', { url: 'https://www.example.com/path?q=1' })).toBe(
112
+ 'Reading example.com',
113
+ )
114
+ expect(computeLabel('mcp__hindsight__retain', {})).toBe('Saving to memory')
115
+ expect(describeToolUse('mcp__hindsight__retain', {})).toBe('Saving to memory')
116
+ })
117
+
118
+ it('never emits raw shell/query syntax from either composer', () => {
119
+ for (const [tool, input] of CORPUS) {
120
+ const label = computeLabel(tool, input)
121
+ if (label == null) continue
122
+ expect(label, `raw syntax leaked for ${tool}`).not.toMatch(/grep -r|ls -la/)
123
+ }
124
+ })
125
+ })