switchroom 0.13.35 → 0.13.37

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.
@@ -25292,6 +25292,39 @@ function parseDriveScope(scope) {
25292
25292
  return { action, target: { kind: "doc", doc_id: rest } };
25293
25293
  }
25294
25294
 
25295
+ // gateway/oversize-card-body.ts
25296
+ function truncateRawToFit(input) {
25297
+ const { raw, render, cap, sentinel } = input;
25298
+ const hardLimit = input.hardLimit ?? cap + 196;
25299
+ const fullBody = render(raw);
25300
+ if (fullBody.length <= cap) {
25301
+ return { body: fullBody, truncated: false };
25302
+ }
25303
+ let lo = 0;
25304
+ let hi = raw.length;
25305
+ let bestSliceLen = 0;
25306
+ while (lo <= hi) {
25307
+ const mid = lo + hi >>> 1;
25308
+ const candidate = raw.slice(0, mid) + sentinel;
25309
+ if (render(candidate).length <= cap) {
25310
+ bestSliceLen = mid;
25311
+ lo = mid + 1;
25312
+ } else {
25313
+ hi = mid - 1;
25314
+ }
25315
+ }
25316
+ let chosenRaw = raw.slice(0, bestSliceLen);
25317
+ const lastNl = chosenRaw.lastIndexOf(`
25318
+ `);
25319
+ if (lastNl > 0)
25320
+ chosenRaw = chosenRaw.slice(0, lastNl);
25321
+ let body = render(chosenRaw + sentinel);
25322
+ if (body.length > hardLimit) {
25323
+ body = body.slice(0, hardLimit - 1);
25324
+ }
25325
+ return { body, truncated: true };
25326
+ }
25327
+
25295
25328
  // secret-detect/suppressor.ts
