switchroom 0.19.2 → 0.19.4

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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -17,6 +17,11 @@ import { readFileSync } from 'node:fs'
17
17
  import { fileURLToPath } from 'node:url'
18
18
  import { dirname, resolve } from 'node:path'
19
19
  import { SESSION_MODEL_FILE } from '../gateway/session-model-file.js'
20
+ import {
21
+ classifyModelSwitchConfirmation,
22
+ formatModelRelaunchDiagLog,
23
+ resolveModelSwitchBootNotice,
24
+ } from '../gateway/model-command.js'
20
25
 
21
26
  const __dirname = dirname(fileURLToPath(import.meta.url))
22
27
  const MODEL_COMMAND_SRC = readFileSync(
@@ -148,7 +153,7 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
148
153
  const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
149
154
  // F1: `launched !== configured` is the deterministic apply-boot signal.
150
155
  expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
151
- expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
156
+ expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })')
152
157
  expect(win).toContain('resolveMainModel(raw ?? undefined)')
153
158
  })
154
159
 
@@ -158,42 +163,193 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
158
163
  expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch applied agent=')
159
164
  })
160
165
 
161
- it('logs outcome KIND explicitly on /model switch rehydration (NOT-APPLIED vs applied vs default)', () => {
162
- // Formatters live in model-command.ts (gateway line-ratchet); gateway only calls them.
163
- expect(GATEWAY_SRC).toContain('formatModelRelaunchDiagLog')
164
- expect(GATEWAY_SRC).toContain('formatModelSwitchConfirmationBody')
165
- expect(GATEWAY_SRC).toContain('formatModelRelaunchSuppressNotAppliedLog')
166
- expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch NOT-APPLIED agent=')
167
- expect(MODEL_COMMAND_SRC).toContain('override=set outcome=applied')
168
- expect(MODEL_COMMAND_SRC).toContain('override=cleared outcome=default')
169
- expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch NOT-APPLIED suppressing not-applied confirmation')
166
+ // #3427 item 2: the following are BEHAVIORAL tests they run the real
167
+ // classifier formatter/notice pipeline and assert the EMITTED outcome
168
+ // (log line / card body), not that a string merely exists in the source.
169
+ // The thin `wires …` test at the end pins that gateway.ts actually calls
170
+ // this pipeline (the gateway boot IIFE cannot be unit-booted).
171
+
172
+ it('emits the NOT-APPLIED log for a silent revert (classify → formatModelRelaunchDiagLog)', () => {
173
+ const confirmation = classifyModelSwitchConfirmation({
174
+ reason: 'user: /model fable (session-only relaunch, menu)',
175
+ launched: 'claude-opus-4-8',
176
+ configured: 'claude-opus-4-8',
177
+ })
178
+ expect(confirmation.kind).toBe('not-applied')
179
+ const log = formatModelRelaunchDiagLog({
180
+ agent: 'klanker',
181
+ launched: 'claude-opus-4-8',
182
+ configured: 'claude-opus-4-8',
183
+ confirmation,
184
+ isApplyBoot: false,
185
+ })
186
+ expect(log).toContain('gw /model relaunch NOT-APPLIED agent=klanker')
187
+ expect(log).toContain('target=fable')
188
+ expect(log).toContain('revertedTo=claude-opus-4-8')
170
189
  })
171
190
 
172
- it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
173
- const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
174
- expect(idx).toBeGreaterThan(0)
175
- const win = GATEWAY_SRC.slice(idx, idx + 2500)
176
- expect(win).toContain('if (confirmation != null && modelSwitchMarkerChat)')
177
- expect(win).toContain('formatModelSwitchConfirmationBody')
178
- expect(MODEL_COMMAND_SRC).toContain('✅ Now running')
179
- expect(MODEL_COMMAND_SRC).toContain('(the configured default)')
191
+ it('emits outcome=applied for a landed switch and outcome=default for a revert', () => {
192
+ const applied = classifyModelSwitchConfirmation({
193
+ reason: 'user: /model claude-haiku-4-5 (session-only relaunch)',
194
+ launched: 'claude-haiku-4-5',
195
+ configured: 'claude-opus-4-8',
196
+ })
197
+ expect(
198
+ formatModelRelaunchDiagLog({
199
+ agent: 'a', launched: 'claude-haiku-4-5', configured: 'claude-opus-4-8',
200
+ confirmation: applied, isApplyBoot: true,
201
+ }),
202
+ ).toContain('override=set outcome=applied')
203
+ const dflt = classifyModelSwitchConfirmation({
204
+ reason: 'user: /model default (revert relaunch)',
205
+ launched: 'claude-opus-4-8',
206
+ configured: 'claude-opus-4-8',
207
+ })
208
+ expect(
209
+ formatModelRelaunchDiagLog({
210
+ agent: 'a', launched: 'claude-opus-4-8', configured: 'claude-opus-4-8',
211
+ confirmation: dflt, isApplyBoot: false,
212
+ }),
213
+ ).toContain('override=cleared outcome=default')
180
214
  })
181
215
 
182
- it('warns instead of a green when a non-default switch silently reverted to the default (silent-revert fix)', () => {
183
- expect(GATEWAY_SRC).toContain('classifyModelSwitchConfirmation({')
184
- expect(GATEWAY_SRC).toContain('formatModelSwitchConfirmationBody')
185
- expect(MODEL_COMMAND_SRC).toContain('⚠️ Your switch to')
186
- expect(MODEL_COMMAND_SRC).toContain("didn't apply")
187
- // LOW-3: re-issue hint keeps target in backticks
188
- expect(MODEL_COMMAND_SRC).toContain('Re-issue `/model ')
216
+ it('sends the green applied card for a landed switch, the default card for an intended revert (F1/N4)', () => {
217
+ const applied = resolveModelSwitchBootNotice({
218
+ agent: 'a',
219
+ confirmation: classifyModelSwitchConfirmation({
220
+ reason: 'user: /model fable (session-only relaunch)',
221
+ launched: 'fable',
222
+ configured: 'claude-opus-4-8',
223
+ }),
224
+ hasSessionModelAlert: false,
225
+ })
226
+ expect(applied.kind).toBe('card')
227
+ if (applied.kind === 'card') {
228
+ expect(applied.body).toContain('✅ Now running `fable`')
229
+ expect(applied.body).toContain('session-only')
230
+ }
231
+ // N4: launched===configured on a /model default reason still confirms.
232
+ const dflt = resolveModelSwitchBootNotice({
233
+ agent: 'a',
234
+ confirmation: classifyModelSwitchConfirmation({
235
+ reason: 'user: /model default (revert relaunch)',
236
+ launched: 'claude-opus-4-8',
237
+ configured: 'claude-opus-4-8',
238
+ }),
239
+ hasSessionModelAlert: false,
240
+ })
241
+ expect(dflt.kind).toBe('card')
242
+ if (dflt.kind === 'card') {
243
+ expect(dflt.body).toContain('✅ Now running `claude-opus-4-8` (the configured default)')
244
+ }
189
245
  })
190
246
 
191
- it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
247
+ it('warns (⚠️, not a green ✅) when a non-default switch silently reverted to the default (silent-revert fix)', () => {
248
+ const notice = resolveModelSwitchBootNotice({
249
+ agent: 'a',
250
+ confirmation: classifyModelSwitchConfirmation({
251
+ reason: 'user: /model fable (session-only relaunch, menu)',
252
+ launched: 'claude-opus-4-8',
253
+ configured: 'claude-opus-4-8',
254
+ }),
255
+ hasSessionModelAlert: false,
256
+ })
257
+ expect(notice.kind).toBe('card')
258
+ if (notice.kind === 'card') {
259
+ expect(notice.body).toContain("⚠️ Your switch to `fable` didn't apply")
260
+ expect(notice.body).toContain('reverted to `claude-opus-4-8`')
261
+ // LOW-3: the re-issue hint keeps the target inside backticks.
262
+ expect(notice.body).toContain('Re-issue `/model fable`')
263
+ expect(notice.body).not.toContain('✅')
264
+ }
265
+ })
266
+
267
+ it('suppresses ONLY the not-applied card when a tailored .session-model-alert is present (LOW-2)', () => {
268
+ const reverted = classifyModelSwitchConfirmation({
269
+ reason: 'user: /model fable (session-only relaunch)',
270
+ launched: 'claude-opus-4-8',
271
+ configured: 'claude-opus-4-8',
272
+ })
273
+ const suppressed = resolveModelSwitchBootNotice({
274
+ agent: 'klanker',
275
+ confirmation: reverted,
276
+ hasSessionModelAlert: true,
277
+ })
278
+ expect(suppressed.kind).toBe('suppress')
279
+ if (suppressed.kind === 'suppress') {
280
+ expect(suppressed.log).toContain('suppressing not-applied confirmation')
281
+ expect(suppressed.log).toContain('agent=klanker')
282
+ expect(suppressed.log).toContain('target=fable')
283
+ }
284
+ // An APPLIED switch still confirms even when an (unrelated) alert exists.
285
+ const applied = resolveModelSwitchBootNotice({
286
+ agent: 'klanker',
287
+ confirmation: classifyModelSwitchConfirmation({
288
+ reason: 'user: /model fable (session-only relaunch)',
289
+ launched: 'fable',
290
+ configured: 'claude-opus-4-8',
291
+ }),
292
+ hasSessionModelAlert: true,
293
+ })
294
+ expect(applied.kind).toBe('card')
295
+ })
296
+
297
+ it('wires the pipeline into the boot rehydration (gateway calls classify → diag log → notice)', () => {
192
298
  const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
193
- const win = GATEWAY_SRC.slice(idx, idx + 2500)
299
+ expect(idx).toBeGreaterThan(0)
300
+ const win = GATEWAY_SRC.slice(idx, idx + 4200)
301
+ expect(win).toContain('classifyModelSwitchConfirmation({')
302
+ expect(win).toContain('formatModelRelaunchDiagLog')
303
+ expect(win).toContain('if (confirmation != null)')
304
+ expect(win).toContain('deliverModelSwitchBootNotice({')
194
305
  expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
195
- expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
196
- expect(win).toContain('formatModelRelaunchSuppressNotAppliedLog')
306
+ // Both notice arms (suppress stderr, card → sendMessage) now live in
307
+ // model-command.ts (deliverModelSwitchBootNotice — behaviorally tested in
308
+ // model-command.test.ts); the gateway injects the shared boot-card deps
309
+ // (stderr log sink + one raw thread-aware send closure).
310
+ expect(win).toContain('...modelBootCardDeps')
311
+ expect(win).toContain('log: (line) => process.stderr.write(line)')
312
+ expect(win).toContain('lockedBot.api.sendMessage(chatId, body, opts)')
313
+ })
314
+
315
+ it('arms the #3427 requested-vs-served tripwire on an apply-boot (comparator + handler)', () => {
316
+ // The source is constructed with the conservative comparator…
317
+ expect(GATEWAY_SRC).toContain('createSessionModelSource({ servedMatchesRequested: servedModelMatchesRequested })')
318
+ // …and the apply-boot rehydration verify-ARMS the override (H1: the ONLY
319
+ // arming site) and registers the handler that logs + warns.
320
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
321
+ const win = GATEWAY_SRC.slice(idx, idx + 4500)
322
+ expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })')
323
+ // The handler body lives in model-command.ts (buildServedModelDivergenceHandler,
324
+ // behaviorally tested in model-command.test.ts — log line + operator card);
325
+ // the gateway registers it ONLY on an apply-boot, fed by the shared deps.
326
+ expect(win).toContain(
327
+ 'sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps))',
328
+ )
329
+ const armIdx = win.indexOf('sessionModelSource.setDivergenceHandler(')
330
+ expect(win.slice(0, armIdx)).toContain('if (isApplyBoot) {')
331
+ // M2: the handler must NOT destroy the override record — a transient
332
+ // fallback substitution self-corrects; freshness already fixes /status.
333
+ // Structurally guaranteed post-extraction: the injected ModelBootCardDeps
334
+ // surface (agent/chat/log/sendCard) carries NO handle to the source, so
335
+ // the handler CANNOT call setOverride. Pin that the deps object stays free
336
+ // of any source handle.
337
+ const depsStart = win.indexOf('const modelBootCardDeps')
338
+ expect(depsStart).toBeGreaterThan(0)
339
+ const depsWin = win.slice(depsStart, win.indexOf('if (isApplyBoot)'))
340
+ expect(depsWin).not.toContain('sessionModelSource')
341
+ })
342
+
343
+ it('H1 (#3437): the command-time relaunch record does NOT verify-arm the tripwire', () => {
344
+ // scheduleModelRelaunch sets the pre-restart status-honesty override with a
345
+ // BARE setOverride — arming it would let an OLD-model assistant line in the
346
+ // pre-restart window false-accuse a valid NEW token. The behavioral guard
347
+ // lives in session-model-source.test.ts; this pins the gateway call sites.
348
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async (model: string, reason: string)')
349
+ expect(idx).toBeGreaterThan(0)
350
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
351
+ expect(win).toContain('sessionModelSource.setOverride(model)')
352
+ expect(win).not.toContain('verify')
197
353
  })
198
354
 
199
355
  it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
@@ -32,6 +32,13 @@ import {
32
32
  parseModelSwitchTarget,
33
33
  modelFamilyToken,
34
34
  MODEL_ALIASES,
35
+ servedModelMatchesRequested,
36
+ formatServedModelDivergenceLog,
37
+ formatServedModelDivergenceCard,
38
+ buildServedModelDivergenceHandler,
39
+ deliverModelSwitchBootNotice,
40
+ unvalidatedIdCaveat,
41
+ type ModelBootCardDeps,
35
42
  type ModelCommandDeps,
36
43
  } from "../gateway/model-command.js";
37
44
 
@@ -691,3 +698,216 @@ describe("modelFamilyToken", () => {
691
698
  expect(modelFamilyToken("sr-gemini-2.5-flash")).toBe("sr-gemini-2.5-flash");
692
699
  });
693
700
  });
