switchroom 0.19.1 → 0.19.3

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 (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -21,8 +21,10 @@ import { describe, it, expect } from 'vitest'
21
21
  import {
22
22
  decideSupersede,
23
23
  decideSupersedeCorrection,
24
+ flushedAnswerMatchesReply,
24
25
  FlushedTurnSupersedeRegistry,
25
26
  DEFAULT_SUPERSEDE_TTL_MS,
27
+ SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
26
28
  type FlushedTurnRecord,
27
29
  } from '../flushed-turn-supersede.js'
28
30
 
@@ -264,3 +266,118 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
264
266
  expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 }).reason).toBe('no-record')
265
267
  })
266
268
  })
269
+
270
+ /**
271
+ * #3429 — async handback edit-in-place supersede of a flush-delivered message
272
+ * can fail to surface client-side.
273
+ *
274
+ * The flush ends its turn synchronously BEFORE recording, so EVERY superseding
275
+ * reply is a late reply — and an async sub-agent handback landing within the
276
+ * 60 s TTL with no live gateway turn resolves the flush-delivered ENDED turn
277
+ * as its owner via the latest-ended tier, the SAME identity as the turn's own
278
+ * canonical late replay. Identity-only supersede then consumed the record and
279
+ * EDITED the flushed message in place with the handback's unrelated content
280
+ * (msgs 10482/10486, 2026-07-20) — Telegram edits never push-notify, so the
281
+ * handback silently failed to surface AND the flushed answer was destroyed.
282
+ *
283
+ * The content gate: `replyText` is compared against the record's flushed text
284
+ * (`flushedAnswerMatchesReply` — whitespace-normalized equality, or containment
285
+ * with a minimum-length guard on the contained side). Same answer → supersede
286
+ * (the wanted correction); different content → 'new-content', send fresh,
287
+ * record NOT consumed.
288
+ */
289
+ describe('#3429 — flushedAnswerMatchesReply (content discriminator)', () => {
290
+ const ANSWER = 'The deploy is green: all 12 services rolled out and health checks pass.'
291
+ const FLUSH_BLOB = `Let me check the rollout status.\n\n${ANSWER}`
292
+
293
+ it('matches whitespace-normalized equality', () => {
294
+ expect(flushedAnswerMatchesReply(ANSWER, ANSWER)).toBe(true)
295
+ expect(flushedAnswerMatchesReply(`${ANSWER}\n`, ANSWER.replace(': ', ': '))).toBe(true)
296
+ })
297
+
298
+ it('matches the classic containment class: flush = narration+answer ⊇ clean reply', () => {
299
+ expect(flushedAnswerMatchesReply(FLUSH_BLOB, ANSWER)).toBe(true)
300
+ })
301
+
302
+ it('matches reverse containment: reply ⊇ partially-delivered flush text', () => {
303
+ expect(flushedAnswerMatchesReply(ANSWER, FLUSH_BLOB)).toBe(true)
304
+ })
305
+
306
+ it('does NOT match genuinely different content (the handback)', () => {
307
+ const handback =
308
+ 'Worker finished: PR #3430 is up with the fix for the vault broker timeout, ' +
309
+ 'tests are green, ready for your review.'
310
+ expect(flushedAnswerMatchesReply(FLUSH_BLOB, handback)).toBe(false)
311
+ })
312
+
313
+ it('short containment below the minimum-length guard does NOT match ' +
314
+ '(a coincidental substring must never claim a handback)', () => {
315
+ const shortReply = 'rolled out'
316
+ expect(shortReply.length).toBeLessThan(SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS)
317
+ expect(FLUSH_BLOB.includes(shortReply)).toBe(true)
318
+ expect(flushedAnswerMatchesReply(FLUSH_BLOB, shortReply)).toBe(false)
319
+ })
320
+
321
+ it('short EQUAL texts still match (equality has no length floor)', () => {
322
+ expect(flushedAnswerMatchesReply('yes, done', 'yes, done')).toBe(true)
323
+ })
324
+ })
325
+
326
+ describe('#3429 — decideSupersede new-content gate', () => {
327
+ const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
328
+ const HANDBACK =
329
+ 'Sub-agent handback: the researcher finished and found three root causes, ' +
330
+ 'written up in the report at /tmp/report.md — want the highlights?'
331
+
332
+ it('same-turn same-answer late reply still supersedes (the wanted correction)', () => {
333
+ const d = decideSupersede(rec({ text: FLUSHED }), {
334
+ liveTurnId: 'turn-A',
335
+ replyText: 'Here is the finished summary of the incident you asked about.',
336
+ now: 1_000_010,
337
+ })
338
+ expect(d.supersede).toBe(true)
339
+ expect(d.reason).toBe('supersede')
340
+ expect(d.recordText).toBe(FLUSHED)
341
+ })
342
+
343
+ it('CORE #3429: same turn identity + DIFFERENT content → new-content, NO supersede', () => {
344
+ const d = decideSupersede(rec({ text: FLUSHED }), {
345
+ liveTurnId: 'turn-A',
346
+ replyText: HANDBACK,
347
+ now: 1_000_010,
348
+ })
349
+ expect(d.supersede).toBe(false)
350
+ expect(d.reason).toBe('new-content')
351
+ expect(d.deleteMessageIds).toEqual([])
352
+ expect(d.recordText).toBe(FLUSHED)
353
+ })
354
+
355
+ it('legacy identity-only callers (no replyText) keep the pre-#3429 behaviour', () => {
356
+ const d = decideSupersede(rec({ text: FLUSHED }), { liveTurnId: 'turn-A', now: 1_000_010 })
357
+ expect(d.supersede).toBe(true)
358
+ })
359
+
360
+ it('take() does NOT consume the record on new-content — the genuine replay ' +
361
+ 'can still correct the flushed message afterwards', () => {
362
+ const reg = new FlushedTurnSupersedeRegistry()
363
+ const now = 1_000_000
364
+ reg.record('chat9', undefined, { turnId: 'turn-F', messageIds: [7001], text: FLUSHED }, now)
365
+
366
+ // The handback lands first: new-content, nothing consumed, nothing deleted.
367
+ const d1 = reg.take('chat9', undefined, { liveTurnId: 'turn-F', replyText: HANDBACK, now: now + 10_000 })
368
+ expect(d1.supersede).toBe(false)
369
+ expect(d1.reason).toBe('new-content')
370
+
371
+ // The turn's own canonical replay lands later: record still there, supersede
372
+ // fires and consumes it.
373
+ const d2 = reg.take('chat9', undefined, {
374
+ liveTurnId: 'turn-F',
375
+ replyText: 'Here is the finished summary of the incident you asked about.',
376
+ now: now + 20_000,
377
+ })
378
+ expect(d2.supersede).toBe(true)
379
+ expect(d2.deleteMessageIds).toEqual([7001])
380
+ // Now consumed.
381
+ expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
382
+ })
383
+ })
@@ -16,8 +16,18 @@ import { describe, it, expect } from 'vitest'
16
16
  import { readFileSync } from 'node:fs'
