switchroom 0.16.29 → 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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  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 +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Validation contract for the #2726 rollout status IPC verbs — hostd asks the
3
+ * caller agent's gateway to POST (terminal ping / first narration message) or
4
+ * EDIT (later phases) an ORDINARY operator-DM message. A rogue process on the
5
+ * same UDS must not inject a malformed payload: requestId + agentName required
6
+ * and shaped, text required non-empty and bounded, messageId (edit) an integer.
7
+ *
8
+ * No Bun-native API → vitest per the mixed-runner gotcha.
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import { validateClientMessage } from "../gateway/ipc-server.js";
12
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
13
+
14
+ const post = {
15
+ type: "rollout_status_post",
16
+ requestId: "ro-1",
17
+ agentName: "overlord",
18
+ text: "⏳ Rollout → v1.2.3 — applying",
19
+ };
20
+ const edit = {
21
+ type: "rollout_status_edit",
22
+ requestId: "ro-1",
23
+ agentName: "overlord",
24
+ messageId: 42,
25
+ text: "✅ Rollout → v1.2.3 — done",
26
+ };
27
+
28
+ describe("validateClientMessage — rollout_status_post", () => {
29
+ it("accepts a well-formed message", () => {
30
+ expect(validateClientMessage(post)).toBe(true);
31
+ });
32
+
33
+ it("rejects a missing / empty / over-long requestId", () => {
34
+ expect(validateClientMessage({ ...post, requestId: undefined })).toBe(false);
35
+ expect(validateClientMessage({ ...post, requestId: "" })).toBe(false);
36
+ expect(validateClientMessage({ ...post, requestId: "x".repeat(65) })).toBe(false);
37
+ });
38
+
39
+ it("rejects a missing / malformed agentName", () => {
40
+ expect(validateClientMessage({ ...post, agentName: undefined })).toBe(false);
41
+ expect(validateClientMessage({ ...post, agentName: "Bad Name" })).toBe(false);
42
+ });
43
+
44
+ it("rejects empty or over-long text", () => {
45
+ expect(validateClientMessage({ ...post, text: "" })).toBe(false);
46
+ expect(
47
+ validateClientMessage({ ...post, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) }),
48
+ ).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("validateClientMessage — rollout_status_edit", () => {
53
+ it("accepts a well-formed message", () => {
54
+ expect(validateClientMessage(edit)).toBe(true);
55
+ });
56
+
57
+ it("rejects a non-integer messageId", () => {
58
+ expect(validateClientMessage({ ...edit, messageId: 1.5 })).toBe(false);
59
+ expect(validateClientMessage({ ...edit, messageId: "42" })).toBe(false);
60
+ expect(validateClientMessage({ ...edit, messageId: undefined })).toBe(false);
61
+ });
62
+
63
+ it("rejects empty text", () => {
64
+ expect(validateClientMessage({ ...edit, text: "" })).toBe(false);
65
+ });
66
+ });
@@ -175,6 +175,30 @@ describe("renderIssuesCard", () => {
175
175
  expect(out).toContain("`<x>::<y>`");
176
176
  expect(out).toContain("<dangerous & stuff>");
177
177
  });
178
+
179
+ it("stacks issue rows with GFM hard breaks so they don't collapse onto one line (#2669)", () => {
180
+ // Card-body regression: the rich-message renderer collapses lone-`\n`
181
+ // separated rows into a run-on blob. The issues card now routes its rows
182
+ // through stackCardLines, so each issue row is separated by a hard break.
183
+ const events = [
184
+ makeEvent({ fingerprint: "a::1", code: "1", severity: "critical", summary: "alpha down", detail: "Fix: restart alpha" }),
185
+ makeEvent({ fingerprint: "a::2", code: "2", severity: "error", summary: "beta slow" }),
186
+ ];
187
+ const out = renderIssuesCard({ agentName: "klanker", events, now: 1_700_000_000_000 })!;
188
+ // Header → rows is a genuine paragraph gap (blank line preserved).
189
+ expect(out).toContain("\n\n");
190
+ // Each adjacent issue row (and its remediation line) is hard-broken, not
191
+ // soft — so no two non-blank body lines are glued by a LONE `\n`.
192
+ const softBreaks = out.split("\n\n").flatMap((block) => {
193
+ // Within a block, every internal `\n` must be a hard break (` \n`).
194
+ return [...block.matchAll(/(?<! {2})\n/g)];
195
+ });
196
+ expect(softBreaks.length).toBe(0);
197
+ // Both rows + the remediation line all rendered, stacked.
198
+ expect(out).toContain("alpha down");
199
+ expect(out).toContain("→ _restart alpha_");
200
+ expect(out).toContain("beta slow");
201
+ });
178
202
  });
179
203
 
180
204
  // ─── createIssuesCardHandle (lifecycle) ──────────────────────────────────────
@@ -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
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { decideObligationTurnEnd } from '../gateway/obligation-turn-end.js'
4
+ import { ObligationLedger } from '../gateway/obligation-ledger.js'
5
+
6
+ /**
7
+ * #2624 — obligation close on replyCalled shipped test-free. Pin BOTH branches:
8
+ *
9
+ * - Normal reply → close at turn_end (finalAnswerDelivered OR replyCalled).
10
+ * - Ack-then-ghost / no-reply → NOT closed at turn_end; stays open so the
11
+ * idle sweep ends it via silence_fallback.
12
+ */
13
+ describe('decideObligationTurnEnd (#2624)', () => {
14
+ it('final answer delivered → close', () => {
15
+ expect(decideObligationTurnEnd(true, false)).toBe('close')
16
+ })
17
+
18
+ it('replyCalled (short sr-* reply demoted to non-final) → close', () => {
19
+ // The LiteLLM sr-* cascade: reply("OK", {disable_notification:true}) is
20
+ // below the 200-char backstop + notification-suppressed, so
21
+ // finalAnswerDelivered is false — but the model explicitly replied.
22
+ expect(decideObligationTurnEnd(false, true)).toBe('close')
23
+ })
24
+
25
+ it('final answer AND replyCalled → close', () => {
26
+ expect(decideObligationTurnEnd(true, true)).toBe('close')
27
+ })
28
+
29
+ it('no reply at all (ack-then-ghost / no-reply) → note-ended, NOT close', () => {
30
+ expect(decideObligationTurnEnd(false, false)).toBe('note-ended')
31
+ })
32
+ })
33
+
34
+ describe('#2624 obligation lifecycle — end-to-end over the ledger', () => {
35
+ const openObligation = (ledger: ObligationLedger, turnId: string) =>
36
+ ledger.openIfAbsent({
37
+ originTurnId: turnId,
38
+ chatId: '123',
39
+ messageId: 1,
40
+ text: 'do the thing',
41
+ openedAt: 1_000,
42
+ })
43
+
44
+ it('normal reply closes the obligation at turn_end', () => {
45
+ const ledger = new ObligationLedger()
46
+ openObligation(ledger, 'turn-A')
47
+ expect(ledger.isOpen('turn-A')).toBe(true)
48
+
49
+ // Simulate the gateway turn_end branch for a turn that called reply.
50
+ if (decideObligationTurnEnd(true, false) === 'close') {
51
+ ledger.close('turn-A')
52
+ } else {
53
+ ledger.noteTurnEnded('turn-A', 2_000)
54
+ }
55
+ expect(ledger.isOpen('turn-A')).toBe(false)
56
+ })
57
+
58
+ it('ack-then-ghost turn_end does NOT close — obligation survives for silence_fallback', () => {
59
+ const ledger = new ObligationLedger()
60
+ openObligation(ledger, 'turn-B')
61
+
62
+ // turn_end with no reply: the ack-then-ghost path.
63
+ if (decideObligationTurnEnd(false, false) === 'close') {
64
+ ledger.close('turn-B')
65
+ } else {
66
+ ledger.noteTurnEnded('turn-B', 2_000)
67
+ }
68
+ // Still open at turn_end — the whole point: it is ended later via
69
+ // silence_fallback, NOT turn_end.
70
+ expect(ledger.isOpen('turn-B')).toBe(true)
71
+
72
+ // The silence-fallback sweep is what finally closes it.
73
+ expect(ledger.close('turn-B')).toBe(true)
74
+ expect(ledger.isOpen('turn-B')).toBe(false)
75
+ })
76
+ })