switchroom 0.21.4 → 0.21.6

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.21.4", COMMIT_SHA = "aa63e74c";
2123
+ var VERSION = "0.21.6", COMMIT_SHA = "ffc152ca";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -21565,7 +21565,7 @@ function allocateAgentUid(name) {
21565
21565
  }
21566
21566
 
21567
21567
  // src/build-info.ts
21568
- var VERSION = "0.21.4";
21568
+ var VERSION = "0.21.6";
21569
21569
 
21570
21570
  // src/setup/hindsight-recall-passthrough.ts
21571
21571
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.4",
4
+ "version": "0.21.6",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "test:vitest": "vitest run",
29
29
  "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
31
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
34
34
  "lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
@@ -46,6 +46,7 @@
46
46
  "lint:gateway-line-ratchet": "node scripts/check-gateway-line-ratchet.mjs",
47
47
  "lint:retry-flood-hooks": "node scripts/check-retry-flood-hooks.mjs",
48
48
  "lint:callback-ctx-wrapping": "node scripts/check-callback-ctx-wrapping.mjs",
49
+ "lint:ctx-send-wrapping": "node scripts/check-ctx-send-wrapping.mjs",
49
50
  "lint:status-pin-single-path": "node scripts/check-status-pin-single-path.mjs",
50
51
  "lint:mcp-instructions-budget": "node scripts/check-mcp-instructions-budget.mjs",
51
52
  "lint:litellm-config-guard": "node scripts/check-litellm-config-guard.mjs",
