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.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- 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/backstop-delivery.ts +223 -23
- 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/captured-answer-resume.ts +259 -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/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- 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-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -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/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- 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/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -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 +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -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 +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- 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-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- 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 +36 -12
- 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/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- 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/reply-terminal-reaction.test.ts +6 -2
- 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 +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- 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 +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -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 +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- 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 +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- 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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
106
|
-
const slice = post.split('\
|
|
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
|
-
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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\(\)/)
|