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.
Files changed (137) hide show
  1. package/README.md +54 -61
  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/drive-write-pretool.mjs +5418 -0
  6. package/dist/cli/switchroom.js +8890 -5560
  7. package/dist/host-control/main.js +582 -43
  8. package/dist/vault/approvals/kernel-server.js +276 -47
  9. package/dist/vault/broker/server.js +333 -69
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +6 -4
  16. package/profiles/_base/start.sh.hbs +3 -3
  17. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  18. package/profiles/default/CLAUDE.md +10 -0
  19. package/profiles/default/CLAUDE.md.hbs +16 -0
  20. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  21. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  22. package/skills/buildkite-api/SKILL.md +31 -8
  23. package/skills/buildkite-cli/SKILL.md +27 -9
  24. package/skills/buildkite-migration/SKILL.md +22 -9
  25. package/skills/buildkite-pipelines/SKILL.md +26 -9
  26. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  27. package/skills/buildkite-test-engine/SKILL.md +25 -8
  28. package/skills/docx/SKILL.md +1 -1
  29. package/skills/file-bug/SKILL.md +34 -6
  30. package/skills/humanizer/SKILL.md +15 -0
  31. package/skills/humanizer-calibrate/SKILL.md +7 -1
  32. package/skills/mcp-builder/SKILL.md +1 -1
  33. package/skills/pdf/SKILL.md +1 -1
  34. package/skills/pptx/SKILL.md +1 -1
  35. package/skills/skill-creator/SKILL.md +21 -1
  36. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  41. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  42. package/skills/switchroom-cli/SKILL.md +63 -64
  43. package/skills/switchroom-health/SKILL.md +23 -10
  44. package/skills/switchroom-install/SKILL.md +3 -3
  45. package/skills/switchroom-manage/SKILL.md +26 -19
  46. package/skills/switchroom-runtime/SKILL.md +67 -15
  47. package/skills/switchroom-status/SKILL.md +26 -1
  48. package/skills/telegram-test-harness/SKILL.md +3 -0
  49. package/skills/webapp-testing/SKILL.md +31 -1
  50. package/skills/xlsx/SKILL.md +1 -1
  51. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  52. package/telegram-plugin/admin-commands/index.ts +9 -5
  53. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  54. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  55. package/telegram-plugin/auto-fallback.ts +28 -301
  56. package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
  57. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  58. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  59. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  60. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  61. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  62. package/telegram-plugin/gateway/auth-command.ts +905 -0
  63. package/telegram-plugin/gateway/auth-line.ts +123 -0
  64. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  65. package/telegram-plugin/gateway/boot-card.ts +23 -37
  66. package/telegram-plugin/gateway/boot-probes.ts +9 -12
  67. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  68. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  69. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  70. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  71. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  72. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  73. package/telegram-plugin/gateway/gateway.ts +1156 -938
  74. package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
  75. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  76. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  77. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  78. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  79. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  80. package/telegram-plugin/model-unavailable.ts +28 -12
  81. package/telegram-plugin/permission-title.ts +56 -0
  82. package/telegram-plugin/quota-check.ts +19 -41
  83. package/telegram-plugin/scripts/build.mjs +0 -1
  84. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  85. package/telegram-plugin/silence-poke.ts +153 -1
  86. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  87. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  88. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  89. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  90. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  91. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  92. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  93. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  94. package/telegram-plugin/tests/boot-probes.test.ts +27 -22
  95. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  96. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  97. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  98. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  99. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  100. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  101. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  102. package/telegram-plugin/turn-flush-safety.ts +55 -1
  103. package/telegram-plugin/uat/SETUP.md +35 -1
  104. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  105. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  106. package/telegram-plugin/uat/runners/report.ts +150 -0
  107. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  108. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  109. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  110. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  111. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  112. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  113. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  114. package/telegram-plugin/auth-dashboard.ts +0 -1104
  115. package/telegram-plugin/auth-slot-parser.ts +0 -497
  116. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  117. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  118. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  119. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  120. package/telegram-plugin/foreman/foreman.ts +0 -1165
  121. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  122. package/telegram-plugin/foreman/setup-state.ts +0 -239
  123. package/telegram-plugin/foreman/state.ts +0 -203
  124. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  125. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  126. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  127. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  128. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  129. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  130. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  131. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  132. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  133. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  134. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  135. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  136. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  137. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -1,183 +0,0 @@
