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.
@@ -7,6 +7,7 @@ import type {
7
7
  RequestConfigApprovalMessage,
8
8
  RequestConfigFinalizeMessage,
9
9
  } from "./ipc-protocol.js";
10
+ import { truncateRawToFit } from "./oversize-card-body.js";
10
11
 
11
12
  /** Pending approval state — in-memory only (no SQLite per RFC §3.4). */
12
13
  interface PendingConfigApproval {
@@ -50,6 +51,18 @@ export interface ConfigApprovalHandlerDeps {
50
51
  messageId: number;
51
52
  text: string;
52
53
  }) => Promise<void>;
54
+ /**
55
+ * Send the full diff as a `.patch` document attachment when the
56
+ * card body exceeds Telegram's 4096-char sendMessage limit
57
+ * (#1762). Best-effort — failures are logged and do NOT block the
58
+ * approval flow (the truncated card is still actionable).
59
+ */
60
+ postAttachment?: (args: {
61
+ chatId: number | string;
62
+ threadId?: number;
63
+ filename: string;
64
+ content: string;
65
+ }) => Promise<void>;
53
66
  log?: (msg: string) => void;
54
67
  }
55
68
 
@@ -59,19 +72,107 @@ export interface ConfigApprovalHandlerDeps {
59
72
  * diffs may be truncated by the API; the validator already caps
60
73
  * unified_diff at ~63 KiB so practical fleet edits fit comfortably.
61
74
  */
75
+ /**
76
+ * Truncate a unified diff for inline display in the card body when
77
+ * the full diff would exceed Telegram's 4096-char sendMessage limit.
78
+ * Caps at `maxLines` lines AND at `maxChars` *raw* characters
79
+ * (whichever trips first), then appends a sentinel pointing to the
80
+ * attached `.patch` document. (#1762)
81
+ *
82
+ * NOTE: This is a *raw-input* cap. HTML escaping happens downstream
83
+ * and can inflate by up to 5x per char (`&` → `&amp;`). The
84
+ * load-bearing post-escape cap lives in `buildConfigApprovalCardBody`
85
+ * (rendered-body cap), which re-truncates the raw diff if escaping
86
+ * blew past Telegram's 4096 sendMessage limit. This function is the
87
+ * cheap fast-path for the common case.
88
+ */
89
+ export function truncateDiffForCard(
90
+ unifiedDiff: string,
91
+ maxLines = 50,
92
+ maxChars = 3000,
93
+ ): string {
94
+ const sentinel = "\n[… diff continues, see attached file]";
95
+ const lines = unifiedDiff.split("\n");
96
+ let out: string;
97
+ if (lines.length <= maxLines) {
98
+ out = unifiedDiff;
99
+ } else {
100
+ out = lines.slice(0, maxLines).join("\n");
101
+ }
102
+ if (out.length > maxChars) {
103
+ // Snap to the last complete line within the cap, falling back to
104
+ // a hard char cut if a single line exceeds maxChars.
105
+ const cap = out.slice(0, maxChars);
106
+ const lastNl = cap.lastIndexOf("\n");
107
+ out = lastNl > 0 ? cap.slice(0, lastNl) : cap;
108
+ }
109
+ return out === unifiedDiff ? out : out + sentinel;
110
+ }
111
+
112
+ /**
113
+ * Telegram `sendMessage` hard limit. We render with `parse_mode=HTML`,
114
+ * so the limit applies to the rendered (escaped) body, NOT the raw
115
+ * pre-escape source. Worst-case escape is `&` → `&amp;` (5x).
116
+ */
117
+ const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
118
+ /** Safety margin under the hard limit for invisible framing wobble. */
119
+ const RENDERED_BODY_CAP = 3900;
120
+ /** Operator-supplied `reason` is unbounded at the wire — clip it. */
121
+ const REASON_MAX_CHARS = 500;
122
+ const REASON_ELLIPSIS = "…";
123
+ /**
124
+ * Sentinel appended to a truncated diff in the inline card body when
125
+ * the full diff ships separately as a `.patch` attachment. Exported
126
+ * so the dispatcher can key oversize detection off the
127
+ * `{truncated}` flag returned by `buildConfigApprovalCardBody`
128
+ * instead of substring-matching this string.
129
+ */
130
+ export const DIFF_SENTINEL = "\n[… diff continues, see attached file]";
131
+
132
+ function escapeHtml(s: string): string {
133
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
134
+ }
135
+
136
+ function clipReason(reason: string): string {
137
+ if (reason.length <= REASON_MAX_CHARS) return reason;
138
+ return reason.slice(0, REASON_MAX_CHARS - REASON_ELLIPSIS.length) +
139
+ REASON_ELLIPSIS;
140
+ }
141
+
142
+ /**
143
+ * Render the card body, guaranteeing the result fits under Telegram's
144
+ * 4096-char sendMessage limit even when HTML escaping inflates the
145
+ * raw diff up to 5x (worst case: all `&`). Strategy:
146
+ *
147
+ * 1. Clip `reason` (user-supplied, unbounded) to REASON_MAX_CHARS.
148
+ * 2. Render with the (already line/char-capped) diff.
149
+ * 3. If the rendered body still exceeds RENDERED_BODY_CAP, binary-
150
+ * shrink the raw diff and re-render until it fits. Truncation
151
+ * happens on the RAW diff (then re-escaped), so we never cut
152
+ * mid-entity like `&am|p;`.
153
+ *
154
+ * The full diff still ships as a `.patch` attachment — this cap only
155
+ * shrinks the inline preview.
156
+ */
62
157
  export function buildConfigApprovalCardBody(args: {
63
158
  agentName: string;
64
159
  reason: string;
65
160
  unifiedDiff: string;
66
- }): string {
67
- const esc = (s: string) =>
68
- s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
69
- return (
161
+ }): { body: string; truncated: boolean } {
162
+ const safeReason = clipReason(args.reason);
163
+ const render = (diff: string): string =>
70
164
  `🛠 <b>Config edit proposed</b>\n` +
71
- `Agent: <code>${esc(args.agentName)}</code>\n` +
72
- `Reason: ${esc(args.reason)}\n\n` +
73
- `<pre>${esc(args.unifiedDiff)}</pre>`
74
- );
165
+ `Agent: <code>${escapeHtml(args.agentName)}</code>\n` +
166
+ `Reason: ${escapeHtml(safeReason)}\n\n` +
167
+ `<pre>${escapeHtml(diff)}</pre>`;
168
+
169
+ return truncateRawToFit({
170
+ raw: args.unifiedDiff,
171
+ render,
172
+ cap: RENDERED_BODY_CAP,
173
+ sentinel: DIFF_SENTINEL,
174
+ hardLimit: TELEGRAM_SENDMESSAGE_LIMIT,
175
+ });
75
176
  }
