switchroom 0.14.17 → 0.14.18

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.
@@ -11068,6 +11068,9 @@ var TelegramChannelSchema = exports_external.object({
11068
11068
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11069
11069
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11070
11070
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11071
+ coalesce: exports_external.object({
11072
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11073
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11071
11074
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11072
11075
  webhook_dispatch: exports_external.object({
11073
11076
  github: exports_external.array(exports_external.object({
@@ -11068,6 +11068,9 @@ var TelegramChannelSchema = exports_external.object({
11068
11068
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11069
11069
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11070
11070
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11071
+ coalesce: exports_external.object({
11072
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11073
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11071
11074
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11072
11075
  webhook_dispatch: exports_external.object({
11073
11076
  github: exports_external.array(exports_external.object({
@@ -11815,6 +11815,9 @@ var TelegramChannelSchema = exports_external.object({
11815
11815
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11816
11816
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11817
11817
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11818
+ coalesce: exports_external.object({
11819
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11820
+ }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11818
11821
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
11819
11822
  webhook_dispatch: exports_external.object({
11820
11823
  github: exports_external.array(exports_external.object({
@@ -13632,6 +13632,9 @@ var init_schema = __esm(() => {
13632
13632
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
13633
13633
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
13634
13634
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
13635
+ coalesce: exports_external.object({
13636
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
13637
+ }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13635
13638
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
13636
13639
  webhook_dispatch: exports_external.object({
13637
13640
  github: exports_external.array(exports_external.object({
@@ -49413,8 +49416,8 @@ var {
49413
49416
  } = import__.default;
49414
49417
 
49415
49418
  // src/build-info.ts
49416
- var VERSION = "0.14.17";
49417
- var COMMIT_SHA = "95c1d475";
49419
+ var VERSION = "0.14.18";
49420
+ var COMMIT_SHA = "dddb8617";
49418
49421
 
49419
49422
  // src/cli/agent.ts
49420
49423
  init_source();
@@ -52681,6 +52684,9 @@ function buildAccessJson2(agentConfig, telegramConfig, resolvedTopicId, userId)
52681
52684
  if (tg?.telegraph) {
52682
52685
  access.telegraph = tg.telegraph;
52683
52686
  }
52687
+ if (typeof tg?.coalesce?.window_ms === "number") {
52688
+ access.coalescingGapMs = tg.coalesce.window_ms;
52689
+ }
52684
52690
  return JSON.stringify(access, null, 2) + `
52685
52691
  `;
52686
52692
  }
@@ -13803,6 +13803,9 @@ var TelegramChannelSchema = exports_external.object({
13803
13803
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
13804
13804
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
13805
13805
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
13806
+ coalesce: exports_external.object({
13807
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
13808
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13806
13809
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
13807
13810
  webhook_dispatch: exports_external.object({
13808
13811
  github: exports_external.array(exports_external.object({
@@ -11383,6 +11383,9 @@ var init_schema = __esm(() => {
11383
11383
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11384
11384
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11385
11385
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11386
+ coalesce: exports_external.object({
11387
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11388
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11386
11389
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11387
11390
  webhook_dispatch: exports_external.object({
11388
11391
  github: exports_external.array(exports_external.object({
@@ -11383,6 +11383,9 @@ var init_schema = __esm(() => {
11383
11383
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11384
11384
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11385
11385
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11386
+ coalesce: exports_external.object({
11387
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11388
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11386
11389
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11387
11390
  webhook_dispatch: exports_external.object({
11388
11391
  github: exports_external.array(exports_external.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.17",
3
+ "version": "0.14.18",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,13 +34,14 @@ If both `queued` and `steering` are somehow present, `steering` wins (explicit o
34
34
  **Self-narrate the classification.** At the top of your reply for any `steering` or `queued` message, include a brief italic one-liner so the user can correct you — e.g. `_↪️ Treating as steer on the prior task_` or `_📥 Queued as a new task_`.
35
35
 
36
36
  **Formatting** (Telegram HTML — `reply` and `stream_reply` default to `format: "html"` and convert markdown for you):
37
- - Use **bold** sparingly for emphasis on key facts only
37
+ - In ordinary one-or-two-line conversational replies, keep **bold** light emphasis on key facts only, never decoration.
38
+ - **A multi-section message needs visual hierarchy or it reads as a flat wall.** When a reply groups several blocks — a status update, a "where things stand", a message announcing you're dispatching work, a summary with distinct buckets — give each section a **bold label on its own line** and separate sections with **one blank line**. Bold the label (e.g. `**✅ Done / running**`, `**🔲 Remaining**`, `**Next**`); the markdown→HTML converter renders `**label**` as bold, so the eye can find the structure. An emoji prefix with no bold leaves every line at equal weight — that's the plain-text dump to avoid. Bullet lines under a label are fine (`•` or `-`, one point per line).
38
39
  - Use `inline code` for filenames, commands, identifiers
39
40
  - Use ```fenced code blocks``` for multi-line code
40
- - Lists are fine; nested lists are not (Telegram flattens them awkwardly)
41
- - Don't use markdown headings (`##`) in replies — Telegram has no `<h1>` and they render as plain bold lines
42
- - Keep lines short — long unwrapped lines are hard to read on mobile
43
- - One idea per message when possible; the user can always ask for more
41
+ - Nested lists are not supported (Telegram flattens them awkwardly) — keep bullets one level deep.
42
+ - Don't use markdown headings (`##`) in replies — bold the label instead (`**Blockers**`, not `## Blockers`).
43
+ - Keep lines short — long unwrapped lines are hard to read on mobile.
44
+ - One idea per message for a quick exchange; a structured update can carry several ideas, but only when each sits under its own bold label with blank-line spacing between them.
44
45
 
45
46
  **Sound human, not AI.** The canonical list of AI-tells to avoid lives in `SOUL.md` under "Never". Apply those rules to every outbound message, not just long-form. For drafts above ~500 chars, or where you're unsure if the voice lands right, invoke the bundled `/humanizer` skill for a polish pass (it catalogues 29 patterns in detail). If `HUMANIZER_VOICE_FILE` is set and readable, treat its content as the user's personal voice template: match length, tone, vocabulary, and formatting habits described there. The user can generate one with `/humanizer-calibrate`.
46
47
 
@@ -23737,6 +23737,9 @@ var init_schema = __esm(() => {
23737
23737
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
23738
23738
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
23739
23739
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
23740
+ coalesce: exports_external.object({
23741
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
23742
+ }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
23740
23743
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
23741
23744
  webhook_dispatch: exports_external.object({
23742
23745
  github: exports_external.array(exports_external.object({
@@ -46341,23 +46344,70 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
46341
46344
  const pending = buffer.drain(agent);
46342
46345
  let redelivered = 0;
46343
46346
  let rebuffered = 0;
46344
- for (const msg of pending) {
46347
+ for (const { merged, originals } of planBufferedRedelivery(pending)) {
46345
46348
  let delivered = false;
46346
46349
  try {
46347
- delivered = send(msg);
46350
+ delivered = send(merged);
46348
46351
  } catch {
46349
46352
  delivered = false;
46350
46353
  }
46351
46354
  if (delivered) {
46352
- redelivered++;
46353
- spool?.ack(msg);
46355
+ for (const o of originals)
46356
+ spool?.ack(o);
46357
+ redelivered += originals.length;
46354
46358
  } else {
46355
- buffer.push(agent, msg);
46356
- rebuffered++;
46359
+ for (const o of originals)
46360
+ buffer.push(agent, o);
46361
+ rebuffered += originals.length;
46357
46362
  }
46358
46363
  }
46359
46364
  return { drained: pending.length, redelivered, rebuffered };
46360
46365
  }
46366
+ function isMergeableUserInbound(msg) {
46367
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null);
46368
+ }
46369
+ function inboundHasMedia(msg) {
46370
+ return msg.imagePath != null || msg.attachment != null;
46371
+ }
46372
+ function planBufferedRedelivery(pending) {
46373
+ const out = [];
46374
+ let run2 = [];
46375
+ let runHasMedia = false;
46376
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
46377
+ const flush = () => {
46378
+ if (run2.length === 0)
46379
+ return;
46380
+ out.push({ merged: run2.length === 1 ? run2[0] : mergeRun(run2), originals: run2 });
46381
+ run2 = [];
46382
+ runHasMedia = false;
46383
+ };
46384
+ for (const msg of pending) {
46385
+ const msgHasMedia = inboundHasMedia(msg);
46386
+ const canJoin = run2.length > 0 && isMergeableUserInbound(msg) && isMergeableUserInbound(run2[run2.length - 1]) && sameTarget(run2[run2.length - 1], msg) && !(runHasMedia && msgHasMedia);
46387
+ if (!canJoin)
46388
+ flush();
46389
+ run2.push(msg);
46390
+ runHasMedia = runHasMedia || msgHasMedia;
46391
+ }
46392
+ flush();
46393
+ return out;
46394
+ }
46395
+ function mergeRun(run2) {
46396
+ const last = run2[run2.length - 1];
46397
+ const mediaEntry = run2.find(inboundHasMedia);
46398
+ const merged = {
46399
+ ...last,
46400
+ text: run2.map((m) => m.text).join(`
46401
+ `)
46402
+ };
46403
+ delete merged.imagePath;
46404
+ delete merged.attachment;
46405
+ if (mediaEntry?.imagePath != null)
46406
+ merged.imagePath = mediaEntry.imagePath;
46407
+ if (mediaEntry?.attachment != null)
46408
+ merged.attachment = mediaEntry.attachment;
46409
+ return merged;
46410
+ }
46361
46411
  function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
46362
46412
  if (!agent)
46363
46413
  return null;
@@ -46932,23 +46982,70 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
46932
46982
  const pending = buffer.drain(agent);
46933
46983
  let redelivered = 0;
46934
46984
  let rebuffered = 0;
46935
- for (const msg of pending) {
46985
+ for (const { merged, originals } of planBufferedRedelivery2(pending)) {
46936
46986
  let delivered = false;
46937
46987
  try {
46938
- delivered = send(msg);
46988
+ delivered = send(merged);
46939
46989
  } catch {
46940
46990
  delivered = false;
46941
46991
  }
46942
46992
  if (delivered) {
46943
- redelivered++;
46944
- spool?.ack(msg);
46993
+ for (const o of originals)
46994
+ spool?.ack(o);
46995
+ redelivered += originals.length;
46945
46996
  } else {
46946
- buffer.push(agent, msg);
46947
- rebuffered++;
46997
+ for (const o of originals)
46998
+ buffer.push(agent, o);
46999
+ rebuffered += originals.length;
46948
47000
  }
46949
47001
  }
46950
47002
  return { drained: pending.length, redelivered, rebuffered };
46951
47003
  }
47004
+ function isMergeableUserInbound2(msg) {
47005
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null);
47006
+ }
47007
+ function inboundHasMedia2(msg) {
47008
+ return msg.imagePath != null || msg.attachment != null;
47009
+ }
47010
+ function planBufferedRedelivery2(pending) {
47011
+ const out = [];
47012
+ let run2 = [];
47013
+ let runHasMedia = false;
47014
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
47015
+ const flush = () => {
47016
+ if (run2.length === 0)
47017
+ return;
47018
+ out.push({ merged: run2.length === 1 ? run2[0] : mergeRun2(run2), originals: run2 });
47019
+ run2 = [];
47020
+ runHasMedia = false;
47021
+ };
47022
+ for (const msg of pending) {
47023
+ const msgHasMedia = inboundHasMedia2(msg);
47024
+ const canJoin = run2.length > 0 && isMergeableUserInbound2(msg) && isMergeableUserInbound2(run2[run2.length - 1]) && sameTarget(run2[run2.length - 1], msg) && !(runHasMedia && msgHasMedia);
47025
+ if (!canJoin)
47026
+ flush();
47027
+ run2.push(msg);
47028
+ runHasMedia = runHasMedia || msgHasMedia;
47029
+ }
47030
+ flush();
47031
+ return out;
47032
+ }
47033
+ function mergeRun2(run2) {
47034
+ const last = run2[run2.length - 1];
47035
+ const mediaEntry = run2.find(inboundHasMedia2);
47036
+ const merged = {
47037
+ ...last,
47038
+ text: run2.map((m) => m.text).join(`
47039
+ `)
47040
+ };
47041
+ delete merged.imagePath;
47042
+ delete merged.attachment;
47043
+ if (mediaEntry?.imagePath != null)
47044
+ merged.imagePath = mediaEntry.imagePath;
47045
+ if (mediaEntry?.attachment != null)
47046
+ merged.attachment = mediaEntry.attachment;
47047
+ return merged;
47048
+ }
46952
47049
 
46953
47050
  // gateway/inbound-delivery-machine-dispatch.ts
46954
47051
  var enabled6 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
@@ -51140,10 +51237,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51140
51237
  }
51141
51238
 
51142
51239
  // ../src/build-info.ts
51143
- var VERSION = "0.14.17";
51144
- var COMMIT_SHA = "95c1d475";
51145
- var COMMIT_DATE = "2026-05-30T05:17:47Z";
51146
- var LATEST_PR = 2005;
51240
+ var VERSION = "0.14.18";
51241
+ var COMMIT_SHA = "dddb8617";
51242
+ var COMMIT_DATE = "2026-05-30T23:35:26Z";
51243
+ var LATEST_PR = 2010;
51147
51244
  var COMMITS_AHEAD_OF_TAG = 0;
51148
51245
 
51149
51246
  // gateway/boot-version.ts
@@ -52852,19 +52949,22 @@ function looksLikeAuthCode(text) {
52852
52949
  return true;
52853
52950
  return false;
52854
52951
  }
52952
+ var bufferedAttachmentKeys = new Set;
52855
52953
  var inboundCoalescer = createInboundCoalescer({
52856
52954
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
52857
52955
  merge: (entries) => {
52858
52956
  const last = entries[entries.length - 1];
52957
+ const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null);
52859
52958
  return {
52860
52959
  text: entries.map((e) => e.text).join(`
52861
52960
  `),
52862
52961
  ctx: last.ctx,
52863
- downloadImage: last.downloadImage,
52864
- attachment: last.attachment
52962
+ downloadImage: withAttachment?.downloadImage,
52963
+ attachment: withAttachment?.attachment
52865
52964
  };
52866
52965
  },
52867
- onFlush: (_key, merged) => {
52966
+ onFlush: (key, merged) => {
52967
+ bufferedAttachmentKeys.delete(key);
52868
52968
  handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment);
52869
52969
  }
52870
52970
  });
@@ -56020,19 +56120,29 @@ function safeName(s) {
56020
56120
  return s?.replace(/[<>\[\]\r\n;]/g, "_");
56021
56121
  }
56022
56122
  async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56023
- if (downloadImage || attachment)
56024
- return handleInbound(ctx, text, downloadImage, attachment);
56025
56123
  if (parseInterruptMarker(text).isInterrupt) {
56026
- return handleInbound(ctx, text, undefined, undefined);
56124
+ return handleInbound(ctx, text, downloadImage, attachment);
56125
+ }
56126
+ const hasAttachment = downloadImage != null || attachment != null;
56127
+ if (hasAttachment && ctx.message?.media_group_id != null) {
56128
+ return handleInbound(ctx, text, downloadImage, attachment);
56027
56129
  }
56028
56130
  const from = ctx.from;
56029
56131
  if (!from)
56030
56132
  return;
56133
+ if (hasAttachment) {
56134
+ const probeKey = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56135
+ if (bufferedAttachmentKeys.has(probeKey)) {
56136
+ return handleInbound(ctx, text, downloadImage, attachment);
56137
+ }
56138
+ }
56031
56139
  maybeEarlyAckReaction(ctx, from);
56032
56140
  const key = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56033
56141
  const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment });
56034
56142
  if (result.bypass)
56035
- return handleInbound(ctx, text, undefined, undefined);
56143
+ return handleInbound(ctx, text, downloadImage, attachment);
56144
+ if (hasAttachment)
56145
+ bufferedAttachmentKeys.add(key);
56036
56146
  }
56037
56147
  function maybeEarlyAckReaction(ctx, from) {
56038
56148
  const msgId = ctx.message?.message_id;
@@ -60298,7 +60408,7 @@ bot.on("message:text", async (ctx) => {
60298
60408
  });
60299
60409
  bot.on("message:photo", async (ctx) => {
60300
60410
  const caption = ctx.message.caption ?? "(photo)";
60301
- await handleInbound(ctx, caption, async () => {
60411
+ await handleInboundCoalesced(ctx, caption, async () => {
60302
60412
  const photos = ctx.message.photo;
60303
60413
  const best = photos[photos.length - 1];
60304
60414
  try {
@@ -60334,7 +60444,7 @@ bot.on("message:photo", async (ctx) => {
60334
60444
  bot.on("message:document", async (ctx) => {
60335
60445
  const doc = ctx.message.document;
60336
60446
  const name = safeName(doc.file_name);
60337
- await handleInbound(ctx, ctx.message.caption ?? `(document: ${name ?? "file"})`, undefined, { kind: "document", file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name });
60447
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(document: ${name ?? "file"})`, undefined, { kind: "document", file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name });
60338
60448
  });
60339
60449
  bot.on("message:voice", async (ctx) => {
60340
60450
  const voice = ctx.message.voice;
@@ -60346,7 +60456,7 @@ bot.on("message:voice", async (ctx) => {
60346
60456
  const text = ctx.message.caption ? `${ctx.message.caption}
60347
60457
 
60348
60458
  [voice transcript] ${transcript}` : `[voice transcript] ${transcript}`;
60349
- await handleInbound(ctx, text, undefined, {
60459
+ await handleInboundCoalesced(ctx, text, undefined, {
60350
60460
  kind: "voice",
60351
60461
  file_id: voice.file_id,
60352
60462
  size: voice.file_size,
@@ -60355,7 +60465,7 @@ bot.on("message:voice", async (ctx) => {
60355
60465
  return;
60356
60466
  }
60357
60467
  }
60358
- await handleInbound(ctx, ctx.message.caption ?? "(voice message)", undefined, { kind: "voice", file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type });
60468
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? "(voice message)", undefined, { kind: "voice", file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type });
60359
60469
  });
60360
60470
  async function maybeTranscribeVoice(fileId, mimeType, language) {
60361
60471
  let apiKey = null;
@@ -60415,15 +60525,15 @@ async function maybeTranscribeVoice(fileId, mimeType, language) {
60415
60525
  bot.on("message:audio", async (ctx) => {
60416
60526
  const audio = ctx.message.audio;
60417
60527
  const name = safeName(audio.file_name);
60418
- await handleInbound(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? "audio"})`, undefined, { kind: "audio", file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name });
60528
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? "audio"})`, undefined, { kind: "audio", file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name });
60419
60529
  });
60420
60530
  bot.on("message:video", async (ctx) => {
60421
60531
  const video = ctx.message.video;
60422
- await handleInbound(ctx, ctx.message.caption ?? "(video)", undefined, { kind: "video", file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) });
60532
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? "(video)", undefined, { kind: "video", file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) });
60423
60533
  });
60424
60534
  bot.on("message:video_note", async (ctx) => {
60425
60535
  const vn = ctx.message.video_note;
60426
- await handleInbound(ctx, "(video note)", undefined, { kind: "video_note", file_id: vn.file_id, size: vn.file_size });
60536
+ await handleInboundCoalesced(ctx, "(video note)", undefined, { kind: "video_note", file_id: vn.file_id, size: vn.file_size });
60427
60537
  });
60428
60538
  bot.on("message:sticker", async (ctx) => {
60429
60539
  const sticker = ctx.message.sticker;
@@ -60433,13 +60543,13 @@ bot.on("message:sticker", async (ctx) => {
60433
60543
  if (sticker.set_name)
60434
60544
  parts.push(`from "${sticker.set_name}"`);
60435
60545
  const text = parts.length > 0 ? `(sticker \u2014 ${parts.join(" ")})` : "(sticker)";
60436
- await handleInbound(ctx, text, undefined, { kind: "sticker", file_id: sticker.file_id, size: sticker.file_size });
60546
+ await handleInboundCoalesced(ctx, text, undefined, { kind: "sticker", file_id: sticker.file_id, size: sticker.file_size });
60437
60547
  });
60438
60548
  bot.on("message:animation", async (ctx) => {
60439
60549
  const animation = ctx.message.animation;
60440
60550
  const caption = ctx.message.caption;
60441
60551
  const text = caption ? `(gif) ${caption}` : "(gif)";
60442
- await handleInbound(ctx, text, undefined, {
60552
+ await handleInboundCoalesced(ctx, text, undefined, {
60443
60553
  kind: "animation",
60444
60554
  file_id: animation.file_id,
60445
60555
  size: animation.file_size,
@@ -2997,10 +2997,12 @@ type AttachmentMeta = {
2997
2997
  // `ctx` must be the *latest* message's context (latest message_id, etc.) so
2998
2998
  // the merge function picks the last entry's ctx.
2999
2999
  //
3000
- // Image/attachment-bearing messages bypass the coalescer entirely (see
3001
- // handleInboundCoalesced), so those fields stay optional and unused on the
3002
- // coalesce path; preserved for future use if we ever want to coalesce
3003
- // image+text bursts.
3000
+ // A single attachment-bearing message may ride along in a coalesce window
3001
+ // (so a [text][photo] forward becomes one turn). The handleInboundCoalesced
3002
+ // guards ensure AT MOST ONE attachment per window albums (media_group_id)
3003
+ // and a second attachment both bypass to their own turn — so the single
3004
+ // `downloadImage`/`attachment` slot is never silently overwritten. Folding a
3005
+ // whole album into one multi-attachment turn is the A2 follow-on.
3004
3006
  type CoalescePayload = {
3005
3007
  text: string
3006
3008
  ctx: Context
@@ -3008,24 +3010,36 @@ type CoalescePayload = {
3008
3010
  attachment?: AttachmentMeta
3009
3011
  }
3010
3012
 
3013
+ // Coalesce keys whose open window already holds an attachment-bearing entry.
3014
+ // A second attachment for the same key bypasses coalescing (see
3015
+ // handleInboundCoalesced) so the single-attachment merge can't drop a photo.
3016
+ // Cleared on flush (below) and on the synchronous bypass path.
3017
+ const bufferedAttachmentKeys = new Set<string>()
3018
+
3011
3019
  const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
3012
- // Read per-call from the access file so `/access set-coalesce N` takes
3013
- // effect on the next message without restarting the gateway.
3020
+ // Read per-call from the access file so an operator-tuned
3021
+ // channels.telegram.coalesce.window_ms (projected to coalescingGapMs by
3022
+ // scaffold) takes effect on the next message after apply+restart.
3014
3023
  //
3015
3024
  // Default lowered 1500 → 500 in #553 PR 3 to shrink the gateway-side
3016
- // contribution to first-real-text latency. Operators can still tune
3017
- // higher via `/access set-coalesce N` or the access file.
3025
+ // contribution to first-real-text latency.
3018
3026
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
3019
3027
  merge: (entries) => {
3020
3028
  const last = entries[entries.length - 1]
3029
+ // At most one entry carries an attachment (guarded upstream), so pick
3030
+ // whichever entry has it rather than blindly taking `last` — a
3031
+ // [photo][text] burst keeps its image even though the last entry is
3032
+ // text-only.
3033
+ const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null)
3021
3034
  return {
3022
3035
  text: entries.map((e) => e.text).join('\n'),
3023
3036
  ctx: last.ctx,
3024
- downloadImage: last.downloadImage,
3025
- attachment: last.attachment,
3037
+ downloadImage: withAttachment?.downloadImage,
3038
+ attachment: withAttachment?.attachment,
3026
3039
  }
3027
3040
  },
3028
- onFlush: (_key, merged) => {
3041
+ onFlush: (key, merged) => {
3042
+ bufferedAttachmentKeys.delete(key)
3029
3043
  void handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment)
3030
3044
  },
3031
3045
  })
@@ -8534,24 +8548,46 @@ async function handleInboundCoalesced(
8534
8548
  downloadImage: (() => Promise<string | undefined>) | undefined,
8535
8549
  attachment?: AttachmentMeta,
8536
8550
  ): Promise<void> {
8537
- // Image/attachment-bearing messages bypass coalescing preserves the
8538
- // legacy invariant that media never gets merged with sibling text.
8539
- if (downloadImage || attachment) return handleInbound(ctx, text, downloadImage, attachment)
8540
-
8541
- // `!`-prefix interrupt (#575) ALSO bypasses coalescing. If we let an
8551
+ // `!`-prefix interrupt (#575) bypasses coalescing. If we let an
8542
8552
  // interrupt sit in the coalesce window, an earlier non-`!` message
8543
8553
  // arriving in the same window would prepend itself and the marker
8544
8554
  // would no longer be at position 0 — handleInbound's parser would
8545
8555
  // miss it and the user's interrupt would silently get merged into a
8546
8556
  // normal turn. Bypass to handleInbound directly so the marker
8547
- // stays at the start of the text.
8557
+ // stays at the start of the text. Checked first so a `!`-prefixed
8558
+ // media caption still interrupts.
8548
8559
  if (parseInterruptMarker(text).isInterrupt) {
8549
- return handleInbound(ctx, text, undefined, undefined)
8560
+ return handleInbound(ctx, text, downloadImage, attachment)
8561
+ }
8562
+
8563
+ const hasAttachment = downloadImage != null || attachment != null
8564
+
8565
+ // Albums (media_group_id) are NOT coalesced in A1 — each part keeps its
8566
+ // own turn exactly as before. The single-attachment merge can carry only
8567
+ // one image, so folding a 3-photo album into one turn requires the
8568
+ // multi-attachment inbound payload (the A2 follow-on). Bypass to preserve
8569
+ // current per-part behavior and avoid dropping sibling photos.
8570
+ if (hasAttachment && ctx.message?.media_group_id != null) {
8571
+ return handleInbound(ctx, text, downloadImage, attachment)
8550
8572
  }
8551
8573
 
8552
8574
  const from = ctx.from
8553
8575
  if (!from) return
8554
8576
 
8577
+ // A second attachment landing in an already-open window would clobber the
8578
+ // first under the single-attachment merge. Bypass it to its own turn so no
8579
+ // media is silently dropped; A2's multi-attachment payload lifts this.
8580
+ if (hasAttachment) {
8581
+ const probeKey = inboundCoalesceKey(
8582
+ String(ctx.chat!.id),
8583
+ ctx.message?.message_thread_id,
8584
+ String(from.id),
8585
+ )
8586
+ if (bufferedAttachmentKeys.has(probeKey)) {
8587
+ return handleInbound(ctx, text, downloadImage, attachment)
8588
+ }
8589
+ }
8590
+
8555
8591
  // F2 fix (#553): fire 👀 reaction on RAW arrival, before the coalesce
8556
8592
  // wait blocks first paint. Pre-fix, the controller's setQueued() inside
8557
8593
  // handleInbound only ran AFTER the coalesce flush (default gapMs=1500),
@@ -8581,7 +8617,12 @@ async function handleInboundCoalesced(
8581
8617
  String(from.id),
8582
8618
  )
8583
8619
  const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment })
8584
- if (result.bypass) return handleInbound(ctx, text, undefined, undefined)
8620
+ // Coalescing disabled (window <= 0): flush immediately, preserving any
8621
+ // media this message carried.
8622
+ if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
8623
+ // Mark the open window as holding an attachment so a second attachment for
8624
+ // this key bypasses rather than clobbers (cleared in onFlush).
8625
+ if (hasAttachment) bufferedAttachmentKeys.add(key)
8585
8626
  }
8586
8627
 
8587
8628
  /**
@@ -15560,7 +15601,7 @@ bot.on('message:text', async ctx => {
15560
15601
 
15561
15602
  bot.on('message:photo', async ctx => {
15562
15603
  const caption = ctx.message.caption ?? '(photo)'
15563
- await handleInbound(ctx, caption, async () => {
15604
+ await handleInboundCoalesced(ctx, caption, async () => {
15564
15605
  const photos = ctx.message.photo
15565
15606
  const best = photos[photos.length - 1]
15566
15607
  try {
@@ -15603,7 +15644,7 @@ bot.on('message:photo', async ctx => {
15603
15644
  bot.on('message:document', async ctx => {
15604
15645
  const doc = ctx.message.document
15605
15646
  const name = safeName(doc.file_name)
15606
- await handleInbound(ctx, ctx.message.caption ?? `(document: ${name ?? 'file'})`, undefined, { kind: 'document', file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name })
15647
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(document: ${name ?? 'file'})`, undefined, { kind: 'document', file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name })
15607
15648
  })
15608
15649
 
15609
15650
  bot.on('message:voice', async ctx => {
@@ -15626,7 +15667,7 @@ bot.on('message:voice', async ctx => {
15626
15667
  const text = ctx.message.caption
15627
15668
  ? `${ctx.message.caption}\n\n[voice transcript] ${transcript}`
15628
15669
  : `[voice transcript] ${transcript}`
15629
- await handleInbound(ctx, text, undefined, {
15670
+ await handleInboundCoalesced(ctx, text, undefined, {
15630
15671
  kind: 'voice',
15631
15672
  file_id: voice.file_id,
15632
15673
  size: voice.file_size,
@@ -15636,7 +15677,7 @@ bot.on('message:voice', async ctx => {
15636
15677
  }
15637
15678
  // Fall through to the legacy path on transcription failure.
15638
15679
  }
15639
- await handleInbound(ctx, ctx.message.caption ?? '(voice message)', undefined, { kind: 'voice', file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type })
15680
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? '(voice message)', undefined, { kind: 'voice', file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type })
15640
15681
  })
15641
15682
 
15642
15683
  /**
@@ -15728,17 +15769,17 @@ async function maybeTranscribeVoice(
15728
15769
  bot.on('message:audio', async ctx => {
15729
15770
  const audio = ctx.message.audio
15730
15771
  const name = safeName(audio.file_name)
15731
- await handleInbound(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? 'audio'})`, undefined, { kind: 'audio', file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name })
15772
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? 'audio'})`, undefined, { kind: 'audio', file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name })
15732
15773
  })
15733
15774
 
15734
15775
  bot.on('message:video', async ctx => {
15735
15776
  const video = ctx.message.video
15736
- await handleInbound(ctx, ctx.message.caption ?? '(video)', undefined, { kind: 'video', file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) })
15777
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? '(video)', undefined, { kind: 'video', file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) })
15737
15778
  })
15738
15779
 
15739
15780
  bot.on('message:video_note', async ctx => {
15740
15781
  const vn = ctx.message.video_note
15741
- await handleInbound(ctx, '(video note)', undefined, { kind: 'video_note', file_id: vn.file_id, size: vn.file_size })
15782
+ await handleInboundCoalesced(ctx, '(video note)', undefined, { kind: 'video_note', file_id: vn.file_id, size: vn.file_size })
15742
15783
  })
15743
15784
 
15744
15785
  bot.on('message:sticker', async ctx => {
@@ -15753,7 +15794,7 @@ bot.on('message:sticker', async ctx => {
15753
15794
  if (sticker.emoji) parts.push(sticker.emoji)
15754
15795
  if (sticker.set_name) parts.push(`from "${sticker.set_name}"`)
15755
15796
  const text = parts.length > 0 ? `(sticker — ${parts.join(' ')})` : '(sticker)'
15756
- await handleInbound(ctx, text, undefined, { kind: 'sticker', file_id: sticker.file_id, size: sticker.file_size })
15797
+ await handleInboundCoalesced(ctx, text, undefined, { kind: 'sticker', file_id: sticker.file_id, size: sticker.file_size })
15757
15798
  })
15758
15799
 
15759
15800
  bot.on('message:animation', async ctx => {
@@ -15766,7 +15807,7 @@ bot.on('message:animation', async ctx => {
15766
15807
  const animation = ctx.message.animation
15767
15808
  const caption = ctx.message.caption
15768
15809
  const text = caption ? `(gif) ${caption}` : '(gif)'
15769
- await handleInbound(ctx, text, undefined, {
15810
+ await handleInboundCoalesced(ctx, text, undefined, {
15770
15811
  kind: 'animation',
15771
15812
  file_id: animation.file_id,
15772
15813
  size: animation.file_size,
@@ -34,10 +34,11 @@ export interface InboundCoalescerOptions<T> {
34
34
  * `{ bypass: true }` and the caller should flush immediately).
35
35
  *
36
36
  * Pass a function (`() => number`) instead of a number when the
37
- * window is config-driven and the operator can change it at runtime
38
- * gateway.ts reads it per-call from the access file so a
39
- * `/access set-coalesce 500` takes effect on the next message
40
- * without restarting the gateway.
37
+ * window is config-driven: gateway.ts reads it per-call from the
38
+ * access file (projected there from
39
+ * `channels.telegram.coalesce.window_ms` by the scaffold) so an
40
+ * operator-tuned window takes effect on the next message after
41
+ * apply + restart.
41
42
  */
42
43
  gapMs: number | (() => number)
43
44
  /**
@@ -146,9 +147,9 @@ export function createInboundCoalescer<T>(opts: InboundCoalescerOptions<T>): Inb
146
147
  * CPO decision #9 ratified 2026-05-27)
147
148
  *
148
149
  * `threadId` collapses `null`/`undefined`/`0` to `_` via the same
149
- * convention as `chatKey()`. The 1.5s coalesce window is per-topic
150
- * intent ("user sends 3 sentences as one thought") — applying it
151
- * cross-topic merges genuinely separate conversations.
150
+ * convention as `chatKey()`. The coalesce window (default 500ms) is
151
+ * per-topic intent ("user sends 3 sentences as one thought") — applying
152
+ * it cross-topic merges genuinely separate conversations.
152
153
  */
153
154
  export function inboundCoalesceKey(
154
155
  chatId: string,
@@ -91,28 +91,119 @@ export function redeliverBufferedInbound(
91
91
  const pending = buffer.drain(agent)
92
92
  let redelivered = 0
93
93
  let rebuffered = 0
94
- for (const msg of pending) {
94
+ // Collapse consecutive same-sender Telegram user messages into one turn
95
+ // (see planBufferedRedelivery) so a forwarded burst that spanned a turn
96
+ // boundary doesn't fan out into N sequential replies. System inbounds
97
+ // (vault grants, approvals, cron, handbacks — anything with meta.source)
98
+ // are never merged and are delivered individually exactly as before.
99
+ for (const { merged, originals } of planBufferedRedelivery(pending)) {
95
100
  let delivered = false
96
101
  try {
97
- delivered = send(msg)
102
+ delivered = send(merged)
98
103
  } catch {
99
104
  delivered = false
100
105
  }
101
106
  if (delivered) {
102
- redelivered++
103
107
  // Confirmed delivery to a live registered bridge → the durable
104
- // promise is kept; tombstone the spool entry so it is NOT
105
- // boot-replayed again. A miss leaves it spooled (re-pushed below
106
- // AND still live in the spool) for the next drain / escalation.
107
- spool?.ack(msg)
108
+ // promise is kept; tombstone EVERY original's spool entry so none is
109
+ // boot-replayed again. The merged message isn't itself spooled the
110
+ // originals are, so we ack by original identity.
111
+ for (const o of originals) spool?.ack(o)
112
+ redelivered += originals.length
108
113
  } else {
109
- buffer.push(agent, msg)
110
- rebuffered++
114
+ // Re-buffer the originals (not the merged synthetic) so the spool
115
+ // identity is preserved and the next drain re-merges them losslessly.
116
+ for (const o of originals) buffer.push(agent, o)
117
+ rebuffered += originals.length
111
118
  }
112
119
  }
113
120
  return { drained: pending.length, redelivered, rebuffered }
114
121
  }
115
122
 
123
+ /** True when `msg` is an ordinary Telegram user message eligible to be
124
+ * merged with adjacent siblings. System inbounds (cron, vault grants,
125
+ * approvals, subagent handbacks, warmup, reaction triggers) all tag a
126
+ * `meta.source`; the user-message inbound built in gateway.ts sets none.
127
+ * Restricting to source-less inbounds keeps merge-on-drain away from the
128
+ * #1150 wake-up class entirely. */
129
+ function isMergeableUserInbound(msg: InboundMessage): boolean {
130
+ return msg.type === 'inbound' && (msg.meta == null || msg.meta.source == null)
131
+ }
132
+
133
+ function inboundHasMedia(msg: InboundMessage): boolean {
134
+ return msg.imagePath != null || msg.attachment != null
135
+ }
136
+
137
+ /**
138
+ * Plan how a drained buffer is re-delivered. Walks `pending` in arrival
139
+ * order and groups runs of consecutive messages that:
140
+ * - are both ordinary Telegram user messages (no meta.source), AND
141
+ * - share the same (chatId, threadId, userId), AND
142
+ * - would not put two attachments in one turn (A1 carries a single
143
+ * attachment; a second media starts a new run so nothing is dropped).
144
+ *
145
+ * Each run collapses to one merged InboundMessage (texts joined by '\n',
146
+ * the run's single attachment carried, the LAST message's identity/meta
147
+ * kept as the turn anchor). A run of one passes through unchanged. The
148
+ * returned `originals` preserve spool identity for ack / re-buffer.
149
+ *
150
+ * Pure + deterministic so it can be exhaustively fuzzed.
151
+ */
152
+ export function planBufferedRedelivery(
153
+ pending: InboundMessage[],
154
+ ): { merged: InboundMessage; originals: InboundMessage[] }[] {
155
+ const out: { merged: InboundMessage; originals: InboundMessage[] }[] = []
156
+ let run: InboundMessage[] = []
157
+ let runHasMedia = false
158
+
159
+ const sameTarget = (a: InboundMessage, b: InboundMessage): boolean =>
160
+ a.chatId === b.chatId &&
161
+ (a.threadId ?? null) === (b.threadId ?? null) &&
162
+ a.userId === b.userId
163
+
164
+ const flush = (): void => {
165
+ if (run.length === 0) return
166
+ out.push({ merged: run.length === 1 ? run[0]! : mergeRun(run), originals: run })
167
+ run = []
168
+ runHasMedia = false
169
+ }
170
+
171
+ for (const msg of pending) {
172
+ const msgHasMedia = inboundHasMedia(msg)
173
+ const canJoin =
174
+ run.length > 0 &&
175
+ isMergeableUserInbound(msg) &&
176
+ isMergeableUserInbound(run[run.length - 1]!) &&
177
+ sameTarget(run[run.length - 1]!, msg) &&
178
+ !(runHasMedia && msgHasMedia)
179
+ if (!canJoin) flush()
180
+ run.push(msg)
181
+ runHasMedia = runHasMedia || msgHasMedia
182
+ }
183
+ flush()
184
+ return out
185
+ }
186
+
187
+ /** Collapse a >1 run into a single turn. The newest message anchors the
188
+ * turn (its messageId/ts/user/meta); texts join in arrival order; the
189
+ * single attachment (if any) rides along from whichever message carried
190
+ * it. Caller guarantees the run is mergeable + has at most one media. */
191
+ function mergeRun(run: InboundMessage[]): InboundMessage {
192
+ const last = run[run.length - 1]!
193
+ const mediaEntry = run.find(inboundHasMedia)
194
+ const merged: InboundMessage = {
195
+ ...last,
196
+ text: run.map((m) => m.text).join('\n'),
197
+ }
198
+ // Re-seat the single attachment/imagePath from the entry that owns it
199
+ // (which may not be `last`), or strip them if the run is text-only.
200
+ delete merged.imagePath
201
+ delete merged.attachment
202
+ if (mediaEntry?.imagePath != null) merged.imagePath = mediaEntry.imagePath
203
+ if (mediaEntry?.attachment != null) merged.attachment = mediaEntry.attachment
204
+ return merged
205
+ }
206
+
116
207
  /**
117
208
  * One opportunistic idle-drain tick. The third drain trigger, beside
118
209
  * `onClientRegistered` (bridge re-register) and the silence-poke
@@ -140,4 +140,25 @@ describe('createInboundCoalescer', () => {
140
140
  expect(flushed).toEqual([])
141
141
  expect(c.size()).toBe(0)
142
142
  })
143
+
144
+ it('hands merge ALL entries in arrival order so the attachment can ride from a non-last entry', () => {
145
+ // The gateway merge picks the single attachment via entries.find(...),
146
+ // NOT entries[last]. Pin that the coalescer preserves arrival order and
147
+ // passes every buffered entry, so a [photo][text] burst keeps the photo.
148
+ interface MediaPayload { text: string; attachment?: string }
149
+ const mediaMerge = (entries: MediaPayload[]): MediaPayload => ({
150
+ text: entries.map((e) => e.text).join('\n'),
151
+ attachment: entries.find((e) => e.attachment != null)?.attachment,
152
+ })
153
+ const flushed: MediaPayload[] = []
154
+ const c = createInboundCoalescer<MediaPayload>({
155
+ gapMs: 1500,
156
+ merge: mediaMerge,
157
+ onFlush: (_key, merged) => flushed.push(merged),
158
+ })
159
+ c.enqueue('c1:u1', { text: 'look', attachment: 'photo-1' }) // media FIRST
160
+ c.enqueue('c1:u1', { text: 'at this' }) // text second
161
+ vi.advanceTimersByTime(1500)
162
+ expect(flushed).toEqual([{ text: 'look\nat this', attachment: 'photo-1' }])
163
+ })
143
164
  })
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect } from 'vitest'
10
- import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
10
+ import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, planBufferedRedelivery, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
11
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
12
 
13
13
  function inbound(source: string, ts = Date.now()): InboundMessage {
@@ -23,6 +23,35 @@ function inbound(source: string, ts = Date.now()): InboundMessage {
23
23
  }
24
24
  }
25
25
 
26
+ /** An ordinary Telegram user message — NO meta.source, so it's mergeable. */
27
+ function userMsg(
28
+ opts: {
29
+ text: string
30
+ chatId?: string
31
+ threadId?: number
32
+ userId?: number
33
+ ts?: number
34
+ imagePath?: string
35
+ attachment?: InboundMessage['attachment']
36
+ },
37
+ ): InboundMessage {
38
+ const ts = opts.ts ?? Date.now()
39
+ const m: InboundMessage = {
40
+ type: 'inbound',
41
+ chatId: opts.chatId ?? 'c1',
42
+ messageId: ts,
43
+ user: 'alice',
44
+ userId: opts.userId ?? 42,
45
+ ts,
46
+ text: opts.text,
47
+ meta: {},
48
+ }
49
+ if (opts.threadId != null) m.threadId = opts.threadId
50
+ if (opts.imagePath != null) m.imagePath = opts.imagePath
51
+ if (opts.attachment != null) m.attachment = opts.attachment
52
+ return m
53
+ }
54
+
26
55
  describe('pending-inbound-buffer', () => {
27
56
  it('push + drain — FIFO order per agent', () => {
28
57
  const buf = createPendingInboundBuffer({ log: () => {} })
@@ -313,3 +342,258 @@ describe('durable-spool integration (finn/carrie lost-on-restart fix)', () => {
313
342
  })
314
343
  })
315
344
  })
345
+
346
+ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a turn boundary)', () => {
347
+ it('passes a single message through unchanged (run of one)', () => {
348
+ const a = userMsg({ text: 'solo', ts: 1 })
349
+ const plan = planBufferedRedelivery([a])
350
+ expect(plan).toHaveLength(1)
351
+ expect(plan[0]!.merged).toBe(a) // identity preserved, no synthetic copy
352
+ expect(plan[0]!.originals).toEqual([a])
353
+ })
354
+
355
+ it('merges consecutive same-sender user messages into one turn (texts joined by \\n)', () => {
356
+ const a = userMsg({ text: 'first', ts: 1 })
357
+ const b = userMsg({ text: 'second', ts: 2 })
358
+ const c = userMsg({ text: 'third', ts: 3 })
359
+ const plan = planBufferedRedelivery([a, b, c])
360
+ expect(plan).toHaveLength(1)
361
+ expect(plan[0]!.merged.text).toBe('first\nsecond\nthird')
362
+ expect(plan[0]!.originals).toEqual([a, b, c]) // all three acked/rebuffered together
363
+ })
364
+
365
+ it('anchors the merged turn on the LAST message identity/meta', () => {
366
+ const a = userMsg({ text: 'first', ts: 10 })
367
+ const b = userMsg({ text: 'second', ts: 20 })
368
+ const plan = planBufferedRedelivery([a, b])
369
+ expect(plan[0]!.merged.messageId).toBe(20)
370
+ expect(plan[0]!.merged.ts).toBe(20)
371
+ })
372
+
373
+ it('NEVER merges a system inbound — meta.source isolates the #1150 wake-up class', () => {
374
+ const u1 = userMsg({ text: 'hi', ts: 1 })
375
+ const grant = inbound('vault_grant_approved', 2)
376
+ const u2 = userMsg({ text: 'there', ts: 3 })
377
+ const plan = planBufferedRedelivery([u1, grant, u2])
378
+ // The grant breaks the run; nothing merges across it.
379
+ expect(plan.map((p) => p.merged.text)).toEqual([
380
+ 'hi',
381
+ 'synthetic vault_grant_approved',
382
+ 'there',
383
+ ])
384
+ expect(plan.every((p) => p.originals.length === 1)).toBe(true)
385
+ })
386
+
387
+ it('does not merge across different senders', () => {
388
+ const a = userMsg({ text: 'from-alice', userId: 1, ts: 1 })
389
+ const b = userMsg({ text: 'from-bob', userId: 2, ts: 2 })
390
+ const plan = planBufferedRedelivery([a, b])
391
+ expect(plan).toHaveLength(2)
392
+ })
393
+
394
+ it('does not merge across different topics (threadId)', () => {
395
+ const a = userMsg({ text: 'planning', threadId: 7, ts: 1 })
396
+ const b = userMsg({ text: 'admin', threadId: 9, ts: 2 })
397
+ const plan = planBufferedRedelivery([a, b])
398
+ expect(plan).toHaveLength(2)
399
+ })
400
+
401
+ it('does not merge across different chats', () => {
402
+ const a = userMsg({ text: 'dm', chatId: 'cA', ts: 1 })
403
+ const b = userMsg({ text: 'group', chatId: 'cB', ts: 2 })
404
+ const plan = planBufferedRedelivery([a, b])
405
+ expect(plan).toHaveLength(2)
406
+ })
407
+
408
+ it('carries a single attachment along even when it is NOT the last message', () => {
409
+ const photo = userMsg({ text: 'look', ts: 1, imagePath: '/tmp/p.jpg' })
410
+ const txt = userMsg({ text: 'at this', ts: 2 })
411
+ const plan = planBufferedRedelivery([photo, txt])
412
+ expect(plan).toHaveLength(1)
413
+ expect(plan[0]!.merged.text).toBe('look\nat this')
414
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/p.jpg')
415
+ })
416
+
417
+ it('carries a single document attachment from the entry that owns it', () => {
418
+ const txt = userMsg({ text: 'here', ts: 1 })
419
+ const doc = userMsg({
420
+ text: '',
421
+ ts: 2,
422
+ attachment: { fileId: 'F1', mimeType: 'application/pdf', fileName: 'r.pdf' },
423
+ })
424
+ const plan = planBufferedRedelivery([txt, doc])
425
+ expect(plan).toHaveLength(1)
426
+ expect(plan[0]!.merged.attachment).toEqual({
427
+ fileId: 'F1',
428
+ mimeType: 'application/pdf',
429
+ fileName: 'r.pdf',
430
+ })
431
+ })
432
+
433
+ it('splits a run rather than putting two attachments in one turn (no silent media loss)', () => {
434
+ const p1 = userMsg({ text: 'one', ts: 1, imagePath: '/tmp/a.jpg' })
435
+ const p2 = userMsg({ text: 'two', ts: 2, imagePath: '/tmp/b.jpg' })
436
+ const plan = planBufferedRedelivery([p1, p2])
437
+ expect(plan).toHaveLength(2)
438
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/a.jpg')
439
+ expect(plan[1]!.merged.imagePath).toBe('/tmp/b.jpg')
440
+ })
441
+
442
+ it('a leading text then media then text → text+media merge, then a fresh run', () => {
443
+ const t1 = userMsg({ text: 'intro', ts: 1 })
444
+ const p = userMsg({ text: 'pic', ts: 2, imagePath: '/tmp/p.jpg' })
445
+ const t2 = userMsg({ text: 'caption', ts: 3 })
446
+ const plan = planBufferedRedelivery([t1, p, t2])
447
+ // All three share one attachment max → single merged turn.
448
+ expect(plan).toHaveLength(1)
449
+ expect(plan[0]!.merged.text).toBe('intro\npic\ncaption')
450
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/p.jpg')
451
+ })
452
+
453
+ it('preserves the run total — sum of originals equals input length (lossless)', () => {
454
+ const msgs = [
455
+ userMsg({ text: 'a', ts: 1 }),
456
+ userMsg({ text: 'b', ts: 2 }),
457
+ inbound('cron', 3),
458
+ userMsg({ text: 'c', ts: 4 }),
459
+ ]
460
+ const plan = planBufferedRedelivery(msgs)
461
+ const total = plan.reduce((n, p) => n + p.originals.length, 0)
462
+ expect(total).toBe(msgs.length)
463
+ })
464
+
465
+ it('end-to-end: redeliverBufferedInbound fans a 3-message burst into ONE send', () => {
466
+ const buf = createPendingInboundBuffer({ log: () => {} })
467
+ buf.push('ziggy', userMsg({ text: 'part 1', ts: 1 }))
468
+ buf.push('ziggy', userMsg({ text: 'part 2', ts: 2 }))
469
+ buf.push('ziggy', userMsg({ text: 'part 3', ts: 3 }))
470
+ const sent: string[] = []
471
+ const r = redeliverBufferedInbound(buf, 'ziggy', (m) => {
472
+ sent.push(m.text)
473
+ return true
474
+ })
475
+ expect(sent).toEqual(['part 1\npart 2\npart 3']) // ONE turn, not three
476
+ expect(r).toEqual({ drained: 3, redelivered: 3, rebuffered: 0 })
477
+ expect(buf.depth('ziggy')).toBe(0)
478
+ })
479
+
480
+ it('end-to-end: a failed send re-buffers ALL originals of the merged run (lossless)', () => {
481
+ const buf = createPendingInboundBuffer({ log: () => {} })
482
+ buf.push('ziggy', userMsg({ text: 'part 1', ts: 1 }))
483
+ buf.push('ziggy', userMsg({ text: 'part 2', ts: 2 }))
484
+ const r = redeliverBufferedInbound(buf, 'ziggy', () => false)
485
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
486
+ expect(buf.depth('ziggy')).toBe(2) // both originals back, nothing lost
487
+ expect(buf.drain('ziggy').map((m) => m.text)).toEqual(['part 1', 'part 2'])
488
+ })
489
+ })
490
+
491
+ describe('planBufferedRedelivery — seeded fuzz over random burst schedules', () => {
492
+ // Tiny deterministic PRNG (mulberry32) so failures reproduce from the seed.
493
+ function rng(seed: number): () => number {
494
+ let s = seed >>> 0
495
+ return () => {
496
+ s |= 0
497
+ s = (s + 0x6d2b79f5) | 0
498
+ let t = Math.imul(s ^ (s >>> 15), 1 | s)
499
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
500
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
501
+ }
502
+ }
503
+
504
+ const SOURCES = ['vault_grant_approved', 'cron', 'reaction', 'approval', 'handback']
505
+
506
+ function randomSchedule(rand: () => number): InboundMessage[] {
507
+ const n = 1 + Math.floor(rand() * 12)
508
+ const out: InboundMessage[] = []
509
+ for (let i = 0; i < n; i++) {
510
+ const roll = rand()
511
+ if (roll < 0.3) {
512
+ // system inbound (has meta.source) — never mergeable
513
+ out.push(inbound(SOURCES[Math.floor(rand() * SOURCES.length)]!, i + 1))
514
+ } else {
515
+ // user message: random sender/topic/chat, sometimes media
516
+ const hasImg = rand() < 0.2
517
+ const hasDoc = !hasImg && rand() < 0.15
518
+ out.push(
519
+ userMsg({
520
+ text: `m${i}`,
521
+ ts: i + 1,
522
+ chatId: rand() < 0.5 ? 'cA' : 'cB',
523
+ userId: rand() < 0.5 ? 1 : 2,
524
+ threadId: rand() < 0.4 ? (rand() < 0.5 ? 7 : 9) : undefined,
525
+ imagePath: hasImg ? `/tmp/img${i}.jpg` : undefined,
526
+ attachment: hasDoc
527
+ ? { fileId: `F${i}`, mimeType: 'application/pdf' }
528
+ : undefined,
529
+ }),
530
+ )
531
+ }
532
+ }
533
+ return out
534
+ }
535
+
536
+ function hasMedia(m: InboundMessage): boolean {
537
+ return m.imagePath != null || m.attachment != null
538
+ }
539
+ function isSystem(m: InboundMessage): boolean {
540
+ return m.meta != null && m.meta.source != null
541
+ }
542
+
543
+ it('holds all invariants across 5000 random schedules', () => {
544
+ const rand = rng(0xC0FFEE)
545
+ for (let iter = 0; iter < 5000; iter++) {
546
+ const pending = randomSchedule(rand)
547
+ const plan = planBufferedRedelivery(pending)
548
+
549
+ // 1. Lossless + order-preserving: flattening originals reproduces input.
550
+ const flat = plan.flatMap((p) => p.originals)
551
+ expect(flat).toEqual(pending)
552
+
553
+ // 2. Count is conserved.
554
+ expect(flat.length).toBe(pending.length)
555
+
556
+ for (const { merged, originals } of plan) {
557
+ // 3. A multi-message run never carries >1 attachment.
558
+ const mediaCount = originals.filter(hasMedia).length
559
+ expect(mediaCount).toBeLessThanOrEqual(1)
560
+
561
+ // 4. A system inbound is NEVER part of a multi-message run, and is
562
+ // never silently mutated (passes through by identity).
563
+ if (originals.length > 1) {
564
+ expect(originals.every((m) => !isSystem(m))).toBe(true)
565
+ } else {
566
+ expect(merged).toBe(originals[0])
567
+ }
568
+
569
+ // 5. Every message in a merged run shares the same (chat, thread, user).
570
+ if (originals.length > 1) {
571
+ const k = (m: InboundMessage) =>
572
+ `${m.chatId}|${m.threadId ?? null}|${m.userId}`
573
+ expect(new Set(originals.map(k)).size).toBe(1)
574
+ // text is the \n-join of the run in order
575
+ expect(merged.text).toBe(originals.map((m) => m.text).join('\n'))
576
+ // the single attachment (if any) comes from the owning entry
577
+ const owner = originals.find(hasMedia)
578
+ expect(merged.imagePath).toBe(owner?.imagePath)
579
+ expect(merged.attachment).toEqual(owner?.attachment)
580
+ }
581
+ }
582
+ }
583
+ })
584
+
585
+ it('redeliver counts always satisfy drained == redelivered + rebuffered (5000 schedules)', () => {
586
+ const rand = rng(0x5EED)
587
+ for (let iter = 0; iter < 5000; iter++) {
588
+ const pending = randomSchedule(rand)
589
+ const buf = createPendingInboundBuffer({ log: () => {} })
590
+ for (const m of pending) buf.push('fuzz', m)
591
+ // Randomly succeed/fail each send to exercise both branches.
592
+ const r = redeliverBufferedInbound(buf, 'fuzz', () => rand() < 0.5)
593
+ expect(r.drained).toBe(pending.length)
594
+ expect(r.redelivered + r.rebuffered).toBe(r.drained)
595
+ // Whatever didn't deliver is still buffered (nothing lost).
596
+ expect(buf.depth('fuzz')).toBe(r.rebuffered)
597
+ }
598
+ })
599
+ })
@@ -61,3 +61,80 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
61
61
  expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
62
62
  })
63
63
  })
64
+
65
+ // Deterministic PRNG (mulberry32) so a failing case is reproducible from the
66
+ // printed seed rather than flaking once and vanishing.
67
+ function mulberry32(seed: number): () => number {
68
+ let a = seed >>> 0
69
+ return () => {
70
+ a |= 0
71
+ a = (a + 0x6d2b79f5) | 0
72
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
73
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
74
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
75
+ }
76
+ }
77
+
78
+ // Adversarial description fragments the gateway might hand us — empty, the
79
+ // literal watcher placeholder, whitespace-only, control chars, multi-byte
80
+ // unicode/emoji, and a pathologically long task. A registry description is
81
+ // only "real" (must win the feed header) when it's a non-empty string.
82
+ const DESC_POOL: (string | null)[] = [
83
+ null,
84
+ '',
85
+ 'sub-agent',
86
+ ' ',
87
+ '\n\t',
88
+ 'Background ten-step worker',
89
+ 'Crawl the repo for dead code',
90
+ '🔧 deploy · staging',
91
+ 'café — naïve façade',
92
+ '0',
93
+ 'false',
94
+ 'a'.repeat(4096),
95
+ 'line1\nline2\nline3',
96
+ 'desc with "quotes" & <tags>',
97
+ ]
98
+
99
+ const WATCHER_POOL = ['sub-agent', '', 'sub-agent ', 'fallback', '🤖', 'x'.repeat(512)]
100
+
101
+ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
102
+ it('holds the #2002 invariants across 20k random registry rows', () => {
103
+ for (let seed = 1; seed <= 20000; seed++) {
104
+ const rng = mulberry32(seed)
105
+ const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)]!
106
+
107
+ const rowMissing = rng() < 0.25
108
+ const background = rng() < 0.5
109
+ const description = pick(DESC_POOL)
110
+ const watcher = pick(WATCHER_POOL)
111
+ const sub = rowMissing ? null : makeSub({ background, description })
112
+
113
+ const out = resolveWorkerFeedDispatch(sub, watcher)
114
+ const ctx = `seed=${seed} rowMissing=${rowMissing} bg=${background} desc=${JSON.stringify(description)} watcher=${JSON.stringify(watcher)}`
115
+
116
+ // 1. Types are always concrete — the feed renderer never sees undefined.
117
+ expect(typeof out.isBackground, ctx).toBe('boolean')
118
+ expect(typeof out.feedDescription, ctx).toBe('string')
119
+
120
+ // 2. isBackground mirrors the row (or false when missing) — a registry
121
+ // miss must never promote a foreground turn into a background one.
122
+ expect(out.isBackground, ctx).toBe(rowMissing ? false : background)
123
+
124
+ const realDescription =
125
+ !rowMissing && typeof description === 'string' && description.length > 0
126
+
127
+ if (realDescription) {
128
+ // 3. THE bug guard: a real registry description always wins the header,
129
+ // regardless of the watcher's generic 'sub-agent' placeholder.
130
+ expect(out.feedDescription, ctx).toBe(description)
131
+ } else {
132
+ // 4. Otherwise we fall back to exactly the watcher label, untouched.
133
+ expect(out.feedDescription, ctx).toBe(watcher)
134
+ }
135
+
136
+ // 5. Pure + deterministic: identical inputs yield a deep-equal result.
137
+ expect(resolveWorkerFeedDispatch(sub, watcher), ctx).toEqual(out)
138
+ }
139
+ })
140
+ })