switchroom 0.18.29 → 0.18.30

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 (38) hide show
  1. package/bin/handoff-briefing.sh +8 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -16
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2074 -1585
  9. package/dist/host-control/main.js +110 -13
  10. package/dist/vault/approvals/kernel-server.js +116 -13
  11. package/dist/vault/broker/server.js +314 -145
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +73 -20
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +560 -96
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/gateway.ts +212 -17
  18. package/telegram-plugin/gateway/model-command.ts +104 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  20. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  21. package/telegram-plugin/llm-error-present.ts +24 -0
  22. package/telegram-plugin/model-unavailable.ts +55 -0
  23. package/telegram-plugin/operator-events.ts +113 -0
  24. package/telegram-plugin/pending-user-notice.ts +88 -0
  25. package/telegram-plugin/shared/local-time.ts +43 -0
  26. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  27. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  28. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
  29. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  30. package/telegram-plugin/tests/local-time.test.ts +68 -1
  31. package/telegram-plugin/tests/model-command.test.ts +133 -0
  32. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  33. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  34. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  35. package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
  36. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  38. package/vendor/hindsight-memory/tests/test_content.py +35 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * litellm-proxy-auth-misconfig.test.ts — outcome-asserting tests for Ken's
3
+ * deterministic error-surfacing policy applied to the LiteLLM proxy-fallback
4
+ * 401 incident (2026-07-16).
5
+ *
6
+ * Root cause: LiteLLM's internal fallback chain re-dispatched WITHOUT the
7
+ * client's OAuth Authorization header onto a keyless `claude-sonnet-5`
8
+ * deployment, so Anthropic returned 401 authentication_error "x-api-key header
9
+ * is required". The gateway classifier mapped ANY authentication_error →
10
+ * credentials-invalid → an always-rendered "🔑 re-authenticate" card that was
11
+ * broadcast to EVERY allowlist chat — wrong audience (a non-operator user like
12
+ * Lisa cannot re-auth) and wrong diagnosis (the login is fine).
13
+ *
14
+ * These assert OUTCOMES:
15
+ * 1. the proxy 401 classifies as an operator-only infra fault, NEVER auth /
16
+ * credentials-invalid, and NEVER renders a re-auth card;
17
+ * 2. a genuine credential expiry still classifies as credentials-expired and
18
+ * still reaches the operator;
19
+ * 3. the audience router sends operator-actionable faults to the operator
20
+ * ONLY, and hands non-operator users a diagnosis-free plain-language
21
+ * notice — while the operator (allowlist head, incl. their own DM) always
22
+ * keeps the full card.
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest'
26
+ import {
27
+ classifyClaudeError,
28
+ renderOperatorEvent,
29
+ decideOperatorEventAudience,
30
+ isOperatorActionableKind,
31
+ renderUserFacingFailureNotice,
32
+ type OperatorEvent,
33
+ type OperatorEventKind,
34
+ } from '../operator-events.js'
35
+ import { isLitellmProxyAuthMisconfig } from '../model-unavailable.js'
36
+ import { parseLlmError, renderLlmError } from '../llm-error-present.js'
37
+ import {
38
+ PendingUserNoticeGate,
39
+ PENDING_USER_NOTICE_TTL_MS,
40
+ type PendingUserNotice,
41
+ } from '../pending-user-notice.js'
42
+
43
+ // The verbatim shape Anthropic returns to the header-less proxy fallback.
44
+ const PROXY_401_MESSAGE =
45
+ 'litellm.AuthenticationError: AnthropicException - {"type":"error","error":{"type":"authentication_error","message":"x-api-key header is required"}} (fallback deployment claude-sonnet-5)'
46
+
47
+ function makeEvent(kind: OperatorEventKind, overrides?: Partial<OperatorEvent>): OperatorEvent {
48
+ return {
49
+ kind,
50
+ agent: 'gymbro',
51
+ detail: '',
52
+ suggestedActions: [],
53
+ firstSeenAt: new Date('2026-07-16T20:00:00Z'),
54
+ ...overrides,
55
+ }
56
+ }
57
+
58
+ describe('isLitellmProxyAuthMisconfig', () => {
59
+ it('detects the definitive keyless-401 marker', () => {
60
+ expect(isLitellmProxyAuthMisconfig(PROXY_401_MESSAGE)).toBe(true)
61
+ expect(isLitellmProxyAuthMisconfig('x-api-key header is required')).toBe(true)
62
+ })
63
+
64
+ it('detects an authentication_error carrying the fallback + x-api-key structural pair', () => {
65
+ expect(
66
+ isLitellmProxyAuthMisconfig(
67
+ 'litellm fallback re-dispatch: authentication_error — upstream demanded x-api-key on backup deployment',
68
+ ),
69
+ ).toBe(true)
70
+ })
71
+
72
+ it('does NOT down-classify a genuine Anthropic OAuth expiry', () => {
73
+ expect(
74
+ isLitellmProxyAuthMisconfig(
75
+ 'authentication_error: OAuth token has expired. Please run /login to refresh.',
76
+ ),
77
+ ).toBe(false)
78
+ expect(isLitellmProxyAuthMisconfig('authentication_error: invalid bearer token')).toBe(false)
79
+ })
80
+
81
+ // #3293 review finding 2 — precision over recall: an AMBIGUOUS proxy
82
+ // envelope (mentions litellm/proxy/api_key but lacks the keyless-fallback
83
+ // signature) must KEEP the credentials diagnosis, not become misconfig.
84
+ it('does NOT claim a genuine expiry wrapped in a proxy envelope mentioning api_key', () => {
85
+ const wrappedExpiry =
86
+ 'litellm.AuthenticationError: AnthropicException - authentication_error: OAuth token has expired (api_key slot proxy-default). Please refresh.'
87
+ expect(isLitellmProxyAuthMisconfig(wrappedExpiry)).toBe(false)
88
+ expect(
89
+ classifyClaudeError({ type: 'authentication_error', status: 401, message: wrappedExpiry }),
90
+ ).toBe('credentials-expired')
91
+ })
92
+
93
+ it('does NOT claim proxy+fallback wording without an explicit x-api-key signal', () => {
94
+ expect(
95
+ isLitellmProxyAuthMisconfig(
96
+ 'litellm proxy fallback: authentication_error while routing to backup deployment',
97
+ ),
98
+ ).toBe(false)
99
+ })
100
+
101
+ it('never throws on junk input', () => {
102
+ expect(isLitellmProxyAuthMisconfig('')).toBe(false)
103
+ // @ts-expect-error — deliberate wrong type
104
+ expect(isLitellmProxyAuthMisconfig(null)).toBe(false)
105
+ })
106
+ })
107
+
108
+ describe('classifyClaudeError — proxy 401 vs genuine credential faults', () => {
109
+ it('classifies the proxy fallback 401 as proxy-misconfig, NOT credentials-invalid', () => {
110
+ const kind = classifyClaudeError({
111
+ type: 'authentication_error',
112
+ status: 401,
113
+ message: PROXY_401_MESSAGE,
114
+ })
115
+ expect(kind).toBe('proxy-misconfig')
116
+ expect(kind).not.toBe('credentials-invalid')
117
+ expect(kind).not.toBe('credentials-expired')
118
+ })
119
+
120
+ it('still classifies a genuine expired credential as credentials-expired', () => {
121
+ const kind = classifyClaudeError({
122
+ type: 'authentication_error',
123
+ message: 'authentication_error: your OAuth token has expired, please refresh',
124
+ })
125
+ expect(kind).toBe('credentials-expired')
126
+ })
127
+
128
+ it('still classifies a bare invalid credential as credentials-invalid', () => {
129
+ const kind = classifyClaudeError({
130
+ type: 'authentication_error',
131
+ message: 'authentication_error: credentials are not valid',
132
+ })
133
+ expect(kind).toBe('credentials-invalid')
134
+ })
135
+ })
136
+
137
+ describe('renderOperatorEvent — proxy-misconfig card', () => {
138
+ it('renders an operator infra card with NO re-auth button and NO login language', () => {
139
+ const { text, keyboard } = renderOperatorEvent(makeEvent('proxy-misconfig', { detail: 'x-api-key header is required' }))
140
+ // No re-auth instruction reaches the surface.
141
+ expect(text.toLowerCase()).not.toContain('re-authenticate')
142
+ expect(text.toLowerCase()).not.toContain('reauth')
143
+ expect(text).toContain('misconfig')
144
+ // Only a Dismiss button — never a Reauth callback.
145
+ const callbacks = keyboard.inline_keyboard.flat().map((b) => b.callback_data ?? '')
146
+ expect(callbacks.some((c) => c.startsWith('op:reauth:'))).toBe(false)
147
+ })
148
+ })
149
+
150
+ describe('parseLlmError — proxy 401 never becomes the user-facing auth kind', () => {
151
+ it('parses the proxy 401 as infra_misconfig (operator-only), source litellm-local', () => {
152
+ const parsed = parseLlmError(PROXY_401_MESSAGE)
153
+ expect(parsed.kind).toBe('infra_misconfig')
154
+ expect(parsed.kind).not.toBe('auth')
155
+ expect(parsed.source).toBe('litellm-local')
156
+ })
157
+
158
+ it('the rendered infra_misconfig line carries NO re-auth recommendation', () => {
159
+ const parsed = parseLlmError(PROXY_401_MESSAGE)
160
+ const { text } = renderLlmError(parsed, 'gymbro', 'UTC')
161
+ expect(text.toLowerCase()).not.toContain('re-authenticate this account')
162
+ })
163
+
164
+ it('a genuine OAuth expiry still parses as the actionable auth kind', () => {
165
+ const parsed = parseLlmError('authentication_error: OAuth token has expired, please refresh')
166
+ expect(parsed.kind).toBe('auth')
167
+ })
168
+ })
169
+
170
+ describe('decideOperatorEventAudience — Ken routing policy', () => {
171
+ const allow = ['5000000001' /* operator */, '5000000002' /* Lisa */, '5000000003']
172
+
173
+ it('routes the proxy misconfig to the operator ONLY; users get a plain notice', () => {
174
+ const { operatorChats, userNoticeChats } = decideOperatorEventAudience(
175
+ 'proxy-misconfig',
176
+ allow,
177
+ allow[0],
178
+ )
179
+ expect(operatorChats).toEqual(['5000000001'])
180
+ expect(userNoticeChats).toEqual(['5000000002', '5000000003'])
181
+ })
182
+
183
+ it('routes credentials-invalid / -expired / credit-exhausted to the operator ONLY', () => {
184
+ for (const kind of ['credentials-invalid', 'credentials-expired', 'credit-exhausted'] as const) {
185
+ const a = decideOperatorEventAudience(kind, allow, allow[0])
186
+ expect(a.operatorChats).toEqual(['5000000001'])
187
+ expect(a.userNoticeChats).toEqual(['5000000002', '5000000003'])
188
+ }
189
+ })
190
+
191
+ it('preserves the operator DM: a lone-operator allowlist still gets the full card, no user notice', () => {
192
+ const a = decideOperatorEventAudience('proxy-misconfig', ['5000000001'], '5000000001')
193
+ expect(a.operatorChats).toEqual(['5000000001'])
194
+ expect(a.userNoticeChats).toEqual([])
195
+ })
196
+
197
+ it('leaves non-operator-actionable kinds broadcasting to every allowlist chat', () => {
198
+ for (const kind of ['rate-limited', 'unknown-5xx', 'agent-crashed', 'quota-exhausted'] as const) {
199
+ const a = decideOperatorEventAudience(kind, allow, allow[0])
200
+ expect(a.operatorChats).toEqual(allow)
201
+ expect(a.userNoticeChats).toEqual([])
202
+ }
203
+ })
204
+
205
+ it('isOperatorActionableKind gates exactly the credential/infra fault classes', () => {
206
+ expect(isOperatorActionableKind('proxy-misconfig')).toBe(true)
207
+ expect(isOperatorActionableKind('credentials-invalid')).toBe(true)
208
+ expect(isOperatorActionableKind('credentials-expired')).toBe(true)
209
+ expect(isOperatorActionableKind('credit-exhausted')).toBe(true)
210
+ expect(isOperatorActionableKind('rate-limited')).toBe(false)
211
+ expect(isOperatorActionableKind('quota-exhausted')).toBe(false)
212
+ })
213
+ })
214
+
215
+ // #3293 review finding 1 — the user notice is gated on the TURN OUTCOME, not
216
+ // the error line: a turn that 401s on the proxy fallback but still delivers a
217
+ // reply must produce NO user notice; only a reply-less turn end flushes it.
218
+ describe('PendingUserNoticeGate — notice fires IFF the turn dies reply-less', () => {
219
+ function makeNotice(overrides?: Partial<PendingUserNotice>): PendingUserNotice {
220
+ return {
221
+ chatIds: ['5000000002', '5000000003'],
222
+ text: renderUserFacingFailureNotice(),
223
+ agent: 'gymbro',
224
+ kind: 'proxy-misconfig',
225
+ atMs: 1_000_000,
226
+ ...overrides,
227
+ }
228
+ }
229
+
230
+ it('DROPS the notice when the turn recovered (delivered a reply)', () => {
231
+ const gate = new PendingUserNoticeGate()
232
+ gate.schedule(makeNotice())
233
+ expect(gate.resolveTurnEnd(true, 1_001_000)).toEqual([])
234
+ // And it does not resurrect on a later failed turn end.
235
+ expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
236
+ })
237
+
238
+ it('SENDS the notice exactly once when the turn ends without a reply', () => {
239
+ const gate = new PendingUserNoticeGate()
240
+ const notice = makeNotice()
241
+ gate.schedule(notice)
242
+ const flushed = gate.resolveTurnEnd(false, 1_001_000)
243
+ expect(flushed).toHaveLength(1)
244
+ expect(flushed[0].chatIds).toEqual(['5000000002', '5000000003'])
245
+ // Exactly once — a second turn end flushes nothing.
246
+ expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
247
+ })
248
+
249
+ it('collapses an error burst within one turn to ONE pending notice per agent', () => {
250
+ const gate = new PendingUserNoticeGate()
251
+ gate.schedule(makeNotice({ atMs: 1_000_000 }))
252
+ gate.schedule(makeNotice({ atMs: 1_000_500 }))
253
+ gate.schedule(makeNotice({ atMs: 1_001_000 }))
254
+ expect(gate.resolveTurnEnd(false, 1_002_000)).toHaveLength(1)
255
+ })
256
+
257
+ it('expires an unresolved notice after the TTL (bias to silence over false failure)', () => {
258
+ const gate = new PendingUserNoticeGate()
259
+ gate.schedule(makeNotice({ atMs: 1_000_000 }))
260
+ const after = 1_000_000 + PENDING_USER_NOTICE_TTL_MS + 1
261
+ expect(gate.hasPending(after)).toBe(false)
262
+ expect(gate.resolveTurnEnd(false, after)).toEqual([])
263
+ })
264
+ })
265
+
266
+ describe('renderUserFacingFailureNotice', () => {
267
+ it('is a diagnosis-free, re-auth-free, byte-free plain-language notice', () => {
268
+ const notice = renderUserFacingFailureNotice()
269
+ const lower = notice.toLowerCase()
270
+ expect(lower).not.toContain('re-auth')
271
+ expect(lower).not.toContain('reauth')
272
+ expect(lower).not.toContain('x-api-key')
273
+ expect(lower).not.toContain('litellm')
274
+ expect(lower).not.toContain('authentication_error')
275
+ // Communicates "our side" honestly.
276
+ expect(lower).toContain('our side')
277
+ })
278
+ })
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { describe, expect, it } from 'vitest'
13
- import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
13
+ import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
14
14
 
