switchroom 0.8.1 → 0.10.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.
Files changed (105) hide show
  1. package/README.md +49 -57
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -1,510 +0,0 @@
1
- /**
2
- * Tests for the /setup wizard state machine (setup-flow.ts).
3
- *
4
- * Pure function tests — no grammY, no SQLite, no network.
5
- *
6
- * Covers:
7
- * - startSetupFlow: no slug, valid slug, invalid slug
8
- * - handleSetupText: full happy-path step transitions
9
- * - Validator helpers: isValidSlug, isValidPersonaName, isValidModel, isValidEmoji
10
- * - Skip / cancel / error paths at each step
11
- * - makeSetupInitialState / advanceSetupState / setupStepLabel helpers
12
- */
13
-
14
- import { describe, it, expect } from 'vitest'
15
- import {
16
- startSetupFlow,
17
- handleSetupText,
18
- makeSetupInitialState,
19
- advanceSetupState,
20
- setupStepLabel,
21
- isValidSlug,
22
- isValidPersonaName,
23
- isValidModel,
24
- isValidEmoji,
25
- isSkip,
26
- isCancel,
27
- } from '../foreman/setup-flow.js'
28
- import type { SetupFlowState } from '../foreman/setup-state.js'
29
-
30
- const CALLER = '12345678'
31
-
32
- // #190: setup-flow now needs the operator's profile list. Tests that don't
33
- // care about the profile validation just pass `default` so the asked-profile
34
- // step accepts any input that maps to one of these.
35
- const PROFILES = ['default', 'coding', 'health-coach', 'executive-assistant']
36
-
37
- function makeState(overrides: Partial<SetupFlowState> = {}): SetupFlowState {
38
- return {
39
- chatId: 'chat1',
40
- step: 'asked-slug',
41
- slug: null,
42
- persona: null,
43
- model: null,
44
- emoji: null,
45
- profile: null,
46
- botToken: null,
47
- allowedUserId: null,
48
- authSessionName: null,
49
- loginUrl: null,
50
- startedAt: 1000,
51
- updatedAt: 1000,
52
- ...overrides,
53
- }
54
- }
55
-
56
- /**
57
- * Test helper — wraps handleSetupText with a default profiles list so the
58
- * 30+ existing call sites don't need to be rewritten one-by-one. Tests that
59
- * specifically exercise the asked-profile validation pass their own
60
- * profiles via the `opts` argument.
61
- */
62
- function call(
63
- state: SetupFlowState | null,
64
- text: string,
65
- opts: { callerId?: string; profiles?: string[] } = {},
66
- ) {
67
- return handleSetupText({
68
- state,
69
- text,
70
- callerId: opts.callerId ?? CALLER,
71
- profiles: opts.profiles ?? PROFILES,
72
- })
73
- }
74
-
75
- // ─── isValidSlug ─────────────────────────────────────────────────────────
76
-
77
- describe('isValidSlug', () => {
78
- it('accepts simple lowercase', () => expect(isValidSlug('gymbro')).toBe(true))
79
- it('accepts hyphens', () => expect(isValidSlug('gym-bro')).toBe(true))
80
- it('accepts underscores', () => expect(isValidSlug('gym_bro')).toBe(true))
81
- it('accepts leading digit', () => expect(isValidSlug('1agent')).toBe(true))
82
- it('rejects uppercase', () => expect(isValidSlug('GymBro')).toBe(false))
83
- it('rejects spaces', () => expect(isValidSlug('gym bro')).toBe(false))
84
- it('rejects empty', () => expect(isValidSlug('')).toBe(false))
85
- it('accepts 51-char slug', () => expect(isValidSlug('a'.repeat(51))).toBe(true))
86
- it('rejects 52-char slug', () => expect(isValidSlug('a'.repeat(52))).toBe(false))
87
- })
88
-
89
- // ─── isValidPersonaName ───────────────────────────────────────────────────
90
-
91
- describe('isValidPersonaName', () => {
92
- it('accepts normal name', () => expect(isValidPersonaName('Gym Bro')).toBe(true))
93
- it('accepts emoji in name', () => expect(isValidPersonaName('Clerk 💼')).toBe(true))
94
- it('rejects empty string', () => expect(isValidPersonaName('')).toBe(false))
95
- it('rejects control char', () => expect(isValidPersonaName('bad\x00name')).toBe(false))
96
- it('rejects 81-char name', () => expect(isValidPersonaName('a'.repeat(81))).toBe(false))
97
- it('accepts 80-char name', () => expect(isValidPersonaName('a'.repeat(80))).toBe(true))
98
- })
99
-
100
- // ─── isValidModel ─────────────────────────────────────────────────────────
101
-
102
- describe('isValidModel', () => {
103
- it('accepts sonnet alias', () => expect(isValidModel('sonnet')).toBe(true))
104
- it('accepts opus alias', () => expect(isValidModel('opus')).toBe(true))
105
- it('accepts haiku alias', () => expect(isValidModel('haiku')).toBe(true))
106
- it('accepts inherit alias', () => expect(isValidModel('inherit')).toBe(true))
107
- it('accepts full model ID', () => expect(isValidModel('claude-sonnet-4-5')).toBe(true))
108
- it('rejects spaces', () => expect(isValidModel('bad model')).toBe(false))
109
- it('rejects empty', () => expect(isValidModel('')).toBe(false))
110
- })
111
-
112
- // ─── isValidEmoji ─────────────────────────────────────────────────────────
113
-
114
- describe('isValidEmoji', () => {
115
- it('accepts single emoji', () => expect(isValidEmoji('🏋️')).toBe(true))
116
- it('accepts simple ascii (single char)', () => expect(isValidEmoji('x')).toBe(true))
117
- it('rejects empty string', () => expect(isValidEmoji('')).toBe(false))
118
- it('rejects only whitespace', () => expect(isValidEmoji(' ')).toBe(false))
119
- })
120
-
121
- // ─── isSkip / isCancel ────────────────────────────────────────────────────
122
-
123
- describe('isSkip', () => {
124
- it('matches "skip"', () => expect(isSkip('skip')).toBe(true))
125
- it('matches "s"', () => expect(isSkip('s')).toBe(true))
126
- it('matches "-"', () => expect(isSkip('-')).toBe(true))
127
- it('ignores case', () => expect(isSkip('SKIP')).toBe(true))
128
- it('does not match other words', () => expect(isSkip('no')).toBe(false))
129
- })
130
-
131
- describe('isCancel', () => {
132
- it('matches "cancel"', () => expect(isCancel('cancel')).toBe(true))
133
- it('matches "/cancel"', () => expect(isCancel('/cancel')).toBe(true))
134
- it('matches "abort"', () => expect(isCancel('abort')).toBe(true))
135
- it('ignores case', () => expect(isCancel('CANCEL')).toBe(true))
136
- it('does not match "yes"', () => expect(isCancel('yes')).toBe(false))
137
- })
138
-
139
- // ─── startSetupFlow ───────────────────────────────────────────────────────
140
-
141
- describe('startSetupFlow', () => {
142
- it('asks for slug when no inline arg', () => {
143
- const action = startSetupFlow(null)
144
- expect(action.kind).toBe('ask-slug')
145
- })
146
-
147
- it('asks for persona when valid inline slug given', () => {
148
- const action = startSetupFlow('gymbro')
149
- expect(action.kind).toBe('ask-persona')
150
- if (action.kind === 'ask-persona') expect(action.slug).toBe('gymbro')
151
- })
152
-
153
- it('returns error for invalid inline slug', () => {
154
- const action = startSetupFlow('INVALID SLUG!')
155
- expect(action.kind).toBe('error')
156
- })
157
- })
158
-
159
- // ─── handleSetupText: cancel at any step ─────────────────────────────────
160
-
161
- describe('handleSetupText: cancel', () => {
162
- const steps = [
163
- 'asked-slug', 'asked-persona', 'asked-model', 'asked-emoji',
164
- 'asked-bot-token', 'confirming-allowlist',
165
- ] as const
166
-
167
- for (const step of steps) {
168
- it(`cancels at step ${step}`, () => {
169
- const state = makeState({ step, slug: 'gymbro', persona: 'Gym Bro' })
170
- const action = handleSetupText({ state, text: 'cancel', callerId: CALLER })
171
- expect(action.kind).toBe('cancel')
172
- if (action.kind === 'cancel') expect(action.reason).toBe('user-cancelled')
173
- })
174
- }
175
- })
176
-
177
- // ─── handleSetupText: null state ──────────────────────────────────────────
178
-
179
- describe('handleSetupText: null state', () => {
180
- it('returns cancel with no-active-flow reason', () => {
181
- const action = handleSetupText({ state: null, text: 'gymbro', callerId: CALLER })
182
- expect(action.kind).toBe('cancel')
183
- if (action.kind === 'cancel') expect(action.reason).toBe('no-active-flow')
184
- })
185
- })
186
-
187
- // ─── handleSetupText: step asked-slug ────────────────────────────────────
188
-
189
- describe('handleSetupText: asked-slug', () => {
190
- it('advances to ask-persona on valid slug', () => {
191
- const state = makeState({ step: 'asked-slug' })
192
- const action = handleSetupText({ state, text: 'gymbro', callerId: CALLER })
193
- expect(action.kind).toBe('ask-persona')
194
- if (action.kind === 'ask-persona') expect(action.slug).toBe('gymbro')
195
- })
196
-
197
- it('returns error on invalid slug', () => {
198
- const state = makeState({ step: 'asked-slug' })
199
- const action = handleSetupText({ state, text: 'BAD SLUG', callerId: CALLER })
200
- expect(action.kind).toBe('error')
201
- if (action.kind === 'error') expect(action.stayInStep).toBe(true)
202
- })
203
- })
204
-
205
- // ─── handleSetupText: step asked-persona ─────────────────────────────────
206
-
207
- describe('handleSetupText: asked-persona', () => {
208
- it('advances to ask-model on valid persona', () => {
209
- const state = makeState({ step: 'asked-persona', slug: 'gymbro' })
210
- const action = handleSetupText({ state, text: 'Gym Bro', callerId: CALLER })
211
- expect(action.kind).toBe('ask-model')
212
- if (action.kind === 'ask-model') {
213
- expect(action.slug).toBe('gymbro')
214
- expect(action.persona).toBe('Gym Bro')
215
- }
216
- })
217
-
218
- it('returns error on empty persona', () => {
219
- const state = makeState({ step: 'asked-persona', slug: 'gymbro' })
220
- const action = handleSetupText({ state, text: '', callerId: CALLER })
221
- expect(action.kind).toBe('error')
222
- })
223
- })
224
-
225
- // ─── handleSetupText: step asked-model ───────────────────────────────────
226
-
227
- describe('handleSetupText: asked-model', () => {
228
- it('advances to ask-emoji with skip', () => {
229
- const state = makeState({ step: 'asked-model', slug: 'gymbro', persona: 'Gym Bro' })
230
- const action = handleSetupText({ state, text: 'skip', callerId: CALLER })
231
- expect(action.kind).toBe('ask-emoji')
232
- if (action.kind === 'ask-emoji') expect(action.model).toBeNull()
233
- })
234
-
235
- it('advances to ask-emoji with valid model', () => {
236
- const state = makeState({ step: 'asked-model', slug: 'gymbro', persona: 'Gym Bro' })
237
- const action = handleSetupText({ state, text: 'sonnet', callerId: CALLER })
238
- expect(action.kind).toBe('ask-emoji')
239
- if (action.kind === 'ask-emoji') expect(action.model).toBe('sonnet')
240
- })
241
-
242
- it('returns error on model string with spaces', () => {
243
- const state = makeState({ step: 'asked-model', slug: 'gymbro', persona: 'Gym Bro' })
244
- // Spaces are not allowed in model IDs
245
- const action = handleSetupText({ state, text: 'bad model name', callerId: CALLER })
246
- expect(action.kind).toBe('error')
247
- if (action.kind === 'error') expect(action.stayInStep).toBe(true)
248
- })
249
- })
250
-
251
- // ─── handleSetupText: step asked-emoji ───────────────────────────────────
252
-
253
- describe('handleSetupText: asked-emoji (#190 — now transitions to ask-profile)', () => {
254
- it('advances to ask-profile with skip (no emoji)', () => {
255
- const state = makeState({ step: 'asked-emoji', slug: 'gymbro', persona: 'Gym Bro', model: 'sonnet' })
256
- const action = call(state, 'skip')
257
- expect(action.kind).toBe('ask-profile')
258
- if (action.kind === 'ask-profile') {
259
- expect(action.emoji).toBeNull()
260
- expect(action.profiles).toEqual(PROFILES)
261
- }
262
- })
263
-
264
- it('advances to ask-profile carrying the emoji', () => {
265
- const state = makeState({ step: 'asked-emoji', slug: 'gymbro', persona: 'Gym Bro', model: null })
266
- const action = call(state, '🏋️')
267
- expect(action.kind).toBe('ask-profile')
268
- if (action.kind === 'ask-profile') expect(action.emoji).toBe('🏋️')
269
- })
270
- })
271
-
272
- // ─── handleSetupText: step asked-profile (#190) ──────────────────────────
273
-
274
- describe('handleSetupText: asked-profile (#190)', () => {
275
- function profileState(): SetupFlowState {
276
- return makeState({
277
- step: 'asked-profile',
278
- slug: 'gymbro',
279
- persona: 'Gym Bro',
280
- model: 'sonnet',
281
- emoji: '🏋️',
282
- })
283
- }
284
-
285
- it('advances to ask-bot-token when profile is in the live list', () => {
286
- const action = call(profileState(), 'health-coach')
287
- expect(action.kind).toBe('ask-bot-token')
288
- if (action.kind === 'ask-bot-token') {
289
- expect(action.profile).toBe('health-coach')
290
- expect(action.slug).toBe('gymbro')
291
- expect(action.emoji).toBe('🏋️')
292
- }
293
- })
294
-
295
- it('returns error stayInStep when profile is unknown', () => {
296
- const action = call(profileState(), 'nonexistent')
297
- expect(action.kind).toBe('error')
298
- if (action.kind === 'error') {
299
- expect(action.stayInStep).toBe(true)
300
- expect(action.message).toContain('nonexistent')
301
- }
302
- })
303
-
304
- it('lists valid profiles in error message', () => {
305
- const action = call(profileState(), 'bogus')
306
- if (action.kind === 'error') {
307
- for (const p of PROFILES) expect(action.message).toContain(p)
308
- }
309
- })
310
-
311
- it('cancels when slug or persona missing', () => {
312
- const action = call(makeState({ step: 'asked-profile' }), 'default')
313
- expect(action.kind).toBe('cancel')
314
- })
315
-
316
- it('respects a different profiles list per call', () => {
317
- const action = call(profileState(), 'tiny-bundle', { profiles: ['tiny-bundle'] })
318
- expect(action.kind).toBe('ask-bot-token')
319
- })
320
- })
321
-
322
- // ─── handleSetupText: step asked-bot-token ───────────────────────────────
323
-
324
- describe('handleSetupText: asked-bot-token', () => {
325
- it('advances to confirm-allowlist with valid token shape', () => {
326
- const state = makeState({
327
- step: 'asked-bot-token',
328
- slug: 'gymbro',
329
- persona: 'Gym Bro',
330
- model: null,
331
- emoji: null,
332
- })
333
- const action = handleSetupText({ state, text: '1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx', callerId: CALLER })
334
- expect(action.kind).toBe('confirm-allowlist')
335
- if (action.kind === 'confirm-allowlist') expect(action.callerId).toBe(CALLER)
336
- })
337
-
338
- it('returns error on bad token shape', () => {
339
- const state = makeState({
340
- step: 'asked-bot-token',
341
- slug: 'gymbro',
342
- persona: 'Gym Bro',
343
- })
344
- const action = handleSetupText({ state, text: 'notavalidtoken', callerId: CALLER })
345
- expect(action.kind).toBe('error')
346
- if (action.kind === 'error') expect(action.stayInStep).toBe(true)
347
- })
348
-
349
- it('returns cancel when slug or persona is missing', () => {
350
- const state = makeState({
351
- step: 'asked-bot-token',
352
- slug: null,
353
- persona: null,
354
- })
355
- const action = handleSetupText({ state, text: '1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx', callerId: CALLER })
356
- expect(action.kind).toBe('cancel')
357
- })
358
- })
359
-
360
- // ─── handleSetupText: step confirming-allowlist ───────────────────────────
361
-
362
- describe('handleSetupText: confirming-allowlist (#189 — now transitions to call-create-agent)', () => {
363
- const baseState = makeState({
364
- step: 'confirming-allowlist',
365
- slug: 'gymbro',
366
- persona: 'Gym Bro',
367
- model: null,
368
- emoji: null,
369
- profile: 'default',
370
- botToken: '1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx',
371
- })
372
-
373
- it('advances to call-create-agent on "yes"', () => {
374
- const action = call(baseState, 'yes')
375
- expect(action.kind).toBe('call-create-agent')
376
- if (action.kind === 'call-create-agent') {
377
- expect(action.allowedUserId).toBe(CALLER)
378
- expect(action.slug).toBe('gymbro')
379
- expect(action.persona).toBe('Gym Bro')
380
- expect(action.profile).toBe('default')
381
- }
382
- })
383
-
384
- it('advances to call-create-agent on "y"', () => {
385
- const action = call(baseState, 'y')
386
- expect(action.kind).toBe('call-create-agent')
387
- if (action.kind === 'call-create-agent') expect(action.allowedUserId).toBe(CALLER)
388
- })
389
-
390
- it('uses custom user_id when not "yes"', () => {
391
- const action = call(baseState, '99999999')
392
- expect(action.kind).toBe('call-create-agent')
393
- if (action.kind === 'call-create-agent') expect(action.allowedUserId).toBe('99999999')
394
- })
395
-
396
- it('falls back to "default" profile for legacy in-flight flows where profile is null', () => {
397
- // Simulates a flow that started before the #190 schema migration —
398
- // SQLite returns NULL for the new `profile` column, the wizard
399
- // shouldn't break.
400
- const legacy = makeState({
401
- step: 'confirming-allowlist',
402
- slug: 'oldgymbro',
403
- persona: 'Old Gym',
404
- botToken: '1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx',
405
- profile: null,
406
- })
407
- const action = call(legacy, 'yes')
408
- expect(action.kind).toBe('call-create-agent')
409
- if (action.kind === 'call-create-agent') expect(action.profile).toBe('default')
410
- })
411
- })
412
-
413
- // ─── handleSetupText: step asked-oauth-code (#189) ───────────────────────
414
-
415
- describe('handleSetupText: asked-oauth-code (#189)', () => {
416
- function oauthState(): SetupFlowState {
417
- return makeState({
418
- step: 'asked-oauth-code',
419
- slug: 'gymbro',
420
- persona: 'Gym Bro',
421
- profile: 'default',
422
- botToken: '1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx',
423
- authSessionName: 'gymbro-foreman',
424
- loginUrl: 'https://example.com/login',
425
- })
426
- }
427
-
428
- it('advances to call-complete-creation with a valid-shape code', () => {
429
- const action = call(oauthState(), 'a1b2c3d4e5')
430
- expect(action.kind).toBe('call-complete-creation')
431
- if (action.kind === 'call-complete-creation') {
432
- expect(action.slug).toBe('gymbro')
433
- expect(action.code).toBe('a1b2c3d4e5')
434
- }
435
- })
436
-
437
- it('returns error stayInStep on too-short code', () => {
438
- const action = call(oauthState(), 'abc')
439
- expect(action.kind).toBe('error')
440
- if (action.kind === 'error') expect(action.stayInStep).toBe(true)
441
- })
442
-
443
- it('cancels when slug missing (corrupt state)', () => {
444
- const action = call(makeState({ step: 'asked-oauth-code' }), 'a1b2c3d4e5')
445
- expect(action.kind).toBe('cancel')
446
- })
447
- })
448
-
449
- // ─── handleSetupText: terminal steps ─────────────────────────────────────
450
-
451
- describe('handleSetupText: terminal steps', () => {
452
- it('returns cancel for reconciling step', () => {
453
- const state = makeState({ step: 'reconciling' })
454
- const action = handleSetupText({ state, text: 'anything', callerId: CALLER })
455
- expect(action.kind).toBe('cancel')
456
- })
457
-
458
- it('returns cancel for done step', () => {
459
- const state = makeState({ step: 'done' })
460
- const action = handleSetupText({ state, text: 'anything', callerId: CALLER })
461
- expect(action.kind).toBe('cancel')
462
- })
463
- })
464
-
465
- // ─── makeSetupInitialState ────────────────────────────────────────────────
466
-
467
- describe('makeSetupInitialState', () => {
468
- it('sets step to asked-slug when no slug', () => {
469
- const s = makeSetupInitialState('chat1', null)
470
- expect(s.step).toBe('asked-slug')
471
- expect(s.slug).toBeNull()
472
- })
473
-
474
- it('sets step to asked-persona when slug provided', () => {
475
- const s = makeSetupInitialState('chat1', 'gymbro')
476
- expect(s.step).toBe('asked-persona')
477
- expect(s.slug).toBe('gymbro')
478
- })
479
- })
480
-
481
- // ─── advanceSetupState ────────────────────────────────────────────────────
482
-
483
- describe('advanceSetupState', () => {
484
- it('merges updates and bumps updatedAt', () => {
485
- const original = makeState({ updatedAt: 1000 })
486
- const advanced = advanceSetupState(original, { step: 'asked-persona', slug: 'gymbro' })
487
- expect(advanced.step).toBe('asked-persona')
488
- expect(advanced.slug).toBe('gymbro')
489
- expect(advanced.chatId).toBe(original.chatId)
490
- expect(advanced.updatedAt).toBeGreaterThanOrEqual(original.updatedAt)
491
- })
492
- })
493
-
494
- // ─── setupStepLabel ───────────────────────────────────────────────────────
495
-
496
- describe('setupStepLabel', () => {
497
- const cases: [import('../foreman/setup-state.js').SetupFlowStep, string][] = [
498
- ['asked-slug', 'waiting for agent slug'],
499
- ['asked-persona', 'waiting for persona name'],
500
- ['asked-model', 'waiting for model choice'],
501
- ['asked-emoji', 'waiting for emoji'],
502
- ['asked-bot-token', 'waiting for BotFather token'],
503
- ['confirming-allowlist', 'waiting for allowlist confirmation'],
504
- ['reconciling', 'provisioning agent'],
505
- ['done', 'done'],
506
- ]
507
- for (const [step, expected] of cases) {
508
- it(`labels ${step} correctly`, () => expect(setupStepLabel(step)).toBe(expected))
509
- }
510
- })
@@ -1,146 +0,0 @@
1
- /**
2
- * Tests for /setup wizard SQLite state (setup-state.ts).
3
- *
4
- * Uses bun:test (not vitest) because setup-state.ts imports bun:sqlite.
5
- * Run with: bun test telegram-plugin/tests/setup-state.test.ts
6
- */
7
-
8
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
9
- import { mkdtempSync, rmSync } from 'fs'
10
- import { tmpdir } from 'os'
11
-
12
- let tmpDir: string
13
-
14
- beforeEach(() => {
15
- tmpDir = mkdtempSync(tmpdir() + '/setup-state-test-')
16
- process.env.SWITCHROOM_FOREMAN_DIR = tmpDir
17
- })
18
-
19
- afterEach(async () => {
20
- const { _resetSetupDbForTest } = await import('../foreman/setup-state.js')
21
- _resetSetupDbForTest()
22
- delete process.env.SWITCHROOM_FOREMAN_DIR
23
- try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
24
- })
25
-
26
- function makeState(chatId = 'chat1') {
27
- const now = Date.now()
28
- return {
29
- chatId,
30
- step: 'asked-slug' as const,
31
- slug: null,
32
- persona: null,
33
- model: null,
34
- emoji: null,
35
- botToken: null,
36
- allowedUserId: null,
37
- startedAt: now,
38
- updatedAt: now,
39
- }
40
- }
41
-
42
- // ─── Round-trip: setSetupState + getSetupState ────────────────────────────
43
-
44
- describe('setup-state: round-trip', () => {
45
- it('stores and retrieves initial state', async () => {
46
- const { setSetupState, getSetupState } = await import('../foreman/setup-state.js')
47
- const state = makeState()
48
- setSetupState(state)
49
- const retrieved = getSetupState('chat1')
50
- expect(retrieved).not.toBeNull()
51
- expect(retrieved?.step).toBe('asked-slug')
52
- expect(retrieved?.slug).toBeNull()
53
- expect(retrieved?.chatId).toBe('chat1')
54
- })
55
-
56
- it('returns null for unknown chatId', async () => {
57
- const { getSetupState } = await import('../foreman/setup-state.js')
58
- expect(getSetupState('nonexistent')).toBeNull()
59
- })
60
-
61
- it('upserts state on repeat setSetupState', async () => {
62
- const { setSetupState, getSetupState } = await import('../foreman/setup-state.js')
63
- const state = makeState()
64
- setSetupState(state)
65
- setSetupState({ ...state, step: 'asked-persona', slug: 'gymbro' })
66
- const retrieved = getSetupState('chat1')
67
- expect(retrieved?.step).toBe('asked-persona')
68
- expect(retrieved?.slug).toBe('gymbro')
69
- })
70
-
71
- it('stores all fields', async () => {
72
- const { setSetupState, getSetupState } = await import('../foreman/setup-state.js')
73
- const now = Date.now()
74
- setSetupState({
75
- chatId: 'chat2',
76
- step: 'asked-bot-token',
77
- slug: 'myagent',
78
- persona: 'My Agent',
79
- model: 'sonnet',
80
- emoji: '🤖',
81
- botToken: '1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx',
82
- allowedUserId: '99999999',
83
- startedAt: now - 1000,
84
- updatedAt: now,
85
- })
86
- const retrieved = getSetupState('chat2')
87
- expect(retrieved?.slug).toBe('myagent')
88
- expect(retrieved?.persona).toBe('My Agent')
89
- expect(retrieved?.model).toBe('sonnet')
90
- expect(retrieved?.emoji).toBe('🤖')
91
- expect(retrieved?.botToken).toBe('1234567890:AAHxxxxxxxxxxxxxxxxxxxxxxx')
92
- expect(retrieved?.allowedUserId).toBe('99999999')
93
- })
94
- })
95
-
96
- // ─── clearSetupState ──────────────────────────────────────────────────────
97
-
98
- describe('setup-state: clearSetupState', () => {
99
- it('removes state for given chat', async () => {
100
- const { setSetupState, clearSetupState, getSetupState } = await import('../foreman/setup-state.js')
101
- setSetupState(makeState('chatA'))
102
- setSetupState(makeState('chatB'))
103
- clearSetupState('chatA')
104
- expect(getSetupState('chatA')).toBeNull()
105
- expect(getSetupState('chatB')).not.toBeNull()
106
- })
107
-
108
- it('is a no-op when chat has no state', async () => {
109
- const { clearSetupState, getSetupState } = await import('../foreman/setup-state.js')
110
- // Should not throw
111
- clearSetupState('nobody')
112
- expect(getSetupState('nobody')).toBeNull()
113
- })
114
- })
115
-
116
- // ─── listActiveSetupFlows ─────────────────────────────────────────────────
117
-
118
- describe('setup-state: listActiveSetupFlows', () => {
119
- it('returns only non-done flows within maxAge', async () => {
120
- const { setSetupState, listActiveSetupFlows } = await import('../foreman/setup-state.js')
121
- const now = Date.now()
122
-
123
- setSetupState({ ...makeState('chat1'), step: 'asked-slug', updatedAt: now })
124
- setSetupState({ ...makeState('chat2'), step: 'done', updatedAt: now })
125
- setSetupState({ ...makeState('chat3'), step: 'asked-persona', updatedAt: now - 2 * 60 * 60 * 1000 }) // 2 hours old
126
-
127
- const active = listActiveSetupFlows(60 * 60 * 1000) // 1 hour
128
- expect(active.length).toBe(1)
129
- expect(active[0].chatId).toBe('chat1')
130
- })
131
-
132
- it('returns empty list when nothing active', async () => {
133
- const { listActiveSetupFlows } = await import('../foreman/setup-state.js')
134
- const active = listActiveSetupFlows()
135
- expect(active).toEqual([])
136
- })
137
-
138
- it('returns multiple active flows', async () => {
139
- const { setSetupState, listActiveSetupFlows } = await import('../foreman/setup-state.js')
140
- const now = Date.now()
141
- setSetupState({ ...makeState('c1'), updatedAt: now })
142
- setSetupState({ ...makeState('c2'), step: 'asked-persona', updatedAt: now })
143
- const active = listActiveSetupFlows()
144
- expect(active.length).toBe(2)
145
- })
146
- })