1
- /**
2
- * End-to-end tests for the auto-fallback notification dispatcher
3
- * (#11 / #420 / #421).
4
- *
5
- * `auto-fallback.ts` returns a `FallbackPlan` (pure). This test
6
- * exercises the side-effecting half: given a plan, does the
7
- * dispatcher emit the right Bot API call to the owner chat?
8
- *
9
- * The pure plan logic itself is covered by `auto-fallback.test.ts`.
10
- * This file locks in the wiring so a regression in dispatch (wrong
11
- * parse_mode, missing link-preview disable, swallowed errors) is
12
- * caught here instead of going silent in production.
13
- */
14
-
15
- import { describe, it, expect, beforeEach, vi } from 'vitest';
16
- import {
17
- dispatchFallbackNotification,
18
- type DispatchOutcome,
19
- } from '../auto-fallback-dispatcher.js';
20
- import type { FallbackPlan } from '../auto-fallback.js';
21
- import { createFakeBotApi, errors, type FakeBot } from './fake-bot-api.js';
22
-
23
- const OWNER = 'chat-owner';
24
-
25
- let bot: FakeBot;
26
-
27
- beforeEach(() => {
28
- bot = createFakeBotApi({ startMessageId: 200 });
29
- });
30
-
31
- function planExecuted(): FallbackPlan {
32
- return {
33
- kind: 'executed',
34
- previousSlot: 'default',
35
- newSlot: 'personal',
36
- resetAtMs: Date.now() + 60_000,
37
- notificationHtml:
38
- '⚠️ <b>Quota exhausted</b> on slot <code>default</code>. Switched to <code>personal</code>.',
39
- agentName: 'clerk',
40
- triggerReason: '429-response',
41
- };
42
- }
43
-
44
- function planExhaustedAll(): FallbackPlan {
45
- return {
46
- kind: 'exhausted-all',
47
- activeSlot: 'default',
48
- resetAtMs: Date.now() + 4 * 60 * 60_000,
49
- notificationHtml:
50
- '🚨 <b>All slots quota-exhausted</b> for clerk. Run /auth add to attach another subscription.',
51
- agentName: 'clerk',
52
- };
53
- }
54
-
55
- describe('dispatchFallbackNotification — happy path', () => {
56
- it('sends executed plan with HTML parse_mode + link preview disabled', async () => {
57
- const plan = planExecuted();
58
- const outcome = await dispatchFallbackNotification({
59
- bot,
60
- ownerChatId: OWNER,
61
- plan,
62
- });
63
- expect(outcome).toEqual<DispatchOutcome>({ kind: 'sent', messageId: 200 });
64
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
65
- const [chat, text, opts] = bot.api.sendMessage.mock.calls[0];
66
- expect(chat).toBe(OWNER);
67
- expect(text).toBe(plan.notificationHtml);
68
- expect(opts).toMatchObject({
69
- parse_mode: 'HTML',
70
- link_preview_options: { is_disabled: true },
71
- });
72
- });
73
-
74
- it('sends exhausted-all plan to owner chat', async () => {
75
- const plan = planExhaustedAll();
76
- const outcome = await dispatchFallbackNotification({
77
- bot,
78
- ownerChatId: OWNER,
79
- plan,
80
- });
81
- expect(outcome.kind).toBe('sent');
82
- const text = bot.api.sendMessage.mock.calls[0][1] as string;
83
- expect(text).toContain('All slots quota-exhausted');
84
- expect(text).toContain('clerk');
85
- });
86
-
87
- it('chat model reflects the sent message', async () => {
88
- await dispatchFallbackNotification({
89
- bot,
90
- ownerChatId: OWNER,
91
- plan: planExecuted(),
92
- });
93
- const sent = bot.messagesIn(OWNER);
94
- expect(sent).toHaveLength(1);
95
- expect(sent[0].text).toContain('Quota exhausted');
96
- expect(sent[0].parse_mode).toBe('HTML');
97
- });
98
- });
99
-
100
- describe('dispatchFallbackNotification — no chat', () => {
101
- it('returns no-chat when ownerChatId is null', async () => {
102
- const outcome = await dispatchFallbackNotification({
103
- bot,
104
- ownerChatId: null,
105
- plan: planExecuted(),
106
- });
107
- expect(outcome).toEqual({ kind: 'no-chat' });
108
- expect(bot.api.sendMessage).not.toHaveBeenCalled();
109
- });
110
-
111
- it('returns no-chat when ownerChatId is undefined (access.allowFrom empty)', async () => {
112
- const outcome = await dispatchFallbackNotification({
113
- bot,
114
- ownerChatId: undefined,
115
- plan: planExecuted(),
116
- });
117
- expect(outcome).toEqual({ kind: 'no-chat' });
118
- expect(bot.api.sendMessage).not.toHaveBeenCalled();
119
- });
120
-
121
- it('returns no-chat for empty string', async () => {
122
- const outcome = await dispatchFallbackNotification({
123
- bot,
124
- ownerChatId: '',
125
- plan: planExecuted(),
126
- });
127
- expect(outcome).toEqual({ kind: 'no-chat' });
128
- expect(bot.api.sendMessage).not.toHaveBeenCalled();
129
- });
130
- });
131
-
132
- describe('dispatchFallbackNotification — error paths', () => {
133
- it('forbidden error (bot blocked) is reported via onError, never throws', async () => {
134
- bot.faults.next('sendMessage', errors.forbidden());
135
- const onError = vi.fn();
136
- const outcome = await dispatchFallbackNotification({
137
- bot,
138
- ownerChatId: OWNER,
139
- plan: planExecuted(),
140
- onError,
141
- });
142
- expect(outcome).toEqual({ kind: 'error' });
143
- expect(onError).toHaveBeenCalledTimes(1);
144
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
145
- });
146
-
147
- it('flood-wait error is reported via onError, never throws', async () => {
148
- bot.faults.next('sendMessage', errors.floodWait(15));
149
- const onError = vi.fn();
150
- const outcome = await dispatchFallbackNotification({
151
- bot,
152
- ownerChatId: OWNER,
153
- plan: planExhaustedAll(),
154
- onError,
155
- });
156
- expect(outcome).toEqual({ kind: 'error' });
157
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
158
- });
159
-
160
- it('network error is reported via onError, never throws', async () => {
161
- bot.faults.next('sendMessage', errors.networkError('ECONNRESET'));
162
- const onError = vi.fn();
163
- const outcome = await dispatchFallbackNotification({
164
- bot,
165
- ownerChatId: OWNER,
166
- plan: planExecuted(),
167
- onError,
168
- });
169
- expect(outcome).toEqual({ kind: 'error' });
170
- expect(onError).toHaveBeenCalledWith(expect.any(Error));
171
- });
172
-
173
- it('omitted onError still resolves cleanly on failure', async () => {
174
- bot.faults.next('sendMessage', errors.forbidden());
175
- // No onError supplied — should not throw, just return error outcome.
176
- const outcome = await dispatchFallbackNotification({
177
- bot,
178
- ownerChatId: OWNER,
179
- plan: planExecuted(),
180
- });
181
- expect(outcome).toEqual({ kind: 'error' });
182
- });
183
- });
@@ -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
- })