25296
25329
  function isSuppressed(text, start, end) {
25297
25330
  const left = Math.max(0, start - WINDOW);
@@ -29060,49 +29093,90 @@ var init_materialize_bot_token = __esm(() => {
29060
29093
  // gateway/config-approval-handler.ts
29061
29094
  var exports_config_approval_handler = {};
29062
29095
  __export(exports_config_approval_handler, {
29096
+ truncateDiffForCard: () => truncateDiffForCard,
29063
29097
  resolvePendingConfigApproval: () => resolvePendingConfigApproval,
29064
29098
  parseConfigApprovalCallback: () => parseConfigApprovalCallback,
29065
29099
  handleRequestConfigFinalize: () => handleRequestConfigFinalize,
29066
29100
  handleRequestConfigApproval: () => handleRequestConfigApproval,
29067
29101
  buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
29068
29102
  _resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
29069
- _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest
29103
+ _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
29104
+ DIFF_SENTINEL: () => DIFF_SENTINEL
29070
29105
  });
29106
+ function truncateDiffForCard(unifiedDiff, maxLines = 50, maxChars = 3000) {
29107
+ const sentinel = `
29108
+ [\u2026 diff continues, see attached file]`;
29109
+ const lines = unifiedDiff.split(`
29110
+ `);
29111
+ let out;
29112
+ if (lines.length <= maxLines) {
29113
+ out = unifiedDiff;
29114
+ } else {
29115
+ out = lines.slice(0, maxLines).join(`
29116
+ `);
29117
+ }
29118
+ if (out.length > maxChars) {
29119
+ const cap = out.slice(0, maxChars);
29120
+ const lastNl = cap.lastIndexOf(`
29121
+ `);
29122
+ out = lastNl > 0 ? cap.slice(0, lastNl) : cap;
29123
+ }
29124
+ return out === unifiedDiff ? out : out + sentinel;
29125
+ }
29126
+ function escapeHtml11(s) {
29127
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29128
+ }
29129
+ function clipReason(reason) {
29130
+ if (reason.length <= REASON_MAX_CHARS)
29131
+ return reason;
29132
+ return reason.slice(0, REASON_MAX_CHARS - REASON_ELLIPSIS.length) + REASON_ELLIPSIS;
29133
+ }
29071
29134
  function buildConfigApprovalCardBody(args) {
29072
- const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29073
- return `\uD83D\uDEE0 <b>Config edit proposed</b>
29074
- ` + `Agent: <code>${esc(args.agentName)}</code>
29075
- ` + `Reason: ${esc(args.reason)}
29076
-
29077
- ` + `<pre>${esc(args.unifiedDiff)}</pre>`;
29135
+ const safeReason = clipReason(args.reason);
29136
+ const render = (diff) => `\uD83D\uDEE0 <b>Config edit proposed</b>
29137
+ ` + `Agent: <code>${escapeHtml11(args.agentName)}</code>
29138
+ ` + `Reason: ${escapeHtml11(safeReason)}
29139
+
29140
+ ` + `<pre>${escapeHtml11(diff)}</pre>`;
29141
+ return truncateRawToFit({
29142
+ raw: args.unifiedDiff,
29143
+ render,
29144
+ cap: RENDERED_BODY_CAP2,
29145
+ sentinel: DIFF_SENTINEL,
29146
+ hardLimit: TELEGRAM_SENDMESSAGE_LIMIT2
29147
+ });
29078
29148
  }
29079
29149
  async function handleRequestConfigApproval(client3, msg, deps) {
29080
- const reply = (verdict, reason) => {
29150
+ const reply = (verdict, reason, denySource) => {
29081
29151
  try {
29082
29152
  client3.send({
29083
29153
  type: "config_approval_resolved",
29084
29154
  requestId: msg.requestId,
29085
29155
  verdict,
29086
- ...reason ? { reason } : {}
29156
+ ...reason ? { reason } : {},
29157
+ ...denySource ? { denySource } : {}
29087
29158
  });
29088
29159
  } catch (err) {
29089
29160
  deps.log?.(`config_approval_resolved send failed (requestId=${msg.requestId}): ${err.message}`);
29090
29161
  }
29091
29162
  };
29092
29163
  if (msg.agentName !== deps.agentName) {
29093
- reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`);
29164
+ reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`, "dispatch_failure");
29094
29165
  return;
29095
29166
  }
29096
29167
  const target = deps.loadTargetChat();
29097
29168
  if (target === null) {
29098
- reply("deny", "no target chat available \u2014 operator not paired?");
29169
+ reply("deny", "no target chat available \u2014 operator not paired?", "dispatch_failure");
29099
29170
  return;
29100
29171
  }
29101
- const body = buildConfigApprovalCardBody({
29172
+ const prelim = truncateDiffForCard(msg.unifiedDiff);
29173
+ const built = buildConfigApprovalCardBody({
29102
29174
  agentName: msg.agentName,
29103
29175
  reason: msg.reason,
29104
- unifiedDiff: msg.unifiedDiff
29176
+ unifiedDiff: prelim
29105
29177
  });
29178
+ const body = built.body;
29179
+ const oversize = prelim !== msg.unifiedDiff || built.truncated;
29106
29180
  const replyMarkup = deps.buildKeyboard(msg.requestId);
29107
29181
  const posted = await deps.postCard({
29108
29182
  chatId: target.chatId,
@@ -29111,9 +29185,12 @@ async function handleRequestConfigApproval(client3, msg, deps) {
29111
29185
  replyMarkup
29112
29186
  });
29113
29187
  if (posted === null) {
29114
- reply("deny", "Telegram sendMessage failed");
29188
+ reply("deny", "Telegram sendMessage failed", "dispatch_failure");
29115
29189
  return;
29116
29190
  }
29191
+ if (oversize) {
29192
+ await maybePostAttachment(deps, target, msg);
29193
+ }
29117
29194
  const entry = {
29118
29195
  requestId: msg.requestId,
29119
29196
  client: client3,
@@ -29179,8 +29256,21 @@ ${escapeHtml11(msg.detail)}` : ""}`;
29179
29256
  deps.log?.(`config finalize card edit failed (requestId=${msg.requestId}): ${err.message}`);
29180
29257
  }
29181
29258
  }
29182
- function escapeHtml11(s) {
29183
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
29259
+ async function maybePostAttachment(deps, target, msg) {
29260
+ if (deps.postAttachment === undefined) {
29261
+ deps.log?.(`oversize config approval card but no postAttachment dep wired (requestId=${msg.requestId})`);
29262
+ return;
29263
+ }
29264
+ try {
29265
+ await deps.postAttachment({
29266
+ chatId: target.chatId,
29267
+ ...target.threadId !== undefined ? { threadId: target.threadId } : {},
29268
+ filename: `config-edit-${msg.requestId}.patch`,
29269
+ content: msg.unifiedDiff
29270
+ });
29271
+ } catch (err) {
29272
+ deps.log?.(`config approval attachment failed (requestId=${msg.requestId}): ${err.message}`);
29273
+ }
29184
29274
  }
29185
29275
  function _resetPendingConfigApprovalsForTest() {
29186
29276
  for (const entry of pending.values()) {
@@ -29207,7 +29297,8 @@ function parseConfigApprovalCallback(data) {
29207
29297
  return null;
29208
29298
  return { requestId, choice };
29209
29299
  }
29210
- var pending;
29300
+ var pending, TELEGRAM_SENDMESSAGE_LIMIT2 = 4096, RENDERED_BODY_CAP2 = 3900, REASON_MAX_CHARS = 500, REASON_ELLIPSIS = "\u2026", DIFF_SENTINEL = `
29301
+ [\u2026 diff continues, see attached file]`;
29211
29302
  var init_config_approval_handler = __esm(() => {
29212
29303
  pending = new Map;
29213
29304
  });
@@ -37491,6 +37582,10 @@ function tick2(now) {
37491
37582
  clearPending(key, "timeout");
37492
37583
  continue;
37493
37584
  }
37585
+ if (activeDeps2.isActiveTurnNewerThan != null && activeDeps2.isActiveTurnNewerThan(key, s.activatedAt)) {
37586
+ clearPending(key, "stale_turn");
37587
+ continue;
37588
+ }
37494
37589
  const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt;
37495
37590
  if (sinceEdit < EDIT_INTERVAL_MS)
37496
37591
  continue;
@@ -44123,6 +44218,10 @@ function validateInput(input) {
44123
44218
  var DEFAULT_TTL_MS = 5 * 60 * 1000;
44124
44219
  var MAX_TTL_MS = 30 * 60 * 1000;
44125
44220
  var MIN_TTL_MS = 30 * 1000;
44221
+ var TELEGRAM_SENDMESSAGE_LIMIT = 4096;
44222
+ var RENDERED_BODY_CAP = 3900;
44223
+ var OVERSIZE_SENTINEL = `
44224
+ [\u2026 preview truncated; open in Drive for full context]`;
44126
44225
  async function handleRequestDriveApproval(client3, msg, deps) {
44127
44226
  const reply = (event) => {
44128
44227
  try {
@@ -44198,20 +44297,36 @@ async function handleRequestDriveApproval(client3, msg, deps) {
44198
44297
  });
44199
44298
  return;
44200
44299
  }
44300
+ let cardText = card.text;
44301
+ let truncatedForFit = false;
44302
+ if (cardText.length > RENDERED_BODY_CAP) {
44303
+ const fit = truncateRawToFit({
44304
+ raw: card.text,
44305
+ render: (slice) => slice,
44306
+ cap: RENDERED_BODY_CAP,
44307
+ sentinel: OVERSIZE_SENTINEL,
44308
+ hardLimit: TELEGRAM_SENDMESSAGE_LIMIT
44309
+ });
44310
+ cardText = fit.body;
44311
+ truncatedForFit = fit.truncated;
44312
+ }
44201
44313
  const posted = await deps.postCard({
44202
44314
  chatId: target.chatId,
44203
44315
  ...target.threadId !== undefined ? { threadId: target.threadId } : {},
44204
- text: card.text,
44316
+ text: cardText,
44205
44317
  replyMarkup: card.reply_markup
44206
44318
  });
44207
44319
  if (posted === null) {
44208
44320
  reply({
44209
44321
  correlationId: msg.correlationId,
44210
44322
  ok: false,
44211
- reason: "Telegram sendMessage failed"
44323
+ reason: truncatedForFit ? "Telegram sendMessage failed even after oversize-body truncation" : "Telegram sendMessage failed"
44212
44324
  });
44213
44325
  return;
44214
44326
  }
44327
+ if (truncatedForFit) {
44328
+ deps.log?.(`drive_approval_posted oversize-truncated correlation=${msg.correlationId} original_len=${card.text.length} rendered_len=${cardText.length}`);
44329
+ }
44215
44330
  deps.log?.(`drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`);
44216
44331
  reply({
44217
44332
  correlationId: msg.correlationId,
@@ -44235,10 +44350,10 @@ var REQUEST_ID_RE = /^[0-9a-f]{32}$/;
44235
44350
  var PENDING_FILE_ID_SENTINEL = "pending-create";
44236
44351
  function buildDiffPreviewCard(input) {
44237
44352
  if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
44238
- throw new Error(`buildDiffPreviewCard: suggestRequestId must be 8 hex chars (got '${input.suggestRequestId}')`);
44353
+ throw new Error(`buildDiffPreviewCard: suggestRequestId must be 32 hex chars (got '${input.suggestRequestId}')`);
44239
44354
  }
44240
44355
  if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
44241
- throw new Error(`buildDiffPreviewCard: writeRequestId must be 8 hex chars (got '${input.writeRequestId}')`);
44356
+ throw new Error(`buildDiffPreviewCard: writeRequestId must be 32 hex chars (got '${input.writeRequestId}')`);
44242
44357
  }
44243
44358
  const preview = input.preview;
44244
44359
  const bodyLines = [];
@@ -48556,10 +48671,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48556
48671
  }
48557
48672
 
48558
48673
  // ../src/build-info.ts
48559
- var VERSION = "0.13.35";
48560
- var COMMIT_SHA = "c41aabe5";
48561
- var COMMIT_DATE = "2026-05-25T01:43:28Z";
48562
- var LATEST_PR = 1765;
48674
+ var VERSION = "0.13.37";
48675
+ var COMMIT_SHA = "623c57e0";
48676
+ var COMMIT_DATE = "2026-05-25T06:09:28Z";
48677
+ var LATEST_PR = 1789;
48563
48678
  var COMMITS_AHEAD_OF_TAG = 0;
48564
48679
 
48565
48680
  // gateway/boot-version.ts
@@ -50465,7 +50580,11 @@ startTimer2({
50465
50580
  ...ctx.threadId != null ? { threadId: ctx.threadId } : {}
50466
50581
  });
50467
50582
  },
50468
- emitMetric: (event) => emitRuntimeMetric(event)
50583
+ emitMetric: (event) => emitRuntimeMetric(event),
50584
+ isActiveTurnNewerThan: (key, activatedAt) => {
50585
+ const turnStartedAt = activeTurnStartedAt.get(key);
50586
+ return turnStartedAt != null && turnStartedAt > activatedAt;
50587
+ }
50469
50588
  });
50470
50589
  var inboundSpool = STATIC ? undefined : createInboundSpool({
50471
50590
  path: join32(STATE_DIR, "inbound-spool.jsonl"),
@@ -50867,6 +50986,16 @@ ${reminder}
50867
50986
  `);
50868
50987
  }
50869
50988
  },
50989
+ postAttachment: async (args) => {
50990
+ const input = new import_grammy9.InputFile(Buffer.from(args.content, "utf8"), args.filename);
50991
+ await robustApiCall(() => bot.api.sendDocument(args.chatId, input, {
50992
+ ...args.threadId !== undefined ? { message_thread_id: args.threadId } : {}
50993
+ }), {
50994
+ chat_id: String(args.chatId),
50995
+ verb: "config-approval-attachment",
50996
+ ...args.threadId !== undefined ? { threadId: args.threadId } : {}
50997
+ });
50998
+ },
50870
50999
  log: (m) => process.stderr.write(`telegram gateway: config-approval \u2014 ${m}
50871
51000
  `)
50872
51001
  });
@@ -51222,6 +51351,7 @@ ${url}`;
51222
51351
  logOutbound("edit", chat_id, decision.messageId, decision.mergedText.length, "silent-anchor-merge");
51223
51352
  process.stderr.write(`telegram gateway: silent-reply auto-edit \u2014 ` + `chat=${chat_id} anchor=${decision.messageId} merged_len=${decision.mergedText.length}
51224
51353
  `);
51354
+ clearPending(statusKey(chat_id, threadId), "reply_finalize");
51225
51355
  noteOutbound3(statusKey(chat_id, threadId), {
51226
51356
  messageId: decision.messageId,
51227
51357
  text: decision.mergedText,
@@ -51362,6 +51492,7 @@ ${url}`;
51362
51492
  if (sentIds.length === chunks.length && chunks.length > 0) {
51363
51493
  const anchorMsgId = sentIds[chunks.length - 1];
51364
51494
  if (typeof anchorMsgId === "number") {
51495
+ clearPending(statusKey(chat_id, threadId), "reply_finalize");
51365
51496
  noteOutbound3(statusKey(chat_id, threadId), {
51366
51497
  messageId: anchorMsgId,
51367
51498
  text: chunks[chunks.length - 1],
@@ -51577,6 +51708,7 @@ async function executeStreamReply(args) {
51577
51708
  outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
51578
51709
  const streamFormat = args.format ?? (access.parseMode ?? "html");
51579
51710
  const streamParseMode = streamFormat === "html" ? "HTML" : streamFormat === "markdownv2" ? "MarkdownV2" : undefined;
51711
+ clearPending(statusKey(sChatId, sThreadId), "reply_finalize");
51580
51712
  noteOutbound3(statusKey(sChatId, sThreadId), {
51581
51713
  messageId: result.messageId,
51582
51714
  text: args.text,
@@ -17,6 +17,7 @@ import {
17
17
  handleRequestConfigFinalize,
18
18
  parseConfigApprovalCallback,
19
19
  resolvePendingConfigApproval,
20
+ truncateDiffForCard,
20
21
  _resetPendingConfigApprovalsForTest,
21
22
  _peekPendingConfigApprovalForTest,
22
23
  } from "./config-approval-handler.js";
@@ -66,7 +67,7 @@ afterEach(() => {
66
67
 
67
68
  describe("buildConfigApprovalCardBody", () => {
68
69
  it("HTML-escapes the diff body so `<` / `&` can't break out of the <pre> block", () => {
69
- const body = buildConfigApprovalCardBody({
70
+ const { body } = buildConfigApprovalCardBody({
70
71
  agentName: "klanker",
71
72
  reason: "<script>",
72
73
  unifiedDiff: "a & b <c>",
@@ -74,6 +75,97 @@ describe("buildConfigApprovalCardBody", () => {
74
75
  expect(body).toContain("&lt;script&gt;");
75
76
  expect(body).toContain("a &amp; b &lt;c&gt;");
76
77
  });
78
+
79
+ it("rendered body stays under Telegram's 4096-char limit when raw diff is all `&` (worst-case 5x escape inflation)", () => {
80
+ // 3000 `&` chars escape to 15000 `&amp;` chars — far past 4096.
81
+ // The post-escape cap MUST kick in and truncate the rendered body.
82
+ const evilDiff = "&".repeat(3000);
83
+ const { body } = buildConfigApprovalCardBody({
84
+ agentName: "klanker",
85
+ reason: "test",
86
+ unifiedDiff: evilDiff,
87
+ });
88
+ expect(body.length).toBeLessThanOrEqual(4096);
89
+ expect(body).toContain("diff continues, see attached file");
90
+ });
91
+
92
+ it("rendered body stays under 4096 when raw diff is all `<` (5x escape)", () => {
93
+ const evilDiff = "<".repeat(3000);
94
+ const { body } = buildConfigApprovalCardBody({
95
+ agentName: "klanker",
96
+ reason: "test",
97
+ unifiedDiff: evilDiff,
98
+ });
99
+ expect(body.length).toBeLessThanOrEqual(4096);
100
+ expect(body).toContain("&lt;");
101
+ });
102
+
103
+ it("clips an unbounded operator-supplied `reason` to ~500 chars with ellipsis", () => {
104
+ const longReason = "x".repeat(2000);
105
+ const { body } = buildConfigApprovalCardBody({
106
+ agentName: "klanker",
107
+ reason: longReason,
108
+ unifiedDiff: "small",
109
+ });
110
+ // The escaped reason should appear, but capped.
111
+ const reasonLine = body
112
+ .split("\n")
113
+ .find((l) => l.startsWith("Reason: "))!;
114
+ // "Reason: " prefix (8) + clipped reason.
115
+ expect(reasonLine.length).toBeLessThanOrEqual(8 + 500);
116
+ expect(reasonLine.endsWith("…")).toBe(true);
117
+ });
118
+
119
+ it("returns truncated:false when the rendered body fits without trimming", () => {
120
+ const { body, truncated } = buildConfigApprovalCardBody({
121
+ agentName: "klanker",
122
+ reason: "small",
123
+ unifiedDiff: "-a\n+b\n",
124
+ });
125
+ expect(truncated).toBe(false);
126
+ expect(body).toContain("<pre>-a\n+b\n</pre>");
127
+ });
128
+
129
+ it("returns truncated:true and appends the sentinel when the body has to shrink", () => {
130
+ const { body, truncated } = buildConfigApprovalCardBody({
131
+ agentName: "klanker",
132
+ reason: "test",
133
+ unifiedDiff: "&".repeat(3000),
134
+ });
135
+ expect(truncated).toBe(true);
136
+ expect(body).toContain("diff continues, see attached file");
137
+ });
138
+
139
+ it("handles a single unbroken line (no `\\n` to snap to) by char-truncation fallback", () => {
140
+ // 8000 `x` chars on a single line. After HTML escape (no inflation
141
+ // for `x`) the diff body alone is 8000 chars + framing — way past
142
+ // the cap. There's no newline to snap to, so the helper must fall
143
+ // through to char-truncation rather than returning empty.
144
+ const oneLongLine = "x".repeat(8000);
145
+ const { body, truncated } = buildConfigApprovalCardBody({
146
+ agentName: "klanker",
147
+ reason: "test",
148
+ unifiedDiff: oneLongLine,
149
+ });
150
+ expect(truncated).toBe(true);
151
+ expect(body.length).toBeLessThanOrEqual(4096);
152
+ // Should still contain SOME of the line content — the helper
153
+ // shouldn't degenerate to "framing + sentinel only" when char-
154
+ // truncation is available.
155
+ expect(body).toMatch(/x{100,}/);
156
+ expect(body).toContain("diff continues, see attached file");
157
+ });
158
+
159
+ it("rendered body stays under 4096 even when reason is also adversarial", () => {
160
+ const evilDiff = "&".repeat(3000);
161
+ const evilReason = "&".repeat(2000);
162
+ const { body } = buildConfigApprovalCardBody({
163
+ agentName: "klanker",
164
+ reason: evilReason,
165
+ unifiedDiff: evilDiff,
166
+ });
167
+ expect(body.length).toBeLessThanOrEqual(4096);
168
+ });
77
169
  });
78
170
 
79
171
  describe("handleRequestConfigApproval", () => {
@@ -100,6 +192,7 @@ describe("handleRequestConfigApproval", () => {
100
192
  requestId: "req-1",
101
193
  verdict: "deny",
102
194
  reason: expect.stringContaining("gateway serves 'klanker'"),
195
+ denySource: "dispatch_failure",
103
196
  },
104
197
  ]);
105
198
  });
@@ -225,6 +318,100 @@ describe("handleRequestConfigFinalize", () => {
225
318
  });
226
319
  });
227
320
 
321
+ describe("oversize diff → attachment fallback (#1762)", () => {
322
+ function bigDiff(lines: number): string {
323
+ // Each line ~80 chars → 200 lines ≈ 16 KB, comfortably > 4096.
324
+ const row =
325
+ "-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
326
+ return Array.from({ length: lines }, (_, i) => `${row}${i}`).join("\n");
327
+ }
328
+
329
+ it("truncateDiffForCard caps the diff and appends a sentinel", () => {
330
+ const truncated = truncateDiffForCard(bigDiff(200), 50, 3000);
331
+ expect(truncated.length).toBeLessThanOrEqual(3050);
332
+ expect(truncated.endsWith("[… diff continues, see attached file]")).toBe(
333
+ true,
334
+ );
335
+ });
336
+
337
+ it("returns the original diff unchanged when below the line cap", () => {
338
+ const small = "--- a\n+++ b\n@@\n-x\n+y\n";
339
+ expect(truncateDiffForCard(small, 50)).toBe(small);
340
+ });
341
+
342
+ it("oversize body still posts a card with buttons AND fires postAttachment", async () => {
343
+ const huge = bigDiff(200);
344
+ const attachmentCalls: Array<{
345
+ chatId: number | string;
346
+ filename: string;
347
+ content: string;
348
+ }> = [];
349
+ const { client, sent, deps } = fakeDeps({
350
+ postAttachment: async (a: {
351
+ chatId: number | string;
352
+ filename: string;
353
+ content: string;
354
+ }) => {
355
+ attachmentCalls.push({
356
+ chatId: a.chatId,
357
+ filename: a.filename,
358
+ content: a.content,
359
+ });
360
+ },
361
+ });
362
+ await handleRequestConfigApproval(
363
+ client,
364
+ { ...baseMsg, unifiedDiff: huge },
365
+ deps,
366
+ );
367
+
368
+ // Card was posted exactly once, with buttons, and within Telegram's limit.
369
+ expect(deps.postCard).toHaveBeenCalledTimes(1);
370
+ const postArgs = (deps.postCard as ReturnType<typeof vi.fn>).mock
371
+ .calls[0]![0] as { text: string; replyMarkup: unknown };
372
+ expect(postArgs.text.length).toBeLessThanOrEqual(4096);
373
+ expect(postArgs.text).toMatch(/diff continues, see attached file/);
374
+ expect(postArgs.replyMarkup).toBeDefined();
375
+
376
+ // Attachment carries the FULL diff, named .patch, keyed by requestId.
377
+ expect(attachmentCalls.length).toBe(1);
378
+ expect(attachmentCalls[0]!.filename).toBe("config-edit-req-1.patch");
379
+ expect(attachmentCalls[0]!.content).toBe(huge);
380
+
381
+ // The pending entry is registered — handler hasn't auto-denied.
382
+ expect(_peekPendingConfigApprovalForTest("req-1")).toBeDefined();
383
+ // No verdict has crossed the wire yet (still pending operator tap).
384
+ expect(sent.filter((s) => s.type === "config_approval_resolved")).toEqual(
385
+ [],
386
+ );
387
+ });
388
+
389
+ it("oversize but no postAttachment dep → card still posts, missing-attachment is logged", async () => {
390
+ const huge = bigDiff(200);
391
+ const logs: string[] = [];
392
+ const { client, deps } = fakeDeps({ log: (m: string) => logs.push(m) });
393
+ await handleRequestConfigApproval(
394
+ client,
395
+ { ...baseMsg, unifiedDiff: huge },
396
+ deps,
397
+ );
398
+ expect(deps.postCard).toHaveBeenCalledTimes(1);
399
+ expect(
400
+ logs.some((l) => l.includes("no postAttachment dep wired")),
401
+ ).toBe(true);
402
+ expect(_peekPendingConfigApprovalForTest("req-1")).toBeDefined();
403
+ });
404
+
405
+ it("postCard failure → deny carries denySource='dispatch_failure'", async () => {
406
+ const { client, sent, deps } = fakeDeps({
407
+ postCard: vi.fn(async () => null),
408
+ });
409
+ await handleRequestConfigApproval(client, baseMsg, deps);
410
+ expect(sent[0]!.verdict).toBe("deny");
411
+ expect(sent[0]!.denySource).toBe("dispatch_failure");
412
+ });
413
+ });
414
+
228
415
  describe("parseConfigApprovalCallback", () => {
229
416
  it("parses well-formed callbacks", () => {
230
417
  expect(parseConfigApprovalCallback("cfg:abc:approve")).toEqual({