@@ -44729,6 +44729,7 @@ function shouldEmitShadowTrace(eventKind, effectCount, globalKind, verbose = gwT
44729
44729
  // shared/bot-runtime.ts
44730
44730
  init_rich_send();
44731
44731
  var tgPostTagStore = new AsyncLocalStorage2;
44732
+ var sendContextStore = new AsyncLocalStorage2;
44732
44733
  function escapeHtmlForTg(text) {
44733
44734
  return text.replace(/([\\`*_~=\[\]|])/g, "\\$1");
44734
44735
  }
@@ -45728,7 +45729,8 @@ function buildReplyForwardContext(p) {
45728
45729
  const replyToMsg = p.ctx.message?.reply_to_message;
45729
45730
  const replyToMessageId = replyToMsg?.message_id;
45730
45731
  const quoteText = p.ctx.message?.quote?.text;
45731
- const replyToTextRaw = quoteText != null && quoteText.length > 0 ? quoteText : replyToMsg ? replyToMsg.text ?? replyToMsg.caption ?? undefined : undefined;
45732
+ const richParentText = extractRichMessageText(replyToMsg?.rich_message);
45733
+ const replyToTextRaw = quoteText != null && quoteText.length > 0 ? quoteText : replyToMsg ? replyToMsg.text ?? replyToMsg.caption ?? richParentText ?? undefined : undefined;
45732
45734
  const replyToText = replyToTextRaw != null ? replyToTextRaw.length > p.replyToTextMax ? replyToTextRaw.slice(0, p.replyToTextMax - 1) + "\u2026" : replyToTextRaw : undefined;
45733
45735
  const replyToTextEscaped = formatReplyToText(replyToTextRaw, p.replyToTextMax);
45734
45736
  const forwardOrigins = p.coalescedForwardOrigins ?? dedupeForwardOrigins2([parseForwardOrigin2(p.ctx.message?.forward_origin)]);
@@ -45749,18 +45751,18 @@ function resolveReplyToFromBuffer(p) {
45749
45751
  let replyToRole;
45750
45752
  let replyToKind;
45751
45753
  const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0;
45752
- if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
45754
+ if (p.historyEnabled && p.replyToMessageId != null) {
45753
45755
  try {
45754
45756
  const recovered = p.lookup(p.replyToMessageId);
45755
- if (recovered != null && recovered.role === "system" && recovered.kind) {
45756
- replyToKind = recovered.kind;
45757
- }
45758
- if (recovered && recovered.text.length > 0) {
45759
- replyToText = recovered.text.length > p.replyToTextMax ? recovered.text.slice(0, p.replyToTextMax - 1) + "\u2026" : recovered.text;
45760
- replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax);
45761
- replyToRole = recovered.role;
45762
- } else if (recovered) {
45757
+ if (recovered != null) {
45763
45758
  replyToRole = recovered.role;
45759
+ if (recovered.role === "system" && recovered.kind) {
45760
+ replyToKind = recovered.kind;
45761
+ }
45762
+ if (liveTextEmpty && recovered.text.length > 0) {
45763
+ replyToText = recovered.text.length > p.replyToTextMax ? recovered.text.slice(0, p.replyToTextMax - 1) + "\u2026" : recovered.text;
45764
+ replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax);
45765
+ }
45764
45766
  }
45765
45767
  } catch {}
45766
45768
  }
@@ -67220,6 +67222,37 @@ function installRichMarkdownGuard(bot) {
67220
67222
  return prev(method, payload, signal);
67221
67223
  });
67222
67224
  }
67225
+ var sendContextStore2 = new AsyncLocalStorage5;
67226
+ function withTgSendContext(ctx, fn) {
67227
+ return sendContextStore2.run(ctx, fn);
67228
+ }
67229
+ function installSystemMessageObserver(bot, observe) {
67230
+ bot.api.config.use(async (prev, method, payload, signal) => {
67231
+ const res = await prev(method, payload, signal);
67232
+ try {
67233
+ const r = res;
67234
+ if (r != null && typeof r === "object" && r.ok === true) {
67235
+ observe(r.result, resolveSendContext(payload));
67236
+ }
67237
+ } catch {}
67238
+ return res;
67239
+ });
67240
+ }
67241
+ function resolveSendContext(payload) {
67242
+ const ctx = sendContextStore2.getStore();
67243
+ const p = payload ?? {};
67244
+ const rawChat = p.chat_id;
67245
+ const chat_id = ctx?.chat_id ?? (typeof rawChat === "string" || typeof rawChat === "number" ? String(rawChat) : undefined);
67246
+ const rawThread = p.message_thread_id;
67247
+ const threadId = ctx?.threadId ?? (typeof rawThread === "number" ? rawThread : undefined);
67248
+ if (chat_id == null && threadId == null && ctx?.verb == null)
67249
+ return;
67250
+ return {
67251
+ ...chat_id != null ? { chat_id } : {},
67252
+ ...threadId != null ? { threadId } : {},
67253
+ ...ctx?.verb != null ? { verb: ctx.verb } : {}
67254
+ };
67255
+ }
67223
67256
 
67224
67257
  // shared/sent-text-capture.ts
67225
67258
  var SENT_TEXT = Symbol.for("switchroom.telegram.sentText");
@@ -104434,10 +104467,10 @@ function startOutboxSweep(deps) {
104434
104467
  }
104435
104468
 
104436
104469
  // ../src/build-info.ts
104437
- var VERSION2 = "0.21.4";
104438
- var COMMIT_SHA = "aa63e74c";
104439
- var COMMIT_DATE = "2026-08-10T17:32:03Z";
104440
- var LATEST_PR = 4597;
104470
+ var VERSION2 = "0.21.6";
104471
+ var COMMIT_SHA = "ffc152ca";
104472
+ var COMMIT_DATE = "2026-08-11T03:22:29Z";
104473
+ var LATEST_PR = 4605;
104441
104474
  var COMMITS_AHEAD_OF_TAG = 0;
104442
104475
 
104443
104476
  // gateway/boot-version.ts
@@ -108611,15 +108644,7 @@ var rawRobustApiCall = createRetryApiCall2({
108611
108644
  floodWaitRemainingMs: probeFloodWaitRemainingMs
108612
108645
  });
108613
108646
  var observeSentMessage = isGatewayMain && HISTORY_ENABLED ? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText }) : undefined;
108614
- var robustApiCall = (fn, opts) => {
108615
- const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts);
108616
- if (observeSentMessage == null)
108617
- return p;
108618
- return p.then((res) => {
108619
- observeSentMessage(res, opts);
108620
- return res;
108621
- });
108622
- };
108647
+ var robustApiCall = (fn, opts) => sendGate.gate(() => withTgSendContext(opts, () => rawRobustApiCall(fn, opts)), opts);
108623
108648
  var swallowingApiCall = createSwallowingRetryApiCall(robustApiCall, (line) => process.stderr.write(line));
108624
108649
  var resetPrivacyForNewSession = makePrivacyResetForNewSession((chatId, threadId, text5) => void swallowingApiCall(() => lockedBot.api.sendMessage(chatId, text5, threadId != null ? { message_thread_id: threadId, disable_notification: false } : { disable_notification: false }), { chat_id: chatId, verb: "privacy-reset-alert", priorityClass: "critical" }));
108625
108650
  var gatedSetMessageReaction = (chatId, messageId, reaction) => robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
@@ -117665,6 +117690,8 @@ async function initGatewayBot() {
117665
117690
  installTgPostLogger(bot);
117666
117691
  installRichMarkdownGuard(bot);
117667
117692
  installSentTextCapture(bot);
117693
+ if (observeSentMessage != null)
117694
+ installSystemMessageObserver(bot, observeSentMessage);
117668
117695
  installEditFloodFuse(bot, {
117669
117696
  ...editFloodFuseConfigFromEnv(process.env),
117670
117697
  onTrip: (i) => process.stderr.write(`edit-flood-fuse ${i.action} method=${i.method} key=${i.key} class=${i.cls}
@@ -302,7 +302,7 @@ import {
302
302
  import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-fuse.js'
303
303
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
304
304
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
305
- import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
305
+ import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags, withTgSendContext, installSystemMessageObserver } from '../shared/bot-runtime.js'
306
306
  import { installSentTextCapture } from '../shared/sent-text-capture.js'
307
307
  import {
308
308
  floodStatePath,
@@ -5603,28 +5603,25 @@ const rawRobustApiCall = createRetryApiCall({
5603
5603
  floodWaitRemainingMs: probeFloodWaitRemainingMs,
5604
5604
  })
5605
5605
 
5606
- // #4571 — card/system-surface history lane. Every card the gateway posts
5607
- // (activity card, status pin, approval/boot/issues/worker-feed cards, progress
5608
- // lines, notices) goes through `robustApiCall`, so observing its RESOLVED
5609
- // result is the one chokepoint that makes every posted message id resolvable
5610
- // when the operator quote-replies to it. Never throws; see
5611
- // system-message-observer.ts for the send-vs-edit and throttling contract.
5612
- // Gated on the SAME condition as `initHistory` above a non-main gateway
5613
- // process never opens the DB, so an observer there would be pure noise.
5614
- // The empty-body alarm (#4576) is the observer's own default — see
5615
- // `defaultEmptyCardTextWarning` in system-message-observer.ts.
5606
+ // #4571 — card/system-surface history lane: every card the gateway posts must leave
5607
+ // a row so its id resolves when the operator quote-replies to it. #4599 moved the
5608
+ // hook off `robustApiCall` (which the `ctx.replyWithRichMessage` slash-command card
5609
+ // path bypasses entirely) onto the grammy transformer layer see
5610
+ // `installSystemMessageObserver`, wired at bot construction, and
5611
+ // system-message-observer.ts for the send-vs-edit / throttling / alarm contract.
5612
+ // Gated as `initHistory` is: a non-main gateway never opens the DB.
5616
5613
  const observeSentMessage = isGatewayMain && HISTORY_ENABLED
5617
5614
  ? makeSystemMessageObserver({ insert: recordSystemOutbound, updateText: updateSystemOutboundText })
5618
5615
  : undefined
5619
5616
 
5617
+ // `withTgSendContext` publishes {chat_id, threadId, verb} down to the transformer layer
5618
+ // where the observer runs — how a card keeps its `kind`. Sends outside this wrapper
5619
+ // still record, just without a verb.
5620
5620
  const robustApiCall = <T>(
5621
5621
  fn: () => Promise<T>,
5622
5622
  opts?: Parameters<typeof rawRobustApiCall<T>>[1],
5623
- ): Promise<T> => {
5624
- const p = sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
5625
- if (observeSentMessage == null) return p
5626
- return p.then((res) => { observeSentMessage(res, opts); return res })
5627
- }
5623
+ ): Promise<T> =>
5624
+ sendGate.gate(() => withTgSendContext(opts, () => rawRobustApiCall(fn, opts)), opts)
5628
5625
 
5629
5626
  // Fire-and-forget wrapper for outbound surfaces that previously had
5630
5627
  // `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
@@ -15061,11 +15058,11 @@ export async function handleInbound(
15061
15058
  } = replyForwardCtx
15062
15059
 
15063
15060
  // Reply-to buffer fallback (post-reset continuity, resolveReplyToFromBuffer).
15064
- // On a native reply to the BOT's OWN message, Telegram delivers
15065
- // reply_to_message.message_id but NOT its .text so the live reply text is
15066
- // empty even though we authored (and, via recordOutbound, persisted) that
15067
- // message to history.db (role='assistant', 30-day retention). Recover it so
15068
- // the antecedent survives a session reset. Fills replyToText (raw, so the
15061
+ // On a native reply to a PLAIN bot-sent message, Telegram delivers
15062
+ // reply_to_message.message_id but NOT its .text (a RICH parent every card
15063
+ // carries rich_message and is resolved LIVE upstream since #4598), so the live
15064
+ // reply text is empty even though we persisted it (role='assistant', 30-day
15065
+ // retention). Recover it so it survives a reset. Fills replyToText (raw, so the
15069
15066
  // recordInbound write below persists it — envelope-only would leave the row
15070
15067
  // NULL and starve future briefings), replyToTextEscaped (channel meta), and
15071
15068
  // replyToRole (the reply_to_role attribute). Only when the live text is
@@ -22887,6 +22884,9 @@ async function initGatewayBot(): Promise<void> {
22887
22884
  // on the resolved Message for the shapes a response can't supply. After the fmt
22888
22885
  // guard so it composes OUTSIDE it. See sent-text-capture.ts.
22889
22886
  installSentTextCapture(bot)
22887
+ // #4599 card-history lane: AFTER sent-text-capture so it composes outside it and
22888
+ // can read the request-side stamp. The seam `ctx.replyWithRichMessage` can't bypass.
22889
+ if (observeSentMessage != null) installSystemMessageObserver(bot, observeSentMessage)
22890
22890
  // #3620 flood fuse — installed LAST so it composes OUTERMOST: the one seam no
22891
22891
  // outbound call can bypass (grammY has no route to the network that skips the
22892
22892
  // transformer stack). Kill-switch SWITCHROOM_EDIT_FUSE=0; see edit-flood-fuse.ts.
@@ -35,6 +35,7 @@ import {
35
35
  } from './inbound-interceptors.js'
36
36
  import type { InboundMessage } from './ipc-protocol.js'
37
37
  import { deriveTurnId } from './derive-turn-id.js'
38
+ import { extractRichMessageText } from './rich-message-handler.js'
38
39
  import { formatReplyToText } from '../steering.js'
39
40
  import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
40
41
  import { safeResolvePersonName, type PersonDirectory } from './resolve-person.js'
@@ -160,10 +161,33 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
160
161
  // gateway runs when neither is present. Accessed defensively in case the
161
162
  // installed grammy/@grammyjs/types predate `TextQuote`.
162
163
  const quoteText = p.ctx.message?.quote?.text
164
+ // Rich-message parents (#4598). Every card the gateway posts goes out via
165
+ // Bot API 10.1 `sendRichMessage`, so a native reply to one delivers a
166
+ // `reply_to_message` whose body lives under `rich_message.blocks` with
167
+ // `text` / `caption` ABSENT. Reading only `.text ?? .caption` therefore
168
+ // yielded `undefined` for 100% of card parents and pushed every such reply
169
+ // onto the history-buffer fallback — which cannot help when no row was ever
170
+ // recorded (a send while the gateway was down, or one that bypassed the
171
+ // recording chokepoint).
172
+ //
173
+ // Measured on the wire, not inferred: a user reply to a `sendRichMessage`
174
+ // card yields `reply_to_message` keys
175
+ // `[message_id, from, chat, date, rich_message]`, `text`/`caption` absent,
176
+ // `rich_message.blocks` fully populated. The older "Telegram omits the
177
+ // parent body" comments on this path predate rich messages.
178
+ //
179
+ // Flattened by the SAME renderer the inbound rich-message handler and the
180
+ // outbound card observer use, so all three agree on what a card "says".
181
+ // Returns `undefined` (never `''`) for an unrenderable block tree, so
182
+ // `resolveReplyToFromBuffer`'s `liveTextEmpty` gate still falls through to
183
+ // the buffer instead of pinning an empty antecedent.
184
+ const richParentText = extractRichMessageText(
185
+ (replyToMsg as { rich_message?: unknown } | undefined)?.rich_message,
186
+ )
163
187
  const replyToTextRaw = (quoteText != null && quoteText.length > 0)
164
188
  ? quoteText
165
189
  : replyToMsg
166
- ? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
190
+ ? (replyToMsg.text ?? replyToMsg.caption ?? richParentText ?? undefined)
167
191
  : undefined
168
192
  const replyToText = replyToTextRaw != null
169
193
  ? (replyToTextRaw.length > p.replyToTextMax
@@ -198,8 +222,11 @@ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
198
222
  export interface ReplyToBufferFallbackParams {
199
223
  /** From {@link buildReplyForwardContext}. */
200
224
  replyToMessageId: number | undefined
201
- /** Raw reply text off the live update (for the SQLite write); empty when the
202
- * reply target is the bot's own message (Telegram omits its text). */
225
+ /** Raw reply text off the live update (for the SQLite write). Empty only
226
+ * when the live update carried NO readable parent body at all — a plain
227
+ * `sendMessage` parent (Telegram omits `.text` on the bot's own plain
228
+ * messages), or a rich parent whose blocks render to nothing. A normal
229
+ * card parent now arrives populated off `rich_message` (#4598). */
203
230
  replyToText: string | undefined
204
231
  /** XML-escaped reply text for the channel meta; empty in the same case. */
205
232
  replyToTextEscaped: string | undefined
@@ -219,21 +246,36 @@ export interface ReplyToBufferFallbackParams {
219
246
  }
220
247
 
221
248
  /**
222
- * Reply-to buffer fallback (post-reset continuity). On a native reply to the
223
- * BOT's OWN message, Telegram delivers `reply_to_message.message_id` but NOT
224
- * its `.text` — so the live reply text is empty even though the gateway
225
- * authored (and, via `recordOutbound`, persisted) that message. This recovers
226
- * the antecedent from the local history buffer so it survives a session reset
227
- * (`resume_mode: handoff`), where the transcript is gone.
249
+ * Reply-to buffer fallback (post-reset continuity). On a native reply to a
250
+ * PLAIN message the bot itself sent, Telegram delivers
251
+ * `reply_to_message.message_id` but NOT its `.text` — so the live reply text
252
+ * is empty even though the gateway authored (and, via `recordOutbound`,
253
+ * persisted) that message. This recovers the antecedent from the local history
254
+ * buffer so it survives a session reset (`resume_mode: handoff`), where the
255
+ * transcript is gone.
256
+ *
257
+ * Since #4598 this is the SECOND line of defence FOR THE TEXT, not the first:
258
+ * a reply to a RICH parent (every card) now carries its body on
259
+ * `reply_to_message.rich_message` and is resolved live in
260
+ * {@link buildReplyForwardContext}, so a live-resolved body is never
261
+ * overwritten from the buffer.
262
+ *
263
+ * The LOOKUP itself, however, still runs on every reply while history is on.
264
+ * `replyToRole` and `replyToKind` have no live-update source at all, so
265
+ * skipping the lookup whenever the live text resolved would strip
266
+ * `reply_to_role` / `reply_to_kind` from precisely the recorded-card replies
267
+ * the #4571 kind lane was built for.
228
268
  *
229
269
  * Returns updated `replyToText` (raw, for the SQLite `recordInbound` write —
230
270
  * envelope-only would leave the row NULL and starve future handoff briefings),
231
271
  * `replyToTextEscaped` (for the channel-meta `reply_to_text`), and the
232
272
  * recovered `replyToRole` ('assistant' = the bot's own message, disambiguating
233
273
  * the "you're replying to yourself" case). Pure except for the injected
234
- * `lookup`. Only fills in when the live reply text is empty — never overwrites
235
- * a non-empty live value (a partial-quote or a reply to a person's message).
236
- * Degrades silently to the id-only inputs on any lookup failure.
274
+ * `lookup`. Only fills in the TEXT when the live reply text is empty — never
275
+ * overwrites a non-empty live value (a partial-quote, a reply to a person's
276
+ * message, or a live-rendered rich parent). Role and kind are filled in from
277
+ * any buffer hit regardless. Degrades silently to the id-only inputs on any
278
+ * lookup failure.
237
279
  */
238
280
  export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
239
281
  replyToText: string | undefined
@@ -248,23 +290,32 @@ export function resolveReplyToFromBuffer(p: ReplyToBufferFallbackParams): {
248
290
  let replyToRole: 'user' | 'assistant' | 'system' | undefined
249
291
  let replyToKind: string | undefined
250
292
  const liveTextEmpty = replyToTextEscaped == null || replyToTextEscaped.length === 0
251
- if (p.historyEnabled && p.replyToMessageId != null && liveTextEmpty) {
293
+ if (p.historyEnabled && p.replyToMessageId != null) {
252
294
  try {
253
295
  const recovered = p.lookup(p.replyToMessageId)
254
- if (recovered != null && recovered.role === 'system' && recovered.kind) {
255
- replyToKind = recovered.kind
256
- }
257
- if (recovered && recovered.text.length > 0) {
258
- replyToText =
259
- recovered.text.length > p.replyToTextMax
260
- ? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
261
- : recovered.text
262
- replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
263
- replyToRole = recovered.role
264
- } else if (recovered) {
265
- // Row exists (authorship known) but text is empty/redacted — still
266
- // surface the role so the model knows whose message it is replying to.
296
+ if (recovered != null) {
297
+ // Role and kind are produced ONLY here — there is no live-update
298
+ // source for either. So the lookup runs on EVERY reply, not just the
299
+ // ones whose text the live update failed to carry: gating it on
300
+ // `liveTextEmpty` would mean a full reply to a recorded card resolved
301
+ // its body live (#4598) and silently lost `reply_to_role="system"` +
302
+ // `reply_to_kind` (#4571) exactly the case the kind lane exists for,
303
+ // and the thing #4599 goes to AsyncLocalStorage lengths to record.
304
+ // Costs one SQLite point-read per reply.
267
305
  replyToRole = recovered.role
306
+ if (recovered.role === 'system' && recovered.kind) {
307
+ replyToKind = recovered.kind
308
+ }
309
+ // The TEXT is still second line of defence: never overwrite a
310
+ // non-empty live value (a partial quote, a person's message, or a
311
+ // rich parent rendered off the wire).
312
+ if (liveTextEmpty && recovered.text.length > 0) {
313
+ replyToText =
314
+ recovered.text.length > p.replyToTextMax
315
+ ? recovered.text.slice(0, p.replyToTextMax - 1) + '…'
316
+ : recovered.text
317
+ replyToTextEscaped = formatReplyToText(recovered.text, p.replyToTextMax)
318
+ }
268
319
  }
269
320
  } catch {
270
321
  // History disabled mid-run / requireDb throws / row missing — degrade
@@ -24,10 +24,29 @@
24
24
  * Rather than add a `recordSystemOutbound(...)` call to each of the ~110 raw
25
25
  * send sites (which is exactly the kind of per-call-site discipline that
26
26
  * decays — the `reply` path was the only site anyone remembered), this hooks
27
- * the ONE chokepoint every gateway outbound already goes through:
28
- * `gateway.ts`'s `robustApiCall` (chat-lock → send-gate → retry policy). Every
29
- * card send in the gateway is routed through it, enforced by the
30
- * `check-bot-api-wrapping` lint guard.
27
+ * ONE chokepoint and observes the resolved response there.
28
+ *
29
+ * WHICH chokepoint took two goes, and the first answer was wrong (#4599). This
30
+ * docblock used to say the hook sat on `gateway.ts`'s `robustApiCall` — "the
31
+ * ONE chokepoint every gateway outbound already goes through … enforced by the
32
+ * `check-bot-api-wrapping` lint guard". Both halves were false from the day it
33
+ * merged. grammY's `ctx.*` sugar builds the payload and calls `bot.api.*`
34
+ * itself, so `switchroomReply` — the helper every SLASH-COMMAND card answers
35
+ * through (`/usage`, `/model`, `/auth`, `/approvals`, `/start`, `/help`) — sends
36
+ * via `ctx.replyWithRichMessage` and never touches `robustApiCall`; and the
37
+ * lint guard could not have caught that, because its verb pattern matched only
38
+ * `(bot|lockedBot|ctx)\.api\.<verb>`, never mentioned `sendRichMessage`, and
39
+ * structurally cannot match a `ctx.replyWith*` call at all. Measured, not
40
+ * inferred: a live agent's `/usage` card at id 20938 left NO row while the
41
+ * `tg-post` transformer logged its `sendRichMessage` POST.
42
+ *
43
+ * The hook therefore lives at the grammy API TRANSFORMER layer
44
+ * (`installSystemMessageObserver` in `shared/bot-runtime.ts`), which grammy
45
+ * resolves immediately around the HTTP POST — below every helper, `ctx.*`
46
+ * shorthand, `lockedBot`, and `bot.api.raw`. No call shape reaches Telegram
47
+ * without passing through it, so no future verb can opt out the way
48
+ * `switchroomReply` silently did. `robustApiCall` still publishes its `verb`
49
+ * down to that layer (`withTgSendContext`) so cards keep their `kind`.
31
50
  *
32
51
  * The observer reads the Telegram RESPONSE, which buys three things for free:
33
52
  * - the real `message_id` (the only thing a reply can point at),
@@ -90,7 +109,7 @@ export interface SentMessageLike {
90
109
  rich_message?: unknown
91
110
  }
92
111
 
93
- /** The subset of `robustApiCall`'s opts the observer reads. */
112
+ /** The call-site metadata the observer reads (`bot-runtime.ts`'s `TgSendContext`). */
94
113
  export interface ObservedCallOpts {
95
114
  chat_id?: string
96
115
  threadId?: number
@@ -293,7 +312,7 @@ type TrackedState = { lane: 'system' | 'foreign'; lastStoredMs: number; storedLe
293
312
 
294
313
  /**
295
314
  * Build the observer. The returned function is called with the RESOLVED result
296
- * of every `robustApiCall` and never throws.
315
+ * of every outbound Bot API call (from the transformer layer) and never throws.
297
316
  */
298
317
  export function makeSystemMessageObserver(
299
318
  deps: SystemMessageObserverDeps,
@@ -5,8 +5,10 @@
5
5
  * standalone foreman bot before its retirement.
6
6
  *
7
7
  * What lives here:
8
- * - `installTgPostLogger` / `installRichMarkdownGuard` — the grammy API
9
- * transformers every outbound call transits.
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 { installRichMarkdownGuard } from '../shared/bot-runtime.js'
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()
@@ -700,14 +700,22 @@ describe('startOrphanedDbSweep', () => {
700
700
  // Multiple ticks: proves the interval repeats, not just fires once.
701
701
  expect(lines.length).toBeGreaterThan(1)
702
702
 
703
- stop()
704
- // A tick is async, so one may be in flight when stop() lands. Let it settle
705
- // before snapshotting — the property under test is "no NEW ticks start",
706
- // not "no line is ever appended after the stop() call returns".
707
- await sleep(60)
708
703
  const atStop = lines.length
709
- await sleep(250)
710
- // A stop() that did not clear the interval would add ~50 more lines here.
711
- expect(lines.length).toBe(atStop)
704
+ stop()
705
+ // The property under test is "no NEW ticks start", not "no line is ever
706
+ // appended after stop() returns": the tick body is async, so one tick can
707
+ // already be in flight when stop() lands and will still emit its line.
708
+ //
709
+ // The sweep's own `running` guard means AT MOST ONE can be in flight, so
710
+ // the tolerance is exactly one line — assert that, rather than trying to
711
+ // out-wait the in-flight tick and then demanding exact equality. Every
712
+ // fixed settle window is a guess a loaded runner beats: 60ms flaked two of
713
+ // three consecutive merge-queue passes and a 40ms quiescence poll flaked
714
+ // again on the very next one (#4601), because a tick that has not yet
715
+ // resolved looks identical to quiescence.
716
+ await sleep(400)
717
+ // Not a weakening: an uncleared 5ms interval adds dozens of lines here, so
718
+ // this still fails hard on a stop() that does not clear the timer.
719
+ expect(lines.length - atStop).toBeLessThanOrEqual(1)
712
720
  })
713
721
  })
@@ -25,6 +25,10 @@
25
25
  * partial quote) over the full parent `.text`.
26
26
  * - `buildInboundEnvelope` emits `reply_to_role` when known and `reply_to_text`
27
27
  * from the recovered escaped form.
28
+ * - `buildReplyForwardContext` reads a RICH parent's body off
29
+ * `reply_to_message.rich_message` LIVE (#4598) — the only path that can
30
+ * resolve a card the buffer never recorded. The last describe block pins
31
+ * that, including a killer case a buffer-only implementation must fail.
28
32
  *
29
33
  * The persisted-row-non-NULL half of the 1a contract (which needs a real
30
34
  * bun:sqlite history.db) lives in reply-to-buffer-history.test.ts (bun).
@@ -98,20 +102,32 @@ describe('resolveReplyToFromBuffer — reply-to buffer fallback (1a)', () => {
98
102
  })
99
103
 
100
104
  it('does NOT overwrite a non-empty LIVE reply text (reply to a person, or a partial quote)', () => {
101
- const lookup = () => {
102
- throw new Error('lookup must not be called when live text is present')
103
- }
104
105
  const out = resolveReplyToFromBuffer({
105
106
  replyToMessageId: 9,
106
107
  replyToText: 'live raw text',
107
108
  replyToTextEscaped: 'live escaped text',
108
109
  historyEnabled: true,
109
110
  replyToTextMax: REPLY_TO_TEXT_MAX,
110
- lookup,
111
+ lookup: () => ({ role: 'user', text: 'STALE BUFFER TEXT' }),
111
112
  })
112
113
  expect(out.replyToText).toBe('live raw text')
113
114
  expect(out.replyToTextEscaped).toBe('live escaped text')
115
+ // …but the lookup still supplies the ROLE, which has no live-update source.
116
+ expect(out.replyToRole).toBe('user')
117
+ })
118
+
119
+ it('a missing row does not clobber a live reply text', () => {
120
+ const out = resolveReplyToFromBuffer({
121
+ replyToMessageId: 9,
122
+ replyToText: 'live raw text',
123
+ replyToTextEscaped: 'live escaped text',
124
+ historyEnabled: true,
125
+ replyToTextMax: REPLY_TO_TEXT_MAX,
126
+ lookup: () => null,
127
+ })
128
+ expect(out.replyToText).toBe('live raw text')
114
129
  expect(out.replyToRole).toBeUndefined()
130
+ expect(out.replyToKind).toBeUndefined()
115
131
  })
116
132
 
117
133
  it('history-disabled guard: never calls lookup, degrades to id-only, does not throw', () => {
@@ -208,6 +224,195 @@ describe('buildReplyForwardContext — native partial-quote preference (2a)', ()
208
224
  })
209
225
  })
210
226
 
227
+ /**
228
+ * Rich-message parents (#4598).
229
+ *
230
+ * Every card the gateway posts ships via Bot API 10.1 `sendRichMessage`, so a
231
+ * native reply to one delivers a `reply_to_message` with `rich_message.blocks`
232
+ * populated and `text` / `caption` ABSENT. Measured on the wire against a real
233
+ * bot: keys `[message_id, from, chat, date, rich_message]`.
234
+ *
235
+ * Before #4598 the body of a card antecedent was 100% buffer-sourced, so a
236
+ * card that never made it into `history.db` — posted while the gateway was
237
+ * down, or through a send path that bypassed the recording chokepoint — was
238
+ * permanently unresolvable no matter how the recording side was fixed. These
239
+ * pin the LIVE read.
240
+ */
241
+ describe('buildReplyForwardContext — rich_message parent (#4598)', () => {
242
+ /** The shape Telegram actually delivers for a reply to a card. */
243
+ function richParent(message_id: number) {
244
+ return {
245
+ message_id,
246
+ rich_message: {
247
+ blocks: [
248
+ { type: 'paragraph', text: [{ type: 'bold', text: 'Usage' }, ' this week'] },
249
+ { type: 'paragraph', text: 'Opus 41% then Sonnet 12%' },
250
+ ],
251
+ },
252
+ }
253
+ }
254
+
255
+ const RENDERED = 'Usage this week\nOpus 41% then Sonnet 12%'
256
+
257
+ it('reads the parent body off rich_message when text and caption are absent', () => {
258
+ const ctx = makeCtx({ reply_to_message: richParent(9938) })
259
+ const out = buildReplyForwardContext({
260
+ ctx,
261
+ coalescedForwardOrigins: undefined,
262
+ replyToTextMax: REPLY_TO_TEXT_MAX,
263
+ })
264
+ expect(out.replyToMessageId).toBe(9938)
265
+ expect(out.replyToText).toBe(RENDERED)
266
+ expect(out.replyToTextEscaped).toBe(RENDERED)
267
+ })
268
+
269
+ it('truncates a long rich body to the cap, like every other antecedent', () => {
270
+ const ctx = makeCtx({
271
+ reply_to_message: {
272
+ message_id: 1,
273
+ rich_message: { blocks: [{ type: 'paragraph', text: 'x'.repeat(400) }] },
274
+ },
275
+ })
276
+ const out = buildReplyForwardContext({
277
+ ctx,
278
+ coalescedForwardOrigins: undefined,
279
+ replyToTextMax: REPLY_TO_TEXT_MAX,
280
+ })
281
+ expect(out.replyToText).toHaveLength(REPLY_TO_TEXT_MAX)
282
+ expect(out.replyToText?.endsWith('…')).toBe(true)
283
+ })
284
+
285
+ it('yields undefined (not empty string) for an unrenderable block tree, so the buffer still runs', () => {
286
+ // A thinking-only / media-stripped card renders to nothing. It must fall
287
+ // THROUGH to the buffer rather than pinning the antecedent to ''.
288
+ const ctx = makeCtx({
289
+ reply_to_message: { message_id: 5, rich_message: { blocks: [{ type: 'thinking' }] } },
290
+ })
291
+ const live = buildReplyForwardContext({
292
+ ctx,
293
+ coalescedForwardOrigins: undefined,
294
+ replyToTextMax: REPLY_TO_TEXT_MAX,
295
+ })
296
+ expect(live.replyToText).toBeUndefined()
297
+ expect(live.replyToTextEscaped).toBeUndefined()
298
+
299
+ const out = resolveReplyToFromBuffer({
300
+ replyToMessageId: live.replyToMessageId,
301
+ replyToText: live.replyToText,
302
+ replyToTextEscaped: live.replyToTextEscaped,
303
+ historyEnabled: true,
304
+ replyToTextMax: REPLY_TO_TEXT_MAX,
305
+ lookup: () => ({ role: 'system', text: 'stored card body', kind: 'activity-summary' }),
306
+ })
307
+ expect(out.replyToText).toBe('stored card body')
308
+ expect(out.replyToKind).toBe('activity-summary')
309
+ })
310
+
311
+ it('prefers a native partial quote over the rich parent body', () => {
312
+ const ctx = makeCtx({
313
+ reply_to_message: richParent(7),
314
+ quote: { text: 'Opus 41%', position: 5, is_manual: true },
315
+ })
316
+ const out = buildReplyForwardContext({
317
+ ctx,
318
+ coalescedForwardOrigins: undefined,
319
+ replyToTextMax: REPLY_TO_TEXT_MAX,
320
+ })
321
+ expect(out.replyToText).toBe('Opus 41%')
322
+ })
323
+
324
+ it('THE BUFFER-ONLY KILLER: the live rich body wins over a DIFFERENT stored row', () => {
325
+ // A buffer-only implementation resolves this reply from the stored row and
326
+ // returns the STALE text — which is the point: this is the one test in the
327
+ // file that a recording-side-only fix cannot pass. The lookup DOES run
328
+ // (role/kind have no live source, see the next test) but it must not be
329
+ // allowed to win the text, so the stub deliberately returns a different
330
+ // body from the one on the wire.
331
+ const ctx = makeCtx({ reply_to_message: richParent(9925) })
332
+ const live = buildReplyForwardContext({
333
+ ctx,
334
+ coalescedForwardOrigins: undefined,
335
+ replyToTextMax: REPLY_TO_TEXT_MAX,
336
+ })
337
+
338
+ let lookupCalls = 0
339
+ const out = resolveReplyToFromBuffer({
340
+ replyToMessageId: live.replyToMessageId,
341
+ replyToText: live.replyToText,
342
+ replyToTextEscaped: live.replyToTextEscaped,
343
+ historyEnabled: true,
344
+ replyToTextMax: REPLY_TO_TEXT_MAX,
345
+ lookup: () => {
346
+ lookupCalls++
347
+ return { role: 'system', text: 'STALE BUFFER TEXT', kind: 'usage-card' }
348
+ },
349
+ })
350
+
351
+ expect(out.replyToText).toBe(RENDERED)
352
+ expect(out.replyToTextEscaped).toBe(RENDERED)
353
+ expect(out.replyToText).not.toContain('STALE BUFFER TEXT')
354
+ // The row was consulted — for role/kind only, never for the body.
355
+ expect(lookupCalls).toBe(1)
356
+ })
357
+
358
+ it('a live-resolved rich body still carries reply_to_role AND reply_to_kind through the envelope', () => {
359
+ // The regression the `liveTextEmpty` gate introduced: an operator FULL-
360
+ // replies (no partial quote) to a live activity card whose row IS in
361
+ // history.db. The body now resolves live off `rich_message` — and if that
362
+ // short-circuits the lookup, the envelope loses `reply_to_role="system"`
363
+ // and `reply_to_kind="activity-summary"` entirely, killing the #4571 kind
364
+ // lane for exactly the case it was built for.
365
+ const ctx = makeCtx({ reply_to_message: richParent(9925) })
366
+ const live = buildReplyForwardContext({
367
+ ctx,
368
+ coalescedForwardOrigins: undefined,
369
+ replyToTextMax: REPLY_TO_TEXT_MAX,
370
+ })
371
+ const resolved = resolveReplyToFromBuffer({
372
+ replyToMessageId: live.replyToMessageId,
373
+ replyToText: live.replyToText,
374
+ replyToTextEscaped: live.replyToTextEscaped,
375
+ historyEnabled: true,
376
+ replyToTextMax: REPLY_TO_TEXT_MAX,
377
+ // The row IS recorded — same card, stored body.
378
+ lookup: () => ({ role: 'system', text: RENDERED, kind: 'activity-summary' }),
379
+ })
380
+ expect(resolved.replyToRole).toBe('system')
381
+ expect(resolved.replyToKind).toBe('activity-summary')
382
+
383
+ const msg = buildInboundEnvelope(
384
+ makeEnvelopeParams({
385
+ ctx,
386
+ replyToMessageId: live.replyToMessageId,
387
+ replyToTextEscaped: resolved.replyToTextEscaped,
388
+ replyToRole: resolved.replyToRole,
389
+ replyToKind: resolved.replyToKind,
390
+ }),
391
+ )
392
+ expect(msg.meta?.reply_to_text).toBe(RENDERED)
393
+ expect(msg.meta?.reply_to_role).toBe('system')
394
+ expect(msg.meta?.reply_to_kind).toBe('activity-summary')
395
+ })
396
+
397
+ it('carries the live rich body through to the inbound envelope', () => {
398
+ const ctx = makeCtx({ reply_to_message: richParent(9925) })
399
+ const live = buildReplyForwardContext({
400
+ ctx,
401
+ coalescedForwardOrigins: undefined,
402
+ replyToTextMax: REPLY_TO_TEXT_MAX,
403
+ })
404
+ const msg = buildInboundEnvelope(
405
+ makeEnvelopeParams({
406
+ ctx,
407
+ replyToMessageId: live.replyToMessageId,
408
+ replyToTextEscaped: live.replyToTextEscaped,
409
+ }),
410
+ )
411
+ expect(msg.meta?.reply_to_message_id).toBe('9925')
412
+ expect(msg.meta?.reply_to_text).toBe(RENDERED)
413
+ })
414
+ })
415
+
211
416
  function makeEnvelopeParams(overrides: Partial<EnvelopeBuildParams>): EnvelopeBuildParams {
212
417
  return {
213
418
  ctx: makeCtx({}),
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * system-message-observer — the card-lane recorder's pure behaviour (#4571).
3
3
  *
4
- * The observer hangs off gateway.ts's single `robustApiCall` chokepoint and
4
+ * The observer hangs off the grammy API transformer seam, installed at bot
5
+ * construction (#4599 moved it there from gateway.ts's `robustApiCall`, which
6
+ * the `ctx.replyWithRichMessage` slash-command path bypassed entirely), and
5
7
  * turns every card the gateway posts (activity summary, status pin, approval /
6
8
  * boot / issues cards, progress lines) into ONE resolvable history row. The
7
9
  * two properties that make it safe to run on the hot send path are asserted
@@ -12,12 +14,20 @@
12
14
  * 2. an id that turns out to belong to a real reply / inbound is demoted to
13
15
  * `foreign` and never written to again.
14
16
  *
17
+ * Both install the observer in their OWN harness bot, so a third block at the
18
+ * bottom of this file pins the single PRODUCTION install line in
19
+ * `initGatewayBot()` — see its docblock.
20
+ *
15
21
  * The end-to-end proof (card posted → id resolvable → a reply pointing at it
16
22
  * is understood) needs a real bun:sqlite history.db and lives in
17
23
  * card-history-lane.test.ts (bun).
18
24
  */
19
25
 
20
26
  import { describe, it, expect } from 'vitest'
27
+ import { readFileSync } from 'node:fs'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { dirname, resolve } from 'node:path'
30
+ import ts from 'typescript'
21
31
  import {
22
32
  makeSystemMessageObserver,
23
33
  normalizeSendVerb,
@@ -223,3 +233,76 @@ describe('makeSystemMessageObserver', () => {
223
233
  expect(store.rows.get(1)?.text).toBe('card 1 edited')
224
234
  })
225
235
  })
236
+
237
+ /**
238
+ * Boot-wiring pin (#4599).
239
+ *
240
+ * Every behaviour test above installs the observer in its OWN harness, so
241
+ * deleting the single production install line in `initGatewayBot()` leaves all
242
+ * of them green while ALL card recording silently vanishes — worse than
243
+ * pre-#4571, because the old `robustApiCall` hook is gone and the empty-body
244
+ * alarm now lives INSIDE the observer, so nothing would fire either. Grammy's
245
+ * installed transformers are anonymous fns with nothing to grip at runtime, so
246
+ * this is a source-level AST assertion on the boot path — the same approach
247
+ * `format-guard-pins.test.ts` uses for `installRichMarkdownGuard`.
248
+ */
249
+ const __dirname = dirname(fileURLToPath(import.meta.url))
250
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
251
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
252
+ const gatewaySource = ts.createSourceFile(
253
+ GATEWAY_PATH,
254
+ GATEWAY_SRC,
255
+ ts.ScriptTarget.Latest,
256
+ true,
257
+ ts.ScriptKind.TS,
258
+ )
259
+
260
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
261
+ for (const s of gatewaySource.statements) {
262
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
263
+ }
264
+ return undefined
265
+ }
266
+
267
+ function countCallsTo(root: ts.Node, name: string): number {
268
+ let count = 0
269
+ const visit = (node: ts.Node): void => {
270
+ if (
271
+ ts.isCallExpression(node) &&
272
+ ts.isIdentifier(node.expression) &&
273
+ node.expression.text === name
274
+ ) {
275
+ count++
276
+ // Installed on the constructed bot instance, with the real observer.
277
+ expect(node.arguments[0]?.getText(gatewaySource)).toBe('bot')
278
+ expect(node.arguments[1]?.getText(gatewaySource)).toBe('observeSentMessage')
279
+ }
280
+ ts.forEachChild(node, visit)
281
+ }
282
+ visit(root)
283
+ return count
284
+ }
285
+
286
+ describe('boot wiring: installSystemMessageObserver is installed on the production Bot', () => {
287
+ it('imports installSystemMessageObserver from ../shared/bot-runtime.js', () => {
288
+ // The import must exist for the boot call to resolve; a refactor that drops
289
+ // the import would break the seam.
290
+ expect(GATEWAY_SRC).toMatch(
291
+ /import\s*\{[^}]*\binstallSystemMessageObserver\b[^}]*\}\s*from\s*'\.\.\/shared\/bot-runtime\.js'/,
292
+ )
293
+ })
294
+
295
+ it('calls installSystemMessageObserver(bot, observeSentMessage) exactly once inside initGatewayBot()', () => {
296
+ const fn = findFunction('initGatewayBot')
297
+ expect(fn?.body).toBeDefined()
298
+ expect(countCallsTo(fn!.body!, 'installSystemMessageObserver')).toBe(1)
299
+ })
300
+
301
+ it('builds the production observer from the real history writers', () => {
302
+ // The install is conditional on `observeSentMessage`; pin what fills it, or
303
+ // the line above could survive against a permanently-undefined observer.
304
+ expect(GATEWAY_SRC).toMatch(
305
+ /const observeSentMessage = isGatewayMain && HISTORY_ENABLED\s*\n?\s*\?\s*makeSystemMessageObserver\(/,
306
+ )
307
+ })
308
+ })