17
17
  import { fileURLToPath } from 'node:url'
18
18
  import { dirname, resolve } from 'node:path'
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'
19
25
 
20
26
  const __dirname = dirname(fileURLToPath(import.meta.url))
27
+ const MODEL_COMMAND_SRC = readFileSync(
28
+ resolve(__dirname, '../gateway/model-command.ts'),
29
+ 'utf8',
30
+ )
21
31
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
22
32
 
23
33
  describe('gateway: the .relaunch-model-intent subsystem is retired (rev 4)', () => {
@@ -143,49 +153,203 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
143
153
  const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
144
154
  // F1: `launched !== configured` is the deterministic apply-boot signal.
145
155
  expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
146
- expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
156
+ expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })')
147
157
  expect(win).toContain('resolveMainModel(raw ?? undefined)')
148
158
  })
149
159
 
150
160
  it('logs the applied model for diagnosability (F1)', () => {
151
- expect(GATEWAY_SRC).toContain('gw /model relaunch applied agent=')
161
+ expect(GATEWAY_SRC).toContain('formatModelRelaunchDiagLog')
152
162
  expect(GATEWAY_SRC).toContain('gw /model relaunch scheduled agent=')
163
+ expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch applied agent=')
153
164
  })
154
165
 
155
- it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
156
- const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
157
- expect(idx).toBeGreaterThan(0)
158
- const win = GATEWAY_SRC.slice(idx, idx + 5200)
159
- // Keyed on the deterministic /model switch reason, so it also fires on a
160
- // launched===configured apply-boot (/model default) — N4. Never optimistic.
161
- expect(win).toContain('if (modelSwitchReason != null && modelSwitchMarkerChat)')
162
- expect(win).toContain('✅ Now running')
163
- // N4: the launched===configured branch still confirms.
164
- expect(win).toContain('(the configured default)')
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')
189
+ })
190
+
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')
214
+ })
215
+
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
+ }
165
245
  })
