switchroom 0.19.1 → 0.19.2

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 (34) hide show
  1. package/dist/agent-scheduler/index.js +29 -1
  2. package/dist/auth-broker/index.js +552 -48
  3. package/dist/cli/autoaccept-poll.js +29 -1
  4. package/dist/cli/drive-write-pretool.mjs +30 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +30 -2
  6. package/dist/cli/switchroom.js +751 -36
  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/skills/switchroom-cli/SKILL.md +25 -0
  13. package/telegram-plugin/auth-snapshot-format.ts +39 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +363 -25
  15. package/telegram-plugin/external-spend.ts +135 -0
  16. package/telegram-plugin/gateway/gateway.ts +83 -67
  17. package/telegram-plugin/gateway/model-command.ts +106 -0
  18. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  19. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  20. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  21. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  22. package/telegram-plugin/quota-bar-format.ts +18 -0
  23. package/telegram-plugin/quota-check.ts +17 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  25. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  26. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
  27. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  28. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  29. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  30. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  31. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  32. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  33. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  34. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
@@ -360,6 +360,22 @@ function main() {
360
360
  }
361
361
 
362
362
  const input = event.tool_input ?? {}
363
+ // F3 (progress-card fork model): a FORK dispatch (`subagent_type === 'fork'`)
364
+ // inherits the PARENT session's model and IGNORES any `tool_input.model`
365
+ // override. Seeding the row's first-paint model from that ignored override
366
+ // makes the worker card show a WRONG model (e.g. "sonnet" while the fork
367
+ // actually runs Opus) until the watcher overwrites it from the fork's own
368
+ // transcript. Suppress the dispatch-time seed for forks — leave model NULL so
369
+ // the card omits the model rather than showing a value the fork won't honor;
370
+ // the transcript-sourced model then paints as soon as the first assistant
371
+ // line lands (transcript wins, exactly as for non-fork workers). Fixing it at
372
+ // the seed source (not downstream in worker-feed-dispatch) keeps the
373
+ // transcript-confirmed model flowing to the terminal card unchanged.
374
+ const isFork = input.subagent_type === 'fork'
375
+ const dispatchModel =
376
+ !isFork && typeof input.model === 'string' && input.model.length > 0
377
+ ? input.model
378
+ : null
363
379
  // Resolve parent_turn_key from the live turn-active marker (the turn whose
364
380
  // tool call is dispatching this sub-agent). Claude Code's PreToolUse payload
365
381
  // carries only its own session id, never the gateway-minted Telegram turn_key
@@ -384,8 +400,9 @@ function main() {
384
400
  // BEFORE the sub-agent writes its first assistant line. Persisted so the
385
401
  // card can render the model from dispatch; the watcher later overwrites it
386
402
  // from the worker's own transcript (transcript wins). Only a non-empty
387
- // string is stored — never guess from config.
388
- model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
403
+ // string is stored — never guess from config. NULL for a fork dispatch,
404
+ // which ignores the model override (see dispatchModel above, F3).
405
+ model: dispatchModel,
389
406
  now: Date.now(),
390
407
  },
391
408
  (err) => {
@@ -42,6 +42,7 @@ import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
42
42
  import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
43
43
  import { escapeMarkdown } from './card-format.js';
44
44
  import { maskEmail } from './demo-mask.js';
45
+ import { formatExternalSpendBlock } from './external-spend.js';
45
46
 
46
47
  // ── dot thresholds ───────────────────────────────────────────────────
47
48
 
@@ -257,6 +258,17 @@ export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
257
258
  * because in that case there IS real data, just stale.
258
259
  */
259
260
  probeFailed?: boolean;
261
+ /**
262
+ * Optional External (OpenRouter / non-Claude cash) spend block — layout B
263
+ * (operator-locked 2026-07-19). When null/undefined the block is omitted
264
+ * entirely (no error rows). When present (including $0.00), bullets land
265
+ * after the recommendation and before the freshness footer.
266
+ */
267
+ externalSpend?: {
268
+ day24hUsd: number;
269
+ day7dUsd: number;
270
+ top: Array<{ label: string; usd: number }>;
271
+ } | null;
260
272
  }
261
273
 