76
177
 
77
178
  /**
@@ -85,6 +186,7 @@ export async function handleRequestConfigApproval(
85
186
  const reply = (
86
187
  verdict: "approve" | "deny" | "timeout",
87
188
  reason?: string,
189
+ denySource?: "operator" | "dispatch_failure",
88
190
  ) => {
89
191
  try {
90
192
  client.send({
@@ -92,6 +194,7 @@ export async function handleRequestConfigApproval(
92
194
  requestId: msg.requestId,
93
195
  verdict,
94
196
  ...(reason ? { reason } : {}),
197
+ ...(denySource ? { denySource } : {}),
95
198
  });
96
199
  } catch (err) {
97
200
  deps.log?.(
@@ -101,21 +204,44 @@ export async function handleRequestConfigApproval(
101
204
  };
102
205
 
103
206
  if (msg.agentName !== deps.agentName) {
104
- reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`);
207
+ reply(
208
+ "deny",
209
+ `gateway serves '${deps.agentName}', not '${msg.agentName}'`,
210
+ "dispatch_failure",
211
+ );
105
212
  return;
106
213
  }
107
214
 
108
215
  const target = deps.loadTargetChat();
109
216
  if (target === null) {
110
- reply("deny", "no target chat available — operator not paired?");
217
+ reply(
218
+ "deny",
219
+ "no target chat available — operator not paired?",
220
+ "dispatch_failure",
221
+ );
111
222
  return;
112
223
  }
113
224
 
114
- const body = buildConfigApprovalCardBody({
225
+ // Pre-flight oversize handling (#1762). Telegram caps sendMessage
226
+ // at 4096 chars and we render with parse_mode=HTML, so the limit
227
+ // applies to the rendered (escaped) body — worst-case `&` → `&amp;`
228
+ // inflates 5x. Fast-path with a cheap raw-input cap, then let
229
+ // buildConfigApprovalCardBody enforce the post-escape rendered cap
230
+ // (which re-truncates the diff if escaping blew past the limit). We
231
+ // ship the full diff as a `.patch` attachment whenever truncation
232
+ // happens in either layer.
233
+ const prelim = truncateDiffForCard(msg.unifiedDiff);
234
+ const built = buildConfigApprovalCardBody({
115
235
  agentName: msg.agentName,
116
236
  reason: msg.reason,
117
- unifiedDiff: msg.unifiedDiff,
237
+ unifiedDiff: prelim,
118
238
  });
239
+ const body = built.body;
240
+ // Oversize iff EITHER the cheap raw fast-path trimmed lines OR the
241
+ // post-escape rendered cap had to re-truncate. Keyed off the
242
+ // builder's structured `truncated` flag instead of substring-
243
+ // matching the sentinel string (#1767 nit).
244
+ const oversize = prelim !== msg.unifiedDiff || built.truncated;
119
245
  const replyMarkup = deps.buildKeyboard(msg.requestId);
120
246
 
121
247
  const posted = await deps.postCard({
@@ -125,9 +251,12 @@ export async function handleRequestConfigApproval(
125
251
  replyMarkup,
126
252
  });
127
253
  if (posted === null) {
128
- reply("deny", "Telegram sendMessage failed");
254
+ reply("deny", "Telegram sendMessage failed", "dispatch_failure");
129
255
  return;
130
256
  }
257
+ if (oversize) {
258
+ await maybePostAttachment(deps, target, msg);
259
+ }
131
260
 
132
261
  const entry: PendingConfigApproval = {
133
262
  requestId: msg.requestId,
@@ -245,8 +374,34 @@ export async function handleRequestConfigFinalize(
245
374
  }
246
375
  }
247
376
 
248
- function escapeHtml(s: string): string {
249
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
377
+ /**
378
+ * Best-effort attachment of the full diff as a `.patch` document.
379
+ * Logged-and-swallowed on failure — the truncated card body remains
380
+ * actionable even without the attachment. (#1762)
381
+ */
382
+ async function maybePostAttachment(
383
+ deps: ConfigApprovalHandlerDeps,
384
+ target: { chatId: number | string; threadId?: number },
385
+ msg: RequestConfigApprovalMessage,
386
+ ): Promise<void> {
387
+ if (deps.postAttachment === undefined) {
388
+ deps.log?.(
389
+ `oversize config approval card but no postAttachment dep wired (requestId=${msg.requestId})`,
390
+ );
391
+ return;
392
+ }
393
+ try {
394
+ await deps.postAttachment({
395
+ chatId: target.chatId,
396
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
397
+ filename: `config-edit-${msg.requestId}.patch`,
398
+ content: msg.unifiedDiff,
399
+ });
400
+ } catch (err) {
401
+ deps.log?.(
402
+ `config approval attachment failed (requestId=${msg.requestId}): ${(err as Error).message}`,
403
+ );
404
+ }
250
405
  }
