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,218 @@
1
+ /**
2
+ * formatting-torture-set — curated fixtures for parse-level formatting
3
+ * regression testing.
4
+ *
5
+ * Each fixture is an INTENT (`input`) plus the STRUCTURE we expect the
6
+ * formatter's output to parse into (checked by `rich-markdown-oracle.ts`).
7
+ * The `input` is the raw text as a model/card surface would author it — the
8
+ * regression test runs it through the SAME outbound transform pipeline the
9
+ * gateway uses (repairEscapedWhitespace -> normalizeParagraphBreaks ->
10
+ * addParagraphSpacers -> splitMarkdownChunks) and then asserts:
11
+ *
12
+ * (a) every emitted chunk is parse-accept valid (no rich-path 400), and
13
+ * (b) the concatenated output parses into `expect` (entity structure).
14
+ *
15
+ * The `expect` block describes WHAT should survive, not a byte-for-byte echo
16
+ * of formatter output — the oracle is independent of the formatter, so this is
17
+ * a genuine cross-check, not a circular self-comparison.
18
+ *
19
+ * F1/F2/F4/F7 fixtures reproduce the specific formatting fixes from PR #2737
20
+ * (see `telegram-plugin/format.ts` and `text-voice-scrub.ts`).
21
+ */
22
+
23
+ import type { EntityType } from './rich-markdown-oracle.js'
24
+
25
+ export interface ExpectEntity {
26
+ readonly type: EntityType
27
+ /** Exact inner text the entity should carry. */
28
+ readonly text: string
29
+ /** For links, the destination. */
30
+ readonly url?: string
31
+ /** For fenced blocks, the language info string. */
32
+ readonly lang?: string
33
+ }
34
+
35
+ export interface TortureFixture {
36
+ readonly name: string
37
+ /** One-line description of the intent this fixture pins. */
38
+ readonly intent: string
39
+ /** Raw authored input, before the outbound transform pipeline. */
40
+ readonly input: string
41
+ /**
42
+ * Entities we expect to find in the transformed output. The test asserts
43
+ * every listed entity is PRESENT (membership), not that these are the only
44
+ * entities — spacer/structure passes may add nothing observable to the
45
+ * entity set. Empty ⇒ only signal (a) is asserted for this fixture.
46
+ */
47
+ readonly expect: ReadonlyArray<ExpectEntity>
48
+ /**
49
+ * When set, the raw input is EXPECTED to be scrubbed by the card-surface
50
+ * voice pass (F1) before it reaches the rich path — the test applies
51
+ * `normalizeDashes` first and asserts the em/en-dash glyph is gone.
52
+ */
53
+ readonly cardSurfaceScrub?: boolean
54
+ }
55
+
56
+ export const TORTURE_SET: ReadonlyArray<TortureFixture> = [
57
+ // ── Multi-item bullet list — one fact per bullet ──────────────────────────
58
+ {
59
+ name: 'multi-item-bullet-list',
60
+ intent: 'A plain multi-item bullet list stays parse-valid and keeps its items intact',
61
+ input: [
62
+ 'Findings:',
63
+ '- Master Bath 1 is clean',
64
+ '- Master Bath 2 shows 33% loss',
65
+ '- Cabinet is clean',
66
+ ].join('\n'),
67
+ expect: [],
68
+ },
69
+
70
+ // ── Middot (·) separator INSIDE a bullet — the collapsed-inline case ──────
71
+ {
72
+ name: 'middot-separator-inside-bullet',
73
+ intent: 'A collapsed one-line bullet list using `·` separators splits without corrupting entities',
74
+ input: '- Master Bath 1, clean · **Master Bath 2**, 33% loss · Cabinet, clean',
75
+ expect: [{ type: 'bold', text: 'Master Bath 2' }],
76
+ },
77
+
78
+ // ── Em-dash in prose (voice scrub is a DIFFERENT surface — see F1) ────────
79
+ {
80
+ name: 'em-dash-in-prose',
81
+ intent: 'Prose containing an em-dash is parse-valid on the rich path (raw, un-scrubbed)',
82
+ input: 'The audit is done — every surface checked, nothing left open.',
83
+ expect: [],
84
+ },
85
+
86
+ // ── Wide / long code block ────────────────────────────────────────────────
87
+ {
88
+ name: 'wide-long-code-block',
89
+ intent: 'A fenced code block with a long line and reserved chars stays balanced and verbatim',
90
+ input: [
91
+ 'Run this:',
92
+ '```bash',
93
+ 'grep -rnE "foo\\|bar\\|baz" src/ | awk -F: \'{print $1}\' | sort -u # a very very very long command line that keeps going and going',
94
+ 'echo "done"',
95
+ '```',
96
+ ].join('\n'),
97
+ expect: [
98
+ {
99
+ type: 'pre',
100
+ lang: 'bash',
101
+ text: [
102
+ 'grep -rnE "foo\\|bar\\|baz" src/ | awk -F: \'{print $1}\' | sort -u # a very very very long command line that keeps going and going',
103
+ 'echo "done"',
104
+ ].join('\n'),
105
+ },
106
+ ],
107
+ },
108
+
109
+ // ── A GFM table ───────────────────────────────────────────────────────────
110
+ {
111
+ name: 'gfm-table',
112
+ intent: 'A GFM table stays row-complete (no bisected/half rows) and parse-valid',
113
+ input: [
114
+ 'Results:',
115
+ '',
116
+ '| Surface | Status | Loss |',
117
+ '| --- | --- | --- |',
118
+ '| Bath 1 | clean | 0% |',
119
+ '| Bath 2 | flagged | 33% |',
120
+ ].join('\n'),
121
+ expect: [],
122
+ },
123
+
124
+ // ── A nested list ─────────────────────────────────────────────────────────
125
+ {
126
+ name: 'nested-list',
127
+ intent: 'A nested bullet list stays parse-valid with inline emphasis preserved',
128
+ input: [
129
+ '- Top level one',
130
+ ' - Nested **bold** item',
131
+ ' - Nested plain item',
132
+ '- Top level two',
133
+ ].join('\n'),
134
+ expect: [{ type: 'bold', text: 'bold' }],
135
+ },
136
+
137
+ // ── A long line that wraps ────────────────────────────────────────────────
138
+ {
139
+ name: 'long-wrapping-line',
140
+ intent: 'A single long prose line is parse-valid and preserves inline entities',
141
+ input:
142
+ 'This is a single very long line of prose that would wrap on a phone screen and keeps going with more and more words to exceed a comfortable width, and it ends with an inline `code token` plus a **bold phrase** to make sure entities survive the length.',
143
+ expect: [
144
+ { type: 'code', text: 'code token' },
145
+ { type: 'bold', text: 'bold phrase' },
146
+ ],
147
+ },
148
+
149
+ // ── Links ─────────────────────────────────────────────────────────────────
150
+ {
151
+ name: 'links',
152
+ intent: 'Inline links parse into link entities with the right label + destination',
153
+ input:
154
+ 'See the [switchroom repo](https://github.com/switchroom/switchroom) and the [docs](https://example.com/docs) for details.',
155
+ expect: [
156
+ { type: 'link', text: 'switchroom repo', url: 'https://github.com/switchroom/switchroom' },
157
+ { type: 'link', text: 'docs', url: 'https://example.com/docs' },
158
+ ],
159
+ },
160
+
161
+ // ── Inline code containing reserved chars ─────────────────────────────────
162
+ {
163
+ name: 'inline-code-reserved-chars',
164
+ intent: 'Inline code holding *, _, [, ], | renders verbatim without breaking entities',
165
+ input: 'The pattern `a_b*c[d]|e` is matched literally, and `f*g` too.',
166
+ expect: [
167
+ { type: 'code', text: 'a_b*c[d]|e' },
168
+ { type: 'code', text: 'f*g' },
169
+ ],
170
+ },
171
+
172
+ // ── F1 — dash-scrub on the card surface ───────────────────────────────────
173
+ // The card/worker-narration surface runs `normalizeDashes` (voice scrub)
174
+ // before the rich path; an em-dash must not survive to the wire. Assert the
175
+ // scrub removes the glyph AND the scrubbed text is parse-valid.
176
+ {
177
+ name: 'F1-card-surface-dash-scrub',
178
+ intent: 'F1: em/en-dash is scrubbed off the card surface and the result stays parse-valid',
179
+ input: 'Cabinet clean — 33% loss noted, second pass pending – rechecking now.',
180
+ expect: [],
181
+ cardSurfaceScrub: true,
182
+ },
183
+
184
+ // ── F2 — a stray pipe must NOT eat paragraph spacing ──────────────────────
185
+ // A prose line containing a loose ` | ` used to be misread as a table row,
186
+ // suppressing the paragraph break that follows. The two paragraphs must stay
187
+ // separated and the whole thing must be parse-valid (no half-table-row).
188
+ {
189
+ name: 'F2-stray-pipe-keeps-paragraph-spacing',
190
+ intent: 'F2: a loose interior pipe in prose does not collapse the following paragraph break',
191
+ input:
192
+ 'You can choose plan A | plan B depending on scale.\n\nEither way the migration completes in one pass.',
193
+ expect: [],
194
+ },
195
+
196
+ // ── F4 — blank line after a closed code fence ─────────────────────────────
197
+ // Prose glued directly onto a fence close (single `\n`) can be swallowed.
198
+ // The block-boundary pass inserts a blank line; the fence must stay balanced
199
+ // and the trailing prose must survive as parse-valid text.
200
+ {
201
+ name: 'F4-blank-line-after-closed-fence',
202
+ intent: 'F4: a closed fence followed immediately by prose stays balanced and the prose survives',
203
+ input: ['Here is the fix:', '```', 'const x = 1', '```', 'And it ships today.'].join('\n'),
204
+ expect: [{ type: 'pre', text: 'const x = 1' }],
205
+ },
206
+
207
+ // ── F7 — over-cap chunk falls back to hard-slice, not dropped ─────────────
208
+ // An indivisible fenced block larger than the cap must be hard-sliced into
209
+ // <= cap pieces rather than emitted whole (which Telegram rejects) or dropped.
210
+ // The test drives this with a small cap so the giant block cannot fit; it
211
+ // asserts no content is lost and every emitted chunk fits the cap.
212
+ {
213
+ name: 'F7-over-cap-hard-slice',
214
+ intent: 'F7: an oversized indivisible fenced block is hard-sliced to <= cap, losing no content',
215
+ input: '```\n' + 'x'.repeat(600) + '\n```',
216
+ expect: [],
217
+ },
218
+ ]
@@ -44,7 +44,7 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
44
44
  return okResult("⏺ Set model to sonnet");
