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.
- package/dist/agent-scheduler/index.js +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -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/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- 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-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- 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-
|
|
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
|
+
});
|
|
@@ -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
|
+
})
|