switchroom 0.16.29 → 0.16.46
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 +101 -84
- package/dist/auth-broker/index.js +100 -83
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +102 -85
- package/dist/cli/skill-validate-pretool.mjs +91 -91
- package/dist/cli/switchroom.js +62124 -60406
- package/dist/cli/ui/index.html +877 -214
- package/dist/host-control/main.js +1000 -256
- package/dist/vault/approvals/kernel-server.js +141 -121
- package/dist/vault/broker/server.js +163 -143
- package/examples/minimal.yaml +1 -1
- package/examples/switchroom.yaml +1 -1
- package/package.json +3 -2
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/profiles/_shared/reply-discipline.md.hbs +9 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/bridge/bridge.ts +2 -1
- package/telegram-plugin/card-format.ts +59 -3
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/bridge/bridge.js +132 -114
- package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
- package/telegram-plugin/dist/server.js +180 -163
- package/telegram-plugin/format.ts +551 -20
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1524 -129
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/model-command.ts +173 -19
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/session-tail.ts +47 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +33 -2
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +79 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/format-consistency.test.ts +223 -0
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
- package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/model-command.test.ts +213 -47
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +121 -8
- package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
- package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
- package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
- package/telegram-plugin/text-voice-scrub.ts +68 -18
- package/telegram-plugin/tool-activity-summary.ts +31 -116
- package/telegram-plugin/tts-normalize.ts +377 -0
- package/telegram-plugin/uat/driver.ts +474 -17
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
- package/telegram-plugin/uat/uat-driver.test.ts +297 -0
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +289 -0
- package/telegram-plugin/voice-presynth.ts +242 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +22 -5
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
bindingWindow,
|
|
12
12
|
formatRelative,
|
|
13
13
|
formatAbsolute,
|
|
14
|
+
formatStatusTime,
|
|
14
15
|
fmtPct,
|
|
15
16
|
recommendation,
|
|
16
17
|
renderAuthSnapshotFormat2,
|
|
@@ -133,6 +134,14 @@ describe('fmtPct', () => {
|
|
|
133
134
|
expect(fmtPct(8.6)).toBe('9%');
|
|
134
135
|
expect(fmtPct(99.6)).toBe('100%');
|
|
135
136
|
});
|
|
137
|
+
it('CLAMPS the displayed value to 100% — never shows over 100 (trust)', () => {
|
|
138
|
+
// Anthropic returns a decimal that can exceed 1.0; quota-check multiplies by
|
|
139
|
+
// 100 → e.g. 1.01 → 101. The displayed number must clamp to 100%.
|
|
140
|
+
expect(fmtPct(101)).toBe('100%');
|
|
141
|
+
expect(fmtPct(100.4)).toBe('100%');
|
|
142
|
+
expect(fmtPct(250)).toBe('100%');
|
|
143
|
+
expect(fmtPct(1.01 * 100)).toBe('100%');
|
|
144
|
+
});
|
|
136
145
|
});
|
|
137
146
|
|
|
138
147
|
// ── formatAbsolute ───────────────────────────────────────────────────
|
|
@@ -149,6 +158,52 @@ describe('formatAbsolute', () => {
|
|
|
149
158
|
});
|
|
150
159
|
});
|
|
151
160
|
|
|
161
|
+
// ── formatStatusTime (date shown only when NOT today, in user tz) ─────
|
|
162
|
+
describe('formatStatusTime', () => {
|
|
163
|
+
// Fixed injected now + fixed tz → fully deterministic (no wall-clock).
|
|
164
|
+
// 2026-05-15T05:00:00Z = Fri 15 May 3:00 PM in Australia/Melbourne (AEST,
|
|
165
|
+
// UTC+10). May is winter there → AEST, not AEDT — the tz database handles it.
|
|
166
|
+
const MEL = 'Australia/Melbourne';
|
|
167
|
+
const NOW_MEL = new Date('2026-05-15T05:00:00Z'); // Fri 3:00 PM Melbourne
|
|
168
|
+
|
|
169
|
+
it('shows TIME ONLY when the target is the same calendar day (user tz)', () => {
|
|
170
|
+
// 2026-05-15T13:00:00Z = Fri 11:00 PM Melbourne — same Melbourne day as now.
|
|
171
|
+
const out = formatStatusTime(new Date('2026-05-15T13:00:00Z'), NOW_MEL, MEL);
|
|
172
|
+
expect(out).toBe('11:00 PM');
|
|
173
|
+
// No weekday/date leaks on a same-day target.
|
|
174
|
+
expect(out).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('includes the WEEKDAY when the target is a different day (within the week)', () => {
|
|
178
|
+
// 2026-05-15T15:00:00Z = Sat 16 May 1:00 AM Melbourne — next Melbourne day.
|
|
179
|
+
const out = formatStatusTime(new Date('2026-05-15T15:00:00Z'), NOW_MEL, MEL);
|
|
180
|
+
expect(out).toBe('Sat 1:00 AM');
|
|
181
|
+
expect(out).toMatch(/^Sat\b/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('includes day-of-month + month when the target is beyond the current week', () => {
|
|
185
|
+
// ~9 days out: 2026-05-24T01:00:00Z = Sun 24 May 11:00 AM Melbourne.
|
|
186
|
+
const out = formatStatusTime(new Date('2026-05-24T01:00:00Z'), NOW_MEL, MEL);
|
|
187
|
+
// Intl en-US renders the weekday+day+month as "Sun, May 24".
|
|
188
|
+
expect(out).toBe('Sun, May 24 11:00 AM');
|
|
189
|
+
expect(out).toMatch(/^Sun\b/);
|
|
190
|
+
expect(out).toContain('May 24');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('is tz-correct — same instant renders differently in UTC vs Melbourne', () => {
|
|
194
|
+
const instant = new Date('2026-05-15T15:00:00Z');
|
|
195
|
+
// In UTC that instant is still Fri 15 May 3:00 PM → same day as NOW_MEL's UTC
|
|
196
|
+
// wall date — but NOW in UTC terms is also 2026-05-15, so time-only.
|
|
197
|
+
expect(formatStatusTime(instant, NOW_MEL, 'UTC')).toBe('3:00 PM');
|
|
198
|
+
// In Melbourne it has crossed midnight into Saturday.
|
|
199
|
+
expect(formatStatusTime(instant, NOW_MEL, MEL)).toBe('Sat 1:00 AM');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns "—" for a null target', () => {
|
|
203
|
+
expect(formatStatusTime(null, NOW_MEL, MEL)).toBe('—');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
152
207
|
// ── renderAuthSnapshotFormat2 ────────────────────────────────────────
|
|
153
208
|
|
|
154
209
|
describe('renderAuthSnapshotFormat2', () => {
|
|
@@ -191,42 +246,120 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
191
246
|
}),
|
|
192
247
|
];
|
|
193
248
|
|
|
194
|
-
|
|
249
|
+
// Helper: parse the GFM table rows (the lines between the header separator
|
|
250
|
+
// and the trailing blank line) into their cell arrays.
|
|
251
|
+
function tableRows(out: string): string[][] {
|
|
252
|
+
const lines = out.split('\n');
|
|
253
|
+
const sep = lines.findIndex((l) => /^\|\s*---/.test(l));
|
|
254
|
+
if (sep < 0) return [];
|
|
255
|
+
const rows: string[][] = [];
|
|
256
|
+
for (let i = sep + 1; i < lines.length; i++) {
|
|
257
|
+
const l = lines[i];
|
|
258
|
+
if (!l.startsWith('|')) break;
|
|
259
|
+
rows.push(l.split('|').slice(1, -1).map((c) => c.trim()));
|
|
260
|
+
}
|
|
261
|
+
return rows;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('renders a GFM table with the State/Account/5h/7d/Status header', () => {
|
|
195
265
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
196
|
-
// Headers present
|
|
197
266
|
expect(out).toContain('🔋 **Auth — fleet status**');
|
|
198
|
-
expect(out).toContain('
|
|
199
|
-
expect(out).toContain('
|
|
200
|
-
//
|
|
201
|
-
expect(out
|
|
267
|
+
expect(out).toContain('| State | Account | 5h | 7d | Status |');
|
|
268
|
+
expect(out).toContain('| --- | --- | --- | --- | --- |');
|
|
269
|
+
// No legacy group headers / health-section titles remain.
|
|
270
|
+
expect(out).not.toContain('**BLOCKED**');
|
|
271
|
+
expect(out).not.toContain('**HEALTHY**');
|
|
202
272
|
});
|
|
203
273
|
|
|
204
|
-
it('
|
|
205
|
-
const
|
|
206
|
-
expect(
|
|
274
|
+
it('sorts the ACTIVE account to the top row regardless of health group', () => {
|
|
275
|
+
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
276
|
+
expect(rows.length).toBe(3);
|
|
277
|
+
// you@example.com is active + healthy; bob is blocked. Active still wins
|
|
278
|
+
// the top row over the blocked account.
|
|
279
|
+
expect(rows[0][1]).toContain('you@example.com');
|
|
207
280
|
});
|
|
208
281
|
|
|
209
|
-
it('
|
|
282
|
+
it('labels the active account with " (active)" and NO black-dot marker', () => {
|
|
210
283
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
211
|
-
|
|
212
|
-
|
|
284
|
+
expect(out).toContain('you@example.com (active)');
|
|
285
|
+
// The ⚫/● active marker is removed entirely.
|
|
286
|
+
expect(out).not.toContain('⚫');
|
|
287
|
+
expect(out).not.toContain('●');
|
|
213
288
|
});
|
|
214
289
|
|
|
215
|
-
it('
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
290
|
+
it('shows the FULL email address, never truncated', () => {
|
|
291
|
+
const longSnaps = [
|
|
292
|
+
snap({ label: 'a-very-long-account-name@really-long-domain.example.com', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
293
|
+
];
|
|
294
|
+
const rows = tableRows(renderAuthSnapshotFormat2(longSnaps, { now: NOW, tz: 'UTC' }));
|
|
295
|
+
expect(rows[0][1]).toContain('a-very-long-account-name@really-long-domain.example.com');
|
|
296
|
+
expect(rows[0][1]).not.toContain('…');
|
|
297
|
+
expect(rows[0][1]).not.toContain('...');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('orders non-active rows blocked-before-healthy', () => {
|
|
301
|
+
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
302
|
+
// After the active row (you), the blocked bob must precede the healthy alice.
|
|
303
|
+
const bob = rows.findIndex((r) => r[1].includes('bob@example.com'));
|
|
304
|
+
const alice = rows.findIndex((r) => r[1].includes('alice@example.com'));
|
|
305
|
+
expect(bob).toBeGreaterThan(0);
|
|
306
|
+
expect(bob).toBeLessThan(alice);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('Status column shows "back <when>" for a blocked account', () => {
|
|
310
|
+
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
311
|
+
const bob = rows.find((r) => r[1].includes('bob@example.com'))!;
|
|
312
|
+
expect(bob[4]).toMatch(/^back .* \(in .+\)$/);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('Status column shows "refills <when>" for a healthy account', () => {
|
|
316
|
+
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
317
|
+
const you = rows.find((r) => r[1].includes('you@example.com'))!;
|
|
318
|
+
expect(you[4]).toMatch(/^refills .* \(in .+\)$/);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('NEVER displays a percentage over 100% even on an over-cap account', () => {
|
|
322
|
+
// Feed 1.01 (decimal) * 100 = 101 → must clamp to 100% in the cell.
|
|
323
|
+
const overSnaps = [
|
|
324
|
+
snap({
|
|
325
|
+
label: 'over@example.com',
|
|
326
|
+
isActive: true,
|
|
327
|
+
quota: quota({ fiveHourUtilizationPct: 101, sevenDayUtilizationPct: 250 }),
|
|
328
|
+
}),
|
|
329
|
+
];
|
|
330
|
+
const rows = tableRows(renderAuthSnapshotFormat2(overSnaps, { now: NOW, tz: 'UTC' }));
|
|
331
|
+
expect(rows[0][2]).toBe('100%'); // 5h
|
|
332
|
+
expect(rows[0][3]).toBe('100%'); // 7d
|
|
333
|
+
// And the blocked state still surfaces via the emoji (🔴) + Status.
|
|
334
|
+
expect(rows[0][0]).toBe('🔴');
|
|
335
|
+
expect(rows[0][4]).toMatch(/^back/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('renders dates in the Status column only when NOT today (user tz)', () => {
|
|
339
|
+
// now = Fri 3:00 PM Melbourne. A reset later the SAME Melbourne day shows
|
|
340
|
+
// time-only; a reset on the next day shows the weekday.
|
|
341
|
+
const MEL = 'Australia/Melbourne';
|
|
342
|
+
const NOW_MEL = new Date('2026-05-15T05:00:00Z'); // Fri 3:00 PM Mel
|
|
343
|
+
const sameDay = [
|
|
344
|
+
snap({ label: 'today@example.com', isActive: true, quota: quota({
|
|
345
|
+
fiveHourUtilizationPct: 5,
|
|
346
|
+
fiveHourResetAt: new Date('2026-05-15T11:00:00Z'), // Fri 9:00 PM Mel (today)
|
|
347
|
+
sevenDayResetAt: new Date('2026-05-22T11:00:00Z'),
|
|
348
|
+
}) }),
|
|
349
|
+
];
|
|
350
|
+
const todayRows = tableRows(renderAuthSnapshotFormat2(sameDay, { now: NOW_MEL, tz: MEL }));
|
|
351
|
+
expect(todayRows[0][4]).toContain('9:00 PM');
|
|
352
|
+
expect(todayRows[0][4]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
|
|
353
|
+
|
|
354
|
+
const nextDay = [
|
|
355
|
+
snap({ label: 'tomorrow@example.com', isActive: true, quota: quota({
|
|
356
|
+
fiveHourUtilizationPct: 5,
|
|
357
|
+
fiveHourResetAt: new Date('2026-05-15T15:00:00Z'), // Sat 1:00 AM Mel (tomorrow)
|
|
358
|
+
sevenDayResetAt: new Date('2026-05-22T11:00:00Z'),
|
|
359
|
+
}) }),
|
|
360
|
+
];
|
|
361
|
+
const tomorrowRows = tableRows(renderAuthSnapshotFormat2(nextDay, { now: NOW_MEL, tz: MEL }));
|
|
362
|
+
expect(tomorrowRows[0][4]).toContain('Sat 1:00 AM');
|
|
230
363
|
});
|
|
231
364
|
|
|
232
365
|
it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
|
|
@@ -251,9 +384,10 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
251
384
|
snap({ label: 'broken@x', isActive: true, quota: null, quotaError: 'HTTP 401' }),
|
|
252
385
|
];
|
|
253
386
|
const out = renderAuthSnapshotFormat2(errSnaps, { now: NOW });
|
|
254
|
-
|
|
387
|
+
// Probe-failed row: ⚪ State emoji + a "probe failed (...)" Status cell.
|
|
388
|
+
expect(out).toContain('probe failed');
|
|
255
389
|
expect(out).toContain('HTTP 401');
|
|
256
|
-
expect(out).toContain('⚪
|
|
390
|
+
expect(out).toContain('⚪');
|
|
257
391
|
});
|
|
258
392
|
|
|
259
393
|
it('renders refresh stamp when liveProbedAtMs given', () => {
|
|
@@ -300,9 +434,8 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
300
434
|
expect(out).not.toContain('alice@example.com');
|
|
301
435
|
expect(out).not.toContain('bob@example.com');
|
|
302
436
|
expect(out).not.toContain('you@example.com');
|
|
303
|
-
// Masked fakes present
|
|
304
|
-
|
|
305
|
-
expect(out).toMatch(/`[^@<]+@example\.com`/);
|
|
437
|
+
// Masked fakes present — plain table cells (no backtick wrap now).
|
|
438
|
+
expect(out).toMatch(/[^@\s<]+@example\.com/);
|
|
306
439
|
});
|
|
307
440
|
|
|
308
441
|
it('WITH demo, the recommendation footer masks the active label', () => {
|
|
@@ -371,6 +504,33 @@ describe('renderFallbackAnnouncement', () => {
|
|
|
371
504
|
expect(out7).toContain('7-day limit on me@x');
|
|
372
505
|
});
|
|
373
506
|
|
|
507
|
+
// Context-correct escaping (#2695 fix): the swap announcement renders each
|
|
508
|
+
// label in TWO contexts, and each demands a different treatment —
|
|
509
|
+
// • **bold** header / trigger-agent → escapeMarkdown (backslash-escaped, so
|
|
510
|
+
// an underscore can't open an emphasis run), and
|
|
511
|
+
// • `code span` (the old→new line, recovery line) → codeSpanSafe (LITERAL,
|
|
512
|
+
// because backslash escaping is inert inside a code span and would show a
|
|
513
|
+
// visible `old\_a\*x`).
|
|
514
|
+
it('escapes labels in the bold header but renders them literally in code spans (#2695)', () => {
|
|
515
|
+
const out = renderFallbackAnnouncement({
|
|
516
|
+
oldLabel: 'old_a*x',
|
|
517
|
+
oldQuota: KEN_5H_BLOWN,
|
|
518
|
+
newLabel: 'new_b*y',
|
|
519
|
+
newQuota: YOU_HEALTHY,
|
|
520
|
+
triggerAgent: 'agent_z*q',
|
|
521
|
+
now: NOW,
|
|
522
|
+
tz: 'UTC',
|
|
523
|
+
});
|
|
524
|
+
// Emphasis contexts: backslash-escaped.
|
|
525
|
+
expect(out).toContain('old\\_a\\*x'); // bold header "· … on old_a*x"
|
|
526
|
+
expect(out).toContain('agent\\_z\\*q'); // "Triggered by: agent **…**"
|
|
527
|
+
// Code-span contexts: literal, no stray backslash.
|
|
528
|
+
expect(out).toContain('`old_a*x`');
|
|
529
|
+
expect(out).toContain('`new_b*y`');
|
|
530
|
+
expect(out).not.toContain('`old\\_a\\*x`');
|
|
531
|
+
expect(out).not.toContain('`new\\_b\\*y`');
|
|
532
|
+
});
|
|
533
|
+
|
|
374
534
|
it('names the triggering agent + recovery countdown for the old account', () => {
|
|
375
535
|
const out = renderFallbackAnnouncement({
|
|
376
536
|
oldLabel: 'ken@x',
|
|
@@ -858,23 +1018,20 @@ describe('#2494 Bug C — blockedReason + thin probe (out_of_credits now informa
|
|
|
858
1018
|
// ── #2494 — row rendering: out_of_credits as informational annotation ─────────
|
|
859
1019
|
|
|
860
1020
|
describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demoted)', () => {
|
|
861
|
-
it('out_of_credits at 0% util → healthy row
|
|
862
|
-
// THE KEY CHANGE: carol scenario — 0% util, out_of_credits → healthy
|
|
863
|
-
//
|
|
1021
|
+
it('out_of_credits at 0% util → healthy (🟢) row, NOT blocked', () => {
|
|
1022
|
+
// THE KEY CHANGE: carol scenario — 0% util, out_of_credits → healthy. In the
|
|
1023
|
+
// table this surfaces as a 🟢 State emoji, not 🔴 — the row is not blocked.
|
|
864
1024
|
const out = renderAuthSnapshotFormat2(
|
|
865
1025
|
[snap({ label: 'dead@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 0, overageDisabledReason: 'out_of_credits' }) })],
|
|
866
1026
|
{ now: NOW },
|
|
867
1027
|
);
|
|
868
|
-
|
|
869
|
-
expect(out).toContain('
|
|
870
|
-
|
|
871
|
-
// Must have informational annotation. #2669: the reason is markdown-
|
|
872
|
-
// escaped, so the underscores in "out_of_credits" render as "out\_of\_credits".
|
|
873
|
-
expect(out).toContain('overage off (out\\_of\\_credits) — serving from quota');
|
|
874
|
-
// Must NOT have old blocked framing
|
|
1028
|
+
expect(out).toContain('| 🟢 |');
|
|
1029
|
+
expect(out).not.toContain('| 🔴 |');
|
|
1030
|
+
// Must NOT have old blocked framing.
|
|
875
1031
|
expect(out).not.toContain('billing disabled');
|
|
876
1032
|
expect(out).not.toContain("won't recover until billing is fixed");
|
|
877
1033
|
expect(out).not.toContain('quota exhausted');
|
|
1034
|
+
expect(out).not.toContain('back ');
|
|
878
1035
|
});
|
|
879
1036
|
|
|
880
1037
|
it('out_of_credits account is a valid failover target (appears in buildSnapshotKeyboard switch buttons)', () => {
|
|
@@ -888,13 +1045,14 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
|
|
|
888
1045
|
expect(allText).toContain('Switch fleet → carol@example.com');
|
|
889
1046
|
});
|
|
890
1047
|
|
|
891
|
-
it('quota-exhausted row shows a
|
|
1048
|
+
it('quota-exhausted row shows a 🔴 + "back <when>" Status, not "billing disabled"', () => {
|
|
892
1049
|
const futureReset = new Date(NOW.getTime() + 45 * 60_000);
|
|
893
1050
|
const out = renderAuthSnapshotFormat2(
|
|
894
1051
|
[snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
|
|
895
1052
|
{ now: NOW },
|
|
896
1053
|
);
|
|
897
|
-
expect(out).toContain('
|
|
1054
|
+
expect(out).toContain('| 🔴 |');
|
|
1055
|
+
expect(out).toMatch(/back .* \(in 45m\)/);
|
|
898
1056
|
expect(out).not.toContain('billing disabled');
|
|
899
1057
|
expect(out).not.toContain('overage off');
|
|
900
1058
|
});
|
|
@@ -914,7 +1072,8 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
|
|
|
914
1072
|
[snap({ label: 'org@x', isActive: false, quota: quota({ fiveHourUtilizationPct: 85, overageDisabledReason: 'org_level_disabled' }) })],
|
|
915
1073
|
{ now: NOW },
|
|
916
1074
|
);
|
|
917
|
-
|
|
1075
|
+
// 85% util → throttling, surfaced as the 🟡 State emoji in the table.
|
|
1076
|
+
expect(out).toContain('| 🟡 |');
|
|
918
1077
|
expect(out).not.toContain('overage off');
|
|
919
1078
|
});
|
|
920
1079
|
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
cleanWorkerResultParagraph,
|
|
4
4
|
formatDuration,
|
|
5
|
+
stackCardLines,
|
|
5
6
|
stripMarkdown,
|
|
6
7
|
truncate,
|
|
7
8
|
} from '../card-format.js'
|
|
@@ -55,6 +56,21 @@ describe('stripMarkdown', () => {
|
|
|
55
56
|
it('does not touch HTML-significant characters (escaping stays separate)', () => {
|
|
56
57
|
expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
|
|
57
58
|
})
|
|
59
|
+
|
|
60
|
+
it('F1: normalizes an em-dash on this card/narration prose surface', () => {
|
|
61
|
+
// The reply path scrubs em-dashes but cards/narration/PTY previews did not,
|
|
62
|
+
// so em-dashes leaked there. stripMarkdown now runs the same dash logic.
|
|
63
|
+
// A clause-joining dash becomes a full stop (never a comma — that produces
|
|
64
|
+
// a comma splice), with the following word recapitalized into a sentence.
|
|
65
|
+
expect(stripMarkdown('build done — shipping now')).toBe('build done. Shipping now')
|
|
66
|
+
expect(stripMarkdown('build done — Shipping now')).toBe('build done. Shipping now')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('F1: preserves an em-dash inside an inline code span', () => {
|
|
70
|
+
// The dash normalizer masks code spans, and it runs BEFORE the backticks
|
|
71
|
+
// are stripped, so a dash inside `` `…` `` survives verbatim.
|
|
72
|
+
expect(stripMarkdown('see `a — b` token')).toBe('see a — b token')
|
|
73
|
+
})
|
|
58
74
|
})
|
|
59
75
|
|
|
60
76
|
describe('cleanWorkerResultParagraph', () => {
|
|
@@ -75,6 +91,19 @@ describe('cleanWorkerResultParagraph', () => {
|
|
|
75
91
|
it('returns empty for whitespace/markup-only input', () => {
|
|
76
92
|
expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
|
|
77
93
|
})
|
|
94
|
+
|
|
95
|
+
it('F1: normalizes em-dashes in worker narration prose', () => {
|
|
96
|
+
const input = '## Result\n\nTests pass — ready to merge'
|
|
97
|
+
expect(cleanWorkerResultParagraph(input)).toBe('Result Tests pass. Ready to merge')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('F1: an em-dash inside a fenced block is preserved (the block is dropped, never normalized)', () => {
|
|
101
|
+
// Fenced blocks are excised whole, so a dash inside one is preserved by
|
|
102
|
+
// removal — it never reaches (nor is mutated by) the dash normalizer, and
|
|
103
|
+
// the surrounding prose dash IS normalized.
|
|
104
|
+
const input = 'before — after\n```\nlet a = b — c\n```\ntail'
|
|
105
|
+
expect(cleanWorkerResultParagraph(input)).toBe('before. After tail')
|
|
106
|
+
})
|
|
78
107
|
})
|
|
79
108
|
|
|
80
109
|
describe('formatDuration', () => {
|
|
@@ -96,3 +125,53 @@ describe('escapeMarkdown / truncate', () => {
|
|
|
96
125
|
expect(truncate('abc', 4)).toBe('abc')
|
|
97
126
|
})
|
|
98
127
|
})
|
|
128
|
+
|
|
129
|
+
describe('stackCardLines — card bodies stack like the reply path', () => {
|
|
130
|
+
// The bug this fixes: the rich-message renderer (#2669) treats a LONE `\n`
|
|
131
|
+
// between two non-blank lines as a SOFT break and collapses them onto one
|
|
132
|
+
// visual line. A card built as a list of styled prose lines and joined with
|
|
133
|
+
// `\n` therefore renders as a run-on blob. stackCardLines promotes every
|
|
134
|
+
// inter-line break to a GFM hard break (` \n`) so the lines stack — the
|
|
135
|
+
// same end-state the reply path reaches via normalizeParagraphBreaks.
|
|
136
|
+
|
|
137
|
+
it('promotes every inter-line break to a GFM hard break so bullets stack', () => {
|
|
138
|
+
const out = stackCardLines(['_✓ Reading a.ts_', '_✓ Searching memory_', '**→ List workspace**'])
|
|
139
|
+
expect(out).toBe('_✓ Reading a.ts_ \n_✓ Searching memory_ \n**→ List workspace**')
|
|
140
|
+
// No LONE `\n` survives between non-blank lines — every break is a hard break.
|
|
141
|
+
expect(out.replace(/ {2}\n/g, '')).not.toContain('\n')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('single line is returned verbatim (no trailing hard break)', () => {
|
|
145
|
+
expect(stackCardLines(['**→ Reading a.ts**'])).toBe('**→ Reading a.ts**')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('preserves an intentional blank-line paragraph gap', () => {
|
|
149
|
+
// A blank entry is a genuine `\n\n` gap (e.g. boot card header → rows);
|
|
150
|
+
// it must NOT become a hard-break run — the blank reconstructs the gap.
|
|
151
|
+
const out = stackCardLines(['🔔 **Header**', '', '_✓ row one_', '_✓ row two_'])
|
|
152
|
+
expect(out).toBe('🔔 **Header**\n\n_✓ row one_ \n_✓ row two_')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('does not accumulate trailing spaces on a re-run (idempotent breaks)', () => {
|
|
156
|
+
const once = stackCardLines(['a.', 'b.', 'c.'])
|
|
157
|
+
// Feeding the stacked output back in (split on hard break) yields the same.
|
|
158
|
+
const twice = stackCardLines(once.split(' \n'))
|
|
159
|
+
expect(twice).toBe(once)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('keeps bold, code-spans and the worker-result rule intact (only breaks change)', () => {
|
|
163
|
+
const out = stackCardLines([
|
|
164
|
+
'🛠 **Worker** · _build_',
|
|
165
|
+
'_✓ Running `git push`_',
|
|
166
|
+
'─────',
|
|
167
|
+
'✅ _Fixed the **bug** in `foo.ts`_',
|
|
168
|
+
])
|
|
169
|
+
// Inline markup is untouched — bold, code spans, the rule line all survive.
|
|
170
|
+
expect(out).toContain('**Worker**')
|
|
171
|
+
expect(out).toContain('`git push`')
|
|
172
|
+
expect(out).toContain('Fixed the **bug** in `foo.ts`')
|
|
173
|
+
expect(out).toContain('─────')
|
|
174
|
+
// And the four lines stack (three hard breaks, no soft breaks).
|
|
175
|
+
expect(out.match(/ {2}\n/g)?.length).toBe(3)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { resolveChatIdFallback, type ChatIdAllowlist } from '../gateway/chat-id-fallback.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression tests for the sr-* chat_id routing hotfixes that shipped
|
|
7
|
+
* test-free, plus the stream_reply fallback parity fix.
|
|
8
|
+
*
|
|
9
|
+
* The reply / stream_reply final-answer paths call resolveChatIdFallback with a
|
|
10
|
+
* String()-coerced chat_id. This suite pins the pure decision:
|
|
11
|
+
* - Bug A — numeric coercion (String() at the callsite makes 42 match "42")
|
|
12
|
+
* - Bug B — executeReply falls back to the active turn's sessionChatId
|
|
13
|
+
* - Bug D — lastActiveTurnChatId survives the silence poke (currentTurn null)
|
|
14
|
+
* - stream_reply — the SAME fallback (mirrors the executeReply Bug-D test)
|
|
15
|
+
*/
|
|
16
|
+
describe('resolveChatIdFallback', () => {
|
|
17
|
+
const access = (allowFrom: string[], groups: Record<string, unknown> = {}): ChatIdAllowlist => ({
|
|
18
|
+
allowFrom,
|
|
19
|
+
groups,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('allowlisted raw chat_id passes through unchanged (tier=raw)', () => {
|
|
23
|
+
const r = resolveChatIdFallback('123', access(['123']), '999', '999', true)
|
|
24
|
+
expect(r).toEqual({ chatId: '123', tier: 'raw' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('a chat_id present in groups (not allowFrom) passes through (tier=raw)', () => {
|
|
28
|
+
const r = resolveChatIdFallback('-100777', access([], { '-100777': {} }), '999', '999', true)
|
|
29
|
+
expect(r).toEqual({ chatId: '-100777', tier: 'raw' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Bug A — numeric coercion. The model passes a JSON number; the caller
|
|
33
|
+
// String()-coerces before calling. A stringified numeric id matches the
|
|
34
|
+
// string allowlist, so it must pass through as tier=raw (no spurious
|
|
35
|
+
// fallback).
|
|
36
|
+
it('Bug A: numerically-coerced chat_id ("42") matches the string allowlist', () => {
|
|
37
|
+
const coerced = String(42 as unknown as number)
|
|
38
|
+
const r = resolveChatIdFallback(coerced, access(['42']), '999', '999', true)
|
|
39
|
+
expect(r).toEqual({ chatId: '42', tier: 'raw' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Bug B — executeReply fallback. Raw chat_id not allowlisted, a live turn is
|
|
43
|
+
// present → route to the active turn's validated sessionChatId.
|
|
44
|
+
it('Bug B: non-allowlisted raw + live turn → active turn sessionChatId', () => {
|
|
45
|
+
const r = resolveChatIdFallback('wrong-id', access(['123']), '123', '123', true)
|
|
46
|
+
expect(r).toEqual({ chatId: '123', tier: 'active' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Bug D — lastActiveTurnChatId survives the silence poke. currentTurn is null
|
|
50
|
+
// (turnSessionChatId undefined, hasLiveTurn false) because clearTurnStarted
|
|
51
|
+
// fired after ≥5 min silence, but the model finally replied after the poke.
|
|
52
|
+
// The last-known chat still routes the answer.
|
|
53
|
+
it('Bug D: no live turn (silence poke) → last-known turn chat', () => {
|
|
54
|
+
const r = resolveChatIdFallback('wrong-id', access(['123']), undefined, '123', false)
|
|
55
|
+
expect(r).toEqual({ chatId: '123', tier: 'last-known' })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// stream_reply Bug-D mirror: stream_reply is the primary final-answer path
|
|
59
|
+
// and now applies the SAME fallback. Identical inputs → identical decision.
|
|
60
|
+
it('stream_reply parity: same fallback after a silence poke (mirrors Bug D)', () => {
|
|
61
|
+
const r = resolveChatIdFallback('wrong-id', access(['555']), undefined, '555', false)
|
|
62
|
+
expect(r).toEqual({ chatId: '555', tier: 'last-known' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('no valid fallback → returns raw so assertAllowedChat throws the human error', () => {
|
|
66
|
+
const r = resolveChatIdFallback('wrong-id', access(['123']), 'also-wrong', 'still-wrong', true)
|
|
67
|
+
expect(r).toEqual({ chatId: 'wrong-id', tier: 'raw' })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('active tier wins over last-known when both are set (turn present)', () => {
|
|
71
|
+
const r = resolveChatIdFallback('wrong-id', access(['active-chat', 'old-chat']), 'active-chat', 'old-chat', true)
|
|
72
|
+
expect(r).toEqual({ chatId: 'active-chat', tier: 'active' })
|
|
73
|
+
})
|
|
74
|
+
})
|