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.
- package/dist/agent-scheduler/index.js +3 -0
- package/dist/auth-broker/index.js +3 -0
- package/dist/cli/notion-write-pretool.mjs +3 -0
- package/dist/cli/switchroom.js +8 -2
- package/dist/host-control/main.js +3 -0
- package/dist/vault/approvals/kernel-server.js +3 -0
- package/dist/vault/broker/server.js +3 -0
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +6 -5
- package/telegram-plugin/dist/gateway/gateway.js +142 -32
- package/telegram-plugin/gateway/gateway.ts +69 -28
- package/telegram-plugin/gateway/inbound-coalesce.ts +8 -7
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +100 -9
- package/telegram-plugin/tests/inbound-coalesce.test.ts +21 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +285 -1
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +77 -0
|
@@ -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({
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -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.
|
|
49417
|
-
var COMMIT_SHA = "
|
|
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
|
@@ -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
|
-
-
|
|
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
|
-
-
|
|
41
|
-
- Don't use markdown headings (`##`) in replies —
|
|
42
|
-
- Keep lines short — long unwrapped lines are hard to read on mobile
|
|
43
|
-
- One idea per message
|
|
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
|
|
46347
|
+
for (const { merged, originals } of planBufferedRedelivery(pending)) {
|
|
46345
46348
|
let delivered = false;
|
|
46346
46349
|
try {
|
|
46347
|
-
delivered = send(
|
|
46350
|
+
delivered = send(merged);
|
|
46348
46351
|
} catch {
|
|
46349
46352
|
delivered = false;
|
|
46350
46353
|
}
|
|
46351
46354
|
if (delivered) {
|
|
46352
|
-
|
|
46353
|
-
|
|
46355
|
+
for (const o of originals)
|
|
46356
|
+
spool?.ack(o);
|
|
46357
|
+
redelivered += originals.length;
|
|
46354
46358
|
} else {
|
|
46355
|
-
|
|
46356
|
-
|
|
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
|
|
46985
|
+
for (const { merged, originals } of planBufferedRedelivery2(pending)) {
|
|
46936
46986
|
let delivered = false;
|
|
46937
46987
|
try {
|
|
46938
|
-
delivered = send(
|
|
46988
|
+
delivered = send(merged);
|
|
46939
46989
|
} catch {
|
|
46940
46990
|
delivered = false;
|
|
46941
46991
|
}
|
|
46942
46992
|
if (delivered) {
|
|
46943
|
-
|
|
46944
|
-
|
|
46993
|
+
for (const o of originals)
|
|
46994
|
+
spool?.ack(o);
|
|
46995
|
+
redelivered += originals.length;
|
|
46945
46996
|
} else {
|
|
46946
|
-
|
|
46947
|
-
|
|
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.
|
|
51144
|
-
var COMMIT_SHA = "
|
|
51145
|
-
var COMMIT_DATE = "2026-05-
|
|
51146
|
-
var LATEST_PR =
|
|
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:
|
|
52864
|
-
attachment:
|
|
52962
|
+
downloadImage: withAttachment?.downloadImage,
|
|
52963
|
+
attachment: withAttachment?.attachment
|
|
52865
52964
|
};
|
|
52866
52965
|
},
|
|
52867
|
-
onFlush: (
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
3001
|
-
//
|
|
3002
|
-
//
|
|
3003
|
-
//
|
|
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
|
|
3013
|
-
//
|
|
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.
|
|
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:
|
|
3025
|
-
attachment:
|
|
3037
|
+
downloadImage: withAttachment?.downloadImage,
|
|
3038
|
+
attachment: withAttachment?.attachment,
|
|
3026
3039
|
}
|
|
3027
3040
|
},
|
|
3028
|
-
onFlush: (
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
150
|
-
* intent ("user sends 3 sentences as one thought") — applying
|
|
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
|
-
|
|
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(
|
|
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
|
|
105
|
-
// boot-replayed again.
|
|
106
|
-
//
|
|
107
|
-
spool?.ack(
|
|
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
|
|
110
|
-
|
|
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
|
+
})
|