switchroom 0.18.29 → 0.18.30
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/bin/handoff-briefing.sh +8 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -16
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2074 -1585
- package/dist/host-control/main.js +110 -13
- package/dist/vault/approvals/kernel-server.js +116 -13
- package/dist/vault/broker/server.js +314 -145
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +73 -20
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +560 -96
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/gateway.ts +212 -17
- package/telegram-plugin/gateway/model-command.ts +104 -0
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +43 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -1
- package/telegram-plugin/tests/model-command.test.ts +133 -0
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +35 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome regression for the forwarded-message history row (#3300 / #3162).
|
|
3
|
+
*
|
|
4
|
+
* SCOPE (honest): this suite pins the PERSISTENCE leg of the end-to-end
|
|
5
|
+
* chain — `parseForwardOrigin` (#3162) feeding `recordInbound` against the
|
|
6
|
+
* real bun:sqlite history store. The ROUTING leg (an update reaching the
|
|
7
|
+
* pipeline at all — the layer where the 2026-07-16 silent drop happened) is
|
|
8
|
+
* pinned by `catch-all-unhandled-message.test.ts`, which drives the real
|
|
9
|
+
* production catch-all module on a real grammy composer. Together the two
|
|
10
|
+
* suites cover the chain; this one alone also passes on pre-#3300 code
|
|
11
|
+
* because #3162's persistence was always correct — it was simply never
|
|
12
|
+
* reached for the dropped message.
|
|
13
|
+
*
|
|
14
|
+
* Runs under bun (history.ts uses bun:sqlite; gateway.ts itself is a
|
|
15
|
+
* side-effecting module that cannot be imported into a test).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
19
|
+
import { mkdtempSync, rmSync } from 'fs'
|
|
20
|
+
import { tmpdir } from 'os'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import {
|
|
23
|
+
initHistory,
|
|
24
|
+
recordInbound,
|
|
25
|
+
query as queryHistory,
|
|
26
|
+
_resetForTests as resetHistory,
|
|
27
|
+
} from '../history.js'
|
|
28
|
+
import { parseForwardOrigin } from '../gateway/forward-origin.js'
|
|
29
|
+
|
|
30
|
+
let stateDir: string
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
resetHistory()
|
|
34
|
+
stateDir = mkdtempSync(join(tmpdir(), 'catch-all-forward-'))
|
|
35
|
+
initHistory(stateDir, 0) // 0 disables the init-time prune so we can seed cleanly
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
resetHistory()
|
|
40
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('(a) a forwarded plain-text message records a history row with forwarded_from', () => {
|
|
44
|
+
it('parses the server-stamped forward_origin and persists forwarded_from', () => {
|
|
45
|
+
const chat_id = '777'
|
|
46
|
+
const message_id = 8100
|
|
47
|
+
// Telegram Bot API 7.0 forward_origin, server-stamped (untrusted body,
|
|
48
|
+
// trusted attrs) — a plain-text message forwarded from a user.
|
|
49
|
+
const forwardOrigin = parseForwardOrigin({
|
|
50
|
+
type: 'user',
|
|
51
|
+
date: 1_700_000_000,
|
|
52
|
+
sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
|
|
53
|
+
})
|
|
54
|
+
expect(forwardOrigin).toBeDefined()
|
|
55
|
+
expect(forwardOrigin!.name).toBe('Ken Thompson')
|
|
56
|
+
|
|
57
|
+
recordInbound({
|
|
58
|
+
chat_id,
|
|
59
|
+
thread_id: null,
|
|
60
|
+
message_id,
|
|
61
|
+
user: 'ken',
|
|
62
|
+
user_id: '777',
|
|
63
|
+
ts: 1_700_000_100,
|
|
64
|
+
text: 'here is the brief I forwarded',
|
|
65
|
+
forwarded_from: forwardOrigin!.name,
|
|
66
|
+
forwarded_from_type: forwardOrigin!.type,
|
|
67
|
+
forwarded_from_id: forwardOrigin!.id != null ? String(forwardOrigin!.id) : null,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const rows = queryHistory({ chat_id, limit: 10 })
|
|
71
|
+
expect(rows).toHaveLength(1)
|
|
72
|
+
const row = rows[0]
|
|
73
|
+
// A delivered turn is present (the message was NOT dropped) AND carries
|
|
74
|
+
// forwarded provenance.
|
|
75
|
+
expect(row.role).toBe('user')
|
|
76
|
+
expect(row.text).toBe('here is the brief I forwarded')
|
|
77
|
+
expect(row.forwarded_from).toBe('Ken Thompson')
|
|
78
|
+
expect(row.forwarded_from_type).toBe('user')
|
|
79
|
+
expect(row.forwarded_from_id).toBe('424242')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a non-forwarded message leaves forwarded_from NULL (no false provenance)', () => {
|
|
83
|
+
const chat_id = '778'
|
|
84
|
+
// undefined forward_origin → parseForwardOrigin returns undefined.
|
|
85
|
+
const forwardOrigin = parseForwardOrigin(undefined)
|
|
86
|
+
expect(forwardOrigin).toBeUndefined()
|
|
87
|
+
|
|
88
|
+
recordInbound({
|
|
89
|
+
chat_id,
|
|
90
|
+
thread_id: null,
|
|
91
|
+
message_id: 8200,
|
|
92
|
+
user: 'ken',
|
|
93
|
+
user_id: '778',
|
|
94
|
+
ts: 1_700_000_200,
|
|
95
|
+
text: 'a normal message',
|
|
96
|
+
forwarded_from: forwardOrigin ?? null,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const rows = queryHistory({ chat_id, limit: 10 })
|
|
100
|
+
expect(rows).toHaveLength(1)
|
|
101
|
+
expect(rows[0].forwarded_from).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the terminal catch-all message handler + diagnostic
|
|
3
|
+
* update tap (#3300).
|
|
4
|
+
*
|
|
5
|
+
* THE CLASS OF BUG: the gateway registered ONLY content-specific handlers —
|
|
6
|
+
* `bot.on('message:text')`, `:photo`, `:document`, … `:paid_media`. grammy
|
|
7
|
+
* ^1.44 routes `bot.on` as filtering middleware (`on → filter(pred, handler)
|
|
8
|
+
* → branch(pred, handler, pass)`, verified in grammy/out/composer.js); when
|
|
9
|
+
* NO registered filter matches an inbound `message`, grammy SILENTLY drops
|
|
10
|
+
* it — no log, no ack, no history row. Live signature (2026-07-16, klanker
|
|
11
|
+
* DM): message_id 19090 was allocated between an outbound reply and the next
|
|
12
|
+
* inbound with ZERO gateway trace while polling stayed healthy.
|
|
13
|
+
*
|
|
14
|
+
* THE FIX under test is the REAL production module
|
|
15
|
+
* (`gateway/unhandled-message.ts`) — the same `installUpdateTap` /
|
|
16
|
+
* `installUnhandledMessageCatchAll` gateway.ts wires — driven through a real
|
|
17
|
+
* grammy 1.44 Bot via `bot.handleUpdate`, with a real `bot.on('message:text')`
|
|
18
|
+
* registered BEFORE the catch-all exactly like the gateway's registration
|
|
19
|
+
* order. (gateway.ts itself is a side-effecting module that cannot be
|
|
20
|
+
* imported into a unit test — the extraction into unhandled-message.ts exists
|
|
21
|
+
* precisely so the registered composer path is testable; a structural suite
|
|
22
|
+
* below guards that gateway.ts actually wires it in that order.)
|
|
23
|
+
*
|
|
24
|
+
* Outcomes asserted:
|
|
25
|
+
* (b) an unregistered content type reaches the catch-all, is logged, and
|
|
26
|
+
* yields a turn with the placeholder text naming the content type;
|
|
27
|
+
* (c) a plain text message is consumed by the specific handler and does
|
|
28
|
+
* NOT double-handle in the catch-all;
|
|
29
|
+
* service-noise messages (forum topic lifecycle etc.) are logged but do
|
|
30
|
+
* NOT become turns;
|
|
31
|
+
* the tap observes every update and rate-limits with a suppression summary.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
35
|
+
import { readFileSync } from 'node:fs'
|
|
36
|
+
import { Bot, type Context } from 'grammy'
|
|
37
|
+
import type { Update } from 'grammy/types'
|
|
38
|
+
import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
|
|
39
|
+
import {
|
|
40
|
+
installUpdateTap,
|
|
41
|
+
installUnhandledMessageCatchAll,
|
|
42
|
+
planUnhandledMessage,
|
|
43
|
+
SERVICE_NOISE_KEYS,
|
|
44
|
+
TAP_MAX_LINES_PER_MINUTE,
|
|
45
|
+
} from '../gateway/unhandled-message.js'
|
|
46
|
+
|
|
47
|
+
interface DeliveredTurn {
|
|
48
|
+
via: 'text' | 'catch-all'
|
|
49
|
+
text: string
|
|
50
|
+
update_id: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wire a real grammy Bot in the gateway's registration order using the REAL
|
|
55
|
+
* production install functions: tap first, specific `message:text` handler,
|
|
56
|
+
* terminal catch-all LAST. `onInbound` records what would flow into
|
|
57
|
+
* handleInboundCoalesced.
|
|
58
|
+
*/
|
|
59
|
+
function buildHarness() {
|
|
60
|
+
const delivered: DeliveredTurn[] = []
|
|
61
|
+
const logLines: string[] = []
|
|
62
|
+
|
|
63
|
+
const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
|
|
64
|
+
bot.botInfo = {
|
|
65
|
+
id: 999,
|
|
66
|
+
is_bot: true,
|
|
67
|
+
first_name: 'TestBot',
|
|
68
|
+
username: 'test_bot',
|
|
69
|
+
can_join_groups: true,
|
|
70
|
+
can_read_all_group_messages: false,
|
|
71
|
+
supports_inline_queries: false,
|
|
72
|
+
can_connect_to_business: false,
|
|
73
|
+
has_main_web_app: false,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// REAL production tap (same call gateway.ts makes).
|
|
77
|
+
installUpdateTap(bot, line => logLines.push(line))
|
|
78
|
+
|
|
79
|
+
// Specific handler — representative of the gateway's message:text
|
|
80
|
+
// registration, placed BEFORE the catch-all exactly as in gateway.ts.
|
|
81
|
+
bot.on('message:text', async ctx => {
|
|
82
|
+
delivered.push({ via: 'text', text: ctx.message.text, update_id: ctx.update.update_id })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// REAL production catch-all, registered LAST (same call gateway.ts makes).
|
|
86
|
+
installUnhandledMessageCatchAll(
|
|
87
|
+
bot,
|
|
88
|
+
async (ctx: Context, text: string) => {
|
|
89
|
+
delivered.push({ via: 'catch-all', text, update_id: ctx.update.update_id })
|
|
90
|
+
},
|
|
91
|
+
line => logLines.push(line),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return { bot, delivered, logLines }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A message update carrying content that no `message:*` filter covers. */
|
|
98
|
+
function makeContentUpdate(update_id: number, content: Record<string, unknown>): Update {
|
|
99
|
+
return {
|
|
100
|
+
update_id,
|
|
101
|
+
message: {
|
|
102
|
+
message_id: 5000 + update_id,
|
|
103
|
+
chat: { id: 777, type: 'private' },
|
|
104
|
+
from: { id: 777, is_bot: false, first_name: 'Test' },
|
|
105
|
+
date: Math.floor(Date.now() / 1000),
|
|
106
|
+
...content,
|
|
107
|
+
},
|
|
108
|
+
} as unknown as Update
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('terminal catch-all — real module on a real grammy 1.44 composer (#3300)', () => {
|
|
112
|
+
beforeEach(() => resetUpdateCounters())
|
|
113
|
+
|
|
114
|
+
it('(c) a plain-text message is consumed by message:text and does NOT reach the catch-all', async () => {
|
|
115
|
+
const { bot, delivered, logLines } = buildHarness()
|
|
116
|
+
await bot.handleUpdate(makeMessageUpdate({ text: 'hello brief', update_id: 42 }))
|
|
117
|
+
|
|
118
|
+
expect(delivered).toHaveLength(1)
|
|
119
|
+
expect(delivered[0]).toMatchObject({ via: 'text', text: 'hello brief' })
|
|
120
|
+
// No catch-all log line — specific handler won (no double-handling).
|
|
121
|
+
expect(logLines.filter(l => l.includes('catch-all inbound'))).toHaveLength(0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('(b) an unregistered content type reaches the catch-all, is logged, and yields a placeholder turn', async () => {
|
|
125
|
+
const { bot, delivered, logLines } = buildHarness()
|
|
126
|
+
await bot.handleUpdate(makeContentUpdate(99, { unknown_future_type: { some: 'payload' } }))
|
|
127
|
+
|
|
128
|
+
// Logged with content KEYS + ids only (never the payload body).
|
|
129
|
+
const catchAllLines = logLines.filter(l => l.includes('catch-all inbound'))
|
|
130
|
+
expect(catchAllLines).toHaveLength(1)
|
|
131
|
+
expect(catchAllLines[0]).toContain('update_id=99')
|
|
132
|
+
expect(catchAllLines[0]).toContain('content_keys=[unknown_future_type]')
|
|
133
|
+
expect(catchAllLines[0]).not.toContain('payload')
|
|
134
|
+
|
|
135
|
+
// Produced a delivered turn with placeholder text naming the content type.
|
|
136
|
+
expect(delivered).toHaveLength(1)
|
|
137
|
+
expect(delivered[0]).toMatchObject({
|
|
138
|
+
via: 'catch-all',
|
|
139
|
+
text: '(unhandled message content: unknown_future_type)',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('a caption on an unhandled content type is used as the turn text (best-effort)', async () => {
|
|
144
|
+
const { bot, delivered } = buildHarness()
|
|
145
|
+
await bot.handleUpdate(
|
|
146
|
+
makeContentUpdate(120, { some_new_media: { id: 'x' }, caption: 'read this brief' }),
|
|
147
|
+
)
|
|
148
|
+
expect(delivered).toHaveLength(1)
|
|
149
|
+
expect(delivered[0]).toMatchObject({ via: 'catch-all', text: 'read this brief' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('known-noise service messages are LOGGED but produce NO agent turn', async () => {
|
|
153
|
+
const { bot, delivered, logLines } = buildHarness()
|
|
154
|
+
await bot.handleUpdate(
|
|
155
|
+
makeContentUpdate(130, { forum_topic_created: { name: 'spam topic', icon_color: 1 } }),
|
|
156
|
+
)
|
|
157
|
+
await bot.handleUpdate(
|
|
158
|
+
makeContentUpdate(131, { new_chat_members: [{ id: 5, is_bot: false, first_name: 'X' }] }),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// No turns delivered — service noise never reaches the agent.
|
|
162
|
+
expect(delivered).toHaveLength(0)
|
|
163
|
+
// But both were explicitly logged (never silently dropped).
|
|
164
|
+
const noiseLines = logLines.filter(l => l.includes('action=log-only'))
|
|
165
|
+
expect(noiseLines).toHaveLength(2)
|
|
166
|
+
expect(noiseLines[0]).toContain('content_keys=[forum_topic_created]')
|
|
167
|
+
expect(noiseLines[1]).toContain('content_keys=[new_chat_members]')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('the diagnostic tap observes EVERY update (pass-through, both routed and caught)', async () => {
|
|
171
|
+
const { bot, logLines } = buildHarness()
|
|
172
|
+
await bot.handleUpdate(makeMessageUpdate({ text: 'routed', update_id: 1 }))
|
|
173
|
+
await bot.handleUpdate(makeContentUpdate(2, { unknown_future_type: {} }))
|
|
174
|
+
|
|
175
|
+
const rx = logLines.filter(l => l.includes('rx update_id='))
|
|
176
|
+
expect(rx).toHaveLength(2)
|
|
177
|
+
expect(rx[0]).toContain('rx update_id=1 type=message')
|
|
178
|
+
expect(rx[1]).toContain('rx update_id=2 type=message')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('planUnhandledMessage — service-noise classification', () => {
|
|
183
|
+
it('every SERVICE_NOISE_KEYS entry classifies log-only', () => {
|
|
184
|
+
for (const key of SERVICE_NOISE_KEYS) {
|
|
185
|
+
const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, [key]: {} })
|
|
186
|
+
expect(plan.action, `key ${key} should be log-only`).toBe('log-only')
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('an unknown content type classifies as a turn (fail-toward-delivery)', () => {
|
|
191
|
+
const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, mystery_type: {} })
|
|
192
|
+
expect(plan).toMatchObject({
|
|
193
|
+
action: 'turn',
|
|
194
|
+
text: '(unhandled message content: mystery_type)',
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('a message mixing noise with real content still becomes a turn', () => {
|
|
199
|
+
const plan = planUnhandledMessage({
|
|
200
|
+
message_id: 1, chat: {}, date: 0,
|
|
201
|
+
boost_added: {}, mystery_media: {}, caption: 'look',
|
|
202
|
+
})
|
|
203
|
+
expect(plan).toMatchObject({ action: 'turn', text: 'look' })
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('installUpdateTap — rate limit', () => {
|
|
208
|
+
it('caps lines per minute and emits ONE suppression summary on window rollover', async () => {
|
|
209
|
+
const logLines: string[] = []
|
|
210
|
+
let now = 1_000_000
|
|
211
|
+
const mw: Array<(ctx: unknown, next: () => Promise<void>) => Promise<void>> = []
|
|
212
|
+
const fakeBot = { use: (fn: (ctx: unknown, next: () => Promise<void>) => Promise<void>) => { mw.push(fn) } }
|
|
213
|
+
installUpdateTap(fakeBot as never, l => logLines.push(l), () => now)
|
|
214
|
+
|
|
215
|
+
const fire = (update_id: number) =>
|
|
216
|
+
mw[0]({ update: { update_id }, message: undefined } as never, async () => {})
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < TAP_MAX_LINES_PER_MINUTE + 50; i++) await fire(i)
|
|
219
|
+
expect(logLines.filter(l => l.includes('rx update_id='))).toHaveLength(TAP_MAX_LINES_PER_MINUTE)
|
|
220
|
+
|
|
221
|
+
// Roll the window: the 50 suppressed lines surface as ONE summary.
|
|
222
|
+
now += 61_000
|
|
223
|
+
await fire(9999)
|
|
224
|
+
const summaries = logLines.filter(l => l.includes('suppressed 50 update lines'))
|
|
225
|
+
expect(summaries).toHaveLength(1)
|
|
226
|
+
// And logging resumes in the fresh window.
|
|
227
|
+
expect(logLines.filter(l => l.includes('rx update_id=9999'))).toHaveLength(1)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ─── Structural guard on the real gateway.ts wiring ────────────────────────
|
|
232
|
+
// The functional harness above proves the composer contract using the REAL
|
|
233
|
+
// production install functions; this guards that gateway.ts wires those same
|
|
234
|
+
// functions in the required order. Hardened beyond a naive regex: it fails
|
|
235
|
+
// if the install call disappears (e.g. refactored away) OR if ANY
|
|
236
|
+
// `bot.on('message…')` registration appears after the catch-all install.
|
|
237
|
+
describe('gateway.ts catch-all registration invariant', () => {
|
|
238
|
+
const SRC = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
239
|
+
|
|
240
|
+
it('imports and installs the real catch-all + tap from unhandled-message.ts', () => {
|
|
241
|
+
expect(SRC).toContain("from './unhandled-message.js'")
|
|
242
|
+
expect(SRC).toContain('installUpdateTap(bot,')
|
|
243
|
+
expect(SRC).toContain('installUnhandledMessageCatchAll(')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('the catch-all install is AFTER every message handler registration', () => {
|
|
247
|
+
const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
|
|
248
|
+
expect(installIdx).toBeGreaterThan(0)
|
|
249
|
+
const after = SRC.slice(installIdx)
|
|
250
|
+
// No message-filter registration of any spelling may follow the terminal
|
|
251
|
+
// catch-all — grammy ordering is the no-double-handling guarantee.
|
|
252
|
+
// (`message_reaction` etc. are DIFFERENT update types, not `message`
|
|
253
|
+
// filters — the pattern requires `message` exactly or `message:<sub>`.)
|
|
254
|
+
expect(after).not.toMatch(/bot\.on\(\s*['"`]message(:|['"`])/)
|
|
255
|
+
// And there must be exactly one catch-all install (no duplicate turns).
|
|
256
|
+
expect(SRC.indexOf('installUnhandledMessageCatchAll(', installIdx + 1)).toBe(-1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('routes the catch-all through handleInboundCoalesced (same gating + forward-origin path)', () => {
|
|
260
|
+
const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
|
|
261
|
+
const body = SRC.slice(installIdx, installIdx + 400)
|
|
262
|
+
expect(body).toMatch(/handleInboundCoalesced\(ctx, text, undefined\)/)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -149,7 +149,7 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
149
149
|
it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
|
|
150
150
|
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
151
151
|
expect(idx).toBeGreaterThan(0)
|
|
152
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
152
|
+
const win = GATEWAY_SRC.slice(idx, idx + 5200)
|
|
153
153
|
// Keyed on the deterministic /model switch reason, so it also fires on a
|
|
154
154
|
// launched===configured apply-boot (/model default) — N4. Never optimistic.
|
|
155
155
|
expect(win).toContain('if (modelSwitchReason != null && modelSwitchMarkerChat)')
|
|
@@ -158,6 +158,30 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
158
158
|
expect(win).toContain('(the configured default)')
|
|
159
159
|
})
|
|
160
160
|
|
|
161
|
+
it('warns instead of a green ✅ when a non-default switch silently reverted to the default (silent-revert fix)', () => {
|
|
162
|
+
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
163
|
+
const win = GATEWAY_SRC.slice(idx, idx + 5200)
|
|
164
|
+
// The confirmation card is derived from the pure classifier, not an inline
|
|
165
|
+
// isApplyBoot ternary — so a reverted non-default switch yields the ⚠️ card.
|
|
166
|
+
expect(win).toContain('classifyModelSwitchConfirmation({')
|
|
167
|
+
expect(win).toContain("confirmation.kind === 'applied'")
|
|
168
|
+
expect(win).toContain("confirmation.kind === 'not-applied'")
|
|
169
|
+
expect(win).toContain("⚠️ Your switch to")
|
|
170
|
+
expect(win).toContain("didn't apply")
|
|
171
|
+
// LOW-3: the re-issue hint interpolates the target inside backticks so a
|
|
172
|
+
// token containing Markdown metachars can't italicize / 400 the send.
|
|
173
|
+
expect(win).toContain('Re-issue \\`/model ${confirmation.target}\\`')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
|
|
177
|
+
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
178
|
+
const win = GATEWAY_SRC.slice(idx, idx + 5200)
|
|
179
|
+
// When start.sh wrote a specific alert for this revert, the classifier's
|
|
180
|
+
// generic not-applied card is suppressed (the alert relay is the message).
|
|
181
|
+
expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
|
|
182
|
+
expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
|
|
183
|
+
})
|
|
184
|
+
|
|
161
185
|
it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
|
|
162
186
|
expect(GATEWAY_SRC).toContain("cleanMarker.reason.startsWith('user: /model')")
|
|
163
187
|
expect(GATEWAY_SRC).toContain('let modelSwitchReason: string | null = null')
|
|
@@ -172,7 +196,7 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
172
196
|
})
|
|
173
197
|
|
|
174
198
|
it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
|
|
175
|
-
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
|
|
199
|
+
const idx = GATEWAY_SRC.indexOf("const alertPath = join(smAgentDir, '.session-model-alert')")
|
|
176
200
|
expect(idx).toBeGreaterThan(0)
|
|
177
201
|
const win = GATEWAY_SRC.slice(idx, idx + 1100)
|
|
178
202
|
expect(win).toContain('unlinkSync(alertPath)')
|