switchroom 0.18.13 → 0.18.15

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.
Files changed (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -1,21 +1,24 @@
1
- // Flag-gated wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts)
2
- // into the live outbound send path.
1
+ // Wiring for the Bot API 10.1 rich renderer (parse.ts + render.ts) into the
2
+ // live outbound send path.
3
3
  //
4
4
  // The renderer (`render/render.ts`, PR #2930) and parser (`render/parse.ts`)
5
5
  // have full unit coverage but were, until this module, wired into NOTHING —
6
- // no outbound message ever flowed through them. This module is the single,
7
- // feature-flagged bridge: the gateway's rich send path (stream-controller.ts)
8
- // runs the assistant's raw markdown through `parse → renderSafe` before it
9
- // reaches `sendRichMessage`, but ONLY when the flag is on.
6
+ // no outbound message ever flowed through them. This module is the single
7
+ // bridge: the gateway's rich send path (stream-controller.ts) runs the
8
+ // assistant's raw markdown through `parse → renderSafe` before it reaches
9
+ // `sendRichMessage`, unless the escape hatch below disables it.
10
10
  //
11
- // Feature flag — `SWITCHROOM_RICH_RENDER`, default OFF:
12
- // Mirrors the `SWITCHROOM_VISIBLE_ANSWER_STREAM` convention
13
- // (`answer-stream-flag.ts`): an env var read at runtime, default off, opted
14
- // in PER AGENT via the `env:` block in `switchroom.yaml` (propagated into
15
- // the container `environment:` by `src/agents/compose.ts`). When off,
16
- // `maybeRenderOutbound` returns the input untouched, so the live send path
17
- // is byte-for-byte unchanged no agent's behaviour moves until an operator
18
- // explicitly flips the flag for a specific agent. Accepts `1/true/on/yes`.
11
+ // Escape hatch — `SWITCHROOM_RICH_RENDER`, default ON:
12
+ // ON BY DEFAULT in every install — an escape hatch, not an opt-in feature,
13
+ // mirroring the send gate's convention (`sendGateEnabledFromEnv` in
14
+ // `send-gate.ts`, default-on since #3153; also `midTurnFloorEnabled`,
15
+ // `SWITCHROOM_RATE_LIMIT_OVERAGE=0`). Enabled unless the var is explicitly
16
+ // set to a falsey/off value (`0`/`false`/`off`/`no`, case-insensitive,
17
+ // trimmed)per agent via the `env:` block in `switchroom.yaml`
18
+ // (propagated into the container `environment:` by
19
+ // `src/agents/compose.ts`). When disabled, `maybeRenderOutbound` returns
20
+ // the input untouched, so the live send path is byte-for-byte the
21
+ // pre-renderer behaviour (raw transcript markdown to `sendRichMessage`).
19
22
  //
20
23
  // Why route through `renderSafe` and not bare `render`:
21
24
  // `renderSafe` guarantees the returned body is never a rich-markdown string
@@ -38,17 +41,19 @@ import { RICH_MESSAGE_MAX_CHARS, splitMarkdownChunks } from "../format.js";
38
41
  */
39
42
  export const PLAIN_TEXT_MAX_CHARS = 4096;
40
43
 
41
- /** Parse the `SWITCHROOM_RICH_RENDER` flag value. Default OFF; accepts the
42
- * same truthy tokens as the other switchroom env flags. Pure so the default
43
- * + parsing are unit-testable. */
44
+ /** Parse the `SWITCHROOM_RICH_RENDER` kill-switch value. Default ON; disabled
45
+ * only by an explicit falsey/off token (`0`/`false`/`off`/`no`,
46
+ * case-insensitive, trimmed) the same disable vocabulary as
47
+ * `sendGateEnabledFromEnv`. Unset, empty, or unrecognised values → ON. Pure
48
+ * so the default + parsing are unit-testable. */
44
49
  export function parseRichRenderEnabled(raw: string | undefined): boolean {
45
- if (raw == null) return false;
50
+ if (raw == null) return true;
46
51
  const v = raw.trim().toLowerCase();
47
- return v === "1" || v === "true" || v === "on" || v === "yes";
52
+ return !(v === "0" || v === "false" || v === "off" || v === "no");
48
53
  }
49
54
 
50
55
  /** Is the rich renderer enabled in this process? Reads the env flag live so a
51
- * test can set/unset it per-case; defaults OFF. */
56
+ * test can set/unset it per-case; defaults ON (escape hatch, not opt-in). */
52
57
  export function richRenderEnabled(
53
58
  env: NodeJS.ProcessEnv = process.env,
54
59
  ): boolean {
@@ -65,13 +70,14 @@ export function renderOutbound(
65
70
  }
66
71
 
67
72
  /**
68
- * Flag-gated transform for the live send path.
73
+ * Kill-switch-gated transform for the live send path.
69
74
  *
70
- * - flag OFF (default): returns the input untouched, `mode: "markdown"`
71
- * identical to the pre-existing behaviour (raw transcript markdown sent
72
- * straight to `sendRichMessage`). No behavioural change for any agent.
73
- * - flag ON: returns `parse renderSafe` output. `mode: "plain"` signals
74
- * the caller to send WITHOUT the rich wrapper (oversized/unsafe content).
75
+ * - enabled (default): returns `parse renderSafe` output. `mode: "plain"`
76
+ * signals the caller to send WITHOUT the rich wrapper (oversized/unsafe
77
+ * content).
78
+ * - disabled (`SWITCHROOM_RICH_RENDER=0`): returns the input untouched,
79
+ * `mode: "markdown"` identical to the pre-renderer behaviour (raw
80
+ * transcript markdown sent straight to `sendRichMessage`).
75
81
  */
76
82
  export function maybeRenderOutbound(
77
83
  text: string,
@@ -83,7 +89,7 @@ export function maybeRenderOutbound(
83
89
  }
84
90
 
85
91
  /**
86
- * Flag-gated, CAP-ENFORCING transform for the live send path.
92
+ * Kill-switch-gated, CAP-ENFORCING transform for the live send path.
87
93
  *
88
94
  * `maybeRenderOutbound` returns ONE `RenderResult` and can only ever fit a
89
95
  * body into a single wire message. But `renderSafe`'s markdown re-escaping
@@ -104,11 +110,13 @@ export function maybeRenderOutbound(
104
110
  * plain degradation would have thrown away: the smaller pieces individually
105
111
  * escape under `maxLen` and come back as `markdown`.
106
112
  *
107
- * - flag OFF (default): `[{ text, mode: "markdown", degradations: [] }]` —
108
- * a single passthrough piece, identical to `maybeRenderOutbound`.
109
- * - flag ON, body fits: `[renderSafe(...)]` — a single piece, identical to
110
- * `maybeRenderOutbound` (byte-for-byte for the common case).
111
- * - flag ON, body oversize: 2+ cap-respecting pieces in send order.
113
+ * - disabled (`SWITCHROOM_RICH_RENDER=0`):
114
+ * `[{ text, mode: "markdown", degradations: [] }]` — a single passthrough
115
+ * piece, identical to `maybeRenderOutbound`.
116
+ * - enabled (default), body fits: `[renderSafe(...)]` a single piece,
117
+ * identical to `maybeRenderOutbound` (byte-for-byte for the common case).
118
+ * - enabled (default), body oversize: 2+ cap-respecting pieces in send
119
+ * order.
112
120
  */
113
121
  export function renderOutboundChunks(
114
122
  text: string,
@@ -164,6 +164,37 @@ export type RuntimeMetricEvent =
164
164
  agent: string
165
165
  prompt_key: string
166
166
  }
167
+ /**
168
+ * Every terminal rate-limit-family operator event (429 burst / 529
169
+ * overload), classified by origin BEFORE the gateway acts on it — fires
170
+ * even when the user-facing card is cooldown-suppressed, so the count is
171
+ * honest. `classification` says where the limit lives (see
172
+ * `RateLimit429Classification` in throttle-tier.ts): `account-scoped` =
173
+ * Anthropic throttled the account (throttle tier ran), `litellm-local` =
174
+ * the LiteLLM proxy's own tpm/rpm/router limiter tripped (calm path, no
175
+ * account attribution), `generic-transient` = other server-side 429/529
176
+ * wording. `action` is what the gateway DECIDED (throttle / failover /
177
+ * calm), emitted PRE-execution: the actual failover fire is dedup-gated
178
+ * downstream (fleetFallbackGate), so N long-reset 429s inside one dedup
179
+ * window emit N `action: 'failover'` metrics for ONE real roll — count
180
+ * decisions here, count rolls via the broker/fallback announcements.
181
+ * Correlating `account-scoped` fires against fleet token throughput is
182
+ * the operator's evidence base for setting LiteLLM `tpm_limit` caps; the
183
+ * `litellm-local` count then shows those caps actually absorbing load.
184
+ * Limit/reset fields are best-effort parses of the error body (null when
185
+ * absent).
186
+ */
187
+ | {
188
+ kind: 'rate_limit_429_classified'
189
+ agent: string
190
+ classification: 'account-scoped' | 'litellm-local' | 'generic-transient'
191
+ action: 'throttle' | 'failover' | 'calm'
192
+ reset_at_ms: number | null
193
+ reset_in_ms: number | null
194
+ limit_type: string | null
195
+ limit: number | null
196
+ current_usage: number | null
197
+ }
167
198
 
168
199
  /**
169
200
  * The JSONL sink lives under the runtime state dir so it's per-agent
@@ -41,7 +41,7 @@ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
41
41
  return env.PROGRESS_CARD_MULTI_AGENT !== '0'
42
42
  }
43
43
  import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
44
- import { isTransientUpstreamSignal } from './model-unavailable.js'
44
+ import { isLitellmProxyLocal429, isTransientUpstreamSignal } from './model-unavailable.js'
45
45
  import { createToolLabelSidecar, type ToolLabelSidecar, type SidecarOptions } from './tool-label-sidecar.js'
46
46
  import { isModelSentinel } from './model-label.js'
47
47
 
@@ -684,9 +684,21 @@ export function detectErrorInTranscriptLine(
684
684
  // model-unavailable.ts) — an ambiguous 429 that merely says "limit" stays
685
685
  // quota-exhausted, biasing toward surfacing a real wall. Other statuses fall
686
686
  // through to the shared classifier.
687
+ //
688
+ // A 429 carrying LiteLLM-proxy-LOCAL limiter wording ("Deployment over
689
+ // user-defined ratelimit", "Rate limit exceeded for api_key: …" — the
690
+ // canonical `litellmProxyLocal429Signals` list) is the proxy's own
691
+ // `tpm_limit`/`rpm_limit` cap tripping BEFORE the request reached
692
+ // Anthropic. Nothing about the account is exhausted — blanket-labeling it
693
+ // quota-exhausted would fire the model-unavailable card + mark-exhausted +
694
+ // fleet failover for a purely proxy-local condition. It is rate-limited
695
+ // (calm path). Precedence when BOTH wordings appear is owned by
696
+ // `classify429Detail` gateway-side; here both branches yield the same
697
+ // 'rate-limited' kind, so order is immaterial.
687
698
  const kind: OperatorEventKind =
688
699
  status === 429
689
- ? isTransientUpstreamSignal(`${text}\n${errStr}`)
700
+ ? isTransientUpstreamSignal(`${text}\n${errStr}`) ||
701
+ isLitellmProxyLocal429(`${text}\n${errStr}`)
690
702
  ? 'rate-limited'
691
703
  : 'quota-exhausted'
692
704
  : classifyClaudeError({ type: errStr, status, message: text })
@@ -228,8 +228,9 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
228
228
  // `maybeRenderOutbound` path; a body whose markdown-escaping pushed the
229
229
  // rendered form past the cap splits into several pieces, each of which fits
230
230
  // its own wire cap and never bisects a fenced block / table row (see
231
- // `renderOutboundChunks`). Flag OFF (default) and a literal `format:'text'`
232
- // stream both yield a single passthrough piece — no behavioural change.
231
+ // `renderOutboundChunks`). Rendering disabled (SWITCHROOM_RICH_RENDER=0)
232
+ // and a literal `format:'text'` stream both yield a single passthrough
233
+ // piece — byte-for-byte the pre-renderer send path.
233
234
  const renderPieces = (text: string): { text: string; rich: boolean }[] => {
234
235
  if (literalText) return [{ text, rich: false }]
235
236
  // A `plain`-mode piece (oversized/unsafe content renderSafe declined to
@@ -118,6 +118,78 @@ describe('runFleetAutoFallback', () => {
118
118
  }
119
119
  });
120
120
 
121
+ it('parsedResetAt (429 throttle tier) names the recovery when the old probe carried no reset', async () => {
122
+ const failover = vi.fn(async () => ({ rolledTo: 'you@x', rolled: ['ken@x'] }));
123
+ const out = await runFleetAutoFallback({
124
+ state: state('ken@x', ['ken@x', 'you@x']),
125
+ quotas: [
126
+ // ken: walled, but the probe carried NO reset time — pre-fix the
127
+ // announcement's recovery line was silently dropped.
128
+ qOk({ fiveHourUtilizationPct: 100, representativeClaim: 'five_hour' }),
129
+ qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
130
+ ],
131
+ failover,
132
+ triggerAgent: 'carrie',
133
+ now: NOW,
134
+ tz: 'UTC',
135
+ // Parsed from the error prose ("resets 5:50am") by the gateway.
136
+ parsedResetAt: new Date('2026-05-15T05:50:00Z'),
137
+ });
138
+
139
+ expect(out.kind).toBe('switched');
140
+ if (out.kind === 'switched') {
141
+ expect(out.announcement).toContain('recovers');
142
+ expect(out.announcement).toContain('in 4h 57m');
143
+ }
144
+ });
145
+
146
+ it('rateLimitTrigger: swaps even when the old account probes HEALTHY (the >threshold leg must execute)', async () => {
147
+ // A terminal transient 429 NEGATES the usage-limit reading, so healthy
148
+ // utilization is the EXPECTED state for the rate-limited account. The
149
+ // healthy-idempotency guard must not self-cancel this swap into a
150
+ // "probed healthy / Stale event?" no-op.
151
+ const failover = vi.fn(async () => ({ rolledTo: 'you@x', rolled: ['ken@x'] }));
152
+ const out = await runFleetAutoFallback({
153
+ state: state('ken@x', ['ken@x', 'you@x']),
154
+ quotas: [
155
+ qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 }), // healthy!
156
+ qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
157
+ ],
158
+ failover,
159
+ triggerAgent: 'carrie',
160
+ now: NOW,
161
+ tz: 'UTC',
162
+ parsedResetAt: new Date('2026-05-15T02:53:00Z'),
163
+ rateLimitTrigger: true,
164
+ });
165
+
166
+ expect(out.kind).toBe('switched');
167
+ expect(failover).toHaveBeenCalledTimes(1);
168
+ if (out.kind === 'switched') {
169
+ // Honest headline: a rate limit, not a utilization-derived window cap.
170
+ expect(out.announcement).toContain('rate limit on ken@x');
171
+ expect(out.announcement).not.toContain('5-hour limit');
172
+ // Recovery line carries the parsed reset (no window was maxed).
173
+ expect(out.announcement).toContain('recovers');
174
+ expect(out.announcement).toContain('in 2h');
175
+ }
176
+ });
177
+
178
+ it('rateLimitTrigger stays subject to the broker outcome (all-blocked passes through)', async () => {
179
+ const failover = vi.fn(async () => ({ rolledTo: null, rolled: [] }));
180
+ const out = await runFleetAutoFallback({
181
+ state: state('ken@x', ['ken@x']),
182
+ quotas: [qOk({ fiveHourUtilizationPct: 12, sevenDayUtilizationPct: 30 })],
183
+ failover,
184
+ triggerAgent: 'carrie',
185
+ now: NOW,
186
+ tz: 'UTC',
187
+ rateLimitTrigger: true,
188
+ });
189
+ expect(out.kind).toBe('all-blocked');
190
+ expect(failover).toHaveBeenCalledTimes(1);
191
+ });
192
+
121
193
  it('idempotency: skips the swap WITHOUT calling failover when active probes healthy', async () => {
122
194
  const failover = vi.fn();
123
195
  const out = await runFleetAutoFallback({
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Unit tests for the forwarded-message origin helpers
3
+ * (telegram-plugin/gateway/forward-origin.ts).
4
+ *
5
+ * These pin the pure pieces of the forward-origin metadata path that live
6
+ * outside gateway.ts so they can be exercised without loadAccess()/IPC:
7
+ * 1. parseForwardOrigin — all four Bot API 7.0 origin shapes, missing
8
+ * fields, hostile (attacker-controlled) names, hidden_user marking.
9
+ * 2. buildForwardOriginMeta — fixed attribute order, XML escaping at the
10
+ * channel-meta boundary, numbered `_2..` siblings for multi-origin
11
+ * bursts (same convention as image_path_2 / attachment_file_id_2).
12
+ * 3. dedupeForwardOrigins — the coalescer's burst collapse: a
13
+ * single-origin album emits one attr set; distinct origins keep
14
+ * arrival order and get numbered.
15
+ *
16
+ * Trust model under test: origin info rides the ATTRS lane only, names are
17
+ * truncated + escaped (they're attacker-controlled), and hidden_user is
18
+ * explicitly marked so agents don't treat a self-reported display name as
19
+ * an authenticated identity.
20
+ */
21
+
22
+ import { describe, expect, it } from 'vitest'
23
+ import type { MessageOrigin } from 'grammy/types'
24
+ import {
25
+ parseForwardOrigin,
26
+ buildForwardOriginMeta,
27
+ dedupeForwardOrigins,
28
+ forwardOriginKey,
29
+ forwardOriginDateIso,
30
+ FORWARDED_FROM_NAME_MAX,
31
+ type ForwardOriginInfo,
32
+ } from '../gateway/forward-origin.js'
33
+
34
+ // Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
35
+ const DATE = 1750000000 // unix seconds
36
+ const DATE_ISO = new Date(DATE * 1000).toISOString()
37
+
38
+ function userOrigin(overrides: Partial<{
39
+ first_name: string
40
+ last_name?: string
41
+ username?: string
42
+ id: number
43
+ }> = {}): MessageOrigin {
44
+ return {
45
+ type: 'user',
46
+ date: DATE,
47
+ sender_user: {
48
+ id: 42,
49
+ is_bot: false,
50
+ first_name: 'Ada',
51
+ last_name: 'Lovelace',
52
+ username: 'adalove',
53
+ ...overrides,
54
+ },
55
+ }
56
+ }
57
+
58
+ describe('parseForwardOrigin — the four origin shapes', () => {
59
+ it('user: first + last name plus (@username), id and date captured', () => {
60
+ const info = parseForwardOrigin(userOrigin())
61
+ expect(info).toEqual({
62
+ name: 'Ada Lovelace (@adalove)',
63
+ type: 'user',
64
+ id: 42,
65
+ date: DATE,
66
+ })
67
+ })
68
+
69
+ it('user: first name only, no username', () => {
70
+ const info = parseForwardOrigin(userOrigin({ last_name: undefined, username: undefined }))
71
+ expect(info?.name).toBe('Ada')
72
+ expect(info?.id).toBe(42)
73
+ })
74
+
75
+ it('hidden_user: self-reported name, marked hidden_user, NO id', () => {
76
+ const info = parseForwardOrigin({
77
+ type: 'hidden_user',
78
+ date: DATE,
79
+ sender_user_name: 'Mystery Sender',
80
+ })
81
+ expect(info).toEqual({
82
+ name: 'Mystery Sender',
83
+ type: 'hidden_user',
84
+ date: DATE,
85
+ })
86
+ // The whole point of the type marker: no verifiable id exists.
87
+ expect(info?.id).toBeUndefined()
88
+ })
89
+
90
+ it('chat: sender_chat title plus (@username)', () => {
91
+ const info = parseForwardOrigin({
92
+ type: 'chat',
93
+ date: DATE,
94
+ sender_chat: { id: -100200300, type: 'supergroup', title: 'Ops Room', username: 'opsroom' } as never,
95
+ })
96
+ expect(info).toEqual({
97
+ name: 'Ops Room (@opsroom)',
98
+ type: 'chat',
99
+ id: -100200300,
100
+ date: DATE,
101
+ })
102
+ })
103
+
104
+ it('channel: chat.title plus (@username), message_id captured', () => {
105
+ const info = parseForwardOrigin({
106
+ type: 'channel',
107
+ date: DATE,
108
+ message_id: 555,
109
+ chat: { id: -100400500, type: 'channel', title: 'Release Notes', username: 'relnotes' } as never,
110
+ })
111
+ expect(info).toEqual({
112
+ name: 'Release Notes (@relnotes)',
113
+ type: 'channel',
114
+ id: -100400500,
115
+ date: DATE,
116
+ messageId: 555,
117
+ })
118
+ })
119
+ })
120
+
121
+ describe('parseForwardOrigin — missing / malformed fields', () => {
122
+ it('returns undefined for a non-forwarded message (no origin)', () => {
123
+ expect(parseForwardOrigin(undefined)).toBeUndefined()
124
+ })
125
+
126
+ it('returns undefined for an unknown future origin type', () => {
127
+ expect(parseForwardOrigin({ type: 'giveaway', date: DATE } as never)).toBeUndefined()
128
+ })
129
+
130
+ it('user origin without sender_user is dropped', () => {
131
+ expect(parseForwardOrigin({ type: 'user', date: DATE } as never)).toBeUndefined()
132
+ })
133
+
134
+ it('hidden_user with an empty name is dropped (nothing to show)', () => {
135
+ expect(
136
+ parseForwardOrigin({ type: 'hidden_user', date: DATE, sender_user_name: '' }),
137
+ ).toBeUndefined()
138
+ })
139
+
140
+ it('user with no printable name falls back to the numeric id as name', () => {
141
+ const info = parseForwardOrigin({
142
+ type: 'user',
143
+ date: DATE,
144
+ sender_user: { id: 42, is_bot: false, first_name: '' },
145
+ })
146
+ expect(info).toEqual({ name: '42', type: 'user', id: 42, date: DATE })
147
+ })
148
+
149
+ it('missing date yields no date (and later no forwarded_date attr)', () => {
150
+ const info = parseForwardOrigin({
151
+ type: 'hidden_user',
152
+ sender_user_name: 'Mystery Sender',
153
+ } as never)
154
+ expect(info?.date).toBeUndefined()
155
+ expect(buildForwardOriginMeta([info!]).forwarded_date).toBeUndefined()
156
+ expect(forwardOriginDateIso(info)).toBeNull()
157
+ })
158
+
159
+ it('channel origin without a title falls back to first/last name shape, then id', () => {
160
+ const noName = parseForwardOrigin({
161
+ type: 'channel',
162
+ date: DATE,
163
+ message_id: 9,
164
+ chat: { id: -100777888, type: 'channel' } as never,
165
+ })
166
+ expect(noName).toEqual({
167
+ name: '-100777888',
168
+ type: 'channel',
169
+ id: -100777888,
170
+ date: DATE,
171
+ messageId: 9,
172
+ })
173
+ })
174
+ })
175
+
176
+ describe('parseForwardOrigin — hostile names (attacker-controlled)', () => {
177
+ it('truncates a 4KB name to the cap (raw, ellipsis-terminated)', () => {
178
+ const bomb = 'x'.repeat(4096)
179
+ const info = parseForwardOrigin(userOrigin({ first_name: bomb, last_name: undefined, username: undefined }))
180
+ expect(info?.name).toHaveLength(FORWARDED_FROM_NAME_MAX)
181
+ expect(info?.name?.endsWith('…')).toBe(true)
182
+ })
183
+
184
+ it('keeps XML metacharacters RAW in the parsed record (SQLite lane)', () => {
185
+ const info = parseForwardOrigin(
186
+ userOrigin({ first_name: '<b>"Bob"&\'friends\'</b>', last_name: undefined, username: undefined }),
187
+ )
188
+ // Escaping happens at the channel-meta boundary, not at parse time —
189
+ // the history buffer stores what the user actually saw.
190
+ expect(info?.name).toBe('<b>"Bob"&\'friends\'</b>')
191
+ })
192
+ })
193
+
194
+ describe('buildForwardOriginMeta — channel-tag attrs', () => {
195
+ it('single origin: bare keys in the fixed order name, type, id, date', () => {
196
+ const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
197
+ expect(Object.keys(meta)).toEqual([
198
+ 'forwarded_from',
199
+ 'forwarded_from_type',
200
+ 'forwarded_from_id',
201
+ 'forwarded_date',
202
+ ])
203
+ expect(meta).toEqual({
204
+ forwarded_from: 'Ada Lovelace (@adalove)',
205
+ forwarded_from_type: 'user',
206
+ forwarded_from_id: '42',
207
+ forwarded_date: DATE_ISO,
208
+ })
209
+ })
210
+
211
+ it('id-less origin (hidden_user) omits forwarded_from_id entirely', () => {
212
+ const meta = buildForwardOriginMeta([
213
+ { name: 'Mystery Sender', type: 'hidden_user', date: DATE },
214
+ ])
215
+ expect(Object.keys(meta)).toEqual([
216
+ 'forwarded_from',
217
+ 'forwarded_from_type',
218
+ 'forwarded_date',
219
+ ])
220
+ expect(meta.forwarded_from_type).toBe('hidden_user')
221
+ })
222
+
223
+ it('XML-escapes hostile names — the rendered attribute value cannot break out', () => {
224
+ const meta = buildForwardOriginMeta([
225
+ { name: '<script>"a"&\'b\'</script>', type: 'user', id: 42, date: DATE },
226
+ ])
227
+ expect(meta.forwarded_from).toBe(
228
+ '&lt;script&gt;&quot;a&quot;&amp;&apos;b&apos;&lt;/script&gt;',
229
+ )
230
+ // Outcome assertion: rendered into a channel tag, the value carries no
231
+ // raw quote/angle characters that could terminate the attribute.
232
+ const rendered = `<channel source="telegram" forwarded_from="${meta.forwarded_from}">`
233
+ expect(rendered).not.toMatch(/forwarded_from="[^"]*[<>][^"]*"/)
234
+ expect((rendered.match(/"/g) ?? []).length).toBe(4) // only the delimiters
235
+ })
236
+
237
+ it('defense in depth: a name that skipped parse-time truncation is capped here too', () => {
238
+ const meta = buildForwardOriginMeta([
239
+ { name: 'y'.repeat(4096), type: 'user', id: 42, date: DATE },
240
+ ])
241
+ // 99 chars + '…' — no 4KB payload reaches the tag.
242
+ expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
243
+ })
244
+
245
+ it('no origins → empty record (no attrs on a normal message)', () => {
246
+ expect(buildForwardOriginMeta([])).toEqual({})
247
+ })
248
+ })
249
+
250
+ describe('coalesced bursts — dedupe + numbered siblings', () => {
251
+ const alice: ForwardOriginInfo = { name: 'Alice Q (@aliceq)', type: 'user', id: 42, date: DATE }
252
+ const relnotes: ForwardOriginInfo = {
253
+ name: 'Release Notes (@relnotes)',
254
+ type: 'channel',
255
+ id: -100400500,
256
+ date: DATE + 60,
257
+ messageId: 7,
258
+ }
259
+
260
+ it('single-origin album (N parts, one sender) collapses to ONE attr set', () => {
261
+ const origins = dedupeForwardOrigins([alice, { ...alice, date: DATE + 5 }, { ...alice, date: DATE + 9 }])
262
+ expect(origins).toHaveLength(1)
263
+ const meta = buildForwardOriginMeta(origins)
264
+ expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
265
+ expect(meta.forwarded_from_2).toBeUndefined()
266
+ // First occurrence wins — the emitted date is the first part's.
267
+ expect(meta.forwarded_date).toBe(DATE_ISO)
268
+ })
269
+
270
+ it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {
271
+ const meta = buildForwardOriginMeta(dedupeForwardOrigins([alice, relnotes]))
272
+ expect(Object.keys(meta)).toEqual([
273
+ 'forwarded_from',
274
+ 'forwarded_from_type',
275
+ 'forwarded_from_id',
276
+ 'forwarded_date',
277
+ 'forwarded_from_2',
278
+ 'forwarded_from_type_2',
279
+ 'forwarded_from_id_2',
280
+ 'forwarded_date_2',
281
+ ])
282
+ expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
283
+ expect(meta.forwarded_from_type_2).toBe('channel')
284
+ expect(meta.forwarded_from_id_2).toBe('-100400500')
285
+ })
286
+
287
+ it('non-forwarded entries in the burst are skipped', () => {
288
+ expect(dedupeForwardOrigins([undefined, alice, undefined])).toEqual([alice])
289
+ expect(dedupeForwardOrigins([undefined, undefined])).toEqual([])
290
+ })
291
+
292
+ it('id-less origins dedupe by name; same type+id dedupes despite name drift', () => {
293
+ const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
294
+ expect(dedupeForwardOrigins([hidden, { ...hidden, date: DATE + 3 }])).toHaveLength(1)
295
+ // Same user id, renamed mid-burst — still one origin.
296
+ expect(dedupeForwardOrigins([alice, { ...alice, name: 'Alice Renamed' }])).toHaveLength(1)
297
+ // Same numeric id but different type stays distinct.
298
+ expect(forwardOriginKey(alice)).not.toBe(forwardOriginKey({ ...alice, type: 'chat' }))
299
+ })
300
+
301
+ it('arrival order is preserved across three distinct origins', () => {
302
+ const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
303
+ const meta = buildForwardOriginMeta(dedupeForwardOrigins([relnotes, hidden, alice]))
304
+ expect(meta.forwarded_from).toBe('Release Notes (@relnotes)')
305
+ expect(meta.forwarded_from_2).toBe('Mystery Sender')
306
+ expect(meta.forwarded_from_3).toBe('Alice Q (@aliceq)')
307
+ expect(meta.forwarded_from_type_3).toBe('user')
308
+ })
309
+ })