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.
- package/dist/agent-scheduler/index.js +29 -1
- package/dist/auth-broker/index.js +552 -48
- package/dist/cli/autoaccept-poll.js +29 -1
- package/dist/cli/drive-write-pretool.mjs +30 -2
- package/dist/cli/ms-365-write-pretool.mjs +30 -2
- package/dist/cli/switchroom.js +751 -36
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +39 -0
- package/telegram-plugin/dist/gateway/gateway.js +363 -25
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/gateway/gateway.ts +83 -67
- package/telegram-plugin/gateway/model-command.ts +106 -0
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +18 -0
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- 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
|
-
|
|
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**
|
|
271
|
+
`**5h window** ${fiveHour} · \`${formatResetRelative(q.fiveHourResetAt, now)}\``,
|
|
257
272
|
);
|
|
258
273
|
lines.push(
|
|
259
|
-
`**7d window**
|
|
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('
|
|
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 +
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
expect(
|
|
162
|
-
expect(
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
expect(
|
|
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 +
|
|
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', () => {
|