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.
- package/dist/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- 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.
|
|
41
|
-
// `releaseTurnBufferGate` helper
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
155
|
-
const handler =
|
|
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(");
|