262
274
  /**
@@ -342,6 +354,12 @@ export function renderUsageCard(
342
354
  const lines = [bar];
343
355
  // Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
344
356
  lines.push(`_${recommendation(snapshots, now, demo)}_`);
357
+ // External cash spend (OpenRouter / non-Claude) — layout B. Omitted when
358
+ // the caller could not fetch a summary (null/undefined: no admin key,
359
+ // timeout, error). Present summary including $0.00 still renders.
360
+ if (opts.externalSpend != null) {
361
+ lines.push(...formatExternalSpendBlock(opts.externalSpend));
362
+ }
345
363
  // Freshness signal: stale-cache warning takes precedence over a live stamp,
346
364
  // which takes precedence over an explicit probe-failed marker (no live data
347
365
  // AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
@@ -252,11 +252,26 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
252
252
  const lines: string[] = [];
253
253
  lines.push("**Claude plan quota**");
254
254
  lines.push("");
255
+ // #2494 Bug C / adversarial-review F3 — a window whose utilization header
256
+ // was absent (a thin probe) has a numeric field that coalesced to 0, so a
257
+ // naive `${pct}%` renders a confident `0%` indistinguishable from a genuine
258
+ // fresh-account 0%. The modern bar card (quota-bar-format.ts ~200) already
259
+ // renders these as "no data"; backport that honesty here. The presence
260
+ // markers are optional — `undefined` means a legacy/real probe (render the
261
+ // percent); only an explicit `false` suppresses the number.
262
+ const fiveHour =
263
+ q.fiveHourUtilPresent === false
264
+ ? "no data"
265
+ : `\`${Math.round(q.fiveHourUtilizationPct)}%\``;
266
+ const sevenDay =
267
+ q.sevenDayUtilPresent === false
268
+ ? "no data"
269
+ : `\`${Math.round(q.sevenDayUtilizationPct)}%\``;
255
270
  lines.push(
256
- `**5h window** \`${Math.round(q.fiveHourUtilizationPct)}%\` · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
271
+ `**5h window** ${fiveHour} · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
257
272
  );
258
273
  lines.push(
259
- `**7d window** \`${Math.round(q.sevenDayUtilizationPct)}%\` · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
274
+ `**7d window** ${sevenDay} · \`${formatResetRelative(q.sevenDayResetAt, now)}\``,
260
275
  );
261
276
  if (q.representativeClaim) {
262
277
  lines.push("");
@@ -58,6 +58,53 @@ describe('activity-card durability wiring', () => {
58
58
  expect(recordCode).not.toMatch(/pinned: true/)
59
59
  })
60
60
 
61
+ // F6 (persist-intent-first ordering): the durable record must be written in
62
+ // the SAME synchronous block as the send, BEFORE the status-pin reconcile and
63
+ // with NO `await` between the send resolving and the persist — otherwise the
64
+ // crash window in which a sent card has no reapable record reopens. Structural
65
+ // lock (the gateway IIFE can't be instantiated in-process); complements the
66
+ // behavioural store/reaper tests in activity-card-store.test.ts.
67
+ it('(F6) persists the record BEFORE the status-pin reconcile, with no await between send and persist', () => {
68
+ const openBranch = between(
69
+ laneSrc,
70
+ 'if (turn.activityMessageId == null) {',
71
+ 'turn.activityLastSentRender = target',
72
+ )
73
+ const persistIdx = openBranch.indexOf('writeActivityCardRecord(')
74
+ const pinIdx = openBranch.indexOf('void reconcileStatusPin(')
75
+ expect(persistIdx).toBeGreaterThanOrEqual(0)
76
+ expect(pinIdx).toBeGreaterThanOrEqual(0)
77
+ // Persist-intent-first: the record write precedes the pin reconcile.
78
+ expect(persistIdx).toBeLessThan(pinIdx)
79
+ // No `await` sits between the send returning and the persist — the whole
80
+ // window from `const sent =` to writeActivityCardRecord( is synchronous.
81
+ const sendToPersist = between(openBranch, 'const sent = await robustApiCall', 'writeActivityCardRecord(')
82
+ const codeOnly = sendToPersist
83
+ .split('\n')
84
+ .filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
85
+ .join('\n')
86
+ expect(codeOnly).not.toMatch(/\bawait\b/)
87
+ })
88
+
89
+ // F5 (persist-intent honesty): the persisted `pinned` mirrors the ACTUAL pin
90
+ // decision (PIN_STATUS_WHILE_WORKING), never a bare `pinned: true`. This is
91
+ // also asserted in the OPEN test above; kept as a named F5 lock so a rename or
92
+ // refactor that reintroduces an unconditional `pinned: true` is caught here too.
93
+ it('(F5) the persisted record mirrors the real pin decision, not an unconditional pinned:true', () => {
94
+ const openBranch = between(
95
+ laneSrc,
96
+ 'if (turn.activityMessageId == null) {',
97
+ 'turn.activityLastSentRender = target',
98
+ )
99
+ const recordBlock = between(openBranch, 'writeActivityCardRecord(', 'void reconcileStatusPin(')
100
+ const recordCode = recordBlock
101
+ .split('\n')
102
+ .filter((l) => !l.trim().startsWith('//'))
103
+ .join('\n')
104
+ expect(recordCode).toMatch(/pinned: PIN_STATUS_WHILE_WORKING/)
105
+ expect(recordCode).not.toMatch(/pinned: true/)
106
+ })
107
+
61
108
  it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
62
109
  const closeBody = between(
63
110
  laneSrc,
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Unit tests for External OpenRouter/$ spend (layout B on /usage).
3
+ * Pure filter/format coverage lives primarily in
4
+ * `src/litellm/external-spend.test.ts`. This file covers the card
5
+ * block formatter + broker-backed fetch ExternalSpendSummary path.
6
+ */
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
8
+ import {
9
+ formatExternalSpendBlock,
10
+ fetchExternalSpendSummary,
11
+ clearExternalSpendCache,
12
+ formatUsd,
13
+ isExternalModel,
14
+ shortModelLabel,
15
+ summarizeExternalSpend,
16
+ } from '../external-spend.js';
17
+ import { renderUsageCard } from '../quota-bar-format.js';
18
+ import type { AccountSnapshot } from '../auth-snapshot-format.js';
19
+ import type { QuotaUtilization } from '../quota-check.js';
20
+
21
+ const NOW = new Date('2026-07-19T15:30:00.000Z');
22
+
23
+ beforeEach(() => {
24
+ clearExternalSpendCache();
25
+ });
26
+
27
+ describe('re-exported pure helpers', () => {
28
+ it('filter / labels / money still work from the telegram module', () => {
29
+ expect(isExternalModel('openrouter/openai/gpt-oss-20b')).toBe(true);
30
+ expect(isExternalModel('claude-sonnet-5')).toBe(false);
31
+ expect(shortModelLabel('openrouter/x-ai/grok-4.5')).toBe('grok-4.5');
32
+ expect(formatUsd(8.07)).toBe('$8.07');
33
+ const s = summarizeExternalSpend(
34
+ [
35
+ {
36
+ startTime: '2026-07-19',
37
+ models: { 'openrouter/openai/gpt-oss-20b': 1.5, 'claude-sonnet-5': 9 },
38
+ },
39
+ ],
40
+ NOW,
41
+ );
42
+ expect(s.day24hUsd).toBeCloseTo(1.5, 5);
43
+ });
44
+ });
45
+
46
+ describe('formatExternalSpendBlock', () => {
47
+ it('renders locked layout B', () => {
48
+ expect(
49
+ formatExternalSpendBlock({
50
+ day24hUsd: 8.07,
51
+ day7dUsd: 119.89,
52
+ top: [
53
+ { label: 'gpt-oss-20b', usd: 6.1 },
54
+ { label: 'grok-4.5', usd: 1.18 },
55
+ { label: 'gemini-3.1-flash-lite', usd: 0.79 },
56
+ ],
57
+ }),
58
+ ).toEqual([
59
+ '- 💸 External',
60
+ '- 24h `$8.07` · 7d `$119.89`',
61
+ '- top `gpt-oss-20b $6.10` · `grok-4.5 $1.18` · `gemini-3.1-flash-lite $0.79`',
62
+ ]);
63
+ });
64
+
65
+ it('omits top and returns [] for null', () => {
66
+ expect(formatExternalSpendBlock({ day24hUsd: 0, day7dUsd: 0, top: [] })).toEqual([
67
+ '- 💸 External',
68
+ '- 24h `$0.00` · 7d `$0.00`',
69
+ ]);
70
+ expect(formatExternalSpendBlock(null)).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe('fetchExternalSpendSummary (broker path)', () => {
75
+ it('returns null when broker says unavailable', async () => {
76
+ const s = await fetchExternalSpendSummary({
77
+ getExternalSpend: async () => ({ available: false, reason: 'master_key_unavailable' }),
78
+ bypassCache: true,
79
+ });
80
+ expect(s).toBeNull();
81
+ });
82
+
83
+ it('maps available broker payload + caches', async () => {
84
+ let calls = 0;
85
+ const getExternalSpend = vi.fn(async () => {
86
+ calls += 1;
87
+ return {
88
+ available: true,
89
+ day24hUsd: 1.5,
90
+ day7dUsd: 10,
91
+ top: [{ label: 'gpt-oss-20b', usd: 1.5 }],
92
+ served: 'live' as const,
93
+ capturedAtMs: Date.now(),
94
+ };
95
+ });
96
+ const first = await fetchExternalSpendSummary({
97
+ getExternalSpend,
98
+ bypassCache: true,
99
+ });
100
+ expect(first).toEqual({
101
+ day24hUsd: 1.5,
102
+ day7dUsd: 10,
103
+ top: [{ label: 'gpt-oss-20b', usd: 1.5 }],
104
+ });
105
+ const second = await fetchExternalSpendSummary({ getExternalSpend });
106
+ expect(second?.day24hUsd).toBeCloseTo(1.5, 5);
107
+ expect(calls).toBe(1);
108
+ });
109
+
110
+ it('returns null on thrown broker error', async () => {
111
+ const s = await fetchExternalSpendSummary({
112
+ getExternalSpend: async () => {
113
+ throw new Error('unreachable');
114
+ },
115
+ bypassCache: true,
116
+ });
117
+ expect(s).toBeNull();
118
+ });
119
+ });
120
+
121
+ describe('renderUsageCard + externalSpend', () => {
122
+ function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
123
+ return {
124
+ fiveHourUtilizationPct: 0,
125
+ sevenDayUtilizationPct: 0,
126
+ fiveHourResetAt: null,
127
+ sevenDayResetAt: null,
128
+ representativeClaim: null,
129
+ overageStatus: null,
130
+ overageDisabledReason: null,
131
+ ...part,
132
+ };
133
+ }
134
+ const snapshots: AccountSnapshot[] = [
135
+ {
136
+ label: 'ken@example.com',
137
+ isActive: true,
138
+ quota: quota({
139
+ fiveHourUtilizationPct: 0,
140
+ sevenDayUtilizationPct: 47,
141
+ fiveHourResetAt: new Date(NOW.getTime() + 60 * 60_000),
142
+ sevenDayResetAt: new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000),
143
+ }),
144
+ },
145
+ ];
146
+ const exhausted = new Map<string, boolean>([['ken@example.com', false]]);
147
+
148
+ it('inserts External between recommendation and freshness', () => {
149
+ const out = renderUsageCard(snapshots, exhausted, {
150
+ now: NOW,
151
+ externalSpend: {
152
+ day24hUsd: 8.07,
153
+ day7dUsd: 119.89,
154
+ top: [{ label: 'gpt-oss-20b', usd: 6.1 }],
155
+ },
156
+ });
157
+ const lines = out.split('\n');
158
+ const recIdx = lines.findIndex((l) => l.includes('Recommendation'));
159
+ const extIdx = lines.findIndex((l) => l.includes('💸 External'));
160
+ expect(extIdx).toBe(recIdx + 1);
161
+ expect(lines[lines.length - 1]).toBe('_Live_');
162
+ });
163
+
164
+ it('omits External when null', () => {
165
+ const out = renderUsageCard(snapshots, exhausted, { now: NOW, externalSpend: null });
166
+ expect(out).not.toContain('💸 External');
167
+ });
168
+ });
@@ -16,8 +16,13 @@ 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'
19
20
 
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url))
22
+ const MODEL_COMMAND_SRC = readFileSync(
23
+ resolve(__dirname, '../gateway/model-command.ts'),
24
+ 'utf8',
25
+ )
21
26
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
22
27
 