251
406
 
252
407
  // Test-only: clear the in-memory pending map between cases.
@@ -111,7 +111,7 @@ describe("buildDiffPreviewCard — input validation", () => {
111
111
  const preview = buildDiffPreview(baseInput());
112
112
  expect(() =>
113
113
  buildDiffPreviewCard({ preview, suggestRequestId: "not-hex" }),
114
- ).toThrow(/8 hex chars/);
114
+ ).toThrow(/32 hex chars/);
115
115
  });
116
116
 
117
117
  it("throws on a malformed writeRequestId", () => {
@@ -122,7 +122,7 @@ describe("buildDiffPreviewCard — input validation", () => {
122
122
  suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
123
123
  writeRequestId: "ABCDEF01", // wrong case
124
124
  }),
125
- ).toThrow(/8 hex chars/);
125
+ ).toThrow(/32 hex chars/);
126
126
  });
127
127
  });
128
128
 
@@ -73,12 +73,12 @@ export function buildDiffPreviewCard(
73
73
  ): BuiltDiffPreviewCard {
74
74
  if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
75
75
  throw new Error(
76
- `buildDiffPreviewCard: suggestRequestId must be 8 hex chars (got '${input.suggestRequestId}')`,
76
+ `buildDiffPreviewCard: suggestRequestId must be 32 hex chars (got '${input.suggestRequestId}')`,
77
77
  );
78
78
  }
79
79
  if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