701
+
702
+ // ── #3427 item 4: requested-vs-served divergence (fallback-model masking) ────
703
+
704
+ describe("servedModelMatchesRequested", () => {
705
+ it("matches a requested alias against its resolved full id (family)", () => {
706
+ expect(servedModelMatchesRequested("sonnet", "claude-sonnet-5")).toBe(true);
707
+ expect(servedModelMatchesRequested("opus", "claude-opus-4-8")).toBe(true);
708
+ expect(servedModelMatchesRequested("fable", "claude-fable-5")).toBe(true);
709
+ });
710
+
711
+ it("flags a requested alias served by a DIFFERENT family (fallback substituted)", () => {
712
+ expect(servedModelMatchesRequested("sonnet", "claude-opus-4-8")).toBe(false);
713
+ expect(servedModelMatchesRequested("haiku", "claude-sonnet-5")).toBe(false);
714
+ });
715
+
716
+ it("matches a full id exactly and as a date-stamped descendant", () => {
717
+ expect(servedModelMatchesRequested("claude-sonnet-5", "claude-sonnet-5")).toBe(true);
718
+ expect(servedModelMatchesRequested("claude-sonnet-5", "claude-sonnet-5-20260203")).toBe(true);
719
+ expect(servedModelMatchesRequested("Claude-Sonnet-5", "claude-sonnet-5")).toBe(true);
720
+ });
721
+
722
+ it("flags an invalid/unknown full id served by the fallback — the masking case", () => {
723
+ // THE #3427 item-4 case: `/model claude-sonnet-9` (nonexistent) launches,
724
+ // --fallback-model silently serves opus. A test that wouldn't fail on the
725
+ // masking is not a test: this must be a MISMATCH.
726
+ expect(servedModelMatchesRequested("claude-sonnet-9", "claude-opus-4-8")).toBe(false);
727
+ // Same-family but wrong version is still a mismatch (sonnet-9 ≠ sonnet-5).
728
+ expect(servedModelMatchesRequested("claude-sonnet-9", "claude-sonnet-5")).toBe(false);
729
+ });
730
+
731
+ it("is conservative: non-claude served ids and non-comparable requests never accuse", () => {
732
+ // LiteLLM/sr-* served names are not deterministically comparable.
733
+ expect(servedModelMatchesRequested("sr-glm-5", "glm-5")).toBe(true);
734
+ expect(servedModelMatchesRequested("claude-sonnet-5", "sr-glm-5")).toBe(true);
735
+ // Legacy friendly-label overrides ("Opus 4.8") are not comparable either.
736
+ expect(servedModelMatchesRequested("Opus 4.8", "claude-opus-4-8")).toBe(true);
737
+ // The default sentinel never diverges.
738
+ expect(servedModelMatchesRequested("default", "claude-opus-4-8")).toBe(true);
739
+ });
740
+
741
+ it("L3 (#3437): legacy id shapes (family after version) never false-accuse an alias request", () => {
742
+ // `claude-3-opus-20240229` → modelFamilyToken yields "3", not "opus"; the
743
+ // segment fallback must still match the alias.
744
+ expect(servedModelMatchesRequested("opus", "claude-3-opus-20240229")).toBe(true);
745
+ expect(servedModelMatchesRequested("haiku", "claude-3-5-haiku-20241022")).toBe(true);
746
+ // …while a genuinely different family still fires.
747
+ expect(servedModelMatchesRequested("sonnet", "claude-3-opus-20240229")).toBe(false);
748
+ });
749
+ });
750
+
751
+ describe("served-model divergence formatters (#3427 item 4)", () => {
752
+ it("log line is greppable and names requested + served", () => {
753
+ const log = formatServedModelDivergenceLog({
754
+ agent: "klanker",
755
+ requested: "claude-sonnet-9",
756
+ served: "claude-opus-4-8",
757
+ });
758
+ expect(log).toContain("gw /model served-model DIVERGENCE agent=klanker");
759
+ expect(log).toContain("requested=claude-sonnet-9");
760
+ expect(log).toContain("served=claude-opus-4-8");
761
+ expect(log.endsWith("\n")).toBe(true);
762
+ });
763
+
764
+ it("card warns, names both models in backticks, and says how to recover", () => {
765
+ const card = formatServedModelDivergenceCard({
766
+ requested: "claude-sonnet-9",
767
+ served: "claude-opus-4-8",
768
+ });
769
+ expect(card).toContain("⚠️");
770
+ expect(card).toContain("`claude-opus-4-8`");
771
+ expect(card).toContain("`claude-sonnet-9`");
772
+ expect(card).toContain("/model");
773
+ expect(card).not.toContain("✅");
774
+ });
775
+
776
+ it("M2 (#3437): the card and log name BOTH causes — never a flat 'invalid' accusation", () => {
777
+ // --fallback-model also substitutes on transient overload/unavailability;
778
+ // the signal cannot distinguish that from a bogus id, so neither surface
779
+ // may assert invalidity as fact.
780
+ const card = formatServedModelDivergenceCard({
781
+ requested: "claude-sonnet-9",
782
+ served: "claude-opus-4-8",
783
+ });
784
+ expect(card).toContain("temporarily unavailable");
785
+ expect(card).toContain("invalid");
786
+ expect(card).toContain("If it persists");
787
+ expect(card).toContain("Either"); // hedged alternatives, not an assertion of invalidity
788
+ const log = formatServedModelDivergenceLog({
789
+ agent: "a", requested: "claude-sonnet-9", served: "claude-opus-4-8",
790
+ });
791
+ expect(log).toContain("invalid/unknown OR model transiently unavailable");
792
+ });
793
+ });
794
+
795
+ describe("boot /model cards — buildServedModelDivergenceHandler / deliverModelSwitchBootNotice (#2996 extraction)", () => {
796
+ function makeCardDeps(overrides: Partial<ModelBootCardDeps> = {}) {
797
+ const logs: string[] = [];
798
+ const sends: Array<{ chatId: string; body: string; opts: Record<string, unknown> }> = [];
799
+ const deps: ModelBootCardDeps = {
800
+ agent: "klanker",
801
+ chat: { chatId: "-100123", threadId: 42 },
802
+ log: (line) => { logs.push(line); },
803
+ sendCard: (chatId, body, opts) => {
804
+ sends.push({ chatId, body, opts });
805
+ return Promise.resolve();
806
+ },
807
+ ...overrides,
808
+ };
809
+ return { deps, logs, sends };
810
+ }
811
+
812
+ it("divergence handler logs the DIVERGENCE line and sends the card to the marker chat + thread", () => {
813
+ const { deps, logs, sends } = makeCardDeps();
814
+ buildServedModelDivergenceHandler(deps)({ requested: "claude-sonnet-9", served: "claude-opus-4-8" });
815
+ expect(logs.some((l) => l.includes("gw /model served-model DIVERGENCE agent=klanker"))).toBe(true);
816
+ expect(sends).toHaveLength(1);
817
+ expect(sends[0].chatId).toBe("-100123");
818
+ expect(sends[0].body).toContain("`claude-opus-4-8`");
819
+ expect(sends[0].opts).toEqual({ parse_mode: "Markdown", message_thread_id: 42 });
820
+ });
821
+
822
+ it("divergence handler with NO marker chat still logs but never sends", () => {
823
+ const { deps, logs, sends } = makeCardDeps({ chat: null });
824
+ buildServedModelDivergenceHandler(deps)({ requested: "sonnet", served: "claude-opus-4-8" });
825
+ expect(logs.some((l) => l.includes("DIVERGENCE"))).toBe(true);
826
+ expect(sends).toHaveLength(0);
827
+ });
828
+
829
+ it("divergence card send rejection is swallowed and logged — never throws", async () => {
830
+ const { deps, logs } = makeCardDeps({
831
+ sendCard: () => Promise.reject(new Error("THREAD_NOT_FOUND")),
832
+ });
833
+ expect(() =>
834
+ buildServedModelDivergenceHandler(deps)({ requested: "claude-sonnet-9", served: "claude-opus-4-8" }),
835
+ ).not.toThrow();
836
+ await new Promise((r) => setTimeout(r, 0));
837
+ expect(logs.some((l) => l.includes("served-model divergence send failed: THREAD_NOT_FOUND"))).toBe(true);
838
+ });
839
+
840
+ it("boot notice: applied confirmation sends the ✅ card (thread-aware), no suppress log", () => {
841
+ const { deps, logs, sends } = makeCardDeps();
842
+ deliverModelSwitchBootNotice({
843
+ ...deps,
844
+ confirmation: { kind: "applied", launched: "claude-opus-4-8" },
845
+ hasSessionModelAlert: false,
846
+ });
847
+ expect(sends).toHaveLength(1);
848
+ expect(sends[0].body).toContain("✅ Now running `claude-opus-4-8`");
849
+ expect(sends[0].opts).toEqual({ parse_mode: "Markdown", message_thread_id: 42 });
850
+ expect(logs).toHaveLength(0);
851
+ });
852
+
853
+ it("boot notice: not-applied + .session-model-alert suppresses the card and writes the suppress log", () => {
854
+ const { deps, logs, sends } = makeCardDeps({ chat: { chatId: "-100123", threadId: null } });
855
+ deliverModelSwitchBootNotice({
856
+ ...deps,
857
+ confirmation: { kind: "not-applied", target: "fable", revertedTo: "claude-sonnet-5" },
858
+ hasSessionModelAlert: true,
859
+ });
860
+ expect(sends).toHaveLength(0);
861
+ expect(logs.some((l) => l.includes("suppressing not-applied confirmation") && l.includes("target=fable"))).toBe(true);
862
+ });
863
+
864
+ it("boot notice: no marker chat is a full no-op (matches pre-extraction inline behavior)", () => {
865
+ const { deps, logs, sends } = makeCardDeps({ chat: null });
866
+ deliverModelSwitchBootNotice({
867
+ ...deps,
868
+ confirmation: { kind: "not-applied", target: "fable", revertedTo: "claude-sonnet-5" },
869
+ hasSessionModelAlert: true,
870
+ });
871
+ expect(sends).toHaveLength(0);
872
+ expect(logs).toHaveLength(0);
873
+ });
874
+
875
+ it("boot notice: threadId null omits message_thread_id from send opts", () => {
876
+ const { deps, sends } = makeCardDeps({ chat: { chatId: "777", threadId: null } });
877
+ deliverModelSwitchBootNotice({
878
+ ...deps,
879
+ confirmation: { kind: "default", launched: "claude-sonnet-5" },
880
+ hasSessionModelAlert: false,
881
+ });
882
+ expect(sends).toHaveLength(1);
883
+ expect(sends[0].chatId).toBe("777");
884
+ expect(sends[0].opts).toEqual({ parse_mode: "Markdown" });
885
+ });
886
+ });
887
+
888
+ describe("unvalidatedIdCaveat — immediate fail-fast warn on free-text claude-* ids (#3427 item 4)", () => {
889
+ const esc = { escapeHtml: (s: string) => s };
890
+
891
+ it("warns for a full claude-* id (cannot be pre-validated)", () => {
892
+ const caveat = unvalidatedIdCaveat(esc, "claude-sonnet-9");
893
+ expect(caveat).not.toBeNull();
894
+ expect(caveat).toContain("claude-sonnet-9");
895
+ expect(caveat).toContain("fallback");
896
+ });
897
+
898
+ it("stays silent for aliases, sr-* ids and the default sentinel", () => {
899
+ expect(unvalidatedIdCaveat(esc, "opus")).toBeNull();
900
+ expect(unvalidatedIdCaveat(esc, "sonnet")).toBeNull();
901
+ expect(unvalidatedIdCaveat(esc, "sr-glm-5")).toBeNull();
902
+ expect(unvalidatedIdCaveat(esc, "default")).toBeNull();
903
+ });
904
+
905
+ it("the /model set ACK carries the caveat for a typed full id — and not for an alias", async () => {
906
+ const { deps } = makeDeps();
907
+ const full = await handleModelCommand({ kind: "set", model: "claude-sonnet-9" }, deps);
908
+ expect(full.text).toContain("can't be validated before launch");
909
+ expect(full.text).toContain("fallback");
910
+ const alias = await handleModelCommand({ kind: "set", model: "opus" }, deps);
911
+ expect(alias.text).not.toContain("can't be validated before launch");
912
+ });
913
+ });