45
45
  },
46
46
  getAgentName: () => "klanker",
47
- getConfiguredModel: () => "claude-sonnet-4-6",
47
+ getConfiguredModel: () => "claude-sonnet-5",
48
48
  escapeHtml: (s) =>
49
49
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
50
50
  preBlock: (s) => `<pre>${s}</pre>`,
@@ -75,9 +75,9 @@ describe("parseModelCommand", () => {
75
75
  model: "claude-opus-4-8",
76
76
  });
77
77
  // 1m-context variant ids carry brackets
78
- expect(parseModelCommand("/model claude-sonnet-4-6[1m]")).toEqual({
78
+ expect(parseModelCommand("/model claude-sonnet-5[1m]")).toEqual({
79
79
  kind: "set",
80
- model: "claude-sonnet-4-6[1m]",
80
+ model: "claude-sonnet-5[1m]",
81
81
  });
82
82
  });
83
83
 
@@ -107,7 +107,7 @@ describe("parseModelCommand", () => {
107
107
 
108
108
  describe("isValidModelArg", () => {
109
109
  it("accepts aliases and full ids", () => {
110
- for (const good of [...MODEL_ALIASES, "claude-opus-4-8", "claude-haiku-4-5-20251001", "claude-sonnet-4-6[1m]"]) {
110
+ for (const good of [...MODEL_ALIASES, "claude-opus-4-8", "claude-haiku-4-5-20251001", "claude-sonnet-5[1m]"]) {
111
111
  expect(isValidModelArg(good), good).toBe(true);
112
112
  }
113
113
  });
@@ -163,7 +163,7 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
163
163
  const { deps, calls } = makeDeps();
164
164
  const reply = await handleModelCommand({ kind: "show" }, deps);
165
165
  expect(calls.length).toBe(0);
166
- expect(reply.text).toContain("claude-sonnet-4-6");
166
+ expect(reply.text).toContain("claude-sonnet-5");
167
167
  expect(reply.text).toContain("/model opus");
168
168
  expect(reply.text).toContain("switchroom.yaml");
169
169
  });
@@ -246,7 +246,7 @@ describe("isSrModel / isClaudeModel helpers", () => {
246
246
  it("isSrModel is true only for sr-* names", () => {
247
247
  expect(isSrModel("sr-gemini-2.5-pro")).toBe(true);
248
248
  expect(isSrModel("sr-deepseek-r1")).toBe(true);
249
- expect(isSrModel("claude-sonnet-4-6")).toBe(false);
249
+ expect(isSrModel("claude-sonnet-5")).toBe(false);
250
250
  expect(isSrModel("sonnet")).toBe(false);
251
251
  expect(isSrModel("")).toBe(false);
252
252
  });
@@ -256,7 +256,7 @@ describe("isSrModel / isClaudeModel helpers", () => {
256
256
  expect(isClaudeModel(alias), alias).toBe(true);
257
257
  }
258
258
  expect(isClaudeModel("claude-opus-4-8")).toBe(true);
259
- expect(isClaudeModel("claude-sonnet-4-6[1m]")).toBe(true);
259
+ expect(isClaudeModel("claude-sonnet-5[1m]")).toBe(true);
260
260
  expect(isClaudeModel("sr-gemini-2.5-pro")).toBe(false);
261
261
  expect(isClaudeModel("gpt-4")).toBe(false);
262
262
  });
@@ -407,7 +407,13 @@ import {
407
407
  MODEL_CALLBACK_REFRESH,
408
408
  MODEL_CALLBACK_HEADER,
409
409
  MODEL_CALLBACK_SR,
410
+ MODEL_CALLBACK_ALIAS,
411
+ MODEL_CALLBACK_PAGE_EXTERNAL,
412
+ MODEL_CALLBACK_PAGE_MAIN,
410
413
  SR_MODEL_LABELS,
414
+ SR_MODEL_ALIASES,
415
+ EXTRA_CLAUDE_ALIASES,
416
+ externalModelNames,
411
417
  isSrToClaudeTransition,
412
418
  type ModelMenuDeps,
413
419
  } from "../gateway/model-command.js";
@@ -415,7 +421,7 @@ import { labelTag } from "../../src/agents/model-picker.js";
415
421
 
416
422
  const OPTIONS = [
417
423
  { index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
418
- { index: 2, label: "Sonnet", detail: "Sonnet 4.6 · Efficient", current: true },
424
+ { index: 2, label: "Sonnet", detail: "Sonnet 5 · Efficient", current: true },
419
425
  { index: 3, label: "Haiku", detail: "Haiku 4.5 · Fastest", current: false },
420
426
  ];
421
427
 
@@ -448,11 +454,16 @@ describe("buildModelMenu", () => {
448
454
  expect(menu.text).toContain("**Sonnet**");
449
455
  expect(menu.text).toContain("29% / 5h · 33% / 7d");
450
456
  expect(menu.keyboard).toBeDefined();
451
- // 3 option rows + refresh row
452
- expect(menu.keyboard!.length).toBe(4);
457
+ // 3 scraped option rows + static Fable row + refresh row
458
+ // (no external row here — discoverSrModels returns [] and the default
459
+ // makeMenuDeps has no SR seed override, but externalModelNames seeds from
460
+ // SR_MODEL_ALIASES, so the External row IS present — see dedicated tests).
453
461
  expect(menu.keyboard![1][0].text).toBe("✅ Sonnet");
454
462
  expect(menu.keyboard![0][0].text).toBe("Default (recommended)");
455
- expect(menu.keyboard![3][0].callback_data).toBe(MODEL_CALLBACK_REFRESH);
463
+ // Refresh is always the last row.
464
+ expect(menu.keyboard![menu.keyboard!.length - 1][0].callback_data).toBe(
465
+ MODEL_CALLBACK_REFRESH,
466
+ );
456
467
  });
457
468
 
458
469
  it("every callback_data fits Telegram's 64-byte cap", async () => {
@@ -585,7 +596,7 @@ describe("sessionModelFromConfirmation", () => {
585
596
 
586
597
  const OPTIONS_WITH_SR = [
587
598
  { index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
588
- { index: 2, label: "Sonnet", detail: "Sonnet 4.6", current: true },
599
+ { index: 2, label: "Sonnet", detail: "Sonnet 5", current: true },
589
600
  { index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
590
601
  { index: 4, label: "sr-deepseek-r1", detail: "", current: false },
591
602
  // internal path — should be filtered out
@@ -635,42 +646,48 @@ describe("buildModelMenu — with sr-* models", () => {
635
646
  });
636
647
  }
637
648
 
638
- it("shows 🌐 buttons for sr-* models, normal buttons for claude models", async () => {
639
- const { deps } = makeMenuDepsWithSr();
640
- const menu = await buildModelMenu(deps);
641
- expect(menu.keyboard).toBeDefined();
642
- const allButtons = menu.keyboard!.flat();
643
- // 🌐 buttons for sr-*
644
- expect(allButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
645
- expect(allButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
646
- // Regular buttons for Claude models
647
- expect(allButtons.find((b) => b.text === "Default (recommended)")).toBeDefined();
648
- // openrouter/* not shown at all
649
- expect(allButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
650
- });
649
+ // Nested-page design (this PR): sr-* models no longer render inline on the
650
+ // main page they live behind the "🌐 External models ▸" button on a second
651
+ // keyboard page. Live discoverSrModels() results are UNION-ed with the static
652
+ // SR_MODEL_ALIASES seed, so the external page always has at least the six
653
+ // curated aliases even when discovery returns [].
651
654
 
652
- it("sr-* buttons use mdl:sr: callback prefix", async () => {
655
+ it("live-discovered sr-* models appear on the EXTERNAL page (not inline on main)", async () => {
653
656
  const { deps } = makeMenuDepsWithSr();
654
- const menu = await buildModelMenu(deps);
657
+ const main = await buildModelMenu(deps, "main");
658
+ const mainButtons = main.keyboard!.flat();
659
+ // Not inline on the main page…
660
+ expect(mainButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeUndefined();
661
+ // …but the External-open button is present.
662
+ expect(mainButtons.find((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL)).toBeDefined();
663
+
664
+ const ext = await buildModelMenu(deps, "external");
665
+ const extButtons = ext.keyboard!.flat();
666
+ expect(extButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
667
+ expect(extButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
668
+ // openrouter/* / non-sr-* never shown at all.
669
+ expect(extButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
670
+ });
671
+
672
+ it("external-page sr-* buttons use the mdl:sr: callback prefix", async () => {
673
+ const { deps } = makeMenuDepsWithSr();
674
+ const menu = await buildModelMenu(deps, "external");
655
675
  const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
656
676
  expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
657
677
  });
658
678
 
659
- it("shows section header rows when both claude and sr-* models present", async () => {
679
+ it("external page has exactly one header row (billed-separately)", async () => {
660
680
  const { deps } = makeMenuDepsWithSr();
661
- const menu = await buildModelMenu(deps);
662
- const allButtons = menu.keyboard!.flat();
663
- const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
664
- expect(headers.length).toBe(2);
665
- expect(headers[0].text).toContain("Claude");
666
- expect(headers[1].text).toContain("OpenRouter");
681
+ const menu = await buildModelMenu(deps, "external");
682
+ const headers = menu.keyboard!.flat().filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
683
+ expect(headers.length).toBe(1);
684
+ expect(headers[0].text).toContain("External");
667
685
  });
668
686
 
669
- it("no section headers when only claude models (no sr-*)", async () => {
687
+ it("main page carries NO header rows (headers live on the external page)", async () => {
670
688
  const { deps } = makeMenuDeps();
671
- const menu = await buildModelMenu(deps);
672
- const allButtons = (menu.keyboard ?? []).flat();
673
- const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
689
+ const menu = await buildModelMenu(deps, "main");
690
+ const headers = (menu.keyboard ?? []).flat().filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
674
691
  expect(headers.length).toBe(0);
675
692
  });
676
693
 
@@ -682,17 +699,11 @@ describe("buildModelMenu — with sr-* models", () => {
682
699
  expect(injectCalls).toHaveLength(0);
683
700
  });
684
701
 
685
- it("shows subscription/OpenRouter legend when sr-* models are present", async () => {
702
+ it("main page points at the External page for OpenRouter-billed models", async () => {
686
703
  const { deps } = makeMenuDepsWithSr();
687
- const menu = await buildModelMenu(deps);
704
+ const menu = await buildModelMenu(deps, "main");
688
705
  expect(menu.text).toContain("Max/Pro subscription");
689
- expect(menu.text).toContain("OpenRouter");
690
- });
691
-
692
- it("no legend when no sr-* models in picker", async () => {
693
- const { deps } = makeMenuDeps();
694
- const menu = await buildModelMenu(deps);
695
- expect(menu.text).not.toContain("OpenRouter");
706
+ expect(menu.text).toContain("External models");
696
707
  });
697
708
  });
698
709
 
@@ -761,3 +772,158 @@ describe("isSrToClaudeTransition", () => {
761
772
  expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
762
773
  });
763
774
  });
775
+
776
+ // ---------------------------------------------------------------------------
777
+ // Paginated picker — Fable in the Claude group + nested External page.
778
+ // ---------------------------------------------------------------------------
779
+
780
+ describe("externalModelNames", () => {
781
+ it("seeds from SR_MODEL_ALIASES values even when discovery is empty", () => {
782
+ const names = externalModelNames([]);
783
+ for (const target of Object.values(SR_MODEL_ALIASES)) {
784
+ expect(names).toContain(target);
785
+ }
786
+ // All six curated aliases, deduped.
787
+ expect(names.length).toBe(new Set(Object.values(SR_MODEL_ALIASES)).size);
788
+ expect(names).toEqual([...names].sort());
789
+ });
790
+
791
+ it("unions live discovery, dedupes, and drops non-sr-* names", () => {
792
+ const names = externalModelNames(["sr-brand-new", "sr-glm-5", "gpt-4o", "voyage-law-2"]);
793
+ expect(names).toContain("sr-brand-new");
794
+ expect(names).toContain("sr-glm-5");
795
+ // sr-glm-5 already came from aliases — deduped, not doubled.
796
+ expect(names.filter((n) => n === "sr-glm-5").length).toBe(1);
797
+ // Non-sr-* names never surface (subscription-honest).
798
+ expect(names).not.toContain("gpt-4o");
799
+ expect(names).not.toContain("voyage-law-2");
800
+ });
801
+ });
802
+
803
+ describe("paginated model menu — main page", () => {
804
+ it("main page includes a Fable button and an External-models-open button", async () => {
805
+ const { deps } = makeMenuDeps();
806
+ const menu = await buildModelMenu(deps);
807
+ const flat = menu.keyboard!.flat();
808
+ const fable = flat.find((b) => b.text === "Fable");
809
+ expect(fable).toBeDefined();
810
+ expect(fable!.callback_data).toBe(`${MODEL_CALLBACK_ALIAS}fable`);
811
+ const ext = flat.find((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL);
812
+ expect(ext).toBeDefined();
813
+ expect(ext!.text).toContain("External");
814
+ // Refresh is last.
815
+ expect(menu.keyboard![menu.keyboard!.length - 1][0].callback_data).toBe(
816
+ MODEL_CALLBACK_REFRESH,
817
+ );
818
+ });
819
+
820
+ it("no External-open button when there are no external models", async () => {
821
+ // Force externalModelNames to be empty by stubbing SR aliases away is not
822
+ // possible (static), but a build with an empty alias set is covered by the
823
+ // externalModelNames unit test. Here we assert the button is gated on the
824
+ // list being non-empty via the real (non-empty) path: it IS present.
825
+ const { deps } = makeMenuDeps();
826
+ const menu = await buildModelMenu(deps);
827
+ const flat = menu.keyboard!.flat();
828
+ expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL)).toBe(true);
829
+ });
830
+
831
+ it("dedupes the static Fable row if the scraped options already include Fable", async () => {
832
+ const { deps } = makeMenuDeps({
833
+ discover: async () => ({
834
+ ok: true as const,
835
+ options: [
836
+ { index: 1, label: "Sonnet", detail: "", current: true },
837
+ { index: 2, label: "Fable", detail: "Fable 5", current: false },
838
+ ],
839
+ currentLabel: "Sonnet",
840
+ }),
841
+ });
842
+ const menu = await buildModelMenu(deps);
843
+ const flat = menu.keyboard!.flat();
844
+ // Exactly one Fable button, and it's the scraped (select) one, not the alias.
845
+ const fables = flat.filter((b) => b.text === "Fable" || b.text === "✅ Fable");
846
+ expect(fables.length).toBe(1);
847
+ expect(fables[0].callback_data.startsWith(MODEL_CALLBACK_ALIAS)).toBe(false);
848
+ });
849
+
850
+ it("every callback_data still fits Telegram's 64-byte cap", async () => {
851
+ const { deps } = makeMenuDeps();
852
+ for (const page of ["main", "external"] as const) {
853
+ const menu = await buildModelMenu(deps, page);
854
+ for (const btn of menu.keyboard!.flat()) {
855
+ expect(Buffer.byteLength(btn.callback_data, "utf-8")).toBeLessThanOrEqual(64);
856
+ }
857
+ }
858
+ });
859
+ });
860
+
861
+ describe("paginated model menu — external page", () => {
862
+ it("lists all six SR_MODEL_ALIASES models plus a Back button", async () => {
863
+ const { deps } = makeMenuDeps();
864
+ const menu = await buildModelMenu(deps, "external");
865
+ const flat = menu.keyboard!.flat();
866
+ for (const target of Object.values(SR_MODEL_ALIASES)) {
867
+ const btn = flat.find((b) => b.callback_data === `${MODEL_CALLBACK_SR}${target}`);
868
+ expect(btn, `missing external button for ${target}`).toBeDefined();
869
+ expect(btn!.text.startsWith("🌐")).toBe(true);
870
+ }
871
+ expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_MAIN)).toBe(true);
872
+ expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_REFRESH)).toBe(true);
873
+ });
874
+
875
+ it("external page body text makes the billed-separately split explicit", async () => {
876
+ const { deps } = makeMenuDeps();
877
+ const menu = await buildModelMenu(deps, "external");
878
+ expect(menu.text).toContain("billed separately");
879
+ expect(menu.text).toContain("OpenRouter");
880
+ expect(menu.text).toContain("subscription");
881
+ });
882
+ });
883
+
884
+ describe("page callbacks swap the keyboard without switching model", () => {
885
+ it("PAGE_EXTERNAL renders the external page and does NOT select/inject", async () => {
886
+ const { deps, calls, injectCalls } = makeMenuDeps();
887
+ const out = await handleModelMenuCallback(MODEL_CALLBACK_PAGE_EXTERNAL, deps);
888
+ expect(calls.select).toEqual([]);
889
+ expect(injectCalls).toEqual([]);
890
+ expect(out.selectedModel).toBeUndefined();
891
+ const flat = out.reply.keyboard!.flat();
892
+ expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_MAIN)).toBe(true);
893
+ expect(out.reply.text).toContain("billed separately");
894
+ });
895
+
896
+ it("PAGE_MAIN renders the main page and does NOT select/inject", async () => {
897
+ const { deps, calls, injectCalls } = makeMenuDeps();
898
+ const out = await handleModelMenuCallback(MODEL_CALLBACK_PAGE_MAIN, deps);
899
+ expect(calls.select).toEqual([]);
900
+ expect(injectCalls).toEqual([]);
901
+ expect(out.selectedModel).toBeUndefined();
902
+ const flat = out.reply.keyboard!.flat();
903
+ expect(flat.some((b) => b.callback_data === MODEL_CALLBACK_PAGE_EXTERNAL)).toBe(true);
904
+ expect(flat.some((b) => b.text === "Fable")).toBe(true);
905
+ });
906
+ });
907
+
908
+ describe("Fable alias callback injects /model fable", () => {
909
+ it("injects exactly '/model fable' and reports the session model", async () => {
910
+ const { deps, calls, injectCalls } = makeMenuDeps();
911
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
912
+ // Alias path uses inject, never the cursor-nav select path.
913
+ expect(calls.select).toEqual([]);
914
+ expect(injectCalls).toHaveLength(1);
915
+ expect(injectCalls[0].command).toBe("/model fable");
916
+ expect(out.reply.text).toContain("✅");
917
+ });
918
+
919
+ it("EXTRA_CLAUDE_ALIASES contains fable", () => {
920
+ expect(EXTRA_CLAUDE_ALIASES.some((a) => a.alias === "fable" && a.label === "Fable")).toBe(true);
921
+ });
922
+
923
+ it("rejects an invalid alias without injecting", async () => {
924
+ const { deps, injectCalls } = makeMenuDeps();
925
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}bad name`, deps);
926
+ expect(injectCalls).toEqual([]);
927
+ expect(out.answer).toContain("Invalid");
928
+ });
929
+ });