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,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-
|
|
47
|
+
getConfiguredModel: () => "claude-sonnet-5",
|
|
48
48
|
escapeHtml: (s) =>
|
|
49
49
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
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-
|
|
78
|
+
expect(parseModelCommand("/model claude-sonnet-5[1m]")).toEqual({
|
|
79
79
|
kind: "set",
|
|
80
|
-
model: "claude-sonnet-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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-*
|
|
655
|
+
it("live-discovered sr-* models appear on the EXTERNAL page (not inline on main)", async () => {
|
|
653
656
|
const { deps } = makeMenuDepsWithSr();
|
|
654
|
-
const
|
|
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("
|
|
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
|
|
663
|
-
|
|
664
|
-
expect(headers.
|
|
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("
|
|
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
|
|
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("
|
|
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("
|
|
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
|
+
});
|