switchroom 0.19.34 → 0.19.36

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 (35) hide show
  1. package/dist/auth-broker/index.js +7 -1
  2. package/dist/cli/skill-validate-pretool.mjs +15 -2
  3. package/dist/cli/switchroom.js +1005 -482
  4. package/dist/host-control/main.js +156 -4
  5. package/dist/vault/approvals/kernel-server.js +7 -1
  6. package/dist/vault/broker/server.js +7 -1
  7. package/package.json +4 -2
  8. package/profiles/_base/start.sh.hbs +37 -12
  9. package/telegram-plugin/dist/gateway/gateway.js +476 -116
  10. package/telegram-plugin/format.ts +70 -15
  11. package/telegram-plugin/gateway/gateway.ts +27 -31
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +279 -23
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  17. package/telegram-plugin/secret-detect/index.ts +24 -1
  18. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  19. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  20. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  21. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  22. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  23. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  24. package/telegram-plugin/tests/model-command.test.ts +415 -4
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  26. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  27. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  29. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  30. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  31. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  33. package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
@@ -50,6 +50,7 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
50
50
  const deps: ModelCommandDeps = {
51
51
  getAgentName: () => "klanker",
52
52
  getConfiguredModel: () => "claude-sonnet-5",
53
+ getConfiguredEffort: () => "low",
53
54
  escapeHtml: (s) =>
54
55
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
55
56
  preBlock: (s) => `<pre>${s}</pre>`,
@@ -241,7 +242,7 @@ import {
241
242
  MODEL_CALLBACK_PAGE_EXTERNAL,
242
243
  SR_MODEL_LABELS,
243
244
  SR_MODEL_ALIASES,
244
- EXTRA_CLAUDE_ALIASES,
245
+ EXTRA_CLAUDE_MENU_TOKENS,
245
246
  expandSrAlias,
246
247
  externalModelNames,
247
248
  type ModelMenuDeps,
@@ -496,14 +497,14 @@ describe("classifyDiscoveredOptions", () => {
496
497
  });
497
498
  });
498
499
 
