switchroom 0.18.31 โ†’ 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Boot-card "Routing" row (.routing-mode observability โ€” 2026-07-17 boot-race
3
+ * incident: silent fableโ†’opus demotion on one agent, silent fableโ†’router-root
4
+ * divergence on another).
5
+ *
6
+ * Contract:
7
+ * - `readRoutingMode` parses `<agentDir>/.routing-mode`, returns null on
8
+ * absence/garbage (tolerant), and flags `prevBoot` when the file's mtime
9
+ * predates this gateway boot (render-timing hazard: the gateway's outer
10
+ * pass can render the card before the inner pass writes this boot's line).
11
+ * - `renderBootCard` renders the row when `routing` is passed; stale values
12
+ * are suffixed "(prev boot)" โ€” never presented as current; divergence
13
+ * (landed โ‰  declared) gets the warning dot; absent file โ†’ no row at all.
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
17
+ import { mkdtempSync, rmSync, writeFileSync, utimesSync, readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { tmpdir } from 'node:os'
20
+ import { readRoutingMode, renderRoutingRow, renderBootCard, containerBootStartMs } from '../gateway/boot-card.js'
21
+
22
+ const LINE =
23
+ 'mode=router-root base=http://litellm:4010 model=fable litellm_ok=1 declared=router-root ts=2026-07-17T02:00:00Z\n'
24
+
25
+ describe('readRoutingMode', () => {
26
+ let dir: string
27
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'routing-mode-')) })
28
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
29
+
30
+ it('parses a current-boot line', () => {
31
+ writeFileSync(join(dir, '.routing-mode'), LINE)
32
+ const r = readRoutingMode(dir, Date.now() - 60_000)
33
+ expect(r).not.toBeNull()
34
+ expect(r!.mode).toBe('router-root')
35
+ expect(r!.declared).toBe('router-root')
36
+ expect(r!.model).toBe('fable')
37
+ expect(r!.litellmOk).toBe('1')
38
+ expect(r!.prevBoot).toBe(false)
39
+ })
40
+
41
+ it('returns null when the file is absent (fleets pre-dating the state file)', () => {
42
+ expect(readRoutingMode(dir, Date.now())).toBeNull()
43
+ })
44
+
45
+ it('returns null on a garbage file with no mode= field', () => {
46
+ writeFileSync(join(dir, '.routing-mode'), 'not a routing line\n')
47
+ expect(readRoutingMode(dir, Date.now())).toBeNull()
48
+ })
49
+
50
+ it('flags prevBoot when the file mtime predates this gateway boot', () => {
51
+ writeFileSync(join(dir, '.routing-mode'), LINE)
52
+ const old = (Date.now() - 3_600_000) / 1000
53
+ utimesSync(join(dir, '.routing-mode'), old, old)
54
+ const r = readRoutingMode(dir, Date.now() - 1_000)
55
+ expect(r).not.toBeNull()
56
+ expect(r!.prevBoot).toBe(true)
57
+ })
58
+ })
59
+
60
+ describe('containerBootStartMs', () => {
61
+ it('derives PID-1 start epoch from /proc/stat btime + /proc/1/stat starttime', () => {
62
+ const btime = 1_700_000_000 // seconds since epoch
63
+ const startTicks = 500 // 5s after boot at 100 HZ
64
+ const fsImpl = {
65
+ readFileSync: ((p: unknown) => {
66
+ if (p === '/proc/stat') return `cpu 1 2 3\nbtime ${btime}\nprocesses 100\n`
67
+ if (p === '/proc/1/stat')
68
+ // pid (comm) state ... field22=starttime. comm has a space+paren to
69
+ // exercise the last-')' split.
70
+ return `1 (init a) S 0 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 ${startTicks} 0`
71
+ throw new Error('unexpected path')
72
+ }) as typeof readFileSync,
73
+ }
74
+ expect(containerBootStartMs(fsImpl)).toBe((btime + startTicks / 100) * 1000)
75
+ })
76
+
77
+ it('returns null when /proc is unavailable (fallback path for callers)', () => {
78
+ const fsImpl = {
79
+ readFileSync: (() => {
80
+ throw new Error('ENOENT')
81
+ }) as typeof readFileSync,
82
+ }
83
+ expect(containerBootStartMs(fsImpl)).toBeNull()
84
+ })
85
+
86
+ it('returns null when btime is missing', () => {
87
+ const fsImpl = {
88
+ readFileSync: ((p: unknown) =>
89
+ p === '/proc/stat' ? 'cpu 1 2 3\nprocesses 100\n' : '1 (init) S 0') as typeof readFileSync,
90
+ }
91
+ expect(containerBootStartMs(fsImpl)).toBeNull()
92
+ })
93
+ })
94
+
95
+ describe('renderRoutingRow / renderBootCard routing row', () => {
96
+ const base = { agentName: 'klanker', version: 'v1' }
97
+
98
+ it('renders a converged row with the routing dot and model', () => {
99
+ const row = renderRoutingRow({
100
+ mode: 'router-root', declared: 'router-root', model: 'fable', litellmOk: '1', prevBoot: false,
101
+ })
102
+ expect(row).toContain('**Routing**')
103
+ expect(row).toContain('router-root')
104
+ expect(row).toContain('`fable`')
105
+ expect(row).toContain('litellm ok')
106
+ expect(row).not.toContain('โ‰ ')
107
+ expect(row).not.toContain('prev boot')
108
+ })
109
+
110
+ it('marks divergence (landed โ‰  declared) with the warning dot', () => {
111
+ const row = renderRoutingRow({
112
+ mode: 'passthrough', declared: 'router-root', model: 'opus', litellmOk: '0', prevBoot: false,
113
+ })
114
+ expect(row).toContain('๐ŸŸก')
115
+ expect(row).toContain('โ‰  declared router-root')
116
+ expect(row).toContain('litellm unconfirmed')
117
+ })
118
+
119
+ it('labels a stale (previous-boot) value instead of presenting it as current', () => {
120
+ const row = renderRoutingRow({
121
+ mode: 'router-root', declared: 'router-root', prevBoot: true,
122
+ })
123
+ expect(row).toContain('(prev boot)')
124
+ })
125
+
126
+ it('renderBootCard includes the routing row when passed, omits it when routing is null/absent', () => {
127
+ const withRow = renderBootCard({
128
+ ...base,
129
+ routing: { mode: 'router-root', declared: 'passthrough', model: 'fable', litellmOk: '1', prevBoot: false },
130
+ })
131
+ expect(withRow).toContain('**Routing**')
132
+ expect(withRow).toContain('router-root')
133
+
134
+ const without = renderBootCard({ ...base })
135
+ expect(without).not.toContain('Routing')
136
+ const withNull = renderBootCard({ ...base, routing: null })
137
+ expect(withNull).toBe(without)
138
+ })
139
+ })
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Outcome pins for the /model and /effort commands extracted from gateway.ts
3
+ * (switchroom#2996 P6 bot.command drain). Registers against a fake grammy Bot
4
+ * and drives the captured handlers with mock contexts: asserts the durable
5
+ * receipt-first contract (#3177 โ€” log line + history row before any branch),
6
+ * the busy โ†’ ack+queue disposition (#3017/#3262), the menu path, and the
7
+ * direct-apply path for both commands.
8
+ */
9
+
10
+ import { describe, it, expect, vi } from 'vitest'
11
+ import {
12
+ registerModelEffortCommands,
13
+ type ModelEffortCommandDeps,
14
+ } from '../gateway/bot-commands-model-effort.js'
15
+
16
+ type Handler = (ctx: any) => Promise<void>
17
+
18
+ function fakeBot() {
19
+ const handlers = new Map<string, Handler>()
20
+ return {
21
+ bot: { command: (name: string, h: Handler) => void handlers.set(name, h) } as any,
22
+ handlers,
23
+ }
24
+ }
25
+
26
+ function makeDeps(over: Partial<ModelEffortCommandDeps> = {}) {
27
+ const log = vi.fn()
28
+ const recordInbound = vi.fn()
29
+ const enqueueSessionCommand = vi.fn()
30
+ const switchroomReply = vi.fn(async () => {})
31
+ const deps: ModelEffortCommandDeps = {
32
+ isAuthorizedSender: () => true,
33
+ getMyAgentName: () => 'klanker',
34
+ resolveThreadId: () => undefined,
35
+ resolveModelEffortBusy: () => ({ currentTurnActive: false, turnInFlight: false }),
36
+ historyEnabled: true,
37
+ recordInbound,
38
+ buildModelDeps: () => ({
39
+ discover: async () => ({ models: [], current: null }),
40
+ isBusy: () => false,
41
+ getAgentName: () => 'klanker',
42
+ getQuotaBrief: async () => null,
43
+ discoverSrModels: async () => [],
44
+ escapeHtml: (s: string) => s,
45
+ getConfiguredModel: () => 'opus',
46
+ preBlock: (s: string) => s,
47
+ scheduleRestart: async () => {},
48
+ scheduleModelRelaunch: async () => {},
49
+ scheduleModelDefaultRelaunch: async () => {},
50
+ }) as any,
51
+ buildEffortDeps: () => ({
52
+ applyEffort: vi.fn(async () => ({ ok: true })),
53
+ getAgentName: () => 'klanker',
54
+ getConfiguredEffort: () => 'high',
55
+ getSessionEffort: () => null,
56
+ escapeHtml: (s: string) => s,
57
+ }) as any,
58
+ modelMenuReplyMarkup: () => undefined,
59
+ effortMenuReplyMarkup: () => undefined,
60
+ enqueueSessionCommand,
61
+ switchroomReply,
62
+ modelMenuEnabled: () => true,
63
+ log,
64
+ ...over,
65
+ }
66
+ return { deps, log, recordInbound, enqueueSessionCommand, switchroomReply }
67
+ }
68
+
69
+ function makeCtx(text: string) {
70
+ return {
71
+ message: { text, message_id: 7, date: 1000 },
72
+ chat: { id: 42 },
73
+ from: { id: 11, username: 'ken' },
74
+ replyWithRichMessage: vi.fn(async () => ({ message_id: 900 })),
75
+ }
76
+ }
77
+
78
+ describe('/model', () => {
79
+ it('drops unauthorized senders silently', async () => {
80
+ const { bot, handlers } = fakeBot()
81
+ const { deps, log } = makeDeps({ isAuthorizedSender: () => false })
82
+ registerModelEffortCommands(bot, deps)
83
+ await handlers.get('model')!(makeCtx('/model'))
84
+ expect(log).not.toHaveBeenCalled()
85
+ })
86
+
87
+ it('writes the durable receipt (log + history row) BEFORE any branch (#3177)', async () => {
88
+ const { bot, handlers } = fakeBot()
89
+ const { deps, log, recordInbound } = makeDeps()
90
+ registerModelEffortCommands(bot, deps)
91
+ await handlers.get('model')!(makeCtx('/model'))
92
+ expect(log.mock.calls[0][0]).toContain('model')
93
+ expect(recordInbound).toHaveBeenCalledWith(
94
+ expect.objectContaining({ chat_id: '42', message_id: 7, user: 'ken', text: '/model' }),
95
+ )
96
+ })
97
+
98
+ it('bare /model on an idle session opens the menu via switchroomReply', async () => {
99
+ const { bot, handlers } = fakeBot()
100
+ const buildModelDeps = vi.fn(() => ({
101
+ // ModelMenuDeps shape โ€” what buildModelMenu really reads.
102
+ discover: async () => ({ models: [], current: null }),
103
+ isBusy: () => false,
104
+ getAgentName: () => 'klanker',
105
+ getQuotaBrief: async () => null,
106
+ discoverSrModels: async () => [],
107
+ escapeHtml: (s: string) => s,
108
+ getConfiguredModel: () => 'opus',
109
+ preBlock: (s: string) => s,
110
+ scheduleRestart: async () => {},
111
+ scheduleModelRelaunch: async () => {},
112
+ scheduleModelDefaultRelaunch: async () => {},
113
+ }) as any)
114
+ const { deps, switchroomReply } = makeDeps({ buildModelDeps })
115
+ registerModelEffortCommands(bot, deps)
116
+ await handlers.get('model')!(makeCtx('/model'))
117
+ expect(switchroomReply).toHaveBeenCalledTimes(1)
118
+ expect(switchroomReply.mock.calls[0][2]).toMatchObject({ html: true })
119
+ })
120
+
121
+ it('typed /model <target> while BUSY acks and queues instead of applying (#3017)', async () => {
122
+ const { bot, handlers } = fakeBot()
123
+ const { deps, enqueueSessionCommand, switchroomReply } = makeDeps({
124
+ resolveModelEffortBusy: () => ({ currentTurnActive: true, turnInFlight: true }),
125
+ })
126
+ registerModelEffortCommands(bot, deps)
127
+ const ctx = makeCtx('/model opus')
128
+ await handlers.get('model')!(ctx)
129
+ expect(ctx.replyWithRichMessage).toHaveBeenCalledTimes(1)
130
+ expect(enqueueSessionCommand).toHaveBeenCalledWith(
131
+ expect.objectContaining({
132
+ kind: 'model',
133
+ origin: 'typed',
134
+ arg: 'opus',
135
+ ackChatId: '42',
136
+ ackMessageId: 900,
137
+ }),
138
+ )
139
+ expect(switchroomReply).not.toHaveBeenCalled()
140
+ })
141
+ })
142
+
143
+ describe('/effort', () => {
144
+ it('shows the menu for bare /effort', async () => {
145
+ const { bot, handlers } = fakeBot()
146
+ const buildEffortDeps = vi.fn(() => ({
147
+ applyEffort: vi.fn(async () => ({ ok: true })),
148
+ getAgentName: () => 'klanker',
149
+ getConfiguredEffort: () => 'high',
150
+ getSessionEffort: () => null,
151
+ escapeHtml: (s: string) => s,
152
+ }) as any)
153
+ const { deps, switchroomReply } = makeDeps({ buildEffortDeps })
154
+ registerModelEffortCommands(bot, deps)
155
+ await handlers.get('effort')!(makeCtx('/effort'))
156
+ expect(switchroomReply).toHaveBeenCalledTimes(1)
157
+ expect(switchroomReply.mock.calls[0][2]).toMatchObject({ html: true })
158
+ })
159
+
160
+ it('set while busy: acks and queues with the requested level', async () => {
161
+ const { bot, handlers } = fakeBot()
162
+ const { deps, enqueueSessionCommand } = makeDeps({
163
+ resolveModelEffortBusy: () => ({ currentTurnActive: true, turnInFlight: false }),
164
+ })
165
+ registerModelEffortCommands(bot, deps)
166
+ const ctx = makeCtx('/effort low')
167
+ await handlers.get('effort')!(ctx)
168
+ expect(ctx.replyWithRichMessage).toHaveBeenCalledTimes(1)
169
+ expect(enqueueSessionCommand).toHaveBeenCalledWith(
170
+ expect.objectContaining({ kind: 'effort', arg: 'low', targetLabel: 'low' }),
171
+ )
172
+ })
173
+
174
+ it('set while idle: applies via handleEffortCommand and replies', async () => {
175
+ const { bot, handlers } = fakeBot()
176
+ const buildEffortDeps = vi.fn(() => ({
177
+ applyEffort: vi.fn(async () => ({ ok: true })),
178
+ getAgentName: () => 'klanker',
179
+ getConfiguredEffort: () => 'high',
180
+ getSessionEffort: () => null,
181
+ escapeHtml: (s: string) => s,
182
+ }) as any)
183
+ const { deps, switchroomReply, enqueueSessionCommand } = makeDeps({ buildEffortDeps })
184
+ registerModelEffortCommands(bot, deps)
185
+ await handlers.get('effort')!(makeCtx('/effort low'))
186
+ expect(enqueueSessionCommand).not.toHaveBeenCalled()
187
+ expect(switchroomReply).toHaveBeenCalledTimes(1)
188
+ })
189
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Outcome pins for the /start /help /status /agents commands extracted from
3
+ * gateway.ts (switchroom#2996 P6 bot.command drain). Registers against a fake
4
+ * grammy Bot, captures the handlers, and drives them with mock contexts:
5
+ * asserts the dmCommandGate silent-drop, the disabled-flag plumbing into the
6
+ * start text, the paired/pending/unpaired /status branches, and the /agents
7
+ * formatter output fed through runSwitchroomCommandFormatted.
8
+ */
9
+
10
+ import { describe, it, expect, vi } from 'vitest'
11
+ import {
12
+ registerStartInfoCommands,
13
+ type StartInfoCommandDeps,
14
+ } from '../gateway/bot-commands-start-info.js'
15
+
16
+ type Handler = (ctx: any) => Promise<void>
17
+
18
+ function fakeBot() {
19
+ const handlers = new Map<string, Handler>()
20
+ return {
21
+ bot: { command: (name: string, h: Handler) => void handlers.set(name, h) } as any,
22
+ handlers,
23
+ }
24
+ }
25
+
26
+ function makeDeps(over: Partial<StartInfoCommandDeps> = {}): StartInfoCommandDeps {
27
+ return {
28
+ dmCommandGate: () => ({
29
+ access: { dmPolicy: 'allowlist', allowFrom: ['42'], pending: {} } as any,
30
+ senderId: '42',
31
+ }),
32
+ isAuthorizedSender: () => true,
33
+ getMyAgentName: () => 'klanker',
34
+ getCommandArgs: () => '',
35
+ hasDemoFlag: () => false,
36
+ buildAgentMetadata: async () =>
37
+ ({
38
+ agentName: 'klanker',
39
+ model: 'opus',
40
+ extendsProfile: null,
41
+ topicName: null,
42
+ topicEmoji: null,
43
+ uptime: '2h',
44
+ status: 'running',
45
+ auth: null,
46
+ }) as any,
47
+ runSwitchroomCommandFormatted: vi.fn(async (_ctx, _args, _label, formatter) => {
48
+ formatter()
49
+ }),
50
+ switchroomExecJson: () => null,
51
+ statusIcon: () => '๐ŸŸข',
52
+ ...over,
53
+ }
54
+ }
55
+
56
+ function makeCtx(over: Record<string, unknown> = {}) {
57
+ return {
58
+ from: { username: 'ken' },
59
+ reply: vi.fn(async () => {}),
60
+ replyWithRichMessage: vi.fn(async () => {}),
61
+ ...over,
62
+ }
63
+ }
64
+
65
+ describe('/start and /help', () => {
66
+ it('silently drops when dmCommandGate rejects', async () => {
67
+ const { bot, handlers } = fakeBot()
68
+ registerStartInfoCommands(bot, makeDeps({ dmCommandGate: () => null }))
69
+ const ctx = makeCtx()
70
+ await handlers.get('start')!(ctx)
71
+ await handlers.get('help')!(ctx)
72
+ expect(ctx.replyWithRichMessage).not.toHaveBeenCalled()
73
+ expect(ctx.reply).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('replies with the start text, carrying the disabled flag through', async () => {
77
+ const { bot, handlers } = fakeBot()
78
+ registerStartInfoCommands(
79
+ bot,
80
+ makeDeps({
81
+ dmCommandGate: () => ({
82
+ access: { dmPolicy: 'disabled', allowFrom: [], pending: {} } as any,
83
+ senderId: '42',
84
+ }),
85
+ }),
86
+ )
87
+ const ctx = makeCtx()
88
+ await handlers.get('start')!(ctx)
89
+ expect(ctx.replyWithRichMessage).toHaveBeenCalledTimes(1)
90
+ // welcome-text startText(name, disabled=true) renders the disabled notice.
91
+ const arg = ctx.replyWithRichMessage.mock.calls[0][0]
92
+ expect(JSON.stringify(arg)).toContain("isn't accepting new connections")
93
+ })
94
+
95
+ it('replies with the help text for allowed senders', async () => {
96
+ const { bot, handlers } = fakeBot()
97
+ registerStartInfoCommands(bot, makeDeps())
98
+ const ctx = makeCtx()
99
+ await handlers.get('help')!(ctx)
100
+ expect(ctx.replyWithRichMessage).toHaveBeenCalledTimes(1)
101
+ })
102
+ })
103
+
104
+ describe('/status branches', () => {
105
+ it('paired: builds agent metadata and replies rich', async () => {
106
+ const buildAgentMetadata = vi.fn(async () =>
107
+ ({
108
+ agentName: 'klanker',
109
+ model: 'opus',
110
+ extendsProfile: null,
111
+ topicName: null,
112
+ topicEmoji: null,
113
+ uptime: '2h',
114
+ status: 'running',
115
+ auth: null,
116
+ }) as any)
117
+ const { bot, handlers } = fakeBot()
118
+ registerStartInfoCommands(bot, makeDeps({ buildAgentMetadata }))
119
+ const ctx = makeCtx()
120
+ await handlers.get('status')!(ctx)
121
+ expect(buildAgentMetadata).toHaveBeenCalledWith('klanker')
122
+ expect(ctx.replyWithRichMessage).toHaveBeenCalledTimes(1)
123
+ expect(ctx.reply).not.toHaveBeenCalled()
124
+ })
125
+
126
+ it('pending: replies with the pairing-code text', async () => {
127
+ const { bot, handlers } = fakeBot()
128
+ registerStartInfoCommands(
129
+ bot,
130
+ makeDeps({
131
+ dmCommandGate: () => ({
132
+ access: {
133
+ dmPolicy: 'pairing',
134
+ allowFrom: [],
135
+ pending: { ABC123: { senderId: '42' } },
136
+ } as any,
137
+ senderId: '42',
138
+ }),
139
+ }),
140
+ )
141
+ const ctx = makeCtx()
142
+ await handlers.get('status')!(ctx)
143
+ expect(ctx.replyWithRichMessage).toHaveBeenCalledTimes(1)
144
+ expect(JSON.stringify(ctx.replyWithRichMessage.mock.calls[0][0])).toContain('ABC123')
145
+ })
146
+
147
+ it('unpaired: falls through to the plain unpaired reply', async () => {
148
+ const { bot, handlers } = fakeBot()
149
+ registerStartInfoCommands(
150
+ bot,
151
+ makeDeps({
152
+ dmCommandGate: () => ({
153
+ access: { dmPolicy: 'pairing', allowFrom: [], pending: {} } as any,
154
+ senderId: '42',
155
+ }),
156
+ }),
157
+ )
158
+ const ctx = makeCtx()
159
+ await handlers.get('status')!(ctx)
160
+ expect(ctx.reply).toHaveBeenCalledTimes(1)
161
+ expect(ctx.replyWithRichMessage).not.toHaveBeenCalled()
162
+ })
163
+ })
164
+
165
+ describe('/agents', () => {
166
+ it('drops unauthorized senders before running the CLI', async () => {
167
+ const runSwitchroomCommandFormatted = vi.fn(async () => {})
168
+ const { bot, handlers } = fakeBot()
169
+ registerStartInfoCommands(
170
+ bot,
171
+ makeDeps({ isAuthorizedSender: () => false, runSwitchroomCommandFormatted }),
172
+ )
173
+ await handlers.get('agents')!(makeCtx())
174
+ expect(runSwitchroomCommandFormatted).not.toHaveBeenCalled()
175
+ })
176
+
177
+ it('formats the agent list exactly (icon, name, status, uptime, template line)', async () => {
178
+ let formatted: string | null = ''
179
+ const { bot, handlers } = fakeBot()
180
+ registerStartInfoCommands(
181
+ bot,
182
+ makeDeps({
183
+ runSwitchroomCommandFormatted: vi.fn(async (_c, args, label, formatter) => {
184
+ expect(args).toEqual(['agent', 'list'])
185
+ expect(label).toBe('agent list')
186
+ formatted = formatter()
187
+ }),
188
+ switchroomExecJson: <T,>() =>
189
+ ({
190
+ agents: [
191
+ {
192
+ name: 'klanker',
193
+ status: 'running',
194
+ uptime: '2h',
195
+ template: 'root-debug',
196
+ topic_name: 'DM',
197
+ topic_emoji: '๐Ÿ”ง',
198
+ },
199
+ ],
200
+ }) as T,
201
+ }),
202
+ )
203
+ await handlers.get('agents')!(makeCtx())
204
+ expect(formatted).toBe(
205
+ '**Agents**\n๐ŸŸข **klanker** ยท running ยท 2h\n _root-debug โ†’ DM ๐Ÿ”ง_',
206
+ )
207
+ })
208
+
209
+ it('renders the empty-list placeholder', async () => {
210
+ let formatted: string | null = ''
211
+ const { bot, handlers } = fakeBot()
212
+ registerStartInfoCommands(
213
+ bot,
214
+ makeDeps({
215
+ runSwitchroomCommandFormatted: vi.fn(async (_c, _a, _l, formatter) => {
216
+ formatted = formatter()
217
+ }),
218
+ switchroomExecJson: <T,>() => ({ agents: [] }) as T,
219
+ }),
220
+ )
221
+ await handlers.get('agents')!(makeCtx())
222
+ expect(formatted).toBe('_No agents defined_')
223
+ })
224
+
225
+ it('returns null (CLI failure passthrough) when exec returns null', async () => {
226
+ let formatted: string | null = 'sentinel'
227
+ const { bot, handlers } = fakeBot()
228
+ registerStartInfoCommands(
229
+ bot,
230
+ makeDeps({
231
+ runSwitchroomCommandFormatted: vi.fn(async (_c, _a, _l, formatter) => {
232
+ formatted = formatter()
233
+ }),
234
+ switchroomExecJson: () => null,
235
+ }),
236
+ )
237
+ await handlers.get('agents')!(makeCtx())
238
+ expect(formatted).toBeNull()
239
+ })
240
+ })
@@ -36,6 +36,18 @@ const gatewaySrc = readFileSync(
36
36
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
37
37
  'utf-8',
38
38
  )
39
+ // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
40
+ // (`sendReply`); the post-send block assertions read there. #2996 P8 PR-B: the
41
+ // `releaseTurnBufferGate` helper body moved verbatim to turn-end.ts (gateway
42
+ // keeps a thin delegating wrapper); the helper-body assertions read there.
43
+ const sendPathSrc = readFileSync(
44
+ resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
45
+ 'utf-8',
46
+ )
47
+ const turnEndSrc = readFileSync(
48
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
49
+ 'utf-8',
50
+ )
39
51
 
40
52
  describe('buffer-gate release decoupled from final-answer classification', () => {
41
53
  // Extract the helper's docstring (everything between the matching
@@ -43,7 +55,7 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
43
55
  // function declaration). The body slice (used by other tests) is
44
56
  // separate โ€” see fnBody().
45
57
  function fnDocstring(): string {
46
- const beforeFn = gatewaySrc.split('function releaseTurnBufferGate')[0] ?? ''
58
+ const beforeFn = turnEndSrc.split('function releaseTurnBufferGate')[0] ?? ''
47
59
  const lastBlockOpen = beforeFn.lastIndexOf('/**')
48
60
  if (lastBlockOpen < 0) return ''
49
61
  return beforeFn.slice(lastBlockOpen)
@@ -52,7 +64,7 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
52
64
  // The function body โ€” everything between `function
53
65
  // releaseTurnBufferGate(...): void {` and its matching `}`. Use
54
66
  // a simple brace-balance over the slice from open-brace onward.
55
- const afterDecl = gatewaySrc.split('function releaseTurnBufferGate')[1] ?? ''
67
+ const afterDecl = turnEndSrc.split('function releaseTurnBufferGate')[1] ?? ''
56
68
  const openIdx = afterDecl.indexOf('{')
57
69
  if (openIdx < 0) return ''
58
70
  let depth = 0
@@ -71,7 +83,7 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
71
83
  // Multitopic component 1: the signature now also accepts an optional
72
84
  // `endingTurn` so the serialize-until-replied drain gate can read its
73
85
  // finalAnswerDelivered flag. The narrow-helper contract is unchanged.
74
- expect(gatewaySrc).toMatch(
86
+ expect(turnEndSrc).toMatch(
75
87
  /function releaseTurnBufferGate\(key: string, endingTurn\?: CurrentTurn\): void/,
76
88
  )
77
89
  // The helper docstring must explain WHY split from
@@ -102,8 +114,8 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
102
114
  it('executeReply calls releaseTurnBufferGate OUTSIDE the isFinalAnswerReply branch', () => {
103
115
  // Slice the executeReply post-send block (between the anchor
104
116
  // comments and the next exported function).
105
- const post = gatewaySrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
106
- const slice = post.split('\nasync function ')[0] ?? ''
117
+ const post = sendPathSrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
118
+ const slice = post.split('\nexport async function ')[0] ?? ''
107
119
  // The narrow `isFinalAnswerReply`-gated finalize MUST stay (it
108
120
  // emits the ๐Ÿ‘ reaction on the final-answer happy path).
109
121
  expect(slice).toMatch(/isFinalAnswerReply\(/)
@@ -131,9 +143,12 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
131
143
  // helper is narrow on purpose. If future code adds new
132
144
  // callsites that aren't turn-terminal, the steer-vs-queue
133
145
  // semantics could drift.
134
- const callMatches = gatewaySrc.match(/releaseTurnBufferGate\(/g) ?? []
135
- // Definition + 2 callsites = 3:
136
- // - executeReply's post-send block (reply-finalize, the original).
146
+ // #2996 P2: the reply-finalize callsite moved (verbatim) into
147
+ // outbound-send-path.ts `sendReply`; gateway.ts keeps the definition,
148
+ // the halt callsite, and the deps-injection reference.
149
+ const callMatches = (gatewaySrc + sendPathSrc).match(/releaseTurnBufferGate\(/g) ?? []
150
+ // Definition + deps-interface member + 2 callsites = 4:
151
+ // - sendReply's post-send block (reply-finalize, the original).
137
152
  // - executeHaltNow (#3020): an interrupt-cancelled turn never reaches
138
153
  // reply-finalize (the C-c killed it and no replacement inbound
139
154
  // follows), so the halt IS that turn's terminal โ€” releasing there is
@@ -142,6 +157,10 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
142
157
  // with executeStreamReply.)
143
158
  // If this count grows the test catches it; reviewer must justify
144
159
  // any new callsite.
145
- expect(callMatches.length).toBe(3)
160
+ // #2996 P8 PR-B adds exactly one more match in gateway.ts: the thin
161
+ // wrapper's delegate call (`turnEndFunnel().releaseTurnBufferGate(...)`)
162
+ // alongside the wrapper's own declaration. Still only TWO turn-terminal
163
+ // callsites (sendReply post-send + executeHaltNow).
164
+ expect(callMatches.length).toBe(5)
146
165
  })
147
166
  })
@@ -20,6 +20,11 @@ const gatewaySrc = readFileSync(
20
20
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
21
21
  'utf-8',
22
22
  )
23
+ // #2996 P8 PR-B: purgeReactionTracking's body moved verbatim to turn-end.ts.
24
+ const turnEndSrc = readFileSync(
25
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
26
+ 'utf-8',
27
+ )
23
28
 
24
29
  describe('#2995 mid-flight busy ack โ€” gateway wiring', () => {
25
30
  it('the buffer-until-idle branch calls maybePostBusyAck for the inbound own chat/topic', () => {
@@ -97,7 +102,7 @@ describe('#2995 mid-flight busy ack โ€” gateway wiring', () => {
97
102
  })
98
103
 
99
104
  it('turn-end cleanup is PER-KEY: dedupe entry deleted and re-check timer cancelled for the ending turn only', () => {
100
- const purge = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
105
+ const purge = turnEndSrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
101
106
  // Per-key delete โ€” a purge for topic A must not reset topic B's dedupe.
102
107
  expect(purge).toMatch(/busyAckPostedKeys\.delete\(key\)/)
103
108
  expect(purge).not.toMatch(/busyAckPostedKeys\.clear\(\)/)