166
246
 
167
- it('warns instead of a green ✅ when a non-default switch silently reverted to the default (silent-revert fix)', () => {
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)', () => {
168
298
  const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
169
- const win = GATEWAY_SRC.slice(idx, idx + 5200)
170
- // The confirmation card is derived from the pure classifier, not an inline
171
- // isApplyBoot ternary — so a reverted non-default switch yields the ⚠️ card.
299
+ expect(idx).toBeGreaterThan(0)
300
+ const win = GATEWAY_SRC.slice(idx, idx + 4200)
172
301
  expect(win).toContain('classifyModelSwitchConfirmation({')
173
- expect(win).toContain("confirmation.kind === 'applied'")
174
- expect(win).toContain("confirmation.kind === 'not-applied'")
175
- expect(win).toContain("⚠️ Your switch to")
176
- expect(win).toContain("didn't apply")
177
- // LOW-3: the re-issue hint interpolates the target inside backticks so a
178
- // token containing Markdown metachars can't italicize / 400 the send.
179
- expect(win).toContain('Re-issue \\`/model ${confirmation.target}\\`')
302
+ expect(win).toContain('formatModelRelaunchDiagLog')
303
+ expect(win).toContain('if (confirmation != null)')
304
+ expect(win).toContain('deliverModelSwitchBootNotice({')
305
+ expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
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)')
180
313
  })
181
314
 
182
- it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
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.
183
320
  const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
184
- const win = GATEWAY_SRC.slice(idx, idx + 5200)
185
- // When start.sh wrote a specific alert for this revert, the classifier's
186
- // generic not-applied card is suppressed (the alert relay is the message).
187
- expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
188
- expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
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')
189
353
  })
190
354
 
191
355
  it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
@@ -213,6 +377,32 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
213
377
  })
214
378
  })
215
379
 
380
+
381
+ describe('gateway SESSION_MODEL_FILE stays pinned to start.sh.hbs rev5 carrier', () => {
382
+ it('hbs applies the same basename gateway writes (no rev4/rev5 name drift)', async () => {
383
+ const { SESSION_MODEL_FILE } = await import('../gateway/session-model-file.js')
384
+ const { readFileSync } = await import('node:fs')
385
+ const { resolve, dirname } = await import('node:path')
386
+ const { fileURLToPath } = await import('node:url')
387
+ expect(SESSION_MODEL_FILE).toBe('.session-model')
388
+ // Vitest may run with import.meta or __dirname depending on config.
389
+ const here = typeof __dirname !== 'undefined'
390
+ ? __dirname
391
+ : dirname(fileURLToPath(import.meta.url))
392
+ const hbs = readFileSync(
393
+ resolve(here, '../../profiles/_base/start.sh.hbs'),
394
+ 'utf8',
395
+ )
396
+ // Bare file test on the rev5 carrier (not only -override/-alert siblings).
397
+ expect(hbs).toMatch(/\[\s*-f\s+[^\]]*\/\.session-model["\s\]]/)
398
+ expect(hbs).toContain('configuredDefaultAtWrite')
399
+ // Legacy migration shim still converts leftover override → .session-model.
400
+ expect(hbs).toContain('.session-model-override')
401
+ expect(hbs).toMatch(/migrated legacy one-shot carrier/)
402
+ })
403
+ })
404
+
405
+
216
406
  describe('gateway: the legacy one-shot carrier is no longer written', () => {
217
407
  it('no gateway code writes .session-model-override anymore (start.sh migration shim only reads it)', () => {
218
408
  expect(GATEWAY_SRC).not.toMatch(/writeFileSync\([^)]*\.session-model-override/)
@@ -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
+ });