switchroom 0.17.10 → 0.18.3

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/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +27 -1
  3. package/dist/auth-broker/index.js +6161 -151
  4. package/dist/cli/notion-write-pretool.mjs +29 -2
  5. package/dist/cli/switchroom.js +578 -454
  6. package/dist/host-control/main.js +6182 -172
  7. package/dist/vault/approvals/kernel-server.js +5891 -164
  8. package/dist/vault/broker/server.js +6597 -881
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +2 -2
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +480 -85
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/package.json +6 -0
  25. package/telegram-plugin/quota-watch.ts +4 -6
  26. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  27. package/telegram-plugin/registry/turns-schema.ts +78 -0
  28. package/telegram-plugin/render/ir.ts +209 -0
  29. package/telegram-plugin/render/parse.ts +363 -0
  30. package/telegram-plugin/render/render.ts +440 -0
  31. package/telegram-plugin/render/rich-render.ts +72 -0
  32. package/telegram-plugin/stream-controller.ts +14 -3
  33. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  34. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  35. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  36. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  37. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  38. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  39. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  41. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  42. package/telegram-plugin/tests/model-command.test.ts +193 -16
  43. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  44. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  45. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  46. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  47. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  48. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  49. package/telegram-plugin/tests/render/render.test.ts +436 -0
  50. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  51. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  52. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  53. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  55. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  56. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  57. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  58. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  59. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  60. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  61. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,136 @@