15
15
  // 2025-06-15T14:26:40Z — a fixed instant so the local rendering is deterministic.
16
16
  const MS = 1750000000 * 1000
@@ -66,3 +66,70 @@ describe('fmtLocalStamp', () => {
66
66
  }
67
67
  })
68
68
  })
69
+
70
+ describe('renderLogTimestampsLocal', () => {
71
+ // #tz-fix audit (MEDIUM gap 2): /logs docker lines carry a leading UTC
72
+ // ISO-Z stamp; render it in local am/pm at display time.
73
+ it('rewrites a leading UTC ISO-Z timestamp into local am/pm', () => {
74
+ // 04:09Z in July → Melbourne AEST (UTC+10) → 02:09 PM.
75
+ const out = renderLogTimestampsLocal(
76
+ '2026-07-16T04:09:00.123456789Z gateway boot ok',
77
+ 'Australia/Melbourne',
78
+ )
79
+ expect(out).toContain('2026-07-16 02:09 PM AEST')
80
+ expect(out).toContain('gateway boot ok')
81
+ expect(out).not.toContain('T04:09')
82
+ expect(out).not.toContain('Z gateway')
83
+ })
84
+
85
+ it('handles a fractionless ISO-Z timestamp', () => {
86
+ const out = renderLogTimestampsLocal('2026-07-16T04:09:00Z hello', 'Australia/Melbourne')
87
+ expect(out).toContain('2026-07-16 02:09 PM AEST hello')
88
+ })
89
+
90
+ it('converts every line and preserves non-timestamped lines verbatim', () => {
91
+ const input = [
92
+ '2026-07-16T04:09:00Z first',
93
+ ' continuation line with no timestamp',
94
+ '2026-07-16T05:10:00Z second',
95
+ ].join('\n')
96
+ const out = renderLogTimestampsLocal(input, 'Australia/Melbourne').split('\n')
97
+ expect(out[0]).toContain('02:09 PM AEST first')
98
+ expect(out[1]).toBe(' continuation line with no timestamp')
99
+ expect(out[2]).toContain('03:10 PM AEST second')
100
+ })
101
+
102
+ it('preserves a line whose leading token is not a valid ISO-Z stamp', () => {
103
+ const raw = 'not-a-timestamp still here'
104
+ expect(renderLogTimestampsLocal(raw, 'Australia/Melbourne')).toBe(raw)
105
+ })
106
+
107
+ it('does not touch a timestamp that is not at line start', () => {
108
+ const raw = 'prefix 2026-07-16T04:09:00Z trailing'
109
+ expect(renderLogTimestampsLocal(raw, 'Australia/Melbourne')).toBe(raw)
110
+ })
111
+
112
+ it('returns empty input unchanged', () => {
113
+ expect(renderLogTimestampsLocal('', 'Australia/Melbourne')).toBe('')
114
+ })
115
+
116
+ // End-to-end shape check against REAL `docker logs --timestamps` output —
117
+ // the /logs path requests --timestamps precisely so this prefix exists.
118
+ // (Captured verbatim from a live container; nanosecond precision.)
119
+ it('converts a verbatim docker --timestamps line (the real /logs input shape)', () => {
120
+ const real = '2026-07-16T20:17:58.433481596Z gateway-supervisor: heartbeat ok'
121
+ const out = renderLogTimestampsLocal(real, 'Australia/Melbourne')
122
+ // 20:17:58Z on 16 Jul → AEST (UTC+10) → 06:17 AM on 17 Jul local.
123
+ expect(out).toBe('Friday 2026-07-17 06:17 AM AEST gateway-supervisor: heartbeat ok')
124
+ expect(out).not.toContain('Z ')
125
+ expect(out).not.toContain('UTC')
126
+ })
127
+
128
+ it('does NOT touch an app-emitted local-zone stamp (no double shift)', () => {
129
+ // Post-#3275 containers run with local TZ baked, so Python-style
130
+ // `%H:%M:%S,mmm` stamps are already local wall clock — converting them
131
+ // as UTC would be wrong. They must pass through verbatim.
132
+ const appLine = '2026-07-16 20:14:14,725 - INFO - scheduler tick'
133
+ expect(renderLogTimestampsLocal(appLine, 'Australia/Melbourne')).toBe(appLine)
134
+ })
135
+ })
@@ -28,6 +28,9 @@ import {
28
28
  isClaudeModel,
29
29
  isBusyRefusalText,
30
30
  isOfflineTrustedModelToken,
31
+ classifyModelSwitchConfirmation,
32
+ parseModelSwitchTarget,
33
+ modelFamilyToken,
31
34
  MODEL_ALIASES,
32
35
  type ModelCommandDeps,
33
36
  } from "../gateway/model-command.js";