80
80
  throw new Error(
81
- `buildDiffPreviewCard: writeRequestId must be 8 hex chars (got '${input.writeRequestId}')`,
81
+ `buildDiffPreviewCard: writeRequestId must be 32 hex chars (got '${input.writeRequestId}')`,
82
82
  );
83
83
  }
84
84
 
@@ -279,6 +279,76 @@ describe("handleRequestDriveApproval — TTL clamping", () => {
279
279
  });
280
280
  });
281
281
 
282
+ // ────────────────────────────────────────────────────────────────────────
283
+ // Oversize-body fit (#1767)
284
+ // ────────────────────────────────────────────────────────────────────────
285
+
286
+ describe("handleRequestDriveApproval — oversize card body fit (#1767)", () => {
287
+ it("truncates the rendered text under 4096 chars before posting", async () => {
288
+ const spy = makeSpy();
289
+ // buildCard returns a body well past Telegram's 4096-char limit
290
+ // (simulates a docTitle / anchor / summary whose HTML-escape
291
+ // inflated past the limit). Handler must shrink it before postCard.
292
+ const giantText =
293
+ "<b>Title</b>\n" +
294
+ Array.from({ length: 200 }, (_, i) => `line-${i}-${"x".repeat(40)}`).join(
295
+ "\n",
296
+ );
297
+ expect(giantText.length).toBeGreaterThan(4096);
298
+ await handleRequestDriveApproval(
299
+ clientFor(spy),
300
+ msgFor(),
301
+ deps({
302
+ spy,
303
+ buildCard: () => ({ text: giantText, reply_markup: { stub: true } }),
304
+ }),
305
+ );
306
+ expect(spy.posted).toHaveLength(1);
307
+ expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(4096);
308
+ expect(spy.posted[0]!.text).toContain("preview truncated");
309
+ // Card was successfully posted → ok:true response went back.
310
+ expect(spy.sent[0]?.ok).toBe(true);
311
+ // The log line surfaces the truncation event.
312
+ expect(
313
+ spy.logs.some((l) => l.includes("oversize-truncated")),
314
+ ).toBe(true);
315
+ });
316
+
317
+ it("does not touch the body when it already fits", async () => {
318
+ const spy = makeSpy();
319
+ const small = "<b>Small</b>\nline 1\nline 2";
320
+ await handleRequestDriveApproval(
321
+ clientFor(spy),
322
+ msgFor(),
323
+ deps({
324
+ spy,
325
+ buildCard: () => ({ text: small, reply_markup: { stub: true } }),
326
+ }),
327
+ );
328
+ expect(spy.posted[0]!.text).toBe(small);
329
+ expect(
330
+ spy.logs.some((l) => l.includes("oversize-truncated")),
331
+ ).toBe(false);
332
+ });
333
+
334
+ it("post failure after truncation surfaces a structured reason", async () => {
335
+ const spy = makeSpy();
336
+ const giantText =
337
+ "<b>Title</b>\n" + "x".repeat(8000);
338
+ await handleRequestDriveApproval(
339
+ clientFor(spy),
340
+ msgFor(),
341
+ deps({
342
+ spy,
343
+ buildCard: () => ({ text: giantText, reply_markup: { stub: true } }),
344
+ postCard: async () => null,
345
+ }),
346
+ );
347
+ expect(spy.sent[0]?.ok).toBe(false);
348
+ expect(spy.sent[0]?.reason).toMatch(/oversize-body truncation/);
349
+ });
350
+ });
351
+
282
352
  // ────────────────────────────────────────────────────────────────────────
283
353
  // Always responds (no path drops the response)
284
354
  // ────────────────────────────────────────────────────────────────────────
@@ -30,6 +30,7 @@ import type {
30
30
  DriveApprovalPostedEvent,
31
31
  RequestDriveApprovalMessage,
32
32
  } from "./ipc-protocol.js";
33
+ import { truncateRawToFit } from "./oversize-card-body.js";
33
34
 
34
35
  // ────────────────────────────────────────────────────────────────────────
35
36
  // Injected deps — caller (gateway.ts) wires these from the existing
@@ -100,6 +101,18 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
100
101
  const MAX_TTL_MS = 30 * 60 * 1000; // 30 minutes
101
102
  const MIN_TTL_MS = 30 * 1000; // 30 seconds
102
103
 