1
+ // Cross-check the new markdown->IR parser (render/parse.ts) against the
2
+ // existing adversarial torture-fixture set (formatting-torture-set.ts).
3
+ //
4
+ // This does NOT exercise the renderer or chunker (out of scope for the
5
+ // parser+IR increment) — it feeds each fixture's raw `input` straight into
6
+ // `parse()` and walks the resulting IR tree collecting a flat entity list,
7
+ // then asserts every entity the fixture's `expect` block calls for is
8
+ // present in that list. This is a structural cross-check independent of the
9
+ // existing gateway-pipeline oracle: it proves the new parser captures the
10
+ // same intent the current hand-rolled pipeline is required to preserve.
11
+ import { describe, it, expect } from 'vitest'
12
+ import { parse } from '../../render/parse.js'
13
+ import type { Block, Inline } from '../../render/ir.js'
14
+ import { TORTURE_SET, type ExpectEntity } from '../formatting-torture-set.js'
15
+ import type { EntityType } from '../rich-markdown-oracle.js'
16
+
17
+ interface FlatEntity {
18
+ type: EntityType
19
+ text: string
20
+ url?: string
21
+ lang?: string
22
+ }
23
+
24
+ function textOf(nodes: Inline[]): string {
25
+ return nodes
26
+ .map((n) => {
27
+ switch (n.type) {
28
+ case 'plain':
29
+ case 'code':
30
+ return n.text
31
+ default:
32
+ return textOf(n.children)
33
+ }
34
+ })
35
+ .join('')
36
+ }
37
+
38
+ function walkInline(node: Inline, out: FlatEntity[]) {
39
+ switch (node.type) {
40
+ case 'bold':
41
+ out.push({ type: 'bold', text: textOf(node.children) })
42
+ node.children.forEach((c) => walkInline(c, out))
43
+ break
44
+ case 'italic':
45
+ out.push({ type: 'italic', text: textOf(node.children) })
46
+ node.children.forEach((c) => walkInline(c, out))
47
+ break
48
+ case 'strike':
49
+ out.push({ type: 'strikethrough', text: textOf(node.children) })
50
+ node.children.forEach((c) => walkInline(c, out))
51
+ break
52
+ case 'code':
53
+ out.push({ type: 'code', text: node.text })
54
+ break
55
+ case 'link':
56
+ out.push({ type: 'link', text: textOf(node.children), url: node.href })
57
+ node.children.forEach((c) => walkInline(c, out))
58
+ break
59
+ case 'plain':
60
+ break
61
+ case 'underline':
62
+ case 'spoiler':
63
+ case 'highlight':
64
+ // No dedicated oracle EntityType for these Bot API 10.1 constructs yet;
65
+ // recurse so any nested bold/italic/code/link inside them is still
66
+ // collected for the cross-check.
67
+ node.children.forEach((c) => walkInline(c, out))
68
+ break
69
+ }
70
+ }
71
+
72
+ function walkBlock(node: Block, out: FlatEntity[]) {
73
+ switch (node.type) {
74
+ case 'paragraph':
75
+ case 'heading':
76
+ node.children.forEach((c) => walkInline(c, out))
77
+ break
78
+ case 'blockquote':
79
+ node.children.forEach((c) => walkBlock(c, out))
80
+ break
81
+ case 'code-block':
82
+ out.push({ type: 'pre', text: node.text, lang: node.language ?? undefined })
83
+ break
84
+ case 'list':
85
+ for (const item of node.items) item.children.forEach((c) => walkBlock(c, out))
86
+ break
87
+ case 'table':
88
+ for (const row of [node.header, ...node.rows]) {
89
+ for (const cell of row.cells) cell.children.forEach((c) => walkInline(c, out))
90
+ }
91
+ break
92
+ case 'thematic-break':
93
+ break
94
+ }
95
+ }
96
+
97
+ function flatten(input: string): FlatEntity[] {
98
+ const doc = parse(input)
99
+ const out: FlatEntity[] = []
100
+ doc.blocks.forEach((b) => walkBlock(b, out))
101
+ return out
102
+ }
103
+
104
+ function matches(found: FlatEntity[], want: ExpectEntity): boolean {
105
+ return found.some(
106
+ (f) =>
107
+ f.type === want.type &&
108
+ f.text === want.text &&
109
+ (want.url === undefined || f.url === want.url) &&
110
+ (want.lang === undefined || f.lang === want.lang),
111
+ )
112
+ }
113
+
114
+ describe('parse: torture-fixture cross-check', () => {
115
+ for (const fixture of TORTURE_SET) {
116
+ if (fixture.expect.length === 0) {
117
+ // No entity assertions for this fixture — just prove parse() doesn't
118
+ // throw and returns at least one block (structure-only fixtures).
119
+ it(`${fixture.name}: parses without throwing`, () => {
120
+ const doc = parse(fixture.input)
121
+ expect(doc.blocks.length).toBeGreaterThan(0)
122
+ })
123
+ continue
124
+ }
125
+
126
+ it(`${fixture.name}: IR captures every expected entity`, () => {
127
+ const found = flatten(fixture.input)
128
+ for (const want of fixture.expect) {
129
+ expect(
130
+ matches(found, want),
131
+ `expected a ${want.type} entity with text ${JSON.stringify(want.text)} in fixture "${fixture.name}"; found: ${JSON.stringify(found)}`,
132
+ ).toBe(true)
133
+ }
134
+ })
135
+ }
136
+ })
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parse } from "../../render/parse.js";
3
+ import type { Block, Inline } from "../../render/ir.js";
4
+
5
+ // Assert every node in a subtree round-trips: source.slice(start, end) is the
6
+ // exact source text the node spans. Walks both inline and block children.
7
+ function assertOffsetsRoundTrip(node: any, source: string) {
8
+ expect(typeof node.start).toBe("number");
9
+ expect(typeof node.end).toBe("number");
10
+ // The slice must be non-empty for real nodes and lie within bounds.
11
+ expect(node.start).toBeGreaterThanOrEqual(0);
12
+ expect(node.end).toBeLessThanOrEqual(source.length);
13
+ expect(node.end).toBeGreaterThanOrEqual(node.start);
14
+ if (Array.isArray(node.children)) {
15
+ for (const c of node.children) assertOffsetsRoundTrip(c, source);
16
+ }
17
+ if (Array.isArray(node.items)) {
18
+ for (const it of node.items) assertOffsetsRoundTrip(it, source);
19
+ }
20
+ if (Array.isArray(node.cells)) {
21
+ for (const c of node.cells) assertOffsetsRoundTrip(c, source);
22
+ }
23
+ }
24
+
25
+ describe("parse: inline palette", () => {
26
+ const cases: Array<{
27
+ name: string;
28
+ md: string;
29
+ // The inline construct's source text (what slice(start,end) must equal).
30
+ fragment: string;
31
+ check: (inline: Inline) => void;
32
+ }> = [
33
+ {
34
+ name: "bold",
35
+ md: "**hi**",
36
+ fragment: "**hi**",
37
+ check: (n) => {
38
+ expect(n.type).toBe("bold");
39
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "hi" });
40
+ },
41
+ },
42
+ {
43
+ name: "italic",
44
+ md: "*hi*",
45
+ fragment: "*hi*",
46
+ check: (n) => {
47
+ expect(n.type).toBe("italic");
48
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "hi" });
49
+ },
50
+ },
51
+ {
52
+ name: "strike",
53
+ md: "~~hi~~",
54
+ fragment: "~~hi~~",
55
+ check: (n) => {
56
+ expect(n.type).toBe("strike");
57
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "hi" });
58
+ },
59
+ },
60
+ {
61
+ name: "inline code",
62
+ md: "`x = 1`",
63
+ fragment: "`x = 1`",
64
+ check: (n) => {
65
+ expect(n).toMatchObject({ type: "code", text: "x = 1" });
66
+ },
67
+ },
68
+ {
69
+ name: "link",
70
+ md: "[label](https://example.com)",
71
+ fragment: "[label](https://example.com)",
72
+ check: (n) => {
73
+ expect(n.type).toBe("link");
74
+ expect((n as any).href).toBe("https://example.com");
75
+ expect((n as any).children[0]).toMatchObject({ type: "plain", text: "label" });
76
+ },
77
+ },
78
+ ];
79
+
80
+ for (const c of cases) {
81
+ it(`parses ${c.name} and offsets round-trip`, () => {
82
+ const doc = parse(c.md);
83
+ expect(doc.blocks).toHaveLength(1);
84
+ const para = doc.blocks[0];
85
+ expect(para.type).toBe("paragraph");
86
+ const inline = (para as any).children[0] as Inline;
87
+ c.check(inline);
88
+ expect(c.md.slice(inline.start, inline.end)).toBe(c.fragment);
89
+ assertOffsetsRoundTrip(para, c.md);
90
+ });
91
+ }
92
+ });
93
+
94
+ describe("parse: heading levels", () => {
95
+ for (let level = 1; level <= 6; level++) {
96
+ it(`parses an h${level} heading`, () => {
97
+ const md = `${"#".repeat(level)} Title`;
98
+ const doc = parse(md);
99
+ expect(doc.blocks).toHaveLength(1);
100
+ const h = doc.blocks[0] as any;
101
+ expect(h.type).toBe("heading");
102
+ expect(h.level).toBe(level);
103
+ expect(h.children[0]).toMatchObject({ type: "plain", text: "Title" });
104
+ expect(md.slice(h.start, h.end)).toBe(md);
105
+ });
106
+ }
107
+ });
108
+
109
+ describe("parse: fenced code with language", () => {
110
+ it("captures text and language", () => {
111
+ const md = "```ts\nconst x = 1;\n```";
112
+ const doc = parse(md);
113
+ const cb = doc.blocks[0] as any;
114
+ expect(cb.type).toBe("code-block");
115
+ expect(cb.language).toBe("ts");
116
+ expect(cb.text).toBe("const x = 1;");
117
+ expect(md.slice(cb.start, cb.end)).toBe(md);
118
+ });
119
+
120
+ it("null language for a bare fence", () => {
121
+ const md = "```\nplain\n```";
122
+ const doc = parse(md);
123
+ const cb = doc.blocks[0] as any;
124
+ expect(cb.type).toBe("code-block");
125
+ expect(cb.language).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe("parse: blockquote", () => {
130
+ it("parses a blockquote with expandable=false", () => {
131
+ const md = "> quoted text";
132
+ const doc = parse(md);
133
+ const bq = doc.blocks[0] as any;
134
+ expect(bq.type).toBe("blockquote");
135
+ expect(bq.expandable).toBe(false);
136
+ expect(bq.children[0].type).toBe("paragraph");
137
+ expect(md.slice(bq.start, bq.end)).toBe(md);
138
+ });
139
+ });
140
+
141
+ describe("parse: lists", () => {
142
+ it("parses an unordered list (start=null)", () => {
143
+ const md = "- one\n- two";
144
+ const doc = parse(md);
145
+ const list = doc.blocks[0] as any;
146
+ expect(list.type).toBe("list");
147
+ expect(list.ordered).toBe(false);
148
+ expect(list.startNumber).toBeNull();
149
+ expect(list.items).toHaveLength(2);
150
+ expect(md.slice(list.start, list.end)).toBe(md);
151
+ assertOffsetsRoundTrip(list, md);
152
+ });
153
+
154
+ it("parses an ordered list with a start number", () => {
155
+ const md = "3. three\n4. four";
156
+ const doc = parse(md);
157
+ const list = doc.blocks[0] as any;
158
+ expect(list.type).toBe("list");
159
+ expect(list.ordered).toBe(true);
160
+ expect(list.startNumber).toBe(3);
161
+ expect(list.items).toHaveLength(2);
162
+ });
163
+
164
+ it("parses task-list checked/unchecked state", () => {
165
+ const md = "- [x] done\n- [ ] todo";
166
+ const doc = parse(md);
167
+ const list = doc.blocks[0] as any;
168
+ expect(list.items[0].checked).toBe(true);
169
+ expect(list.items[1].checked).toBe(false);
170
+ });
171
+
172
+ it("plain (non-task) list items have checked=null", () => {
173
+ const md = "- a\n- b";
174
+ const doc = parse(md);
175
+ const list = doc.blocks[0] as any;
176
+ expect(list.items[0].checked).toBeNull();
177
+ expect(list.items[1].checked).toBeNull();
178
+ });
179
+ });
180
+
181
+ describe("parse: GFM table with mixed alignment", () => {
182
+ it("captures header, rows, and per-column alignment", () => {
183
+ const md = ["| L | C | R |", "|:--|:-:|--:|", "| 1 | 2 | 3 |"].join("\n");
184
+ const doc = parse(md);
185
+ const table = doc.blocks[0] as any;
186
+ expect(table.type).toBe("table");
187
+ expect(table.align).toEqual(["left", "center", "right"]);
188
+ expect(table.header.cells).toHaveLength(3);
189
+ expect(table.rows).toHaveLength(1);
190
+ expect(table.rows[0].cells[0].children[0]).toMatchObject({ type: "plain", text: "1" });
191
+ expect(md.slice(table.start, table.end)).toBe(md);
192
+ assertOffsetsRoundTrip(table, md);
193
+ });
194
+ });
195
+
196
+ describe("parse: thematic break", () => {
197
+ it("parses a horizontal rule", () => {
198
+ const md = "---";
199
+ const doc = parse(md);
200
+ const tb = doc.blocks[0] as any;
201
+ expect(tb.type).toBe("thematic-break");
202
+ expect(md.slice(tb.start, tb.end)).toBe(md);
203
+ });
204
+ });
205
+
206
+ describe("parse: nested inline", () => {
207
+ it("bold containing italic, offsets round-trip at both levels", () => {
208
+ const md = "**bold *and italic* here**";
209
+ const doc = parse(md);
210
+ const bold = (doc.blocks[0] as any).children[0];
211
+ expect(bold.type).toBe("bold");
212
+ expect(md.slice(bold.start, bold.end)).toBe("**bold *and italic* here**");
213
+ const italic = bold.children.find((c: any) => c.type === "italic");
214
+ expect(italic).toBeDefined();
215
+ expect(md.slice(italic.start, italic.end)).toBe("*and italic*");
216
+ assertOffsetsRoundTrip(doc.blocks[0] as Block, md);
217
+ });
218
+ });
219
+
220
+ describe("parse: unsupported construct fallback", () => {
221
+ it("degrades a raw HTML block to plain text without dropping content", () => {
222
+ // Raw HTML blocks (mdast `html`) are outside the palette; they must
223
+ // survive as plain source text.
224
+ const md = "<div class=\"x\">raw</div>";
225
+ const doc = parse(md);
226
+ const block = doc.blocks[0] as any;
227
+ expect(block.type).toBe("paragraph");
228
+ const plain = block.children[0];
229
+ expect(plain.type).toBe("plain");
230
+ expect(plain.text).toBe(md);
231
+ expect(md.slice(plain.start, plain.end)).toBe(md);
232
+ });
233
+ });
234
+
235
+ describe("parse: astral-plane emoji offsets are UTF-16 units", () => {
236
+ it("uses UTF-16 code-unit offsets, not codepoints or bytes", () => {
237
+ // U+1F680 ROCKET is one codepoint but TWO UTF-16 code units (a surrogate
238
+ // pair) and FOUR UTF-8 bytes. The bold node after it must start at the
239
+ // UTF-16 offset.
240
+ const emoji = "\u{1F680}"; // 🚀, length 2 in UTF-16
241
+ expect(emoji.length).toBe(2);
242
+ const md = `${emoji} **go**`;
243
+ const doc = parse(md);
244
+ const para = doc.blocks[0] as any;
245
+ const bold = para.children.find((c: any) => c.type === "bold");
246
+ expect(bold).toBeDefined();
247
+ // "🚀 " is 3 UTF-16 code units (2 + space), so bold starts at offset 3.
248
+ expect(bold.start).toBe(3);
249
+ expect(md.slice(bold.start, bold.end)).toBe("**go**");
250
+ // Sanity: byte-based or codepoint-based offsets would give 2 or a
251
+ // different index, not 3.
252
+ expect(md.slice(bold.start, bold.end)).not.toBe("*go**");
253
+ });
254
+ });
255
+
256
+ describe("parse: underline vs bold (`__` vs `**`)", () => {
257
+ it("folds a `__…__` run into an underline node (not bold)", () => {
258
+ const md = "__underlined__";
259
+ const doc = parse(md);
260
+ const u = (doc.blocks[0] as any).children[0];
261
+ expect(u.type).toBe("underline");
262
+ expect(u.children[0]).toMatchObject({ type: "plain", text: "underlined" });
263
+ expect(md.slice(u.start, u.end)).toBe(md);
264
+ });
265
+
266
+ it("keeps a `**…**` run as bold", () => {
267
+ const b = (parse("**strong**").blocks[0] as any).children[0];
268
+ expect(b.type).toBe("bold");
269
+ });
270
+
271
+ it("keeps single-delimiter `_…_` as italic", () => {
272
+ const i = (parse("_em_").blocks[0] as any).children[0];
273
+ expect(i.type).toBe("italic");
274
+ });
275
+
276
+ it("recovers nested bold inside underline with offsets intact", () => {
277
+ const md = "__a **b** c__";
278
+ const doc = parse(md);
279
+ const u = (doc.blocks[0] as any).children[0];
280
+ expect(u.type).toBe("underline");
281
+ expect(md.slice(u.start, u.end)).toBe(md);
282
+ const bold = u.children.find((c: any) => c.type === "bold");
283
+ expect(bold).toBeDefined();
284
+ expect(md.slice(bold.start, bold.end)).toBe("**b**");
285
+ assertOffsetsRoundTrip(doc.blocks[0], md);
286
+ });
287
+ });
288
+
289
+ describe("parse: spoiler (`||…||`) + highlight (`==…==`)", () => {
290
+ it("splits `||secret||` into a spoiler node", () => {
291
+ const md = "before ||secret|| after";
292
+ const doc = parse(md);
293
+ const kids = (doc.blocks[0] as any).children;
294
+ expect(kids.map((k: any) => k.type)).toEqual(["plain", "spoiler", "plain"]);
295
+ const sp = kids[1];
296
+ expect(sp.children[0]).toMatchObject({ type: "plain", text: "secret" });
297
+ expect(md.slice(sp.start, sp.end)).toBe("||secret||");
298
+ assertOffsetsRoundTrip(doc.blocks[0], md);
299
+ });
300
+
301
+ it("splits `==marked==` into a highlight node", () => {
302
+ const md = "a ==marked== b";
303
+ const doc = parse(md);
304
+ const kids = (doc.blocks[0] as any).children;
305
+ const hi = kids.find((k: any) => k.type === "highlight");
306
+ expect(hi).toBeDefined();
307
+ expect(hi.children[0]).toMatchObject({ type: "plain", text: "marked" });
308
+ expect(md.slice(hi.start, hi.end)).toBe("==marked==");
309
+ });
310
+
311
+ it("recognises both delimiters in one run", () => {
312
+ const md = "||s|| and ==m==";
313
+ const types = (parse(md).blocks[0] as any).children.map((c: any) => c.type);
314
+ expect(types).toContain("spoiler");
315
+ expect(types).toContain("highlight");
316
+ });
317
+
318
+ it("leaves an unclosed / single delimiter as literal plain text", () => {
319
+ // `a || b` has no closing `||` — must NOT become a spoiler.
320
+ const kids = (parse("a || b").blocks[0] as any).children;
321
+ expect(kids.every((k: any) => k.type === "plain")).toBe(true);
322
+ });
323
+ });
324
+
325
+ describe("parse: nested lists", () => {
326
+ it("folds a nested sub-list under a list item", () => {
327
+ const md = "- a\n - nested1\n - nested2\n- b";
328
+ const doc = parse(md);
329
+ const list = doc.blocks[0] as any;
330
+ expect(list.type).toBe("list");
331
+ const item0Kinds = list.items[0].children.map((c: any) => c.type);
332
+ expect(item0Kinds).toContain("list");
333
+ const nested = list.items[0].children.find((c: any) => c.type === "list");
334
+ expect(nested.items).toHaveLength(2);
335
+ assertOffsetsRoundTrip(list, md);
336
+ });
337
+ });
338
+
339
+ describe("expandable blockquote (Bot API 10.1 `**>` marker)", () => {
340
+ it("parses a single-line `**>` quote into an expandable blockquote", () => {
341
+ const md = "**> a collapsible line";
342
+ const doc = parse(md);
343
+ const bq = doc.blocks[0] as any;
344
+ expect(bq.type).toBe("blockquote");
345
+ expect(bq.expandable).toBe(true);
346
+ });
347
+
348
+ it("marks a multi-line quote expandable when the FIRST line carries `**>`", () => {
349
+ const md = "**> line one\n> line two\n> line three";
350
+ const doc = parse(md);
351
+ const bq = doc.blocks[0] as any;
352
+ expect(bq.type).toBe("blockquote");
353
+ expect(bq.expandable).toBe(true);
354
+ // All three quoted lines fold into ONE blockquote.
355
+ expect(bq.children.length).toBeGreaterThanOrEqual(1);
356
+ });
357
+
358
+ it("leaves a plain `>` quote non-expandable", () => {
359
+ const doc = parse("> just an ordinary quote");
360
+ const bq = doc.blocks[0] as any;
361
+ expect(bq.type).toBe("blockquote");
362
+ expect(bq.expandable).toBe(false);
363
+ });
364
+
365
+ it("keeps content offsets byte-identical to the ORIGINAL source", () => {
366
+ const md = "**> quoted body here";
367
+ const doc = parse(md);
368
+ const bq = doc.blocks[0] as any;
369
+ // The marker rewrite (`**>` -> ` >`) is length-preserving, so the inner
370
+ // text still slices out of the ORIGINAL markdown exactly.
371
+ const plain = bq.children[0].children[0];
372
+ expect(md.slice(plain.start, plain.end)).toBe("quoted body here");
373
+ assertOffsetsRoundTrip(bq, md);
374
+ });
375
+
376
+ it("only recognises the marker at column 0 (leading indent is not expandable)", () => {
377
+ // The renderer only ever emits `**>` at column 0; a leading-indented
378
+ // variant is deliberately NOT treated as an expandable quote (matching it
379
+ // would push the length-preserving rewrite past the 3-space blockquote
380
+ // budget into indented-code-block territory).
381
+ const md = " **> indented";
382
+ const doc = parse(md);
383
+ const bq = doc.blocks[0] as any;
384
+ expect(bq.expandable).not.toBe(true);
385
+ });
386
+
387
+ it("does not treat inline `**` bold followed by `>` mid-line as expandable", () => {
388
+ // `**bold** > text` is a paragraph, not a quote — the marker must be at
389
+ // line start.
390
+ const doc = parse("**bold** > not a quote");
391
+ expect(doc.blocks[0].type).toBe("paragraph");
392
+ });
393
+ });