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,137 +0,0 @@
1
- /**
2
- * Boot card per-account quota rendering — issue #708.
3
- *
4
- * Verifies:
5
- * - When `accounts` is absent / empty, the card stays silent (today's
6
- * contract — no accounts section).
7
- * - When `accounts` is present, the card appends an "Accounts (N)"
8
- * header and one line per account with 5h % / 7d % / nearest reset.
9
- * - The ▶ marker tags the active-for-this-agent account; ↳ tags the
10
- * rest.
11
- * - HTML escaping on account labels.
12
- * - Account with no quota fields (label only) renders the row without
13
- * any percent / reset suffix.
14
- */
15
-
16
- import { describe, it, expect } from 'vitest'
17
- import {
18
- renderBootCard,
19
- renderAccountRows,
20
- } from '../gateway/boot-card.js'
21
- import type { AccountSummary } from '../auth-dashboard.js'
22
-
23
- const NOW = new Date('2026-05-05T10:00:00Z')
24
-
25
- function mk(overrides: Partial<AccountSummary> = {}): AccountSummary {
26
- return {
27
- label: 'pixsoul@gmail.com',
28
- health: 'healthy',
29
- enabledHere: true,
30
- activeForThisAgent: false,
31
- fiveHourPct: 10,
32
- sevenDayPct: 79,
33
- fiveHourResetAt: NOW.getTime() + 2 * 3600_000 + 14 * 60_000,
34
- sevenDayResetAt: NOW.getTime() + 5 * 86_400_000,
35
- ...overrides,
36
- }
37
- }
38
-
39
- describe('renderBootCard — per-account quota (issue #708)', () => {
40
- it('omits the accounts section when accounts is undefined', () => {
41
- const out = renderBootCard({ agentName: 'clerk', version: 'v0.7.0' })
42
- expect(out).not.toContain('Accounts')
43
- })
44
-
45
- it('omits the accounts section when accounts is empty', () => {
46
- const out = renderBootCard({
47
- agentName: 'clerk',
48
- version: 'v0.7.0',
49
- accounts: [],
50
- })
51
- expect(out).not.toContain('Accounts')
52
- })
53
-
54
- it('renders the active account with ▶ and inline 5h / 7d / reset', () => {
55
- const out = renderBootCard({
56
- agentName: 'clerk',
57
- version: 'v0.7.0',
58
- accounts: [mk({ activeForThisAgent: true })],
59
- now: NOW,
60
- })
61
- expect(out).toContain('Accounts (1)')
62
- expect(out).toContain('▶')
63
- expect(out).toContain('pixsoul@gmail.com')
64
- expect(out).toContain('10%')
65
- expect(out).toContain('79%')
66
- expect(out).toContain('5h resets in')
67
- })
68
-
69
- it('renders fallback accounts with ↳', () => {
70
- const out = renderBootCard({
71
- agentName: 'clerk',
72
- version: 'v0.7.0',
73
- now: NOW,
74
- accounts: [
75
- mk({ activeForThisAgent: true }),
76
- mk({ label: 'ken+work@example.com', activeForThisAgent: false }),
77
- ],
78
- })
79
- expect(out).toContain('Accounts (2)')
80
- expect(out).toMatch(/↳ <code>ken\+work@example\.com<\/code>/)
81
- })
82
-
83
- it('escapes HTML in labels', () => {
84
- const out = renderAccountRows(
85
- [mk({ label: 'evil<script>', activeForThisAgent: true })],
86
- NOW,
87
- )
88
- expect(out.join('\n')).toContain('evil&lt;script&gt;')
89
- expect(out.join('\n')).not.toContain('<script>')
90
- })
91
-
92
- it('renders a row with no quota numbers as label-only', () => {
93
- const summary: AccountSummary = {
94
- label: 'just-added',
95
- health: 'healthy',
96
- enabledHere: true,
97
- activeForThisAgent: false,
98
- }
99
- const out = renderAccountRows([summary], NOW)
100
- expect(out).toHaveLength(2)
101
- expect(out[1]).toBe('↳ <code>just-added</code>')
102
- })
103
-
104
- it('shows 7d reset when 5h reset is missing', () => {
105
- const out = renderAccountRows(
106
- [
107
- mk({
108
- activeForThisAgent: true,
109
- fiveHourPct: 0,
110
- sevenDayPct: 99,
111
- fiveHourResetAt: undefined,
112
- sevenDayResetAt: NOW.getTime() + 86_400_000 + 3 * 3600_000,
113
- }),
114
- ],
115
- NOW,
116
- )
117
- expect(out.join('\n')).toContain('7d resets in')
118
- })
119
-
120
- it('drops the reset suffix once the reset timestamp has elapsed', () => {
121
- const out = renderAccountRows(
122
- [
123
- mk({
124
- activeForThisAgent: true,
125
- fiveHourPct: 0,
126
- sevenDayPct: 0,
127
- fiveHourResetAt: NOW.getTime() - 60_000,
128
- sevenDayResetAt: undefined,
129
- }),
130
- ],
131
- NOW,
132
- )
133
- // Past-reset timestamps return "" from formatNearestAccountResetSuffix,
134
- // so the line should not contain "resets in" at all.
135
- expect(out.join('\n')).not.toContain('resets in')
136
- })
137
- })
@@ -1,359 +0,0 @@
1
- /**
2
- * Tests for the create-agent flow state machine (foreman-create-flow.ts).
3
- *
4
- * Pure function tests — no grammY, no SQLite, no network.
5
- *
6
- * Covers:
7
- * - startCreateFlow: valid/invalid name, inline name, no name
8
- * - handleFlowText: step transitions (asked-name → asked-profile → asked-bot-token → ...)
9
- * - Error paths: invalid name, unknown profile, bad token shape, short code
10
- * - makeInitialState / advanceState / stepLabel helpers
11
- */
12
-
13
- import { describe, it, expect } from 'vitest'
14
- import {
15
- startCreateFlow,
16
- handleFlowText,
17
- makeInitialState,
18
- advanceState,
19
- stepLabel,
20
- isValidAgentName,
21
- } from '../foreman/foreman-create-flow.js'
22
- import type { CreateFlowState } from '../foreman/state.js'
23
-
24
- const PROFILES = ['default', 'health-coach', 'coding-assistant']
25
-
26
- // ─── isValidAgentName ─────────────────────────────────────────────────────
27
-
28
- describe('foreman-create-flow: isValidAgentName', () => {
29
- it('accepts lowercase simple name', () => expect(isValidAgentName('gymbro')).toBe(true))
30
- it('accepts name with hyphens', () => expect(isValidAgentName('my-agent')).toBe(true))
31
- it('accepts name with underscores', () => expect(isValidAgentName('my_agent')).toBe(true))
32
- it('accepts name starting with digit', () => expect(isValidAgentName('1agent')).toBe(true))
33
- it('rejects uppercase', () => expect(isValidAgentName('Gymbro')).toBe(false))
34
- it('rejects empty string', () => expect(isValidAgentName('')).toBe(false))
35
- it('rejects spaces', () => expect(isValidAgentName('my agent')).toBe(false))
36
- it('rejects semicolon', () => expect(isValidAgentName('agent; evil')).toBe(false))
37
- it('accepts 51-char name', () => expect(isValidAgentName('a'.repeat(51))).toBe(true))
38
- it('rejects 52-char name', () => expect(isValidAgentName('a'.repeat(52))).toBe(false))
39
- })
40
-
41
- // ─── startCreateFlow ──────────────────────────────────────────────────────
42
-
43
- describe('foreman-create-flow: startCreateFlow', () => {
44
- it('asks for name when no inline name given', () => {
45
- const action = startCreateFlow(null, PROFILES)
46
- expect(action.kind).toBe('ask-name')
47
- })
48
-
49
- it('asks for profile when valid inline name given', () => {
50
- const action = startCreateFlow('gymbro', PROFILES)
51
- expect(action.kind).toBe('ask-profile')
52
- if (action.kind === 'ask-profile') {
53
- expect(action.profiles).toEqual(PROFILES)
54
- }
55
- })
56
-
57
- it('returns error when inline name is invalid', () => {
58
- const action = startCreateFlow('Bad Name!', PROFILES)
59
- expect(action.kind).toBe('error')
60
- if (action.kind === 'error') {
61
- expect(action.message).toContain('Bad Name!')
62
- expect(action.stayInStep).toBe(false)
63
- }
64
- })
65
-
66
- it('returns error for uppercase inline name', () => {
67
- const action = startCreateFlow('MyAgent', PROFILES)
68
- expect(action.kind).toBe('error')
69
- })
70
- })
71
-
72
- // ─── handleFlowText — null state ─────────────────────────────────────────
73
-
74
- describe('foreman-create-flow: handleFlowText with null state', () => {
75
- it('cancels when no active flow', () => {
76
- const action = handleFlowText({ state: null, text: 'hello', profiles: PROFILES })
77
- expect(action.kind).toBe('cancel')
78
- })
79
- })
80
-
81
- // ─── handleFlowText — asked-name step ────────────────────────────────────
82
-
83
- describe('foreman-create-flow: handleFlowText step=asked-name', () => {
84
- function makeState(): CreateFlowState {
85
- return makeInitialState('chat-1', null) // step = 'asked-name'
86
- }
87
-
88
- it('transitions to ask-profile on valid name', () => {
89
- const action = handleFlowText({ state: makeState(), text: 'gymbro', profiles: PROFILES })
90
- expect(action.kind).toBe('ask-profile')
91
- if (action.kind === 'ask-profile') {
92
- expect(action.profiles).toEqual(PROFILES)
93
- }
94
- })
95
-
96
- it('returns error on invalid name, stayInStep=true', () => {
97
- const action = handleFlowText({ state: makeState(), text: 'Bad Name!', profiles: PROFILES })
98
- expect(action.kind).toBe('error')
99
- if (action.kind === 'error') {
100
- expect(action.stayInStep).toBe(true)
101
- }
102
- })
103
-
104
- it('error message mentions the bad input', () => {
105
- const action = handleFlowText({ state: makeState(), text: 'MyBotIsGreat', profiles: PROFILES })
106
- if (action.kind === 'error') {
107
- expect(action.message).toContain('MyBotIsGreat')
108
- }
109
- })
110
-
111
- it('accepts name with hyphens', () => {
112
- const action = handleFlowText({ state: makeState(), text: 'my-agent', profiles: PROFILES })
113
- expect(action.kind).toBe('ask-profile')
114
- })
115
- })
116
-
117
- // ─── handleFlowText — asked-profile step ──────────────────────────────────
118
-
119
- describe('foreman-create-flow: handleFlowText step=asked-profile', () => {
120
- function makeState(name = 'gymbro'): CreateFlowState {
121
- return {
122
- chatId: 'chat-1',
123
- step: 'asked-profile',
124
- name,
125
- profile: null,
126
- botToken: null,
127
- authSessionName: null,
128
- loginUrl: null,
129
- startedAt: Date.now(),
130
- updatedAt: Date.now(),
131
- }
132
- }
133
-
134
- it('transitions to ask-bot-token on valid profile', () => {
135
- const action = handleFlowText({ state: makeState(), text: 'health-coach', profiles: PROFILES })
136
- expect(action.kind).toBe('ask-bot-token')
137
- if (action.kind === 'ask-bot-token') {
138
- expect(action.profile).toBe('health-coach')
139
- expect(action.name).toBe('gymbro')
140
- }
141
- })
142
-
143
- it('returns error on unknown profile, stayInStep=true', () => {
144
- const action = handleFlowText({ state: makeState(), text: 'nonexistent-profile', profiles: PROFILES })
145
- expect(action.kind).toBe('error')
146
- if (action.kind === 'error') {
147
- expect(action.stayInStep).toBe(true)
148
- expect(action.message).toContain('nonexistent-profile')
149
- }
150
- })
151
-
152
- it('lists valid profiles in error message', () => {
153
- const action = handleFlowText({ state: makeState(), text: 'bad', profiles: PROFILES })
154
- if (action.kind === 'error') {
155
- for (const p of PROFILES) {
156
- expect(action.message).toContain(p)
157
- }
158
- }
159
- })
160
-
161
- it('cancels with missing-name when state.name is unset (#28 item 1)', () => {
162
- // Pre-#28 fix this fell back to using the profile name as the agent
163
- // name. Now we cancel cleanly so the user gets a clear restart
164
- // signal instead of an agent named "default".
165
- const stateNoName = {
166
- chatId: 'chat-1',
167
- step: 'asked-profile' as const,
168
- name: null,
169
- profile: null,
170
- botToken: null,
171
- authSessionName: null,
172
- loginUrl: null,
173
- startedAt: Date.now(),
174
- updatedAt: Date.now(),
175
- }
176
- const action = handleFlowText({ state: stateNoName, text: 'default', profiles: PROFILES })
177
- expect(action.kind).toBe('cancel')
178
- if (action.kind === 'cancel') {
179
- expect(action.reason).toBe('missing-name')
180
- }
181
- })
182
- })
183
-
184
- // ─── handleFlowText — asked-bot-token step ───────────────────────────────
185
-
186
- describe('foreman-create-flow: handleFlowText step=asked-bot-token', () => {
187
- function makeState(): CreateFlowState {
188
- return {
189
- chatId: 'chat-1',
190
- step: 'asked-bot-token',
191
- name: 'gymbro',
192
- profile: 'health-coach',
193
- botToken: null,
194
- authSessionName: null,
195
- loginUrl: null,
196
- startedAt: Date.now(),
197
- updatedAt: Date.now(),
198
- }
199
- }
200
-
201
- it('transitions to call-create-agent on token-shaped input', () => {
202
- const token = '1234567890:AAHaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
203
- const action = handleFlowText({ state: makeState(), text: token, profiles: PROFILES })
204
- expect(action.kind).toBe('call-create-agent')
205
- if (action.kind === 'call-create-agent') {
206
- expect(action.botToken).toBe(token)
207
- expect(action.name).toBe('gymbro')
208
- expect(action.profile).toBe('health-coach')
209
- }
210
- })
211
-
212
- it('returns error on token with no colon, stayInStep=true', () => {
213
- const action = handleFlowText({ state: makeState(), text: 'notavalidtoken', profiles: PROFILES })
214
- expect(action.kind).toBe('error')
215
- if (action.kind === 'error') {
216
- expect(action.stayInStep).toBe(true)
217
- }
218
- })
219
-
220
- it('returns error on token too short', () => {
221
- const action = handleFlowText({ state: makeState(), text: 'a:b', profiles: PROFILES })
222
- expect(action.kind).toBe('error')
223
- if (action.kind === 'error') {
224
- expect(action.stayInStep).toBe(true)
225
- }
226
- })
227
-
228
- it('cancels if name or profile missing in state', () => {
229
- const state: CreateFlowState = {
230
- ...makeState(),
231
- name: null,
232
- }
233
- const action = handleFlowText({ state, text: '1234567890:AAHsomething', profiles: PROFILES })
234
- expect(action.kind).toBe('cancel')
235
- })
236
- })
237
-
238
- // ─── handleFlowText — asked-oauth-code step ──────────────────────────────
239
-
240
- describe('foreman-create-flow: handleFlowText step=asked-oauth-code', () => {
241
- function makeState(): CreateFlowState {
242
- return {
243
- chatId: 'chat-1',
244
- step: 'asked-oauth-code',
245
- name: 'gymbro',
246
- profile: 'health-coach',
247
- botToken: '1234567890:AAHsomething',
248
- authSessionName: 'gymbro-auth-123',
249
- loginUrl: 'https://claude.ai/oauth/authorize?...',
250
- startedAt: Date.now(),
251
- updatedAt: Date.now(),
252
- }
253
- }
254
-
255
- it('transitions to call-complete-creation on plausible code', () => {
256
- const action = handleFlowText({ state: makeState(), text: 'abc12345', profiles: PROFILES })
257
- expect(action.kind).toBe('call-complete-creation')
258
- if (action.kind === 'call-complete-creation') {
259
- expect(action.name).toBe('gymbro')
260
- expect(action.code).toBe('abc12345')
261
- }
262
- })
263
-
264
- it('returns error on code that is too short, stayInStep=true', () => {
265
- const action = handleFlowText({ state: makeState(), text: 'ab', profiles: PROFILES })
266
- expect(action.kind).toBe('error')
267
- if (action.kind === 'error') {
268
- expect(action.stayInStep).toBe(true)
269
- }
270
- })
271
-
272
- it('cancels if name missing in state', () => {
273
- const state: CreateFlowState = { ...makeState(), name: null }
274
- const action = handleFlowText({ state, text: 'abc12345', profiles: PROFILES })
275
- expect(action.kind).toBe('cancel')
276
- })
277
- })
278
-
279
- // ─── handleFlowText — done step ──────────────────────────────────────────
280
-
281
- describe('foreman-create-flow: handleFlowText step=done', () => {
282
- it('returns cancel when flow is already done', () => {
283
- const state: CreateFlowState = {
284
- chatId: 'chat-1',
285
- step: 'done',
286
- name: 'gymbro',
287
- profile: 'health-coach',
288
- botToken: null,
289
- authSessionName: null,
290
- loginUrl: null,
291
- startedAt: Date.now(),
292
- updatedAt: Date.now(),
293
- }
294
- const action = handleFlowText({ state, text: 'hello', profiles: PROFILES })
295
- expect(action.kind).toBe('cancel')
296
- })
297
- })
298
-
299
- // ─── makeInitialState ─────────────────────────────────────────────────────
300
-
301
- describe('foreman-create-flow: makeInitialState', () => {
302
- it('sets step to asked-name when name is null', () => {
303
- const state = makeInitialState('chat-1', null)
304
- expect(state.step).toBe('asked-name')
305
- expect(state.name).toBeNull()
306
- })
307
-
308
- it('sets step to asked-profile when name provided', () => {
309
- const state = makeInitialState('chat-1', 'gymbro')
310
- expect(state.step).toBe('asked-profile')
311
- expect(state.name).toBe('gymbro')
312
- })
313
-
314
- it('sets startedAt and updatedAt', () => {
315
- const before = Date.now()
316
- const state = makeInitialState('chat-1', null)
317
- const after = Date.now()
318
- expect(state.startedAt).toBeGreaterThanOrEqual(before)
319
- expect(state.startedAt).toBeLessThanOrEqual(after)
320
- expect(state.updatedAt).toBe(state.startedAt)
321
- })
322
- })
323
-
324
- // ─── advanceState ─────────────────────────────────────────────────────────
325
-
326
- describe('foreman-create-flow: advanceState', () => {
327
- it('merges updates into state', () => {
328
- const state = makeInitialState('chat-1', 'gymbro')
329
- const next = advanceState(state, { step: 'asked-bot-token', profile: 'health-coach' })
330
- expect(next.step).toBe('asked-bot-token')
331
- expect(next.profile).toBe('health-coach')
332
- expect(next.name).toBe('gymbro')
333
- expect(next.chatId).toBe('chat-1')
334
- })
335
-
336
- it('updates updatedAt', () => {
337
- const state = makeInitialState('chat-1', null)
338
- const before = Date.now()
339
- const next = advanceState(state, { step: 'asked-profile', name: 'gymbro' })
340
- expect(next.updatedAt).toBeGreaterThanOrEqual(before)
341
- })
342
-
343
- it('preserves startedAt', () => {
344
- const state = makeInitialState('chat-1', null)
345
- const next = advanceState(state, { step: 'asked-profile' })
346
- expect(next.startedAt).toBe(state.startedAt)
347
- })
348
- })
349
-
350
- // ─── stepLabel ────────────────────────────────────────────────────────────
351
-
352
- describe('foreman-create-flow: stepLabel', () => {
353
- it('returns a non-empty string for each step', () => {
354
- const steps = ['asked-name', 'asked-profile', 'asked-bot-token', 'asked-oauth-code', 'done'] as const
355
- for (const step of steps) {
356
- expect(stepLabel(step).length).toBeGreaterThan(0)
357
- }
358
- })
359
- })