104
+ /**
105
+ * Telegram `sendMessage` hard limit. `buildDiffPreviewCard` HTML-
106
+ * escapes the docTitle + every body line with no upstream length
107
+ * cap, so an adversarial or just-unusually-long docTitle / anchor
108
+ * displayName / agent-supplied summary can render past 4096 once
109
+ * `&` / `<` / `>` inflate up to 5x. We rebuild a truncated body
110
+ * keyed off the wrapper-attested fields when that happens. (#1767)
111
+ */
112
+ const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
113
+ const RENDERED_BODY_CAP = 3900;
114
+ const OVERSIZE_SENTINEL = "\n[… preview truncated; open in Drive for full context]";
115
+
103
116
  /**
104
117
  * Top-level handler called by ipc-server's onRequestDriveApproval.
105
118
  * Always sends a single `drive_approval_posted` reply (success or
@@ -204,20 +217,56 @@ export async function handleRequestDriveApproval(
204
217
  });
205
218
  return;
206
219
  }
220
+ // Oversize guard (#1767). buildDiffPreviewCard renders title +
221
+ // every body line HTML-escaped with no length cap. Worst-case `&`
222
+ // / `<` / `>` inflate 5x — a long docTitle or anchor displayName
223
+ // pushes past Telegram's 4096-char sendMessage limit and the API
224
+ // returns a generic 400 that surfaces as a silent E_DENIED. Cap
225
+ // the rendered body BEFORE posting.
226
+ let cardText = card.text;
227
+ let truncatedForFit = false;
228
+ if (cardText.length > RENDERED_BODY_CAP) {
229
+ const fit = truncateRawToFit({
230
+ raw: card.text,
231
+ // The "raw" here is the already-escaped card text — we don't
232
+ // have access to pre-escape source at this layer because
233
+ // buildDiffPreviewCard owns escaping. Line-snap is the safe
234
+ // granularity: HTML entities like `&amp;` never span `\n`, so
235
+ // truncating at a newline boundary can't bisect an entity.
236
+ // The defensive single-long-line fallback may char-cut into an
237
+ // entity, but the drive-preview lines are short (anchor name,
238
+ // metrics, capped 200-char summary) so this is unreachable
239
+ // unless a multi-KB docTitle slipped through Drive itself.
240
+ render: (slice) => slice,
241
+ cap: RENDERED_BODY_CAP,
242
+ sentinel: OVERSIZE_SENTINEL,
243
+ hardLimit: TELEGRAM_SENDMESSAGE_LIMIT,
244
+ });
245
+ cardText = fit.body;
246
+ truncatedForFit = fit.truncated;
247
+ }
248
+
207
249
  const posted = await deps.postCard({
208
250
  chatId: target.chatId,
209
251
  ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
210
- text: card.text,
252
+ text: cardText,
211
253
  replyMarkup: card.reply_markup,
212
254
  });
213
255
  if (posted === null) {
214
256
  reply({
215
257
  correlationId: msg.correlationId,
216
258
  ok: false,
217
- reason: "Telegram sendMessage failed",
259
+ reason: truncatedForFit
260
+ ? "Telegram sendMessage failed even after oversize-body truncation"
261
+ : "Telegram sendMessage failed",
218
262
  });
219
263
  return;
220
264
  }
265
+ if (truncatedForFit) {
266
+ deps.log?.(
267
+ `drive_approval_posted oversize-truncated correlation=${msg.correlationId} original_len=${card.text.length} rendered_len=${cardText.length}`,
268
+ );
269
+ }
221
270
 
222
271
  deps.log?.(
223
272
  `drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`,
@@ -3438,6 +3438,14 @@ pendingProgress.startTimer({
3438
3438
  )
3439
3439
  },
3440
3440
  emitMetric: (event) => emitRuntimeMetric(event),
3441
+ // #1760 defense-in-depth: if a newer turn for this chat is active at
3442
+ // tick time, the prior turn's pending-progress is stale (the
3443
+ // canonical teardown was missed). Drop the ticker instead of editing
3444
+ // the old anchor — see pending-work-progress.ts's docblock.
3445
+ isActiveTurnNewerThan: (key, activatedAt) => {
3446
+ const turnStartedAt = activeTurnStartedAt.get(key)
3447
+ return turnStartedAt != null && turnStartedAt > activatedAt
3448
+ },
3441
3449
  })
3442
3450
 
3443
3451
  // Per-agent buffer for synthetic inbounds the gateway couldn't deliver
@@ -4113,6 +4121,24 @@ const ipcServer: IpcServer = createIpcServer({
4113
4121
  )
4114
4122
  }
4115
4123
  },
4124
+ // #1762: send the full diff as a `.patch` attachment when the
4125
+ // card body would exceed Telegram's 4096-char sendMessage limit.
4126
+ postAttachment: async (args) => {
4127
+ const input = new InputFile(Buffer.from(args.content, 'utf8'), args.filename)
4128
+ await robustApiCall(
4129
+ () =>
4130
+ bot.api.sendDocument(args.chatId, input, {
4131
+ ...(args.threadId !== undefined
4132
+ ? { message_thread_id: args.threadId }
4133
+ : {}),
4134
+ }),
4135
+ {
4136
+ chat_id: String(args.chatId),
4137
+ verb: 'config-approval-attachment',
4138
+ ...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
4139
+ },
4140
+ )
4141
+ },
4116
4142
  log: (m) =>
4117
4143
  process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
4118
4144
  })
@@ -4693,6 +4719,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4693
4719
  // - #1664 silent-end re-prompt fires even when the
4694
4720
  // accumulated silent content qualifies as substantive;
4695
4721
  // - retries within the dedup window may double-send.
4722
+ // #1760 primary fix — clear any stale prior-turn ticker
4723
+ // before re-anchoring on this silent-reply edit. See the
4724
+ // matching comment at the executeReply finalize site below.
4725
+ pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
4696
4726
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
4697
4727
  messageId: decision.messageId,
4698
4728
  text: decision.mergedText,
@@ -4886,6 +4916,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4886
4916
  if (sentIds.length === chunks.length && chunks.length > 0) {
4887
4917
  const anchorMsgId = sentIds[chunks.length - 1]
4888
4918
  if (typeof anchorMsgId === 'number') {
4919
+ // #1760 primary fix — clear any stale prior-turn ticker BEFORE
4920
+ // re-anchoring. The canonical teardown wires (turn_end,
4921
+ // subagent_handback, inbound) can be missed (e.g. SDK turn_end
4922
+ // event dropped, as in the #1760 live evidence). Tearing down on
4923
+ // every reply-finalize is idempotent and resilient: it's a no-op
4924
+ // when nothing is active, and drops a stale ambient before the
4925
+ // new turn captures its anchor.
4926
+ pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
4889
4927
  pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
4890
4928
  messageId: anchorMsgId,
4891
4929
  text: chunks[chunks.length - 1],
@@ -5323,6 +5361,10 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5323
5361
  streamFormat === 'html' ? 'HTML'
5324
5362
  : streamFormat === 'markdownv2' ? 'MarkdownV2'
5325
5363
  : undefined
5364
+ // #1760 primary fix — clear any stale prior-turn ticker before
5365
+ // re-anchoring on stream_reply done. See the matching comment at
5366
+ // the executeReply finalize site.
5367
+ pendingProgress.clearPending(statusKey(sChatId, sThreadId), 'reply_finalize')
5326
5368
  pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
5327
5369
  messageId: result.messageId,
5328
5370
  text: args.text as string,
@@ -107,8 +107,17 @@ export interface ConfigApprovalResolvedEvent {
107
107
  /** Echoes the requestId from the originating request_config_approval. */
