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.
- package/dist/agent-scheduler/index.js +88 -82
- package/dist/auth-broker/index.js +87 -81
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +89 -83
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +1621 -737
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +271 -239
- package/dist/vault/approvals/kernel-server.js +90 -84
- package/dist/vault/broker/server.js +91 -85
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +2 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +2090 -1046
- package/telegram-plugin/dist/server.js +180 -162
- package/telegram-plugin/format.ts +305 -31
- package/telegram-plugin/gateway/gateway.ts +262 -63
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/stream-reply-handler.ts +19 -1
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/card-format.test.ts +28 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/telegram-format.test.ts +33 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +20 -108
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +472 -22
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-ondemand.ts +161 -10
- package/telegram-plugin/voice-presynth.ts +242 -0
- 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
|
+
})
|