499
- describe("externalModelNames / SR_MODEL_LABELS / EXTRA_CLAUDE_ALIASES", () => {
500
+ describe("externalModelNames / SR_MODEL_LABELS / EXTRA_CLAUDE_MENU_TOKENS", () => {
500
501
  it("seeds from the curated alias targets and merges discovered sr-*", () => {
501
502
  const names = externalModelNames(["sr-gpt-oss-20b"]);
502
503
  expect(names).toContain("sr-gemini-2.5-flash");
503
504
  expect(names).toContain("sr-gpt-oss-20b");
504
505
  });
505
- it("Fable is offered as an extra Claude alias", () => {
506
- expect(EXTRA_CLAUDE_ALIASES.some((a) => a.alias === "fable")).toBe(true);
506
+ it("Fable is offered as an extra Claude menu token", () => {
507
+ expect(EXTRA_CLAUDE_MENU_TOKENS.some((t) => t.token === "fable")).toBe(true);
507
508
  });
508
509
  it("labels are display-only strings", () => {
509
510
  expect(SR_MODEL_LABELS["sr-glm-5"]).toBeTypeOf("string");
@@ -911,3 +912,413 @@ describe("unvalidatedIdCaveat — immediate fail-fast warn on free-text claude-*
911
912
  expect(alias.text).not.toContain("can't be validated before launch");
912
913
  });
913
914
  });
915
+
916
+ // ---------------------------------------------------------------------------
917
+ // Short `/model` spellings for pinned Claude ids (CLAUDE_MODEL_ALIASES).
918
+ //
919
+ // Every assertion here FAILS on the pre-change code: `opus48` / `opus-4-8` were
920
+ // passed through verbatim to the carrier (claude would 4xx or silently serve the
921
+ // fallback), were refused by the offline-trust gate, and were not reachable from
922
+ // the picker. The negative block pins the design constraint that made this a
923
+ // separate map from SR_MODEL_ALIASES: these are Anthropic OAuth-passthrough
924
+ // models and must NEVER be classified as external/OpenRouter routes.
925
+ // ---------------------------------------------------------------------------
926
+ import { readFileSync } from "node:fs";
927
+ import {
928
+ CLAUDE_MODEL_ALIASES,
929
+ canonicalModelToken,
930
+ expandClaudeAlias,
931
+ expandModelAlias,
932
+ thinkingEffortCaveat,
933
+ buildModelMenu as buildModelMenuForAliases,
934
+ } from "../gateway/model-command.js";
935
+
936
+ const OPUS_48 = "claude-opus-4-8";
937
+
938
+ describe("CLAUDE_MODEL_ALIASES — short spellings for a pinned Claude id", () => {
939
+ it("both requested spellings expand to the canonical id", () => {
940
+ expect(expandModelAlias("opus48")).toBe(OPUS_48);
941
+ expect(expandModelAlias("opus-4-8")).toBe(OPUS_48);
942
+ expect(expandClaudeAlias("opus48")).toBe(OPUS_48);
943
+ expect(expandClaudeAlias("opus-4-8")).toBe(OPUS_48);
944
+ });
945
+
946
+ it("expansion is case-insensitive", () => {
947
+ for (const spelling of ["OPUS48", "Opus48", "OPUS-4-8", "Opus-4-8"]) {
948
+ expect(expandModelAlias(spelling)).toBe(OPUS_48);
949
+ }
950
+ });
951
+
952
+ it("leaves every other token alone (sr aliases still expand, ids pass through)", () => {
953
+ expect(expandModelAlias("opus")).toBe("opus");
954
+ expect(expandModelAlias("default")).toBe("default");
955
+ expect(expandModelAlias("flash")).toBe("sr-gemini-2.5-flash");
956
+ expect(expandModelAlias("sr-glm-5")).toBe("sr-glm-5");
957
+ expect(expandModelAlias(OPUS_48)).toBe(OPUS_48);
958
+ // The Claude map must not swallow an sr-* shortcut, nor vice versa.
959
+ expect(expandClaudeAlias("flash")).toBe("flash");
960
+ expect(expandSrAlias("opus48")).toBe("opus48");
961
+ });
962
+
963
+ it("every target is a full claude-* id and a legal model arg", () => {
964
+ for (const target of Object.values(CLAUDE_MODEL_ALIASES)) {
965
+ expect(target.startsWith("claude-")).toBe(true);
966
+ expect(isValidModelArg(target)).toBe(true);
967
+ }
968
+ });
969
+
970
+ it("typed `/model opus48` relaunches on the canonical id, not the shortcut", async () => {
971
+ const { deps, relaunchCalls } = makeDeps();
972
+ const reply = await handleModelCommand({ kind: "set", model: "opus48" }, deps);
973
+ expect(relaunchCalls).toEqual([
974
+ { model: OPUS_48, reason: `user: /model ${OPUS_48} (session-only relaunch)` },
975
+ ]);
976
+ expect(reply.text).toContain(OPUS_48);
977
+ // The raw shortcut must never reach the carrier / `claude --model`.
978
+ expect(relaunchCalls[0].model).not.toBe("opus48");
979
+ });
980
+
981
+ it("typed `/model opus-4-8` relaunches on the canonical id", async () => {
982
+ const { deps, relaunchCalls } = makeDeps();
983
+ await handleModelCommand({ kind: "set", model: "opus-4-8" }, deps);
984
+ expect(relaunchCalls).toEqual([
985
+ { model: OPUS_48, reason: `user: /model ${OPUS_48} (session-only relaunch)` },
986
+ ]);
987
+ });
988
+
989
+ it("typed `/model OPUS48` (mixed case) relaunches on the canonical id", async () => {
990
+ const { deps, relaunchCalls } = makeDeps();
991
+ await handleModelCommand({ kind: "set", model: "OPUS48" }, deps);
992
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: OPUS_48 })]);
993
+ });
994
+
995
+ it("a mid-turn `/model opus48` QUEUES the expanded id (start.sh never sees the shortcut)", () => {
996
+ const busy = { currentTurnActive: true, turnInFlight: false, menuEnabled: true };
997
+ expect(planModelCommand({ kind: "set", model: "opus48" }, busy)).toEqual({
998
+ kind: "queue",
999
+ target: OPUS_48,
1000
+ });
1001
+ expect(planModelCommand({ kind: "set", model: "opus-4-8" }, busy)).toEqual({
1002
+ kind: "queue",
1003
+ target: OPUS_48,
1004
+ });
1005
+ });
1006
+
1007
+ it("a queued shortcut survives a boot: both the KEY and the TARGET are offline-trusted", () => {
1008
+ expect(isOfflineTrustedModelToken("opus48")).toBe(true);
1009
+ expect(isOfflineTrustedModelToken("opus-4-8")).toBe(true);
1010
+ expect(isOfflineTrustedModelToken("OPUS48")).toBe(true);
1011
+ expect(isOfflineTrustedModelToken(OPUS_48)).toBe(true);
1012
+ // Still refuses an arbitrary hand-typed pinned id.
1013
+ expect(isOfflineTrustedModelToken("claude-opus-4-9")).toBe(false);
1014
+ });
1015
+
1016
+ it("parser → expansion round-trip: both spellings reach the canonical id", () => {
1017
+ // The parser alone is not the contract (it tokenizes anything shape-legal —
1018
+ // `opus_4_8` parses too and expands to nothing, see the near-miss issue).
1019
+ // What must hold is the PIPELINE: parse, then the one expansion every
1020
+ // `/model` path applies, lands on the canonical id.
1021
+ for (const text of ["/model opus48", "/model opus-4-8", "/model OPUS48"]) {
1022
+ const parsed = parseModelCommand(text);
1023
+ expect(parsed?.kind).toBe("set");
1024
+ expect(expandModelAlias((parsed as { model: string }).model)).toBe(OPUS_48);
1025
+ }
1026
+ });
1027
+
1028
+ it("NEGATIVE: the shortcuts are Claude, never external / sr-*", () => {
1029
+ for (const token of ["opus48", "opus-4-8", "OPUS48", OPUS_48]) {
1030
+ expect(isSrModel(token)).toBe(false);
1031
+ expect(isClaudeModel(token)).toBe(true);
1032
+ }
1033
+ // Not in the external map, in either direction.
1034
+ for (const key of Object.keys(CLAUDE_MODEL_ALIASES)) {
1035
+ expect(key in SR_MODEL_ALIASES).toBe(false);
1036
+ expect(Object.values(SR_MODEL_ALIASES)).not.toContain(key);
1037
+ }
1038
+ for (const target of Object.values(CLAUDE_MODEL_ALIASES)) {
1039
+ expect(Object.values(SR_MODEL_ALIASES)).not.toContain(target);
1040
+ expect(SR_MODEL_LABELS[target]).toBeUndefined();
1041
+ }
1042
+ // And never rendered on the 🌐 External keyboard page.
1043
+ const external = externalModelNames(["sr-gpt-oss-20b"]);
1044
+ for (const token of [...Object.keys(CLAUDE_MODEL_ALIASES), ...Object.values(CLAUDE_MODEL_ALIASES)]) {
1045
+ expect(external).not.toContain(token);
1046
+ }
1047
+ });
1048
+
1049
+ it("the ACK names the canonical id and never echoes the raw shortcut back", async () => {
1050
+ // Renamed from a "NEGATIVE: … no external ack copy" assertion that was
1051
+ // vacuous: `sr-` / `billed separately` are equally absent from the
1052
+ // pre-change ack, so it could not fail on the bug it claimed to guard.
1053
+ // (The real external-classification constraint is pinned by the
1054
+ // "NEGATIVE: the shortcuts are Claude, never external / sr-*" case above,
1055
+ // which DOES fail without the expansion.) What is genuinely discriminating
1056
+ // here is that the operator is told the id they actually got.
1057
+ const { deps } = makeDeps();
1058
+ const reply = await handleModelCommand({ kind: "set", model: "opus48" }, deps);
1059
+ expect(reply.text).toContain(OPUS_48);
1060
+ expect(reply.text).not.toMatch(/\bopus48\b/);
1061
+ expect(reply.text).not.toContain("billed separately");
1062
+ });
1063
+
1064
+ it("selectable: the pinned id is a main-page keyboard button", async () => {
1065
+ const { deps } = makeMenuDeps();
1066
+ const menu = await buildModelMenuForAliases(deps);
1067
+ const buttons = (menu.keyboard ?? []).flat();
1068
+ expect(buttons.some((b) => b.callback_data === `${MODEL_CALLBACK_ALIAS}${OPUS_48}`)).toBe(true);
1069
+ expect(buttons.some((b) => b.text === "Opus 4.8")).toBe(true);
1070
+ });
1071
+
1072
+ it("selectable mid-turn too: the static quick list carries the same button", async () => {
1073
+ const { deps } = makeMenuDeps({ isBusy: () => true });
1074
+ const menu = await buildModelMenuForAliases(deps);
1075
+ const buttons = (menu.keyboard ?? []).flat();
1076
+ expect(buttons.some((b) => b.callback_data === `${MODEL_CALLBACK_ALIAS}${OPUS_48}`)).toBe(true);
1077
+ });
1078
+
1079
+ it("end-to-end: the RENDERED 'Opus 4.8' button's own payload relaunches on the canonical id", async () => {
1080
+ // Drive the payload the keyboard actually mints rather than a hand-typed
1081
+ // `mdl:alias:claude-opus-4-8` — the latter is recognized on the pre-change
1082
+ // code too, so it could not fail on the bug it guards. Rendering first ties
1083
+ // the button's existence and its payload together in one assertion.
1084
+ const { deps, relaunchCalls } = makeMenuDeps();
1085
+ const menu = await buildModelMenuForAliases(deps);
1086
+ const button = (menu.keyboard ?? []).flat().find((b) => b.text === "Opus 4.8");
1087
+ expect(button).toBeDefined();
1088
+ await handleModelMenuCallback(button!.callback_data, deps);
1089
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: OPUS_48 })]);
1090
+ });
1091
+
1092
+ it("a shortcut arriving on a callback is expanded, not rejected as unrecognized", async () => {
1093
+ const { deps, relaunchCalls } = makeMenuDeps();
1094
+ await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}opus48`, deps);
1095
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: OPUS_48 })]);
1096
+ });
1097
+
1098
+ it("discoverable: help and the dashboard both name every spelling", async () => {
1099
+ const { deps } = makeDeps();
1100
+ const help = await handleModelCommand({ kind: "help" }, deps);
1101
+ const show = await handleModelCommand({ kind: "show" }, deps);
1102
+ for (const spelling of Object.keys(CLAUDE_MODEL_ALIASES)) {
1103
+ expect(help.text).toContain(spelling);
1104
+ expect(show.text).toContain(`/model ${spelling}`);
1105
+ }
1106
+ expect(help.text).toContain(OPUS_48);
1107
+ expect(show.text).toContain(OPUS_48);
1108
+ });
1109
+
1110
+ it("the exemption itself: a curated TARGET carries no 'can't be validated' caveat", async () => {
1111
+ // The riskiest line in the change is the
1112
+ // `Object.values(CLAUDE_MODEL_ALIASES).includes(lower)` early return in
1113
+ // `unvalidatedIdCaveat`. Driving `/model opus48` does NOT exercise it: on
1114
+ // the pre-change code `opus48` doesn't start with `claude-`, so the caveat
1115
+ // is null for an unrelated reason. Test the full canonical id directly.
1116
+ const deps = { escapeHtml: (s: string) => s, getConfiguredEffort: () => "low" };
1117
+ for (const target of Object.values(CLAUDE_MODEL_ALIASES)) {
1118
+ expect(unvalidatedIdCaveat(deps, target)).toBeNull();
1119
+ }
1120
+ // …and via the ack, on the full-id spelling as well as the shortcut.
1121
+ const { deps: cmdDeps } = makeDeps();
1122
+ for (const spelling of ["opus48", "opus-4-8", OPUS_48]) {
1123
+ const reply = await handleModelCommand({ kind: "set", model: spelling }, cmdDeps);
1124
+ expect(reply.text).not.toContain("can't be validated before launch");
1125
+ }
1126
+ // An UNcurated pinned id still warns — the exemption is not a blanket
1127
+ // silencing of the caveat.
1128
+ const other = await handleModelCommand({ kind: "set", model: "claude-opus-4-9" }, cmdDeps);
1129
+ expect(other.text).toContain("can't be validated before launch");
1130
+ });
1131
+ });
1132
+
1133
+ // ---------------------------------------------------------------------------
1134
+ // MAJOR-1: expansion and the caveat exemption must agree on ONE canonical form.
1135
+ //
1136
+ // Pre-fix, `expandModelAlias` emitted its argument VERBATIM on a map miss (no
1137
+ // lowercase, no trim) while `unvalidatedIdCaveat` lowercased before comparing.
1138
+ // So `/model CLAUDE-OPUS-4-8` (phone autocapitalize) was launched as the
1139
+ // uncanonical token `CLAUDE-OPUS-4-8` — an id claude cannot resolve, hence a
1140
+ // silent `--fallback-model` serve — under a clean green ack, because the
1141
+ // exemption matched the lowercased form. Both halves are asserted here.
1142
+ // ---------------------------------------------------------------------------
1143
+ describe("canonicalization — one form reaches `claude --model` and the caveat gate", () => {
1144
+ it("canonicalModelToken lowercases claude-* ids, trims everything, touches nothing else", () => {
1145
+ expect(canonicalModelToken("CLAUDE-OPUS-4-8")).toBe(OPUS_48);
1146
+ expect(canonicalModelToken("Claude-Opus-4-8")).toBe(OPUS_48);
1147
+ expect(canonicalModelToken(` ${OPUS_48} `)).toBe(OPUS_48);
1148
+ expect(canonicalModelToken("Claude-Opus-4-9")).toBe("claude-opus-4-9");
1149
+ // Non-claude tokens keep their case — sr-* ids are not ours to rewrite.
1150
+ expect(canonicalModelToken("sr-GLM-5")).toBe("sr-GLM-5");
1151
+ expect(canonicalModelToken(" sr-glm-5 ")).toBe("sr-glm-5");
1152
+ expect(canonicalModelToken("opus")).toBe("opus");
1153
+ });
1154
+
1155
+ it("expandModelAlias emits the canonical form on a MISS, not the raw argument", () => {
1156
+ expect(expandModelAlias("CLAUDE-OPUS-4-8")).toBe(OPUS_48);
1157
+ expect(expandModelAlias("Claude-Opus-4-8")).toBe(OPUS_48);
1158
+ expect(expandModelAlias("Claude-Opus-4-9")).toBe("claude-opus-4-9");
1159
+ // …and trims, matching what `unvalidatedIdCaveat` has always done.
1160
+ expect(expandModelAlias(" opus48 ")).toBe(OPUS_48);
1161
+ expect(expandModelAlias(` ${OPUS_48} `)).toBe(OPUS_48);
1162
+ });
1163
+
1164
+ it("a mixed-case pinned id is LAUNCHED canonical, not verbatim", async () => {
1165
+ for (const spelling of ["CLAUDE-OPUS-4-8", "Claude-Opus-4-8"]) {
1166
+ const { deps, relaunchCalls } = makeDeps();
1167
+ const reply = await handleModelCommand({ kind: "set", model: spelling }, deps);
1168
+ expect(relaunchCalls).toEqual([
1169
+ { model: OPUS_48, reason: `user: /model ${OPUS_48} (session-only relaunch)` },
1170
+ ]);
1171
+ // The uncanonical token must not survive anywhere the operator can see
1172
+ // it either — the ack names what actually boots.
1173
+ expect(reply.text).not.toContain(spelling);
1174
+ expect(reply.text).toContain(OPUS_48);
1175
+ }
1176
+ });
1177
+
1178
+ it("an UNCURATED mixed-case pinned id is canonicalized AND still warned about", async () => {
1179
+ const { deps, relaunchCalls } = makeDeps();
1180
+ const reply = await handleModelCommand({ kind: "set", model: "CLAUDE-OPUS-4-9" }, deps);
1181
+ expect(relaunchCalls[0].model).toBe("claude-opus-4-9");
1182
+ expect(reply.text).toContain("can't be validated before launch");
1183
+ });
1184
+
1185
+ it("the caveat exemption agrees with expansion across the whole casing matrix", () => {
1186
+ const deps = { escapeHtml: (s: string) => s, getConfiguredEffort: () => "low" };
1187
+ for (const spelling of [
1188
+ OPUS_48,
1189
+ "CLAUDE-OPUS-4-8",
1190
+ "Claude-Opus-4-8",
1191
+ ` ${OPUS_48} `,
1192
+ " CLAUDE-OPUS-4-8 ",
1193
+ ]) {
1194
+ // Exempt (curated target) …
1195
+ expect(unvalidatedIdCaveat(deps, spelling)).toBeNull();
1196
+ // … and that is only sound because expansion emits the SAME form.
1197
+ expect(expandModelAlias(spelling)).toBe(OPUS_48);
1198
+ }
1199
+ for (const spelling of ["claude-opus-4-9", "CLAUDE-OPUS-4-9", "Claude-Sonnet-9"]) {
1200
+ expect(unvalidatedIdCaveat(deps, spelling)).not.toBeNull();
1201
+ }
1202
+ });
1203
+
1204
+ it("the callback shape gate still runs on the RAW payload (trim must not widen it)", async () => {
1205
+ const { deps, relaunchCalls } = makeMenuDeps();
1206
+ // `expandModelAlias` trims; the shape gate exists to guarantee a single
1207
+ // whitespace-free token reaches the tmux pane, so a padded payload must
1208
+ // still be refused rather than trimmed into legality.
1209
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS} ${OPUS_48} `, deps);
1210
+ expect(out.answer).toBe("Invalid model name");
1211
+ expect(relaunchCalls).toEqual([]);
1212
+ });
1213
+ });
1214
+
1215
+ // ---------------------------------------------------------------------------
1216
+ // MAJOR-2: the #1978 adaptive-thinking risk must surface at SWITCH time.
1217
+ //
1218
+ // `assessThinkingEffortRisk` had exactly one consumer — `switchroom doctor`,
1219
+ // which reads the CONFIGURED `model:` out of switchroom.yaml. A `/model` switch
1220
+ // is session-scoped, so every route onto a pinned Opus 4.x id (typed, shortcut,
1221
+ // or the one-tap "Opus 4.8" button this change adds) reached the exact
1222
+ // (pinned Opus 4.x, effort > low) combination doctor exists to warn about with
1223
+ // no warning anywhere. These fail on BOTH the pre-change code and the
1224
+ // pre-fix PR head.
1225
+ // ---------------------------------------------------------------------------
1226
+ const MERGE_400_MARKER = "issue #1978";
1227
+
1228
+ describe("thinking_effort × pinned Opus 4.x — surfaced when the switch is made", () => {
1229
+ it("typed switch onto a pinned Opus 4.x at medium effort warns in the ack", async () => {
1230
+ const { deps } = makeDeps({ getConfiguredEffort: () => "medium" });
1231
+ const reply = await handleModelCommand({ kind: "set", model: OPUS_48 }, deps);
1232
+ expect(reply.text).toContain(MERGE_400_MARKER);
1233
+ expect(reply.text).toContain("medium");
1234
+ });
1235
+
1236
+ it("the SHORTCUT spelling warns too — the risk follows the expanded target", async () => {
1237
+ const { deps } = makeDeps({ getConfiguredEffort: () => "high" });
1238
+ const reply = await handleModelCommand({ kind: "set", model: "opus48" }, deps);
1239
+ expect(reply.text).toContain(MERGE_400_MARKER);
1240
+ });
1241
+
1242
+ it("the one-tap 'Opus 4.8' BUTTON warns too — the easiest route into the combo", async () => {
1243
+ const { deps } = makeMenuDeps({ getConfiguredEffort: () => "medium" });
1244
+ const menu = await buildModelMenuForAliases(deps);
1245
+ const button = (menu.keyboard ?? []).flat().find((b) => b.text === "Opus 4.8");
1246
+ const out = await handleModelMenuCallback(button!.callback_data, deps);
1247
+ expect(out.reply.text).toContain(MERGE_400_MARKER);
1248
+ });
1249
+
1250
+ it("NEGATIVE: safe combos stay quiet — low effort, Opus 5, the bare alias, sonnet", async () => {
1251
+ const atLow = makeDeps({ getConfiguredEffort: () => "low" });
1252
+ expect(
1253
+ (await handleModelCommand({ kind: "set", model: OPUS_48 }, atLow.deps)).text,
1254
+ ).not.toContain(MERGE_400_MARKER);
1255
+
1256
+ const atMedium = makeDeps({ getConfiguredEffort: () => "medium" });
1257
+ for (const safe of ["opus", "claude-opus-5-20260601", "sonnet", "sr-glm-5"]) {
1258
+ const reply = await handleModelCommand({ kind: "set", model: safe }, atMedium.deps);
1259
+ expect(reply.text).not.toContain(MERGE_400_MARKER);
1260
+ }
1261
+ });
1262
+
1263
+ it("NEGATIVE: an unreadable effort never blocks or fails the switch", async () => {
1264
+ const { deps, relaunchCalls } = makeDeps({
1265
+ getConfiguredEffort: () => { throw new Error("agent list unavailable"); },
1266
+ });
1267
+ const reply = await handleModelCommand({ kind: "set", model: OPUS_48 }, deps);
1268
+ expect(relaunchCalls).toEqual([expect.objectContaining({ model: OPUS_48 })]);
1269
+ expect(reply.text).not.toContain(MERGE_400_MARKER);
1270
+
1271
+ const unset = makeDeps({ getConfiguredEffort: () => null });
1272
+ expect(
1273
+ (await handleModelCommand({ kind: "set", model: OPUS_48 }, unset.deps)).text,
1274
+ ).not.toContain(MERGE_400_MARKER);
1275
+ });
1276
+
1277
+ it("thinkingEffortCaveat is advisory only — it never rewrites the effort", () => {
1278
+ const escapeHtml = (s: string) => s;
1279
+ expect(thinkingEffortCaveat({ escapeHtml, getConfiguredEffort: () => "max" }, OPUS_48))
1280
+ .toContain(MERGE_400_MARKER);
1281
+ expect(thinkingEffortCaveat({ escapeHtml, getConfiguredEffort: () => "low" }, OPUS_48))
1282
+ .toBeNull();
1283
+ expect(thinkingEffortCaveat({ escapeHtml, getConfiguredEffort: () => "max" }, "opus"))
1284
+ .toBeNull();
1285
+ });
1286
+ });
1287
+
1288
+ // ---------------------------------------------------------------------------
1289
+ // The queued-across-restart persist path (gateway.ts). `persistQueuedCommandForRestart`
1290
+ // is module-local in a ~25k-line file that cannot be imported into a unit test,
1291
+ // so this is a source ratchet in the style of
1292
+ // `tests/scaffold.session-model.test.ts`: it pins the ONE line that decides
1293
+ // which token survives a restart. Pre-change that call used `expandSrAlias`, so
1294
+ // a queued `/model opus48` was persisted as the raw shortcut and start.sh
1295
+ // launched a token claude cannot resolve.
1296
+ // ---------------------------------------------------------------------------
1297
+ describe("gateway persist path uses the shared expansion, not the sr-only one", () => {
1298
+ const gatewaySrc = readFileSync(
1299
+ new URL("../gateway/gateway.ts", import.meta.url),
1300
+ "utf-8",
1301
+ );
1302
+
1303
+ it("the `.session-model` carrier write expands through expandModelAlias", () => {
1304
+ expect(gatewaySrc).toContain(
1305
+ "writeSessionModelFile(agentDir, expandModelAlias(action.arg), configured)",
1306
+ );
1307
+ expect(gatewaySrc).not.toContain("writeSessionModelFile(agentDir, expandSrAlias(");
1308
+ // …and the symbol is actually imported, so the above is live code.
1309
+ expect(gatewaySrc).toMatch(/^\s*expandModelAlias,$/m);
1310
+ });
1311
+
1312
+ it("the MODEL deps wire the configured effort for the switch-time guard", () => {
1313
+ // Anchored on the model deps' own `getConfiguredModel` reader — the effort
1314
+ // reader already existed on the EFFORT deps (`buildEffortDeps`), so a bare
1315
+ // substring match would pass without the model side being wired at all.
1316
+ const lines = gatewaySrc.split("\n");
1317
+ const anchor = lines.findIndex((l) =>
1318
+ l.includes("return data?.agents?.find(a => a.name === getMyAgentName())?.model ?? null"),
1319
+ );
1320
+ expect(anchor).toBeGreaterThan(-1);
1321
+ const window = lines.slice(anchor, anchor + 15).join("\n");
1322
+ expect(window).toContain("getConfiguredEffort: () => getConfiguredEffortForPersist(),");
1323
+ });
1324
+ });
@@ -379,7 +379,7 @@ describe('outbound-send-path — temporal pass wiring (#3501)', () => {
379
379
  it('temporal runs AFTER punctuation/bold and BEFORE scrubVoice (structural pin)', () => {
380
380
  const src = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
381
381
  const start = src.indexOf('export function normalizeOutboundBody(')
382
- const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text))', start)
382
+ const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text),', start)
383
383
  const temporalIdx = src.indexOf('normalizeTemporal(text, tz, nowMs)', start)
384
384
  const scrubIdx = src.indexOf('scrubVoice(text)', start)
385
385
  expect(boldIdx).toBeGreaterThan(start)