108
108
  requestId: string;
109
109
  verdict: "approve" | "deny" | "timeout";
110
- /** Diagnostic detail when present (currently unused; reserved). */
110
+ /** Diagnostic detail when present. */
111
111
  reason?: string;
112
+ /**
113
+ * Distinguishes an actual operator tap-deny (`"operator"`) from a
114
+ * gateway-side dispatch failure that auto-denied because the card
115
+ * never reached the operator (`"dispatch_failure"`). Only set on
116
+ * `verdict: "deny"` events. Caller (hostd) maps `dispatch_failure`
117
+ * to a distinct error code so the failure isn't misattributed to
118
+ * the operator. Issue #1762.
119
+ */
120
+ denySource?: "operator" | "dispatch_failure";
112
121
  }
113
122
 
114
123
  export type GatewayToClient =
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for the shared truncateRawToFit helper (#1767).
3
+ *
4
+ * Covers:
5
+ * - No-op when the rendered body already fits.
6
+ * - Binary-search shrinks the raw slice; result fits under `cap`.
7
+ * - Line-snap when newlines exist; sentinel appended.
8
+ * - Char-truncation fallback when a single unbroken line exceeds
9
+ * the budget (no `\n` to snap to).
10
+ * - Defensive hard-cut when even the framing-alone overflows.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import { truncateRawToFit } from "./oversize-card-body.js";
15
+
16
+ const SENTINEL = "\n[… truncated]";
17
+
18
+ function frame(escapeMultiplier: number) {
19
+ // Render closure that mimics a `<pre>`-wrapped HTML-escaped body
20
+ // where every `&` inflates `escapeMultiplier`-fold.
21
+ return (raw: string) => {
22
+ const inflated = raw.replace(/&/g, "&".repeat(escapeMultiplier));
23
+ return `<b>Hdr</b>\n<pre>${inflated}</pre>`;
24
+ };
25
+ }
26
+
27
+ describe("truncateRawToFit", () => {
28
+ it("returns the full body unchanged when it fits under cap", () => {
29
+ const raw = "small content";
30
+ const { body, truncated } = truncateRawToFit({
31
+ raw,
32
+ render: (s) => `<pre>${s}</pre>`,
33
+ cap: 100,
34
+ sentinel: SENTINEL,
35
+ });
36
+ expect(truncated).toBe(false);
37
+ expect(body).toBe("<pre>small content</pre>");
38
+ });
39
+
40
+ it("shrinks raw via binary-search until the rendered body fits the cap (5x escape inflation)", () => {
41
+ const raw = "&".repeat(2000); // 2000 chars raw, 10000 after 5x inflate
42
+ const { body, truncated } = truncateRawToFit({
43
+ raw,
44
+ render: frame(5),
45
+ cap: 1000,
46
+ sentinel: SENTINEL,
47
+ });
48
+ expect(truncated).toBe(true);
49
+ expect(body.length).toBeLessThanOrEqual(1000);
50
+ expect(body).toContain("truncated");
51
+ });
52
+
53
+ it("snaps to the last newline within the chosen raw prefix (no mid-line cut)", () => {
54
+ const raw = ["line-a", "line-b", "line-c", "line-d", "line-e"]
55
+ .map((l) => l.repeat(50))
56
+ .join("\n");
57
+ const { body, truncated } = truncateRawToFit({
58
+ raw,
59
+ render: (s) => `<pre>${s}</pre>`,
60
+ cap: 600,
61
+ sentinel: SENTINEL,
62
+ });
63
+ expect(truncated).toBe(true);
64
+ // The portion before the sentinel must end at a complete line —
65
+ // no partial `line-?` suffix mid-word.
66
+ const beforeSentinel = body.slice(
67
+ 0,
68
+ body.length - SENTINEL.length - "</pre>".length,
69
+ );
70
+ // Every line in the source repeated its label 50x — assert the
71
+ // tail of the kept content ends with a full repeated block, not
72
+ // a chopped one. Easiest check: the body must not end mid-word
73
+ // like `line-` (the dash followed by no letter).
74
+ expect(beforeSentinel).not.toMatch(/line-$/);
75
+ });
76
+
77
+ it("falls through to char-truncation when a single unbroken line exceeds cap", () => {
78
+ const raw = "x".repeat(5000); // no newlines at all
79
+ const { body, truncated } = truncateRawToFit({
80
+ raw,
81
+ render: (s) => `<pre>${s}</pre>`,
82
+ cap: 500,
83
+ sentinel: SENTINEL,
84
+ });
85
+ expect(truncated).toBe(true);
86
+ expect(body.length).toBeLessThanOrEqual(500);
87
+ // Some of the line content survives — the helper shouldn't
88
+ // collapse to "<pre>" + sentinel only.
89
+ expect(body).toMatch(/x{100,}/);
90
+ expect(body).toContain("truncated");
91
+ });
92
+
93
+ it("hard-cuts at hardLimit when framing alone overflows (defensive)", () => {
94
+ // Framing-alone is 5000 chars (way past cap), and the renderer
95
+ // ignores its input — so no slice of raw can ever fit. The
96
+ // helper should hard-cut to hardLimit rather than loop forever.
97
+ const huge = "Z".repeat(5000);
98
+ const { body, truncated } = truncateRawToFit({
99
+ raw: "ignored",
100
+ render: () => huge,
101
+ cap: 1000,
102
+ sentinel: SENTINEL,
103
+ hardLimit: 1100,
104
+ });
105
+ expect(truncated).toBe(true);
106
+ expect(body.length).toBeLessThanOrEqual(1100);
107
+ });
108
+ });