@@ -528,3 +531,133 @@ describe("#3177 — routing decision helpers", () => {
528
531
  expect(line).toContain("arg=opus");
529
532
  });
530
533
  });
534
+
535
+ describe("parseModelSwitchTarget", () => {
536
+ it("extracts the target from a menu relaunch reason", () => {
537
+ expect(
538
+ parseModelSwitchTarget("user: /model fable (session-only relaunch, menu)"),
539
+ ).toBe("fable");
540
+ });
541
+ it("extracts the target from a typed relaunch reason", () => {
542
+ expect(
543
+ parseModelSwitchTarget("user: /model sr-gemini-2.5-flash (session-only relaunch)"),
544
+ ).toBe("sr-gemini-2.5-flash");
545
+ });
546
+ it("extracts the default sentinel from a revert reason", () => {
547
+ expect(parseModelSwitchTarget("user: /model default (revert relaunch)")).toBe("default");
548
+ });
549
+ it("returns null for a non-/model reason", () => {
550
+ expect(parseModelSwitchTarget("cli: restart")).toBeNull();
551
+ });
552
+ });
553
+
554
+ describe("classifyModelSwitchConfirmation", () => {
555
+ // The regression this fix closes: a /model fable apply-boot that reverted to
556
+ // the configured default (opus) must be reported as NOT-applied (⚠️), not as a
557
+ // green "✅ Now running opus (the configured default)". Under the OLD inline
558
+ // logic (isApplyBoot ? applied : green-default) this exact case produced the
559
+ // misleading green card — this asserts the corrected outcome.
560
+ it("flags a non-default switch that reverted to the configured default as not-applied", () => {
561
+ const out = classifyModelSwitchConfirmation({
562
+ reason: "user: /model fable (session-only relaunch, menu)",
563
+ launched: "opus",
564
+ configured: "opus",
565
+ });
566
+ expect(out).toEqual({ kind: "not-applied", target: "fable", revertedTo: "opus" });
567
+ });
568
+
569
+ it("reports an applied switch when the launched model differs from the default", () => {
570
+ const out = classifyModelSwitchConfirmation({
571
+ reason: "user: /model fable (session-only relaunch, menu)",
572
+ launched: "fable",
573
+ configured: "opus",
574
+ });
575
+ expect(out).toEqual({ kind: "applied", launched: "fable" });
576
+ });
577
+
578
+ it("reports the default (green) for an intended /model default revert", () => {
579
+ const out = classifyModelSwitchConfirmation({
580
+ reason: "user: /model default (revert relaunch)",
581
+ launched: "opus",
582
+ configured: "opus",
583
+ });
584
+ expect(out).toEqual({ kind: "default", launched: "opus" });
585
+ });
586
+
587
+ it("reports the default (green) when the operator asked for the configured model itself", () => {
588
+ // /model opus while configured=opus is a SUCCESS, not a silent revert.
589
+ const out = classifyModelSwitchConfirmation({
590
+ reason: "user: /model opus (session-only relaunch)",
591
+ launched: "opus",
592
+ configured: "opus",
593
+ });
594
+ expect(out).toEqual({ kind: "default", launched: "opus" });
595
+ });
596
+
597
+ it("is case-insensitive on the target-vs-launched comparison", () => {
598
+ const out = classifyModelSwitchConfirmation({
599
+ reason: "user: /model OPUS (session-only relaunch)",
600
+ launched: "opus",
601
+ configured: "opus",
602
+ });
603
+ expect(out).toEqual({ kind: "default", launched: "opus" });
604
+ });
605
+
606
+ it("treats an empty launched value (unreadable .active-session-model) as a revert to the default", () => {
607
+ const out = classifyModelSwitchConfirmation({
608
+ reason: "user: /model fable (session-only relaunch, menu)",
609
+ launched: "",
610
+ configured: "opus",
611
+ });
612
+ expect(out).toEqual({ kind: "not-applied", target: "fable", revertedTo: "opus" });
613
+ });
614
+
615
+ // LOW-1: an ALIAS target must family-match its RESOLVED full-id revert, so a
616
+ // `/model sonnet` on a `claude-sonnet-5`-default agent (start.sh wrote the full
617
+ // id to .active-session-model on a config-changed / proxy-down revert path) is
618
+ // NOT falsely reported as "sonnet didn't apply". This is the exact case the
619
+ // reviewer flagged; a naive string compare returns not-applied here.
620
+ it("family-matches an alias target to its resolved full-id default (sonnet ≡ claude-sonnet-5)", () => {
621
+ const out = classifyModelSwitchConfirmation({
622
+ reason: "user: /model sonnet (session-only relaunch)",
623
+ launched: "claude-sonnet-5",
624
+ configured: "claude-sonnet-5",
625
+ });
626
+ expect(out).toEqual({ kind: "default", launched: "claude-sonnet-5" });
627
+ });
628
+
629
+ it("still flags a genuinely different-family switch that reverted to the full-id default", () => {
630
+ // /model opus reverting to a claude-sonnet-5 default is a REAL failed switch.
631
+ const out = classifyModelSwitchConfirmation({
632
+ reason: "user: /model opus (session-only relaunch)",
633
+ launched: "claude-sonnet-5",
634
+ configured: "claude-sonnet-5",
635
+ });
636
+ expect(out).toEqual({ kind: "not-applied", target: "opus", revertedTo: "claude-sonnet-5" });
637
+ });
638
+
639
+ it("family-matches a full-id target to the aliased default (claude-opus-4-8 ≡ opus)", () => {
640
+ const out = classifyModelSwitchConfirmation({
641
+ reason: "user: /model claude-opus-4-8 (session-only relaunch)",
642
+ launched: "opus",
643
+ configured: "opus",
644
+ });
645
+ expect(out).toEqual({ kind: "default", launched: "opus" });
646
+ });
647
+ });
648
+
649
+ describe("modelFamilyToken", () => {
650
+ it("reduces a claude full id to its family, dropping version + date", () => {
651
+ expect(modelFamilyToken("claude-sonnet-5")).toBe("sonnet");
652
+ expect(modelFamilyToken("claude-opus-4-8")).toBe("opus");
653
+ expect(modelFamilyToken("claude-haiku-4-5-20251001")).toBe("haiku");
654
+ });
655
+ it("passes a bare alias through, lowercased", () => {
656
+ expect(modelFamilyToken("Sonnet")).toBe("sonnet");
657
+ expect(modelFamilyToken("fable")).toBe("fable");
658
+ });
659
+ it("keeps sr-* routing ids verbatim (no claude- prefix)", () => {
660
+ expect(modelFamilyToken("sr-glm-5")).toBe("sr-glm-5");
661
+ expect(modelFamilyToken("sr-gemini-2.5-flash")).toBe("sr-gemini-2.5-flash");
662
+ });
663
+ });
@@ -17,8 +17,10 @@ import {
17
17
  readSessionModelFileRaw,
18
18
  restoreSessionModelFileRaw,
19
19
  clearSessionModelFile,
20
+ consumeSessionModelCarrierOnHealthyBoot,
20
21
  readConfiguredDefaultModel,
21
22
  SESSION_MODEL_FILE,
23
+ SESSION_MODEL_BOOT_ATTEMPTS_FILE,
22
24
  CONFIGURED_DEFAULT_MODEL_FILE,
23
25
  parseSessionEffort,
24
26
  writeSessionEffortFile,
@@ -80,6 +82,27 @@ describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
80
82
  })