23
28
  describe('gateway: the .relaunch-model-intent subsystem is retired (rev 4)', () => {
@@ -148,44 +153,47 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
148
153
  })
149
154
 
150
155
  it('logs the applied model for diagnosability (F1)', () => {
151
- expect(GATEWAY_SRC).toContain('gw /model relaunch applied agent=')
156
+ expect(GATEWAY_SRC).toContain('formatModelRelaunchDiagLog')
152
157
  expect(GATEWAY_SRC).toContain('gw /model relaunch scheduled agent=')
158
+ expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch applied agent=')
159
+ })
160
+
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')
153
170
  })
154
171
 
155
172
  it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
156
173
  const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
157
174
  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)')
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)')
165
180
  })
166
181
 
167
182
  it('warns instead of a green ✅ when a non-default switch silently reverted to the default (silent-revert fix)', () => {
168
- 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.
172
- 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}\\`')
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 ')
180
189
  })
181
190
 
182
191
  it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
183
192
  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).
193
+ const win = GATEWAY_SRC.slice(idx, idx + 2500)
187
194
  expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
188
195
  expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
196
+ expect(win).toContain('formatModelRelaunchSuppressNotAppliedLog')
189
197
  })
190
198
 
191
199
  it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
@@ -213,6 +221,32 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
213
221
  })
214
222
  })
215
223
 
224
+
225
+ describe('gateway SESSION_MODEL_FILE stays pinned to start.sh.hbs rev5 carrier', () => {
226
+ it('hbs applies the same basename gateway writes (no rev4/rev5 name drift)', async () => {
227
+ const { SESSION_MODEL_FILE } = await import('../gateway/session-model-file.js')
228
+ const { readFileSync } = await import('node:fs')
229
+ const { resolve, dirname } = await import('node:path')
230
+ const { fileURLToPath } = await import('node:url')
231
+ expect(SESSION_MODEL_FILE).toBe('.session-model')
232
+ // Vitest may run with import.meta or __dirname depending on config.
233
+ const here = typeof __dirname !== 'undefined'
234
+ ? __dirname
235
+ : dirname(fileURLToPath(import.meta.url))
236
+ const hbs = readFileSync(
237
+ resolve(here, '../../profiles/_base/start.sh.hbs'),
238
+ 'utf8',
239
+ )
240
+ // Bare file test on the rev5 carrier (not only -override/-alert siblings).
241
+ expect(hbs).toMatch(/\[\s*-f\s+[^\]]*\/\.session-model["\s\]]/)
242
+ expect(hbs).toContain('configuredDefaultAtWrite')
243
+ // Legacy migration shim still converts leftover override → .session-model.
244
+ expect(hbs).toContain('.session-model-override')
245
+ expect(hbs).toMatch(/migrated legacy one-shot carrier/)
246
+ })
247
+ })
248
+
249
+
216
250
  describe('gateway: the legacy one-shot carrier is no longer written', () => {
217
251
  it('no gateway code writes .session-model-override anymore (start.sh migration shim only reads it)', () => {
218
252
  expect(GATEWAY_SRC).not.toMatch(/writeFileSync\([^)]*\.session-model-override/)
@@ -441,4 +441,47 @@ describe('renderUsageCard', () => {
441
441
  const out = renderUsageCard(snapshots, exhausted, { now: NOW });
442
442
  expect(out).toContain('- **ken@example.com** (active)');
443
443
  });
444
+
445
+ it('inserts External layout-B block after recommendation and before freshness', () => {
446
+ const out = renderUsageCard(snapshots, exhausted, {
447
+ now: NOW,
448
+ externalSpend: {
449
+ day24hUsd: 8.07,
450
+ day7dUsd: 119.89,
451
+ top: [
452
+ { label: 'gpt-oss-20b', usd: 6.1 },
453
+ { label: 'grok-4.5', usd: 1.18 },
454
+ { label: 'gemini-3.1-flash-lite', usd: 0.79 },
455
+ ],
456
+ },
457
+ });
458
+ const lines = out.split('\n');
459
+ // recommendation then External header then totals then top then freshness
460
+ const recIdx = lines.findIndex((l) => l.includes('Recommendation:'));
461
+ expect(recIdx).toBeGreaterThan(0);
462
+ expect(lines[recIdx + 1]).toBe('- 💸 External');
463
+ expect(lines[recIdx + 2]).toBe('- 24h `$8.07` · 7d `$119.89`');
464
+ expect(lines[recIdx + 3]).toBe(
465
+ '- top `gpt-oss-20b $6.10` · `grok-4.5 $1.18` · `gemini-3.1-flash-lite $0.79`',
466
+ );
467
+ expect(lines[lines.length - 1]).toBe('_Live_');
468
+ });
469
+
470
+ it('omits External block when externalSpend is null/undefined', () => {
471
+ const bare = renderUsageCard(snapshots, exhausted, { now: NOW });
472
+ const withNull = renderUsageCard(snapshots, exhausted, { now: NOW, externalSpend: null });
473
+ expect(bare).not.toContain('💸 External');
474
+ expect(withNull).not.toContain('💸 External');
475
+ expect(bare).toBe(withNull);
476
+ });
477
+
478
+ it('still renders External block at $0.00 when summary is present', () => {
479
+ const out = renderUsageCard(snapshots, exhausted, {
480
+ now: NOW,
481
+ externalSpend: { day24hUsd: 0, day7dUsd: 0, top: [] },
482
+ });
483
+ expect(out).toContain('- 💸 External');
484
+ expect(out).toContain('- 24h `$0.00` · 7d `$0.00`');
485
+ expect(out).not.toContain('- top ');
486
+ });
444
487
  });
@@ -118,6 +118,63 @@ describe('formatQuotaBlock', () => {
118
118
  })
119
119
  expect(block).toContain('Overage: disabled (spend_cap_reached)')
120
120
  })
121
+
122
+ it('renders "no data" (never a confident 0%) for an absent window header (F3)', () => {
123
+ // Adversarial-review F3 — a thin probe leaves the utilization field
124
+ // coalesced to 0 but flags the window absent. The legacy block renderer
125
+ // used to print a confident `0%`, indistinguishable from a genuine
126
+ // fresh-account 0%. Backport the modern card's handling: absent → no data.
127
+ const block = formatQuotaBlock({
128
+ fiveHourUtilizationPct: 0,
129
+ sevenDayUtilizationPct: 0,
130
+ fiveHourResetAt: null,
131
+ sevenDayResetAt: null,
132
+ representativeClaim: null,
133
+ overageStatus: null,
134
+ overageDisabledReason: null,
135
+ fiveHourUtilPresent: false,
136
+ sevenDayUtilPresent: false,
137
+ })
138
+ const fiveLine = block.split('\n').find((l) => l.includes('5h window'))!
139
+ const sevenLine = block.split('\n').find((l) => l.includes('7d window'))!
140
+ expect(fiveLine).toContain('no data')
141
+ expect(sevenLine).toContain('no data')
142
+ // The confident-0% lie must be gone.
143
+ expect(fiveLine).not.toContain('`0%`')
144
+ expect(sevenLine).not.toContain('`0%`')
145
+ })
146
+
147
+ it('still renders the percent when a window is present but genuinely 0% (F3 guard)', () => {
148
+ const block = formatQuotaBlock({
149
+ fiveHourUtilizationPct: 0,
150
+ sevenDayUtilizationPct: 0,
151
+ fiveHourResetAt: null,
152
+ sevenDayResetAt: null,
153
+ representativeClaim: null,
154
+ overageStatus: null,
155
+ overageDisabledReason: null,
156
+ fiveHourUtilPresent: true,
157
+ sevenDayUtilPresent: true,
158
+ })
159
+ expect(block).toContain('`0%`')
160
+ expect(block).not.toContain('no data')
161
+ })
162
+
163
+ it('renders the percent for legacy snapshots with no presence markers (F3 back-compat)', () => {
164
+ // Optional markers unset → real/legacy probe → render the number.
165
+ const block = formatQuotaBlock({
166
+ fiveHourUtilizationPct: 0,
167
+ sevenDayUtilizationPct: 5,
168
+ fiveHourResetAt: null,
169
+ sevenDayResetAt: null,
170
+ representativeClaim: null,
171
+ overageStatus: null,
172
+ overageDisabledReason: null,
173
+ })
174
+ expect(block).toContain('`0%`')
175
+ expect(block).toContain('`5%`')
176
+ expect(block).not.toContain('no data')
177
+ })
121
178
  })
122
179
 
123
180
  describe('fetchQuota', () => {