switchroom 0.8.1 → 0.11.0
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/README.md +54 -61
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +8890 -5560
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +9 -5
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +905 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +23 -37
- package/telegram-plugin/gateway/boot-probes.ts +9 -12
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +1156 -938
- package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +27 -22
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +35 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
|
@@ -292,13 +292,16 @@ describe('probeQuota — #1163: /v1/messages headers path', () => {
|
|
|
292
292
|
expect(result.detail).toContain('18% / 7d')
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
-
it('surfaces auth rejection with
|
|
295
|
+
it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
|
|
296
296
|
const fakeFetch: typeof fetch = async () =>
|
|
297
297
|
new Response(null, { status: 403 }) as Response
|
|
298
298
|
|
|
299
299
|
const result = await probeQuota(claudeDir, agentDir, fakeFetch)
|
|
300
300
|
expect(result.status).toBe('degraded')
|
|
301
|
-
|
|
301
|
+
// Post-RFC-H: per-agent `auth login` is retired. probeQuota emits the
|
|
302
|
+
// broker-aware "replace the account" hint pointing at `auth add ...
|
|
303
|
+
// --replace` instead. See telegram-plugin/gateway/boot-probes.ts.
|
|
304
|
+
expect(result.nextStep).toMatch(/switchroom auth add .*--from-oauth --replace/)
|
|
302
305
|
})
|
|
303
306
|
|
|
304
307
|
it('writing rate-limited result to cache produces a readable 30 s entry', () => {
|
|
@@ -893,42 +896,40 @@ describe('probeAccount — nextStep agent-name interpolation', () => {
|
|
|
893
896
|
}
|
|
894
897
|
})
|
|
895
898
|
|
|
896
|
-
it('not-signed-in hint
|
|
899
|
+
it('not-signed-in hint points at RFC H fleet-wide auth verbs', async () => {
|
|
897
900
|
tmpDir = setupAgentDir({})
|
|
898
|
-
const result = await probeAccount(tmpDir
|
|
901
|
+
const result = await probeAccount(tmpDir)
|
|
899
902
|
expect(result.status).toBe('degraded')
|
|
900
903
|
expect(result.detail).toBe('not signed in')
|
|
901
904
|
expect(result.nextStep).toBeDefined()
|
|
902
|
-
expect(result.nextStep).toContain('switchroom auth
|
|
903
|
-
expect(result.nextStep).
|
|
905
|
+
expect(result.nextStep).toContain('switchroom auth add')
|
|
906
|
+
expect(result.nextStep).toContain('--from-oauth')
|
|
907
|
+
expect(result.nextStep).toContain('switchroom auth use')
|
|
908
|
+
// RFC H: hint must not point at the retired per-agent `auth login` verb.
|
|
909
|
+
expect(result.nextStep).not.toContain('auth login')
|
|
904
910
|
})
|
|
905
911
|
|
|
906
|
-
it('expired-token hint
|
|
912
|
+
it('expired-token hint points at broker auto-refresh + manual fallback', async () => {
|
|
907
913
|
tmpDir = setupAgentDir(
|
|
908
914
|
{ oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
|
|
909
915
|
{ expiresAt: Date.now() - 86_400_000 }, // expired yesterday
|
|
910
916
|
)
|
|
911
|
-
const result = await probeAccount(tmpDir
|
|
917
|
+
const result = await probeAccount(tmpDir)
|
|
912
918
|
expect(result.status).toBe('fail')
|
|
913
|
-
expect(result.nextStep).toContain('switchroom auth
|
|
914
|
-
expect(result.nextStep).
|
|
919
|
+
expect(result.nextStep).toContain('switchroom auth refresh')
|
|
920
|
+
expect(result.nextStep).toContain('--replace')
|
|
921
|
+
expect(result.nextStep).not.toContain('auth login')
|
|
915
922
|
})
|
|
916
923
|
|
|
917
|
-
it('expiring-soon hint
|
|
924
|
+
it('expiring-soon hint points at broker auto-refresh window', async () => {
|
|
918
925
|
tmpDir = setupAgentDir(
|
|
919
926
|
{ oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
|
|
920
927
|
{ expiresAt: Date.now() + 3 * 86_400_000 }, // 3 days left (< 7)
|
|
921
928
|
)
|
|
922
|
-
const result = await probeAccount(tmpDir, { agentName: 'lawgpt' })
|
|
923
|
-
expect(result.status).toBe('degraded')
|
|
924
|
-
expect(result.nextStep).toContain('switchroom auth login lawgpt')
|
|
925
|
-
expect(result.nextStep).not.toContain('<agent>')
|
|
926
|
-
})
|
|
927
|
-
|
|
928
|
-
it('falls back to <agent> placeholder when no agentName provided (backwards-compat)', async () => {
|
|
929
|
-
tmpDir = setupAgentDir({})
|
|
930
929
|
const result = await probeAccount(tmpDir)
|
|
931
|
-
expect(result.
|
|
930
|
+
expect(result.status).toBe('degraded')
|
|
931
|
+
expect(result.nextStep).toContain('switchroom auth refresh')
|
|
932
|
+
expect(result.nextStep).not.toContain('auth login')
|
|
932
933
|
})
|
|
933
934
|
})
|
|
934
935
|
|
|
@@ -1149,14 +1150,18 @@ describe('nextStep — agent systemd states', () => {
|
|
|
1149
1150
|
})
|
|
1150
1151
|
|
|
1151
1152
|
describe('nextStep — quota / hindsight / broker / kernel / scheduler', () => {
|
|
1152
|
-
it('quota: no OAuth token → degraded with
|
|
1153
|
+
it('quota: no OAuth token → degraded with RFC-H add+use hint', async () => {
|
|
1153
1154
|
const dir = mkdtempSync(join(tmpdir(), 'quota-nextstep-'))
|
|
1154
1155
|
const oldCachePath = process.env.SWITCHROOM_QUOTA_CACHE_PATH
|
|
1155
1156
|
process.env.SWITCHROOM_QUOTA_CACHE_PATH = join(dir, 'cache.json')
|
|
1156
1157
|
try {
|
|
1157
1158
|
const r = await probeQuota(dir, dir, (async () => new Response('{}')) as unknown as typeof fetch)
|
|
1158
1159
|
expect(r.status).toBe('degraded')
|
|
1159
|
-
|
|
1160
|
+
// Post-RFC-H: the no-token nextStep points at `auth add` (register a
|
|
1161
|
+
// fleet account) + `auth use` (set fleet active), not the retired
|
|
1162
|
+
// per-agent `auth login`. See telegram-plugin/gateway/boot-probes.ts.
|
|
1163
|
+
expect(r.nextStep).toMatch(/switchroom auth add .*--from-oauth/)
|
|
1164
|
+
expect(r.nextStep).toMatch(/switchroom auth use/)
|
|
1160
1165
|
} finally {
|
|
1161
1166
|
if (oldCachePath) process.env.SWITCHROOM_QUOTA_CACHE_PATH = oldCachePath
|
|
1162
1167
|
else delete process.env.SWITCHROOM_QUOTA_CACHE_PATH
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createFleetFallbackGate } from "../fleet-fallback-gate.js";
|
|
3
|
+
|
|
4
|
+
function fakeClock(start = 0) {
|
|
5
|
+
let now = start;
|
|
6
|
+
return {
|
|
7
|
+
nowFn: () => now,
|
|
8
|
+
advance(ms: number) { now += ms; },
|
|
9
|
+
set(ms: number) { now = ms; },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("createFleetFallbackGate — wouldFire honesty contract", () => {
|
|
14
|
+
test("fresh state: wouldFire is true", () => {
|
|
15
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: fakeClock().nowFn });
|
|
16
|
+
expect(gate.wouldFire()).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("in-flight: wouldFire is false until action resolves", async () => {
|
|
20
|
+
const clock = fakeClock();
|
|
21
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
22
|
+
|
|
23
|
+
let resolveAction: (b: boolean) => void = () => {};
|
|
24
|
+
const action = () => new Promise<boolean>((r) => { resolveAction = r; });
|
|
25
|
+
|
|
26
|
+
const firePromise = gate.fire(action);
|
|
27
|
+
|
|
28
|
+
expect(gate.wouldFire()).toBe(false);
|
|
29
|
+
expect(gate.inspect().inFlight).toBe(true);
|
|
30
|
+
|
|
31
|
+
resolveAction(true);
|
|
32
|
+
await firePromise;
|
|
33
|
+
|
|
34
|
+
// After fire stamps lastFiredAtMs, dedup window blocks until clock advances.
|
|
35
|
+
expect(gate.wouldFire()).toBe(false);
|
|
36
|
+
clock.advance(30_000);
|
|
37
|
+
expect(gate.wouldFire()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("post-fire dedup window blocks wouldFire", async () => {
|
|
41
|
+
const clock = fakeClock();
|
|
42
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
43
|
+
|
|
44
|
+
await gate.fire(async () => true);
|
|
45
|
+
expect(gate.wouldFire()).toBe(false);
|
|
46
|
+
|
|
47
|
+
clock.advance(29_999);
|
|
48
|
+
expect(gate.wouldFire()).toBe(false);
|
|
49
|
+
|
|
50
|
+
clock.advance(1);
|
|
51
|
+
expect(gate.wouldFire()).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("no-op fires (action returns false) DO NOT arm dedup window", async () => {
|
|
55
|
+
const clock = fakeClock();
|
|
56
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
57
|
+
|
|
58
|
+
await gate.fire(async () => false);
|
|
59
|
+
// Window NOT armed — wouldFire should still be true immediately.
|
|
60
|
+
expect(gate.wouldFire()).toBe(true);
|
|
61
|
+
expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("thrown action: dedup window NOT armed, gate releases in-flight", async () => {
|
|
65
|
+
const clock = fakeClock();
|
|
66
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
67
|
+
const errors: unknown[] = [];
|
|
68
|
+
|
|
69
|
+
await gate.fire(async () => { throw new Error("broker exploded"); }, (e) => errors.push(e));
|
|
70
|
+
|
|
71
|
+
expect(gate.inspect().inFlight).toBe(false);
|
|
72
|
+
expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
|
|
73
|
+
expect(gate.wouldFire()).toBe(true);
|
|
74
|
+
expect((errors[0] as Error).message).toBe("broker exploded");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("no onError: thrown action still releases in-flight without crashing", async () => {
|
|
78
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: fakeClock().nowFn });
|
|
79
|
+
|
|
80
|
+
await gate.fire(async () => { throw new Error("silent"); });
|
|
81
|
+
|
|
82
|
+
expect(gate.inspect().inFlight).toBe(false);
|
|
83
|
+
expect(gate.wouldFire()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("createFleetFallbackGate — fire semantics", () => {
|
|
88
|
+
test("collapses concurrent callers to one in-flight Promise", async () => {
|
|
89
|
+
const clock = fakeClock();
|
|
90
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
91
|
+
let calls = 0;
|
|
92
|
+
let resolveAction: (b: boolean) => void = () => {};
|
|
93
|
+
|
|
94
|
+
const action = () => {
|
|
95
|
+
calls += 1;
|
|
96
|
+
return new Promise<boolean>((r) => { resolveAction = r; });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const p1 = gate.fire(action);
|
|
100
|
+
const p2 = gate.fire(action);
|
|
101
|
+
const p3 = gate.fire(action);
|
|
102
|
+
|
|
103
|
+
// Same in-flight promise returned to all three callers.
|
|
104
|
+
expect(p1).toBe(p2);
|
|
105
|
+
expect(p2).toBe(p3);
|
|
106
|
+
expect(calls).toBe(1);
|
|
107
|
+
|
|
108
|
+
resolveAction(true);
|
|
109
|
+
await Promise.all([p1, p2, p3]);
|
|
110
|
+
expect(calls).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("fire during dedup window resolves immediately without invoking action", async () => {
|
|
114
|
+
const clock = fakeClock();
|
|
115
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
116
|
+
let calls = 0;
|
|
117
|
+
|
|
118
|
+
await gate.fire(async () => { calls += 1; return true; });
|
|
119
|
+
expect(calls).toBe(1);
|
|
120
|
+
|
|
121
|
+
await gate.fire(async () => { calls += 1; return true; });
|
|
122
|
+
expect(calls).toBe(1);
|
|
123
|
+
|
|
124
|
+
clock.advance(30_000);
|
|
125
|
+
|
|
126
|
+
await gate.fire(async () => { calls += 1; return true; });
|
|
127
|
+
expect(calls).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("createFleetFallbackGate — broker reachability check", () => {
|
|
132
|
+
test("brokerReachable=false makes wouldFire return false even on fresh state", () => {
|
|
133
|
+
const gate = createFleetFallbackGate({
|
|
134
|
+
dedupMs: 30_000,
|
|
135
|
+
nowFn: fakeClock().nowFn,
|
|
136
|
+
brokerReachable: () => false,
|
|
137
|
+
});
|
|
138
|
+
expect(gate.wouldFire()).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("brokerReachable=true gates as if no check provided", () => {
|
|
142
|
+
const gate = createFleetFallbackGate({
|
|
143
|
+
dedupMs: 30_000,
|
|
144
|
+
nowFn: fakeClock().nowFn,
|
|
145
|
+
brokerReachable: () => true,
|
|
146
|
+
});
|
|
147
|
+
expect(gate.wouldFire()).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("brokerReachable=false makes fire() short-circuit without invoking action", async () => {
|
|
151
|
+
let calls = 0;
|
|
152
|
+
const gate = createFleetFallbackGate({
|
|
153
|
+
dedupMs: 30_000,
|
|
154
|
+
nowFn: fakeClock().nowFn,
|
|
155
|
+
brokerReachable: () => false,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await gate.fire(async () => { calls += 1; return true; });
|
|
159
|
+
expect(calls).toBe(0);
|
|
160
|
+
expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("brokerReachable can flip from false to true between calls", async () => {
|
|
164
|
+
let reachable = false;
|
|
165
|
+
let calls = 0;
|
|
166
|
+
const gate = createFleetFallbackGate({
|
|
167
|
+
dedupMs: 30_000,
|
|
168
|
+
nowFn: fakeClock().nowFn,
|
|
169
|
+
brokerReachable: () => reachable,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(gate.wouldFire()).toBe(false);
|
|
173
|
+
await gate.fire(async () => { calls += 1; return true; });
|
|
174
|
+
expect(calls).toBe(0);
|
|
175
|
+
|
|
176
|
+
reachable = true;
|
|
177
|
+
expect(gate.wouldFire()).toBe(true);
|
|
178
|
+
await gate.fire(async () => { calls += 1; return true; });
|
|
179
|
+
expect(calls).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("createFleetFallbackGate — reset (test seam)", () => {
|
|
184
|
+
test("reset clears in-flight + lastFiredAtMs", async () => {
|
|
185
|
+
const clock = fakeClock();
|
|
186
|
+
const gate = createFleetFallbackGate({ dedupMs: 30_000, nowFn: clock.nowFn });
|
|
187
|
+
|
|
188
|
+
await gate.fire(async () => true);
|
|
189
|
+
expect(gate.inspect().lastFiredAtMs).toBeGreaterThan(Number.NEGATIVE_INFINITY);
|
|
190
|
+
expect(gate.wouldFire()).toBe(false);
|
|
191
|
+
|
|
192
|
+
gate.reset();
|
|
193
|
+
expect(gate.inspect().lastFiredAtMs).toBe(Number.NEGATIVE_INFINITY);
|
|
194
|
+
expect(gate.inspect().inFlight).toBe(false);
|
|
195
|
+
expect(gate.wouldFire()).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -154,7 +154,7 @@ describe('formatModelUnavailableCard — actionable card', () => {
|
|
|
154
154
|
return resetAt ? { kind, resetAt, raw: 'test' } : { kind, raw: 'test' }
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
it('quota_exhausted with reset → snapshot-stable card', () => {
|
|
157
|
+
it('quota_exhausted with reset → snapshot-stable card (manual-action shape)', () => {
|
|
158
158
|
const card = formatModelUnavailableCard(
|
|
159
159
|
detection('quota_exhausted', new Date('2026-05-03T13:00:00Z')),
|
|
160
160
|
'gymbro',
|
|
@@ -165,12 +165,30 @@ describe('formatModelUnavailableCard — actionable card', () => {
|
|
|
165
165
|
Reason: quota exhausted (resets in 5h)
|
|
166
166
|
|
|
167
167
|
<b>What to try</b>
|
|
168
|
-
• <code>/
|
|
168
|
+
• <code>/auth use <label></code> — switch the fleet to a healthy account
|
|
169
169
|
• <code>/auth add</code> — attach another subscription
|
|
170
170
|
• <code>/usage</code> — show quota breakdown"
|
|
171
171
|
`)
|
|
172
172
|
})
|
|
173
173
|
|
|
174
|
+
it('autoFallbackInFlight=true → quiet variant (no manual command list)', () => {
|
|
175
|
+
// Regression for the "lying card" bug — when the gateway has
|
|
176
|
+
// already kicked off `fireFleetAutoFallback`, the card MUST NOT
|
|
177
|
+
// list manual commands the user shouldn't run. Otherwise the
|
|
178
|
+
// user manually types /auth use while a fleet swap is mid-flight,
|
|
179
|
+
// racing two writes through the broker.
|
|
180
|
+
const card = formatModelUnavailableCard(
|
|
181
|
+
detection('quota_exhausted', new Date('2026-05-03T13:00:00Z')),
|
|
182
|
+
'gymbro',
|
|
183
|
+
{ now: NOW, autoFallbackInFlight: true },
|
|
184
|
+
)
|
|
185
|
+
expect(card).toContain('Auto-failover in progress')
|
|
186
|
+
expect(card).not.toContain('What to try')
|
|
187
|
+
expect(card).not.toContain('/auth use')
|
|
188
|
+
expect(card).not.toContain('/auth add')
|
|
189
|
+
expect(card).not.toContain('/authfallback')
|
|
190
|
+
})
|
|
191
|
+
|
|
174
192
|
it('overload without reset omits the parenthetical', () => {
|
|
175
193
|
const card = formatModelUnavailableCard(detection('overload'), 'clerk', { now: NOW })
|
|
176
194
|
expect(card).toContain('Reason: model overloaded')
|
|
@@ -183,11 +201,14 @@ describe('formatModelUnavailableCard — actionable card', () => {
|
|
|
183
201
|
expect(card).not.toContain('(resets')
|
|
184
202
|
})
|
|
185
203
|
|
|
186
|
-
it('
|
|
204
|
+
it('default (no autoFallback) variant includes the actionable suggestions', () => {
|
|
187
205
|
const card = formatModelUnavailableCard(detection('quota_exhausted'), 'gymbro', { now: NOW })
|
|
188
|
-
expect(card).toContain('<code>/
|
|
206
|
+
expect(card).toContain('<code>/auth use')
|
|
189
207
|
expect(card).toContain('<code>/auth add</code>')
|
|
190
208
|
expect(card).toContain('<code>/usage</code>')
|
|
209
|
+
// Regression — `/authfallback` is no longer a verb (post-RFC-H);
|
|
210
|
+
// pre-fix the card lied by suggesting it.
|
|
211
|
+
expect(card).not.toContain('/authfallback')
|
|
191
212
|
})
|
|
192
213
|
|
|
193
214
|
it('names the slot in the header when one is supplied', () => {
|
|
@@ -283,9 +304,13 @@ describe('integration — gateway suppresses raw stderr in favour of the card',
|
|
|
283
304
|
// The actionable card replaces the raw verbatim error.
|
|
284
305
|
expect(card).toContain('Model unavailable')
|
|
285
306
|
expect(card).toContain('quota exhausted')
|
|
286
|
-
|
|
307
|
+
// Post-RFC-H: `/authfallback` is no longer a verb. The default
|
|
308
|
+
// (non-auto-fallback) card now points at `/auth use <label>` —
|
|
309
|
+
// the canonical fleet-wide swap.
|
|
310
|
+
expect(card).toContain('/auth use')
|
|
287
311
|
expect(card).toContain('/auth add')
|
|
288
312
|
expect(card).toContain('/usage')
|
|
313
|
+
expect(card).not.toContain('/authfallback')
|
|
289
314
|
|
|
290
315
|
// And the raw stderr text never appears in the user-facing card.
|
|
291
316
|
expect(card).not.toContain('out of extra usage')
|
|
@@ -103,4 +103,35 @@ describe('summarizeToolForTitle (#186)', () => {
|
|
|
103
103
|
const input = JSON.stringify({ skill: 'mail', name: 'wrong' })
|
|
104
104
|
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
|
|
105
105
|
})
|
|
106
|
+
|
|
107
|
+
test('MCP curated: agent-config tools render as human verb-phrases (#1215)', () => {
|
|
108
|
+
expect(summarizeToolForTitle('mcp__agent-config__skill_list', undefined)).toBe(
|
|
109
|
+
'List its own installed skills',
|
|
110
|
+
)
|
|
111
|
+
expect(summarizeToolForTitle('mcp__agent-config__cron_list', undefined)).toBe(
|
|
112
|
+
'List its own scheduled tasks',
|
|
113
|
+
)
|
|
114
|
+
expect(summarizeToolForTitle('mcp__agent-config__peers_list', undefined)).toBe(
|
|
115
|
+
'List the other agents on this instance',
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('MCP curated: hostd tools render as human verb-phrases (#1215)', () => {
|
|
120
|
+
expect(summarizeToolForTitle('mcp__hostd__agent_logs', undefined)).toBe(
|
|
121
|
+
"Read another agent's container logs",
|
|
122
|
+
)
|
|
123
|
+
expect(summarizeToolForTitle('mcp__hostd__agent_exec', undefined)).toBe(
|
|
124
|
+
'Run a read-only inspection inside another agent',
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('MCP fallback: unknown mcp tool renders as `<server>: <verb with spaces>`', () => {
|
|
129
|
+
expect(summarizeToolForTitle('mcp__some-server__do_thing', undefined)).toBe(
|
|
130
|
+
'some-server: do thing',
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('MCP malformed: bare mcp__ prefix without __<server>__<verb> shape is left alone', () => {
|
|
135
|
+
expect(summarizeToolForTitle('mcp__bad', undefined)).toBe('mcp__bad')
|
|
136
|
+
})
|
|
106
137
|
})
|
|
@@ -380,41 +380,11 @@ describe('fetchAccountQuota — cache + token resolution', () => {
|
|
|
380
380
|
}
|
|
381
381
|
})
|
|
382
382
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
new Response('{}', {
|
|
389
|
-
status: 200,
|
|
390
|
-
headers: {
|
|
391
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
392
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.17',
|
|
393
|
-
},
|
|
394
|
-
})
|
|
395
|
-
try {
|
|
396
|
-
const r = await fetchAccountQuota('work@example.com', {
|
|
397
|
-
home,
|
|
398
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
399
|
-
})
|
|
400
|
-
expect(r.ok).toBe(true)
|
|
401
|
-
const snapPath = join(
|
|
402
|
-
home,
|
|
403
|
-
'.switchroom',
|
|
404
|
-
'accounts',
|
|
405
|
-
'work@example.com',
|
|
406
|
-
'quota.json',
|
|
407
|
-
)
|
|
408
|
-
// The bug: writeAccountQuota was called without opts.home, so the
|
|
409
|
-
// snapshot landed under the real $HOME instead of the test home.
|
|
410
|
-
expect(existsSync(snapPath)).toBe(true)
|
|
411
|
-
const snap = JSON.parse(readFileSync(snapPath, 'utf-8'))
|
|
412
|
-
expect(snap.fiveHourPct).toBeCloseTo(42, 0)
|
|
413
|
-
expect(snap.sevenDayPct).toBeCloseTo(17, 0)
|
|
414
|
-
} finally {
|
|
415
|
-
rmSync(home, { recursive: true, force: true })
|
|
416
|
-
}
|
|
417
|
-
})
|
|
383
|
+
// Removed in RFC H: per-account quota.json disk persistence is gone.
|
|
384
|
+
// switchroom-auth-broker holds canonical quota state and exposes it
|
|
385
|
+
// via list-state; the gateway's in-process cache is enough between
|
|
386
|
+
// restarts (and the broker survives gateway restarts, so the state
|
|
387
|
+
// is preserved at the broker side anyway).
|
|
418
388
|
})
|
|
419
389
|
|
|
420
390
|
describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
|