81
83
  })
82
84
 
85
+ describe('consumeSessionModelCarrierOnHealthyBoot (#3284 healthy-boot consume)', () => {
86
+ it('deletes BOTH the carrier and the bounded-retry attempt counter', () => {
87
+ writeFileSync(join(dir, SESSION_MODEL_FILE), 'whatever\n')
88
+ writeFileSync(join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), '2\n')
89
+ consumeSessionModelCarrierOnHealthyBoot(dir)
90
+ expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
91
+ expect(existsSync(join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE))).toBe(false)
92
+ })
93
+
94
+ it('is idempotent / best-effort when neither file exists (no throw)', () => {
95
+ expect(() => consumeSessionModelCarrierOnHealthyBoot(dir)).not.toThrow()
96
+ expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
97
+ })
98
+
99
+ it('clears the counter even when the carrier is already gone (wedge that lost its carrier mid-race)', () => {
100
+ writeFileSync(join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE), '1\n')
101
+ consumeSessionModelCarrierOnHealthyBoot(dir)
102
+ expect(existsSync(join(dir, SESSION_MODEL_BOOT_ATTEMPTS_FILE))).toBe(false)
103
+ })
104
+ })
105
+
83
106
  describe('readConfiguredDefaultModel', () => {
84
107
  it('reads the trimmed value; null when absent or empty', () => {
85
108
  expect(readConfiguredDefaultModel(dir)).toBeNull()