switchroom 0.18.32 → 0.19.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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -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
+ })
@@ -37,12 +37,17 @@ const gatewaySrc = readFileSync(
37
37
  'utf-8',
38
38
  )
39
39
  // #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
40
- // (`sendReply`); the post-send block assertions read there. The
41
- // `releaseTurnBufferGate` helper itself stays in gateway.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.
42
43
  const sendPathSrc = readFileSync(
43
44
  resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
44
45
  'utf-8',
45
46
  )
47
+ const turnEndSrc = readFileSync(
48
+ resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
49
+ 'utf-8',
50
+ )
46
51
 
47
52
  describe('buffer-gate release decoupled from final-answer classification', () => {
48
53
  // Extract the helper's docstring (everything between the matching
@@ -50,7 +55,7 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
50
55
  // function declaration). The body slice (used by other tests) is
51
56
  // separate — see fnBody().
52
57
  function fnDocstring(): string {
53
- const beforeFn = gatewaySrc.split('function releaseTurnBufferGate')[0] ?? ''
58
+ const beforeFn = turnEndSrc.split('function releaseTurnBufferGate')[0] ?? ''
54
59
  const lastBlockOpen = beforeFn.lastIndexOf('/**')
55
60
  if (lastBlockOpen < 0) return ''
56
61
  return beforeFn.slice(lastBlockOpen)
@@ -59,7 +64,7 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
59
64
  // The function body — everything between `function
60
65
  // releaseTurnBufferGate(...): void {` and its matching `}`. Use
61
66
  // a simple brace-balance over the slice from open-brace onward.
62
- const afterDecl = gatewaySrc.split('function releaseTurnBufferGate')[1] ?? ''
67
+ const afterDecl = turnEndSrc.split('function releaseTurnBufferGate')[1] ?? ''
63
68
  const openIdx = afterDecl.indexOf('{')
64
69
  if (openIdx < 0) return ''
65
70
  let depth = 0
@@ -78,7 +83,7 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
78
83
  // Multitopic component 1: the signature now also accepts an optional
79
84
  // `endingTurn` so the serialize-until-replied drain gate can read its
80
85
  // finalAnswerDelivered flag. The narrow-helper contract is unchanged.
81
- expect(gatewaySrc).toMatch(
86
+ expect(turnEndSrc).toMatch(
82
87
  /function releaseTurnBufferGate\(key: string, endingTurn\?: CurrentTurn\): void/,
83
88
  )
84
89
  // The helper docstring must explain WHY split from
@@ -152,6 +157,10 @@ describe('buffer-gate release decoupled from final-answer classification', () =>
152
157
  // with executeStreamReply.)
153
158
  // If this count grows the test catches it; reviewer must justify
154
159
  // any new callsite.
155
- expect(callMatches.length).toBe(4)
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)
156
165
  })
157
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\(\)/)
@@ -129,30 +129,39 @@ describe("agent button taps use the turn-gate (mid-turn → buffer)", () => {
129
129
  });
130
130
 
131
131
  describe("the agent: callback handler routes through the helper", () => {
132
+ // #2996 P6: the agent-callback family moved to
133
+ // agent-button-callback-handler.ts (handleAgentButtonCallback); the
134
+ // structural pins now scrape the extracted module. The injected
135
+ // deliverButtonTapInbound + restarting-notice bindings stay in gateway.ts.
136
+ const moduleSrc = readFileSync(
137
+ resolve(__dirname, "..", "gateway", "agent-button-callback-handler.ts"),
138
+ "utf8",
139
+ );
132
140
  it("delivers the tap via deliverButtonTapInbound, not a raw sendToAgent", () => {
133
- // Locate the agent-callback handler block.
134
- const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
141
+ const anchor = moduleSrc.indexOf("const agentCb = parseAgentCallback(data)");
135
142
  expect(anchor, "agent-callback handler missing").toBeGreaterThan(0);
136
- const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
143
+ const handler = moduleSrc.slice(anchor);
137
144
  // The tap is routed through the turn-safe helper...
138
- expect(handler).toMatch(/await deliverButtonTapInbound\(selfAgentBtn, inboundMsg\)/);
145
+ expect(handler).toMatch(/await deps\.deliverButtonTapInbound\(selfAgentBtn, inboundMsg\)/);
139
146
  // ...and NOT via a raw ungated sendToAgent of the tap inbound (the
140
147
  // regressed path that stranded the tap mid-turn).
141
148
  expect(handler).not.toMatch(/ipcServer\.sendToAgent\(selfAgentBtn, inboundMsg\)/);
149
+ // The gateway binding still wires the real helper.
150
+ expect(gatewaySrc).toMatch(/deliverButtonTapInbound,/);
142
151
  });
143
152
 
144
153
  it("still shows the restart notice only on bridge-offline", () => {
145
- const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
146
- const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
154
+ const handler = moduleSrc;
147
155
  // The restart notice is gated on the bridge-offline outcome — a mid-turn
148
156
  // buffered tap must NOT nag the user (it will be actioned at idle).
149
157
  expect(handler).toMatch(/btnOutcome === 'buffered-bridge-offline'/);
150
- expect(handler).toMatch(/button-tap-restarting-notice/);
158
+ // The notice text + verb live in the gateway's injected binding.
159
+ expect(gatewaySrc).toMatch(/button-tap-restarting-notice/);
151
160
  });
152
161
 
153
162
  it("preserves the ack toast and single-use keyboard strip (UX unchanged)", () => {
154
- const anchor = gatewaySrc.indexOf("const agentCb = parseAgentCallback(data)");
155
- const handler = gatewaySrc.slice(anchor, gatewaySrc.indexOf("// Permission request buttons.", anchor));
163
+ const anchor = moduleSrc.indexOf("const agentCb = parseAgentCallback(data)");
164
+ const handler = moduleSrc.slice(anchor);
156
165
  // Ack toast fires FIRST (before delivery) so the tap always registers…
157
166
  const ackIdx = handler.indexOf("answerCallbackQuery({ text: ackText })");
158
167
  const deliverIdx = handler.indexOf("deliverButtonTapInbound(");