switchroom 0.21.3 → 0.21.5
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 +23 -1
- package/dist/cli/switchroom.js +79 -16
- package/dist/host-control/main.js +1 -1
- package/package.json +4 -2
- package/telegram-plugin/dist/gateway/gateway.js +406 -121
- package/telegram-plugin/gateway/gateway.ts +24 -22
- package/telegram-plugin/gateway/inbound-router.ts +77 -26
- package/telegram-plugin/gateway/orphaned-db-sweep.ts +315 -0
- package/telegram-plugin/gateway/system-message-observer.ts +25 -6
- package/telegram-plugin/history.ts +328 -62
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -4
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -2
- package/telegram-plugin/shared/bot-runtime.ts +119 -2
- package/telegram-plugin/tests/card-history-lane.test.ts +171 -2
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +721 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +209 -4
- package/telegram-plugin/tests/system-message-observer.test.ts +84 -1
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* standalone foreman bot before its retirement.
|
|
6
6
|
*
|
|
7
7
|
* What lives here:
|
|
8
|
-
* - `installTgPostLogger` / `installRichMarkdownGuard`
|
|
9
|
-
*
|
|
8
|
+
* - `installTgPostLogger` / `installRichMarkdownGuard` /
|
|
9
|
+
* `installSystemMessageObserver` — the grammy API transformers every
|
|
10
|
+
* outbound call transits. This layer, not any caller-side wrapper, is the
|
|
11
|
+
* one seam no `ctx.*` helper or raw `bot.api.*` call can bypass.
|
|
10
12
|
* - `makeSwitchroomExec` / `makeSwitchroomExecCombined` — factory fns for
|
|
11
13
|
* the switchroom CLI exec helpers (callers pass their own CLI path / config
|
|
12
14
|
* env so each process can be configured independently).
|
|
@@ -292,6 +294,121 @@ export function installRichMarkdownGuard(bot: Bot): void {
|
|
|
292
294
|
})
|
|
293
295
|
}
|
|
294
296
|
|
|
297
|
+
// ─── outbound send observation (#4571 / #4599) ────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The call-site metadata a send observer wants but the wire payload cannot
|
|
301
|
+
* supply. Structurally the same shape as `ObservedCallOpts` in
|
|
302
|
+
* `gateway/system-message-observer.ts`; declared here so this module keeps its
|
|
303
|
+
* no-imports-from-gateway rule.
|
|
304
|
+
*/
|
|
305
|
+
export interface TgSendContext {
|
|
306
|
+
chat_id?: string
|
|
307
|
+
threadId?: number
|
|
308
|
+
/** The caller's own label for what this send IS (`activity-summary.send`). */
|
|
309
|
+
verb?: string
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const sendContextStore = new AsyncLocalStorage<TgSendContext | undefined>()
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Publish the enclosing call's `{chat_id, threadId, verb}` to the API
|
|
316
|
+
* transformer layer for the duration of `fn`'s async chain. `gateway.ts`'s
|
|
317
|
+
* `robustApiCall` wraps every call it issues; anything sent outside it (a
|
|
318
|
+
* `ctx.reply*` helper, a raw `bot.api.*`) simply observes with no verb.
|
|
319
|
+
*/
|
|
320
|
+
export function withTgSendContext<T>(ctx: TgSendContext | undefined, fn: () => T): T {
|
|
321
|
+
return sendContextStore.run(ctx, fn)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Exposed for the transformer (and tests). Undefined outside a wrapped call. */
|
|
325
|
+
export function _getTgSendContext(): TgSendContext | undefined {
|
|
326
|
+
return sendContextStore.getStore()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Install the card-history send observer as a grammy API TRANSFORMER — the
|
|
331
|
+
* real universal outbound seam (#4599).
|
|
332
|
+
*
|
|
333
|
+
* #4571 hooked the observer onto `gateway.ts`'s `robustApiCall`, and
|
|
334
|
+
* `system-message-observer.ts` claimed that was "the ONE chokepoint every
|
|
335
|
+
* gateway outbound already goes through … enforced by the
|
|
336
|
+
* `check-bot-api-wrapping` lint guard". That claim was false on both halves.
|
|
337
|
+
* grammY's `ctx.*` sugar builds the payload and calls `bot.api.*` directly, so
|
|
338
|
+
* `switchroomReply` (`gateway.ts`, the helper every SLASH-COMMAND card answers
|
|
339
|
+
* through — `/usage`, `/model`, `/auth`, `/approvals`, `/start`, `/help`) sends
|
|
340
|
+
* via `ctx.replyWithRichMessage` and never transits `robustApiCall` at all; and
|
|
341
|
+
* the lint guard could not have caught it, because its verb pattern matched
|
|
342
|
+
* only `(bot|lockedBot|ctx)\.api\.<verb>` — it never mentioned `sendRichMessage`
|
|
343
|
+
* and structurally cannot match a `ctx.replyWith*` helper.
|
|
344
|
+
*
|
|
345
|
+
* Measured, not inferred: on a live agent's buffer the `/usage` card at id
|
|
346
|
+
* 20938 left NO row, while the `tg-post` transformer in this very file logged
|
|
347
|
+
* its `sendRichMessage` POST. The transformer layer SAW the send the recorder
|
|
348
|
+
* missed. That is the whole argument for moving here: grammy resolves this
|
|
349
|
+
* chain immediately around the HTTP POST, below every helper, every `ctx.*`
|
|
350
|
+
* shorthand, `lockedBot`, and `bot.api.raw` — there is no call shape that
|
|
351
|
+
* reaches Telegram without passing through it, so no future verb can silently
|
|
352
|
+
* opt out the way `switchroomReply` did.
|
|
353
|
+
*
|
|
354
|
+
* `observe` is injected rather than imported: this module must not depend on
|
|
355
|
+
* anything under `gateway/` (see the file header). The caller keeps ownership
|
|
356
|
+
* of the history writers, the `isGatewayMain && HISTORY_ENABLED` gate, and the
|
|
357
|
+
* empty-body alarm.
|
|
358
|
+
*
|
|
359
|
+
* Only a RESOLVED, `ok:true` response is observed. grammy hands this chain the
|
|
360
|
+
* raw `ApiResponse` and converts `{ok:false}` into a thrown `GrammyError` only
|
|
361
|
+
* after the chain returns (see `installTgPostLogger`'s docblock), so a
|
|
362
|
+
* rejection arrives here as a resolved body and must be skipped — recording a
|
|
363
|
+
* Telegram error object as a card body is exactly the empty-row failure #4576
|
|
364
|
+
* was. Non-message results (`getUpdates` arrays, `getMe`, `true` from
|
|
365
|
+
* `answerCallbackQuery`) are filtered by the observer's own shape test.
|
|
366
|
+
*
|
|
367
|
+
* Pure observation: the response is returned untouched and nothing here can
|
|
368
|
+
* throw into the send path.
|
|
369
|
+
*/
|
|
370
|
+
export function installSystemMessageObserver(
|
|
371
|
+
bot: Bot,
|
|
372
|
+
observe: (result: unknown, opts?: TgSendContext) => void,
|
|
373
|
+
): void {
|
|
374
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
375
|
+
const res = await prev(method, payload, signal)
|
|
376
|
+
try {
|
|
377
|
+
const r = res as unknown as { ok?: boolean; result?: unknown }
|
|
378
|
+
if (r != null && typeof r === 'object' && r.ok === true) {
|
|
379
|
+
observe(r.result, resolveSendContext(payload))
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
/* observing a send must never break the send */
|
|
383
|
+
}
|
|
384
|
+
return res
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Merge the caller-published context with what the outbound PAYLOAD itself
|
|
390
|
+
* carries. The context wins where both exist (it is the caller's own intent);
|
|
391
|
+
* the payload is what keeps an unwrapped `ctx.reply*` send attributable to a
|
|
392
|
+
* chat and topic at all. The response's own `chat.id` still outranks both
|
|
393
|
+
* downstream — this is only the fallback tier.
|
|
394
|
+
*/
|
|
395
|
+
function resolveSendContext(payload: unknown): TgSendContext | undefined {
|
|
396
|
+
const ctx = sendContextStore.getStore()
|
|
397
|
+
const p = (payload ?? {}) as Record<string, unknown>
|
|
398
|
+
const rawChat = p.chat_id
|
|
399
|
+
const chat_id =
|
|
400
|
+
ctx?.chat_id ??
|
|
401
|
+
(typeof rawChat === 'string' || typeof rawChat === 'number' ? String(rawChat) : undefined)
|
|
402
|
+
const rawThread = p.message_thread_id
|
|
403
|
+
const threadId = ctx?.threadId ?? (typeof rawThread === 'number' ? rawThread : undefined)
|
|
404
|
+
if (chat_id == null && threadId == null && ctx?.verb == null) return undefined
|
|
405
|
+
return {
|
|
406
|
+
...(chat_id != null ? { chat_id } : {}),
|
|
407
|
+
...(threadId != null ? { threadId } : {}),
|
|
408
|
+
...(ctx?.verb != null ? { verb: ctx.verb } : {}),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
295
412
|
// ─── robustApiCall factory: REMOVED (#3863) ───────────────────────────────
|
|
296
413
|
//
|
|
297
414
|
// `createRobustApiCall` used to live here as a second `createRetryApiCall`
|
|
@@ -49,8 +49,13 @@ import {
|
|
|
49
49
|
type EnvelopeBuildParams,
|
|
50
50
|
} from '../gateway/inbound-router.js'
|
|
51
51
|
import { makeSystemMessageObserver } from '../gateway/system-message-observer.js'
|
|
52
|
-
import { Bot } from 'grammy'
|
|
53
|
-
import {
|
|
52
|
+
import { Bot, Context } from 'grammy'
|
|
53
|
+
import {
|
|
54
|
+
installRichMarkdownGuard,
|
|
55
|
+
installSystemMessageObserver,
|
|
56
|
+
makeSwitchroomReply,
|
|
57
|
+
withTgSendContext,
|
|
58
|
+
} from '../shared/bot-runtime.js'
|
|
54
59
|
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
55
60
|
import { richMessage } from '../rich-send.js'
|
|
56
61
|
|
|
@@ -317,6 +322,170 @@ describe('#4576: the stored card body is NOT empty — real sendRichMessage resp
|
|
|
317
322
|
})
|
|
318
323
|
})
|
|
319
324
|
|
|
325
|
+
describe('#4599: the SLASH-COMMAND card path is recorded too', () => {
|
|
326
|
+
const BOT_INFO = {
|
|
327
|
+
id: 123456, is_bot: true as const, first_name: 'Test', username: 'test_bot',
|
|
328
|
+
can_join_groups: false, can_read_all_group_messages: false,
|
|
329
|
+
supports_inline_queries: false, can_connect_to_business: false,
|
|
330
|
+
has_main_web_app: false,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The gateway's REAL transformer stack with the observer installed exactly
|
|
335
|
+
* where gateway.ts installs it (after `installSentTextCapture`), transport
|
|
336
|
+
* stubbed to answer with the true `Message.RichMessageMessage` shape echoing
|
|
337
|
+
* the markdown that was actually POSTed.
|
|
338
|
+
*
|
|
339
|
+
* `editMessageText` re-uses the `message_id` in the payload, so an edit
|
|
340
|
+
* returns the id it edited — the shape the observer's send-vs-edit rule
|
|
341
|
+
* depends on.
|
|
342
|
+
*/
|
|
343
|
+
function makeCardBot(observe: (result: unknown, opts?: { verb?: string }) => void) {
|
|
344
|
+
let nextId = 30_000
|
|
345
|
+
const posted: string[] = []
|
|
346
|
+
const fakeFetch = (async (_url: unknown, init: { body?: string } | undefined) => {
|
|
347
|
+
const payload = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
|
|
348
|
+
posted.push(String(payload.__method__ ?? ''))
|
|
349
|
+
const rich = payload.rich_message as { markdown?: unknown } | undefined
|
|
350
|
+
const id = typeof payload.message_id === 'number' ? payload.message_id : nextId++
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
status: 200,
|
|
354
|
+
json: async () => ({
|
|
355
|
+
ok: true,
|
|
356
|
+
result: {
|
|
357
|
+
message_id: id,
|
|
358
|
+
date: 0,
|
|
359
|
+
chat: { id: Number(CHAT), type: 'private' },
|
|
360
|
+
...(typeof rich?.markdown === 'string'
|
|
361
|
+
? { rich_message: { blocks: [{ type: 'paragraph', text: { text: rich.markdown } }] } }
|
|
362
|
+
: { text: String(payload.text ?? '') }),
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
} as unknown as Response
|
|
366
|
+
}) as unknown as typeof fetch
|
|
367
|
+
|
|
368
|
+
const bot = new Bot('123456:TEST_TOKEN', { botInfo: BOT_INFO, client: { fetch: fakeFetch } })
|
|
369
|
+
installRichMarkdownGuard(bot)
|
|
370
|
+
installSentTextCapture(bot)
|
|
371
|
+
installSystemMessageObserver(bot, observe)
|
|
372
|
+
return { bot, posted }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A grammy Context for an inbound `/usage`, as the command handler receives it. */
|
|
376
|
+
function makeCtx(bot: Bot): Context {
|
|
377
|
+
return new Context(
|
|
378
|
+
{
|
|
379
|
+
update_id: 1,
|
|
380
|
+
message: {
|
|
381
|
+
message_id: 20_930,
|
|
382
|
+
date: 0,
|
|
383
|
+
chat: { id: Number(CHAT), type: 'private' as const },
|
|
384
|
+
from: { id: 111, is_bot: false, first_name: 'Alice' },
|
|
385
|
+
text: '/usage',
|
|
386
|
+
},
|
|
387
|
+
} as never,
|
|
388
|
+
bot.api,
|
|
389
|
+
BOT_INFO,
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
it('a /usage card sent via switchroomReply leaves exactly one non-empty row', async () => {
|
|
394
|
+
// THE #4599 DEFECT, pinned. `switchroomReply` answers every slash command
|
|
395
|
+
// through `ctx.replyWithRichMessage`, which builds its own payload and calls
|
|
396
|
+
// `bot.api.*` directly — it never transits `robustApiCall`, where the #4571
|
|
397
|
+
// recorder was hooked. Measured on a live agent: the `/usage` card at id
|
|
398
|
+
// 20938 left no row, so a quote-reply to it resolved to nothing. A recorder
|
|
399
|
+
// that only sees `robustApiCall` cannot pass this test.
|
|
400
|
+
const observe = makeObserver()
|
|
401
|
+
const { bot } = makeCardBot(observe)
|
|
402
|
+
const switchroomReply = makeSwitchroomReply(() => undefined)
|
|
403
|
+
const BODY = 'Usage this week — Opus 41 percent, Sonnet 12 percent'
|
|
404
|
+
|
|
405
|
+
await switchroomReply(makeCtx(bot), BODY, { html: true })
|
|
406
|
+
|
|
407
|
+
const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
|
|
408
|
+
(r) => r.role === 'system',
|
|
409
|
+
)
|
|
410
|
+
expect(cards).toHaveLength(1)
|
|
411
|
+
expect(cards[0]!.text).toBe(BODY)
|
|
412
|
+
|
|
413
|
+
// …and the quote-reply the operator actually makes now resolves.
|
|
414
|
+
const resolved = resolveReplyToFromBuffer({
|
|
415
|
+
replyToMessageId: cards[0]!.message_id,
|
|
416
|
+
replyToText: undefined,
|
|
417
|
+
replyToTextEscaped: undefined,
|
|
418
|
+
historyEnabled: true,
|
|
419
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
420
|
+
lookup: boundLookup,
|
|
421
|
+
})
|
|
422
|
+
expect(resolved.replyToRole).toBe('system')
|
|
423
|
+
expect(resolved.replyToText).toBe(BODY)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('the PLAIN ctx.reply branch of switchroomReply is recorded as well', async () => {
|
|
427
|
+
const observe = makeObserver()
|
|
428
|
+
const { bot } = makeCardBot(observe)
|
|
429
|
+
const switchroomReply = makeSwitchroomReply(() => undefined)
|
|
430
|
+
|
|
431
|
+
await switchroomReply(makeCtx(bot), 'plain notice, no markdown', {})
|
|
432
|
+
|
|
433
|
+
const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
|
|
434
|
+
(r) => r.role === 'system',
|
|
435
|
+
)
|
|
436
|
+
expect(cards).toHaveLength(1)
|
|
437
|
+
expect(cards[0]!.text).toBe('plain notice, no markdown')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('a wrapped send keeps its verb → kind, and its edits do NOT add a second row', async () => {
|
|
441
|
+
// The behaviour the move must PRESERVE: `robustApiCall` publishes its verb
|
|
442
|
+
// through `withTgSendContext`, so the 117 live `role='system'` rows keep
|
|
443
|
+
// their `kind` (`activity-summary`, `worker-feed`, …) instead of degrading
|
|
444
|
+
// to null now that the recorder no longer sits on that wrapper.
|
|
445
|
+
let clock = 1_000
|
|
446
|
+
const observe = makeObserver(() => clock)
|
|
447
|
+
const { bot } = makeCardBot(observe)
|
|
448
|
+
|
|
449
|
+
const sent = await withTgSendContext({ chat_id: CHAT, verb: 'activity-summary.send' }, () =>
|
|
450
|
+
bot.api.sendRichMessage(Number(CHAT), richMessage('Working — 3 tools')),
|
|
451
|
+
)
|
|
452
|
+
const id = (sent as { message_id: number }).message_id
|
|
453
|
+
|
|
454
|
+
clock += 60_000
|
|
455
|
+
await withTgSendContext({ chat_id: CHAT, verb: 'activity-summary.edit' }, () =>
|
|
456
|
+
bot.api.editMessageText(Number(CHAT), id, richMessage('Working — 11 tools')),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
|
|
460
|
+
(r) => r.role === 'system',
|
|
461
|
+
)
|
|
462
|
+
expect(cards).toHaveLength(1)
|
|
463
|
+
expect(cards[0]!.message_id).toBe(id)
|
|
464
|
+
expect(cards[0]!.kind).toBe('activity-summary')
|
|
465
|
+
expect(cards[0]!.text).toBe('Working — 11 tools')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('a Telegram REJECTION is never recorded as a card', async () => {
|
|
469
|
+
// grammy resolves the transformer chain with the raw `{ok:false}` body and
|
|
470
|
+
// only throws afterwards, so a failed send reaches the observer as a
|
|
471
|
+
// RESOLVED response. Recording its error object would be #4576 all over.
|
|
472
|
+
const observe = makeObserver()
|
|
473
|
+
const failFetch = (async () =>
|
|
474
|
+
({
|
|
475
|
+
ok: true,
|
|
476
|
+
status: 200,
|
|
477
|
+
json: async () => ({ ok: false, error_code: 400, description: 'chat not found' }),
|
|
478
|
+
}) as unknown as Response) as unknown as typeof fetch
|
|
479
|
+
const bot = new Bot('123456:TEST_TOKEN', { botInfo: BOT_INFO, client: { fetch: failFetch } })
|
|
480
|
+
installSystemMessageObserver(bot, observe)
|
|
481
|
+
|
|
482
|
+
await expect(
|
|
483
|
+
bot.api.sendRichMessage(Number(CHAT), richMessage('never lands')),
|
|
484
|
+
).rejects.toThrow()
|
|
485
|
+
expect(query({ chat_id: CHAT, limit: 50, include_system: true })).toHaveLength(0)
|
|
486
|
+
})
|
|
487
|
+
})
|
|
488
|
+
|
|
320
489
|
describe('the card lane does not pollute normal history reads', () => {
|
|
321
490
|
it('query() (get_recent_messages) lists the conversation, never the cards', () => {
|
|
322
491
|
const observe = makeObserver()
|