switchroom 0.18.6 → 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
9
  import {
10
10
  createPtyPartialHandler,
11
11
  handlePtyPartialPure,
12
+ looksLikeRawApiError,
12
13
  type PtyHandlerState,
13
14
  type PtyHandlerDeps,
14
15
  } from '../pty-partial-handler.js'
@@ -65,6 +66,35 @@ describe('handlePtyPartialPure', () => {
65
66
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
66
67
  })
67
68
 
69
+ it('suppresses a raw API-error TUI line so it never leaks to chat (#2922 Bug 3)', async () => {
70
+ const state = makeState({ currentSessionChatId: '1' })
71
+ const deps = makeDeps(bot)
72
+ // The exact shape Claude Code's TUI renders on a transient 429.
73
+ const raw =
74
+ "API Error: Server is temporarily limiting requests (not your usage limit) · " +
75
+ "b'{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\"}}'"
76
+ const action = handlePtyPartialPure(raw, state, deps)
77
+ expect(action).toBe('error-suppressed')
78
+ await microtaskFlush()
79
+ // Nothing sent — the operator-event pipeline owns the user-facing card.
80
+ expect(bot.api.sendMessage).not.toHaveBeenCalled()
81
+ expect(state.activeDraftStreams.size).toBe(0)
82
+ // Not recorded as a preview, so it can't poison later dedup either.
83
+ expect(state.lastPtyPreviewByChat.size).toBe(0)
84
+ })
85
+
86
+ it('does NOT suppress ordinary assistant text that merely mentions errors', async () => {
87
+ const state = makeState({ currentSessionChatId: '1' })
88
+ const action = handlePtyPartialPure(
89
+ "Here's how to handle an error in your retry loop:",
90
+ state,
91
+ makeDeps(bot),
92
+ )
93
+ expect(action).toBe('update-new')
94
+ await microtaskFlush()
95
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
96
+ })
97
+
68
98
  it('dedups when same text arrives twice in a row', async () => {
69
99
  const state = makeState({ currentSessionChatId: '1' })
70
100
  const deps = makeDeps(bot)
@@ -324,3 +354,29 @@ describe('createPtyPartialHandler — session + buffer replay', () => {
324
354
  expect(state.activeDraftStreams.size).toBe(0)
325
355
  })
326
356
  })
357
+
358
+ describe('looksLikeRawApiError (#2922 Bug 3)', () => {
359
+ it('flags the CLI "API Error: … · b\'{…}\'" line', () => {
360
+ expect(
361
+ looksLikeRawApiError(
362
+ "API Error: Server is temporarily limiting requests · b'{\"type\":\"error\"}'",
363
+ ),
364
+ ).toBe(true)
365
+ })
366
+
367
+ it('flags a bare rate_limit_error JSON body', () => {
368
+ expect(
369
+ looksLikeRawApiError('{"type":"error","error":{"type":"rate_limit_error"}}'),
370
+ ).toBe(true)
371
+ })
372
+
373
+ it('flags overloaded_error and is_error markers', () => {
374
+ expect(looksLikeRawApiError('{"type":"overloaded_error"}')).toBe(true)
375
+ expect(looksLikeRawApiError('{"is_error":true,"content":"boom"}')).toBe(true)
376
+ })
377
+
378
+ it('does not flag ordinary prose mentioning "error"', () => {
379
+ expect(looksLikeRawApiError('I hit an error handling that request')).toBe(false)
380
+ expect(looksLikeRawApiError('')).toBe(false)
381
+ })
382
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Regression tests for the chunk-boundary cap bug (fix/rich-render-chunk-boundary-cap).
3
+ *
4
+ * THE BUG: the outbound safe renderer (`renderSafe`) re-escapes GFM-special
5
+ * characters, which GROWS a body. A raw chunk sized just under the wire cap can
6
+ * escape PAST it. `renderSafe`'s only oversize recourse is to degrade the WHOLE
7
+ * document to `mode:"plain"` (raw source, no rich wrapper) — it never re-splits
8
+ * a multi-block body. The send path then ships that plain body through the
9
+ * 4096-char plain `sendMessage` endpoint, so a ~32k plain body is rejected by
10
+ * Telegram (`message is too long`) and the answer is dropped.
11
+ *
12
+ * `renderOutboundChunks` closes the gap: every returned piece fits its own wire
13
+ * cap (rich `<= maxLen`, plain `<= plainMax`) and is cut only at
14
+ * `splitMarkdownChunks`' safe boundaries (never bisecting a fence / table row).
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
18
+ import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
19
+
20
+ const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
21
+ const OFF = {} as NodeJS.ProcessEnv;
22
+
23
+ /** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
24
+ * fenced block has an ODD count. */
25
+ function fenceCount(s: string): number {
26
+ return (s.match(/^```/gm) ?? []).length;
27
+ }
28
+
29
+ describe("renderOutboundChunks", () => {
30
+ it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
31
+ const raw = "**bold** and _italic_ | a | table |";
32
+ const pieces = renderOutboundChunks(raw, OFF);
33
+ expect(pieces).toHaveLength(1);
34
+ expect(pieces[0].text).toBe(raw);
35
+ expect(pieces[0].mode).toBe("markdown");
36
+ });
37
+
38
+ it("flag ON, body that fits is a single piece (common case)", () => {
39
+ const pieces = renderOutboundChunks("just some plain prose", ON);
40
+ expect(pieces).toHaveLength(1);
41
+ expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
42
+ });
43
+
44
+ it("REGRESSION: a near-cap escapable body splits into cap-respecting pieces", () => {
45
+ // Prose whose escaping (`_ * |` each gain a leading `\`) grows it past the
46
+ // cap. Use small caps so the test is fast; the invariant is cap-agnostic.
47
+ const maxLen = 200;
48
+ const plainMax = 80;
49
+ const unit = "a_b*c|d ";
50
+ const raw = unit.repeat(40); // 320 raw chars, ~1.5x after escaping
51
+ const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
52
+
53
+ // The whole body did NOT fit as one message — it was re-split.
54
+ expect(pieces.length).toBeGreaterThan(1);
55
+ for (const p of pieces) {
56
+ if (p.mode === "plain") {
57
+ // A plain piece rides the plain `sendMessage` endpoint — must fit its cap.
58
+ expect(p.text.length).toBeLessThanOrEqual(plainMax);
59
+ } else {
60
+ expect(p.text.length).toBeLessThanOrEqual(maxLen);
61
+ }
62
+ }
63
+ });
64
+
65
+ it("REGRESSION: never bisects a fenced code block when splitting", () => {
66
+ const maxLen = 300;
67
+ const plainMax = 250; // >= the fence size, so a fence never needs a hard slice.
68
+ // A fenced block big enough that a naive length cut would land inside it,
69
+ // wrapped in prose so the whole document overflows and must be re-split.
70
+ const fence = "```\n" + "code line here\n".repeat(8) + "```";
71
+ const prose = "word ".repeat(40);
72
+ const raw = `${prose}\n\n${fence}\n\n${prose}`;
73
+ const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
74
+
75
+ expect(pieces.length).toBeGreaterThan(1);
76
+ for (const p of pieces) {
77
+ // Every emitted piece has BALANCED fence delimiters — no piece opens a
78
+ // fence it doesn't close (which would swallow the next piece's text).
79
+ expect(fenceCount(p.text) % 2).toBe(0);
80
+ const cap = p.mode === "plain" ? plainMax : maxLen;
81
+ expect(p.text.length).toBeLessThanOrEqual(cap);
82
+ }
83
+ });
84
+
85
+ it("REAL-CAP: a ~32k escapable body never yields an over-4096 plain piece", () => {
86
+ // The production failure: raw just under RICH_MESSAGE_MAX_CHARS, escaping
87
+ // pushes the rendered form over it. On the buggy path this became ONE
88
+ // ~32k plain body sent through the 4096 plain endpoint. Here every plain
89
+ // piece is <= 4096 and every rich piece <= 32768.
90
+ const unit = "a_b*c|d ";
91
+ const raw = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
92
+ const pieces = renderOutboundChunks(raw, ON);
93
+ for (const p of pieces) {
94
+ const cap = p.mode === "plain" ? PLAIN_TEXT_MAX_CHARS : RICH_MESSAGE_MAX_CHARS;
95
+ expect(p.text.length).toBeLessThanOrEqual(cap);
96
+ }
97
+ }, 30000);
98
+ });
@@ -18,7 +18,13 @@ import {
18
18
  humanizeElapsed,
19
19
  buildResumeInterruptedInbound,
20
20
  buildResumeWatchdogReportInbound,
21
+ buildResumeDeferredReportInbound,
22
+ isResumeSyntheticTurn,
21
23
  selectResumeBuilder,
24
+ decideBootResumeKind,
25
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
26
+ renderInterruptedSubagentsBlock,
27
+ type InterruptedSubagent,
22
28
  } from '../gateway/resume-inbound-builder.js'
23
29
  import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
24
30
 
@@ -158,6 +164,155 @@ describe('buildResumeInterruptedInbound', () => {
158
164
  })
159
165
  })
160
166
 
167
+ describe('interrupted sub-agent block', () => {
168
+ const twoRunning: InterruptedSubagent[] = [
169
+ { agentType: 'worker', description: 'refactor the auth module and add tests', status: 'running' },
170
+ { agentType: 'researcher', description: 'survey the pricing pages of 5 competitors', status: 'running' },
171
+ ]
172
+
173
+ it('renderInterruptedSubagentsBlock lists each worker with type + prompt', () => {
174
+ const block = renderInterruptedSubagentsBlock(twoRunning)
175
+ expect(block).toContain('2 sub-agents were still')
176
+ expect(block).toContain('did NOT complete')
177
+ expect(block).toContain('[worker]')
178
+ expect(block).toContain('refactor the auth module and add tests')
179
+ expect(block).toContain('[researcher]')
180
+ expect(block).toContain('survey the pricing pages of 5 competitors')
181
+ expect(block).toContain('Re-dispatch the ones still needed')
182
+ })
183
+
184
+ it('returns empty string when there are no in-flight sub-agents', () => {
185
+ expect(renderInterruptedSubagentsBlock(undefined)).toBe('')
186
+ expect(renderInterruptedSubagentsBlock([])).toBe('')
187
+ })
188
+
189
+ it('interrupted-turn inbound contains BOTH running sub-agents', () => {
190
+ const msg = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
191
+ expect(msg.text).toContain('refactor the auth module and add tests')
192
+ expect(msg.text).toContain('survey the pricing pages of 5 competitors')
193
+ expect(msg.text).toContain('did NOT complete')
194
+ expect(msg.text).toContain('Re-dispatch the ones still needed')
195
+ })
196
+
197
+ it('interrupted-turn inbound is UNCHANGED when no sub-agents were running', () => {
198
+ const withNone = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: [] })
199
+ const bare = buildResumeInterruptedInbound({ turn: makeTurn() })
200
+ expect(withNone.text).toBe(bare.text)
201
+ expect(withNone.text).not.toContain('did NOT complete')
202
+ })
203
+
204
+ it('truncates each dispatch prompt to ~200 chars', () => {
205
+ const long = 'x'.repeat(400)
206
+ const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: long }])
207
+ // The 400-char prompt must not survive in full; the entry line is capped.
208
+ expect(block).not.toContain(long)
209
+ expect(block).toContain('…')
210
+ // 200-char cap → the truncated slice (199 chars + ellipsis) is present.
211
+ expect(block).toContain('x'.repeat(199))
212
+ expect(block).not.toContain('x'.repeat(201))
213
+ })
214
+
215
+ it('caps the list at 10 and summarises the remainder', () => {
216
+ const many: InterruptedSubagent[] = Array.from({ length: 14 }, (_, i) => ({
217
+ agentType: 'worker',
218
+ description: `task number ${i + 1}`,
219
+ }))
220
+ const block = renderInterruptedSubagentsBlock(many)
221
+ expect(block).toContain('14 sub-agents were still')
222
+ expect(block).toContain('task number 10')
223
+ expect(block).not.toContain('task number 11')
224
+ expect(block).toContain('…and 4 more.')
225
+ // Exactly 10 numbered entries rendered.
226
+ const numbered = block.match(/^\s+\d+\. \[/gm) ?? []
227
+ expect(numbered.length).toBe(10)
228
+ })
229
+
230
+ it('falls back to a generic label + placeholder when type/prompt are missing', () => {
231
+ const block = renderInterruptedSubagentsBlock([{ agentType: null, description: null }])
232
+ expect(block).toContain('[sub-agent]')
233
+ expect(block).toContain('(no task description recorded)')
234
+ })
235
+
236
+ it('singularises a single killed sub-agent', () => {
237
+ const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: 'do X' }])
238
+ expect(block).toContain('1 sub-agent was still')
239
+ expect(block).not.toContain('1 sub-agents')
240
+ })
241
+
242
+ it('codepoint-safe truncation never splits a surrogate pair', () => {
243
+ // 199 ASCII chars, then an astral-plane emoji (2 UTF-16 code units)
244
+ // straddling the cap. A code-unit slice would cut the pair in half and
245
+ // leave a lone surrogate; the codepoint-safe slice must not.
246
+ const desc = 'a'.repeat(199) + '🚀' + 'b'.repeat(50)
247
+ const block = renderInterruptedSubagentsBlock([{ agentType: 'worker', description: desc }])
248
+ expect(block).toContain('…')
249
+ // No lone surrogates anywhere in the rendered block.
250
+ expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(block)).toBe(false)
251
+ expect(block).toBe(block.normalize('NFC')) // sanity: still a well-formed string
252
+ })
253
+
254
+ it('the watchdog report inbound carries the block in DEFERRED form (ask-first contract)', () => {
255
+ const msg = buildResumeWatchdogReportInbound({
256
+ turn: makeTurn({ ended_via: 'timeout' }),
257
+ idleMs: 300_000,
258
+ subagents: twoRunning,
259
+ })
260
+ expect(msg.text).toContain('did NOT complete')
261
+ expect(msg.text).toContain('refactor the auth module and add tests')
262
+ // Deferred wording: re-dispatch is conditional on the user asking to retry…
263
+ expect(msg.text).toContain("If the user asks you to retry, they'll need re-dispatching")
264
+ expect(msg.text).toContain("don't assume their work landed")
265
+ // …and the resume-path imperative must NOT appear — it would contradict
266
+ // the watchdog inbound's "Do NOT silently resume … ask" hang-safety gate.
267
+ expect(msg.text).not.toContain('Re-dispatch the ones still needed')
268
+ expect(msg.text).not.toContain('before declaring the task done')
269
+ })
270
+
271
+ it('the resume-path inbound keeps the assertive imperative (and not the deferred form)', () => {
272
+ const msg = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
273
+ expect(msg.text).toContain('Re-dispatch the ones still needed before declaring the task done')
274
+ expect(msg.text).not.toContain('If the user asks you to retry')
275
+ })
276
+
277
+ for (const reason of ['loop-guard', 'clean-restart-suppressed'] as const) {
278
+ it(`the deferred report (${reason}) carries the block in DEFERRED form — suppressed resumes still name worker deaths`, () => {
279
+ const msg = buildResumeDeferredReportInbound({
280
+ turn: makeTurn(),
281
+ reason,
282
+ subagents: twoRunning,
283
+ })
284
+ expect(msg.text).toContain('did NOT complete')
285
+ expect(msg.text).toContain('refactor the auth module and add tests')
286
+ expect(msg.text).toContain('survey the pricing pages of 5 competitors')
287
+ // Deferred wording only — never the resume-path imperative, which would
288
+ // contradict this inbound's "Do NOT silently resume … ask" contract.
289
+ expect(msg.text).toContain("If the user asks you to retry, they'll need re-dispatching")
290
+ expect(msg.text).not.toContain('Re-dispatch the ones still needed')
291
+ expect(msg.text).not.toContain('before declaring the task done')
292
+ // Appending the block must not disturb the loop-guard anchor at pos 0.
293
+ expect(msg.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
294
+ })
295
+ }
296
+
297
+ it('deferred report without subagents is unchanged (no block)', () => {
298
+ const withNone = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard', subagents: [] })
299
+ const bare = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
300
+ expect(withNone.text).toBe(bare.text)
301
+ expect(withNone.text).not.toContain('did NOT complete')
302
+ })
303
+
304
+ it('appending the block preserves the RESUME_SYNTHETIC_PROMPT_PREFIX anchor on resume + watchdog inbounds too', () => {
305
+ const resume = buildResumeInterruptedInbound({ turn: makeTurn(), subagents: twoRunning })
306
+ expect(resume.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
307
+ const report = buildResumeWatchdogReportInbound({
308
+ turn: makeTurn({ ended_via: 'timeout' }),
309
+ idleMs: 300_000,
310
+ subagents: twoRunning,
311
+ })
312
+ expect(report.text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
313
+ })
314
+ })
315
+
161
316
  describe('buildResumeWatchdogReportInbound', () => {
162
317
  it('sets the resume_watchdog_timeout source and idle_ms passthrough', () => {
163
318
  const turn = makeTurn({ ended_via: 'timeout' })
@@ -243,3 +398,134 @@ describe('selectResumeBuilder', () => {
243
398
  expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
244
399
  })
245
400
  })
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Loop-guard: RESUME_SYNTHETIC_PROMPT_PREFIX / isResumeSyntheticTurn
404
+ // ---------------------------------------------------------------------------
405
+
406
+ describe('resume synthetic-turn detection (loop-guard anchor)', () => {
407
+ it('EVERY synthetic boot inbound text starts with the machine-stable prefix', () => {
408
+ // The loop-guard keys on this prefix landing in user_prompt_preview, so if
409
+ // a prose edit drops it the chain-cap silently breaks. Pin it here.
410
+ const turn = makeTurn({ user_prompt_preview: 'do the thing' })
411
+ expect(buildResumeInterruptedInbound({ turn }).text.startsWith(RESUME_SYNTHETIC_PROMPT_PREFIX)).toBe(true)
412
+ expect(
413
+ buildResumeWatchdogReportInbound({ turn: makeTurn({ ended_via: 'timeout' }), idleMs: 1000 }).text.startsWith(
414
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
415
+ ),
416
+ ).toBe(true)
417
+ expect(
418
+ buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' }).text.startsWith(
419
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
420
+ ),
421
+ ).toBe(true)
422
+ expect(
423
+ buildResumeDeferredReportInbound({ turn, reason: 'clean-restart-suppressed' }).text.startsWith(
424
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
425
+ ),
426
+ ).toBe(true)
427
+ })
428
+
429
+ it('isResumeSyntheticTurn is true for a turn whose preview is a synthetic prompt', () => {
430
+ const synthetic = buildResumeInterruptedInbound({ turn: makeTurn() })
431
+ // A resume turn stores the first ~200 chars of the synthetic text as its
432
+ // preview (channel wrapper stripped) — simulate that.
433
+ const resumeTurn = makeTurn({ user_prompt_preview: synthetic.text.slice(0, 200) })
434
+ expect(isResumeSyntheticTurn(resumeTurn)).toBe(true)
435
+ })
436
+
437
+ it('isResumeSyntheticTurn is false for real user work and for null preview', () => {
438
+ expect(isResumeSyntheticTurn(makeTurn({ user_prompt_preview: 'refactor the auth module' }))).toBe(false)
439
+ expect(isResumeSyntheticTurn(makeTurn({ user_prompt_preview: null }))).toBe(false)
440
+ })
441
+ })
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // buildResumeDeferredReportInbound
445
+ // ---------------------------------------------------------------------------
446
+
447
+ describe('buildResumeDeferredReportInbound', () => {
448
+ it('emits source=resume_deferred and carries the dedup anchor + reason', () => {
449
+ const turn = makeTurn({ turn_key: 'zz:3' })
450
+ const msg = buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' })
451
+ expect(msg.meta.source).toBe('resume_deferred')
452
+ expect(msg.meta.resume_turn_key).toBe('zz:3')
453
+ expect(msg.meta.defer_reason).toBe('loop-guard')
454
+ })
455
+
456
+ it('loop-guard framing tells the model the chain was capped, not to resume', () => {
457
+ const msg = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
458
+ expect(msg.text.toLowerCase()).toContain('resume of earlier interrupted work')
459
+ expect(msg.text).toContain('Do NOT silently resume')
460
+ })
461
+
462
+ it('clean-restart-suppressed framing cites boot_resume: never', () => {
463
+ const msg = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'clean-restart-suppressed' })
464
+ expect(msg.text).toContain('boot_resume: never')
465
+ expect(msg.text).toContain('Do NOT silently resume')
466
+ })
467
+
468
+ it('routes to the origin thread when the turn carried one', () => {
469
+ const turn = makeTurn({ chat_id: '-100999', thread_id: '77' })
470
+ const msg = buildResumeDeferredReportInbound({ turn, reason: 'loop-guard' })
471
+ expect(msg.chatId).toBe('-100999')
472
+ expect(msg.threadId).toBe(77)
473
+ expect(msg.meta.message_thread_id).toBe('77')
474
+ })
475
+ })
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // decideBootResumeKind — boot-resume precedence + bounded resume chain
479
+ // ---------------------------------------------------------------------------
480
+
481
+ describe('decideBootResumeKind', () => {
482
+ const MAX = 10_800_000 // 3h
483
+
484
+ it('resumes genuinely in-flight work after a deliberate restart (not suppressed)', () => {
485
+ // The core product fix: clean restart mid-turn → resume, not silence.
486
+ const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'ship the release' })
487
+ expect(decideBootResumeKind({ pending, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
488
+ })
489
+
490
+ it("boot_resume:never (suppressed) → 'defer-suppressed' passive report, never silence", () => {
491
+ const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'ship the release' })
492
+ expect(decideBootResumeKind({ pending, suppressed: true, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-suppressed')
493
+ })
494
+
495
+ it('watchdog timeout still reports even when not suppressed', () => {
496
+ const pending = makeTurn({ ended_via: 'timeout', user_prompt_preview: 'ship the release' })
497
+ expect(decideBootResumeKind({ pending, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('report')
498
+ })
499
+
500
+ it('BOUNDED CHAIN: a restart DURING a resume turn does NOT re-resume — loop-guard fires', () => {
501
+ // Simulate the chain the coordinator flagged:
502
+ // A: real work, interrupted → resume R1 (turn B created from R1's text)
503
+ // restart lands during B → pending is B, whose preview IS the synthetic
504
+ // resume prompt. We must NOT mint a second resume (endless loop risk).
505
+ const r1 = buildResumeInterruptedInbound({ turn: makeTurn({ user_prompt_preview: 'real work' }) })
506
+ const turnB = makeTurn({ ended_via: 'restart', user_prompt_preview: r1.text.slice(0, 200) })
507
+ // Even though NOT suppressed (would normally resume), the loop-guard wins:
508
+ expect(decideBootResumeKind({ pending: turnB, suppressed: false, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-loop')
509
+ })
510
+
511
+ it('BOUNDED CHAIN: a restart during a deferred-report turn stays capped (still defer-loop, never resume)', () => {
512
+ // Depth does not grow: a report-of-report is still a passive report, never
513
+ // a resume — so no unbounded restart→resume→restart chain can form.
514
+ const deferred = buildResumeDeferredReportInbound({ turn: makeTurn(), reason: 'loop-guard' })
515
+ const turnC = makeTurn({ ended_via: 'restart', user_prompt_preview: deferred.text.slice(0, 200) })
516
+ const kind = decideBootResumeKind({ pending: turnC, suppressed: false, ageMs: 1000, maxAgeMs: MAX })
517
+ expect(kind).toBe('defer-loop')
518
+ expect(kind).not.toBe('resume')
519
+ })
520
+
521
+ it('loop-guard takes precedence over suppression too (synthetic turn never resumes or double-reports as resume)', () => {
522
+ const r1 = buildResumeInterruptedInbound({ turn: makeTurn() })
523
+ const turnB = makeTurn({ ended_via: 'restart', user_prompt_preview: r1.text.slice(0, 200) })
524
+ expect(decideBootResumeKind({ pending: turnB, suppressed: true, ageMs: 1000, maxAgeMs: MAX })).toBe('defer-loop')
525
+ })
526
+
527
+ it('stale in-flight work downgrades to report when older than maxAgeMs', () => {
528
+ const pending = makeTurn({ ended_via: 'restart', user_prompt_preview: 'stale work' })
529
+ expect(decideBootResumeKind({ pending, suppressed: false, ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
530
+ })
531
+ })
@@ -15,6 +15,8 @@ import {
15
15
  createSwallowingRetryApiCall,
16
16
  retryWithThreadFallback,
17
17
  isHtmlParseRejectError,
18
+ isLocalResourceError,
19
+ LOCAL_RESOURCE_EXHAUSTED,
18
20
  type RetryObserver,
19
21
  } from '../retry-api-call.js'
20
22
  import { errors, makeGrammyError } from './fake-bot-api.js'
@@ -512,3 +514,60 @@ describe('isHtmlParseRejectError', () => {
512
514
  ).toBe(true)
513
515
  })
514
516
  })
517
+
518
+ describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)', () => {
519
+ it('classifies ENOSPC / EDQUOT / EIO / ENOMEM by errno code', () => {
520
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOSPC' }))).toBe(true)
521
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EDQUOT' }))).toBe(true)
522
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EIO' }))).toBe(true)
523
+ expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOMEM' }))).toBe(true)
524
+ })
525
+
526
+ it('classifies by message when no code is present (incl. EIO, word-boundaried)', () => {
527
+ expect(isLocalResourceError(new Error('ENOSPC: no space left on device, write'))).toBe(true)
528
+ expect(isLocalResourceError(new Error('disk quota exceeded'))).toBe(true)
529
+ expect(isLocalResourceError(new Error('EIO: i/o error, write'))).toBe(true)
530
+ // No false match on a substring (e.g. a word containing the letters).
531
+ expect(isLocalResourceError(new Error('DENOSPCX not a real code'))).toBe(false)
532
+ })
533
+
534
+ it('does NOT classify a remote GrammyError or ordinary error', () => {
535
+ expect(isLocalResourceError(errors.floodWait(10))).toBe(false)
536
+ expect(isLocalResourceError(new Error('fetch failed'))).toBe(false)
537
+ })
538
+
539
+ it('throws LOCAL_RESOURCE_EXHAUSTED immediately without retrying', async () => {
540
+ // Before the fix: an ENOSPC thrown by the send-staging step fell through
541
+ // to the network-retry branch pattern OR was rethrown but only after the
542
+ // caller kept re-driving sends — the storm that tripped the flood ban.
543
+ // Now it must fail FAST on the first attempt with a distinct marker.
544
+ const sleep = vi.fn(async () => {})
545
+ let calls = 0
546
+ const retry = createRetryApiCall({ maxRetries: 3, sleep })
547
+ await expect(
548
+ retry(async () => {
549
+ calls++
550
+ throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' })
551
+ }),
552
+ ).rejects.toThrow(LOCAL_RESOURCE_EXHAUSTED)
553
+ expect(calls).toBe(1) // no retry
554
+ expect(sleep).not.toHaveBeenCalled() // no backoff-into-flood
555
+ })
556
+
557
+ it('fires onFloodWait with the retry_after when a 429 is seen', async () => {
558
+ const seen: number[] = []
559
+ const sleep = vi.fn(async () => {})
560
+ let n = 0
561
+ const retry = createRetryApiCall({
562
+ maxRetries: 3,
563
+ sleep,
564
+ onFloodWait: (s) => seen.push(s),
565
+ })
566
+ const out = await retry(async () => {
567
+ if (n++ === 0) throw errors.floodWait(42)
568
+ return 'ok'
569
+ })
570
+ expect(out).toBe('ok')
571
+ expect(seen).toEqual([42])
572
+ })
573
+ })
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * #2555 — run-hook.sh must tolerate a Node exit-134 (uv_thread_create abort
10
+ * under memory pressure): retry once, and if it still aborts, skip cleanly
11
+ * (exit 0) rather than propagating 134. Real non-134 statuses pass through.
12
+ *
13
+ * We drive the wrapper with a fake command (a tiny sh script) whose exit code
14
+ * is scripted via a counter file, so we exercise the exact control flow
15
+ * without needing a real memory-pressured Node.
16
+ */
17
+ const wrapper = resolve(
18
+ fileURLToPath(new URL('../hooks/run-hook.sh', import.meta.url)),
19
+ )
20
+
21
+ describe('#2555 run-hook.sh exit-134 tolerance', () => {
22
+ let dir: string
23
+ let fake: string
24
+ let counter: string
25
+
26
+ beforeEach(() => {
27
+ dir = mkdtempSync(join(tmpdir(), 'run-hook-'))
28
+ fake = join(dir, 'fake.sh')
29
+ counter = join(dir, 'n')
30
+ writeFileSync(counter, '0')
31
+ })
32
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
33
+
34
+ /** Fake command: exits `codes[attemptIndex]`, records each invocation. */
35
+ function writeFake(codes: number[]): void {
36
+ writeFileSync(
37
+ fake,
38
+ [
39
+ '#!/bin/sh',
40
+ `n=$(cat "${counter}")`,
41
+ `echo "$((n + 1))" > "${counter}"`,
42
+ 'case "$n" in',
43
+ ...codes.map((c, i) => ` ${i}) exit ${c} ;;`),
44
+ ` *) exit ${codes[codes.length - 1]} ;;`,
45
+ 'esac',
46
+ ].join('\n'),
47
+ )
48
+ }
49
+
50
+ const run = (input = '') =>
51
+ spawnSync('sh', [wrapper, 'sh', fake], { encoding: 'utf-8', input })
52
+
53
+ const attempts = () => Number(readFileSync(counter, 'utf-8').trim())
54
+
55
+ it('passes a clean exit 0 through without retrying', () => {
56
+ writeFake([0])
57
+ const r = run()
58
+ expect(r.status).toBe(0)
59
+ expect(attempts()).toBe(1)
60
+ })
61
+
62
+ it('passes a real non-134 failure through unchanged (no retry, not masked)', () => {
63
+ writeFake([2])
64
+ const r = run()
65
+ expect(r.status).toBe(2)
66
+ expect(attempts()).toBe(1)
67
+ })
68
+
69
+ it('retries ONCE on a 134 abort and succeeds on the second attempt', () => {
70
+ writeFake([134, 0])
71
+ const r = run()
72
+ expect(r.status).toBe(0)
73
+ expect(attempts()).toBe(2)
74
+ })
75
+
76
+ it('skips cleanly (exit 0) when it aborts 134 twice — no crash card', () => {
77
+ writeFake([134, 134])
78
+ const r = run()
79
+ expect(r.status).toBe(0) // skipped cleanly, NOT 134
80
+ expect(attempts()).toBe(2)
81
+ expect(r.stderr).toMatch(/skipping hook cleanly/)
82
+ })
83
+
84
+ it('exports a shrunk UV_THREADPOOL_SIZE to the child', () => {
85
+ writeFileSync(fake, `#!/bin/sh\necho "$UV_THREADPOOL_SIZE"\nexit 0\n`)
86
+ const r = run()
87
+ expect(r.stdout.trim()).toBe('1')
88
+ })
89
+
90
+ it('replays the SAME stdin payload on the retry (scanner never sees empty input)', () => {
91
+ // Fake: attempt 0 reads stdin then aborts 134; attempt 1 reads stdin and
92
+ // records it, then exits 0. If stdin were not preserved, the recorded
93
+ // payload on the retry would be empty.
94
+ const seen = join(dir, 'seen-stdin')
95
+ writeFileSync(
96
+ fake,
97
+ [
98
+ '#!/bin/sh',
99
+ `n=$(cat "${counter}")`,
100
+ `echo "$((n + 1))" > "${counter}"`,
101
+ 'data=$(cat)', // drain stdin (the abort-after-read scenario)
102
+ `echo "$data" > "${seen}.$n"`,
103
+ '[ "$n" = "0" ] && exit 134',
104
+ 'exit 0',
105
+ ].join('\n'),
106
+ )
107
+ const r = run('SECRET-PAYLOAD-123')
108
+ expect(r.status).toBe(0)
109
+ expect(attempts()).toBe(2)
110
+ // The RETRY (attempt 1) must have received the full payload, not empty.
111
+ expect(readFileSync(`${seen}.1`, 'utf-8').trim()).toBe('SECRET-PAYLOAD-123')
112
+ })
113
+
114
+ it('FAILS CLOSED (propagates 134) for a security hook that aborts twice', () => {
115
+ // A genuinely broken secret scanner must NOT silently pass — exit 0 after
116
+ // two aborts would be a silent security bypass.
117
+ const secFake = join(dir, 'secret-guard-pretool.mjs')
118
+ writeFileSync(
119
+ secFake,
120
+ [
121
+ '#!/bin/sh',
122
+ `n=$(cat "${counter}")`,
123
+ `echo "$((n + 1))" > "${counter}"`,
124
+ 'exit 134',
125
+ ].join('\n'),
126
+ )
127
+ const r = spawnSync('sh', [wrapper, 'sh', secFake], { encoding: 'utf-8', input: '{}' })
128
+ expect(r.status).toBe(134) // fail closed — NOT skipped
129
+ expect(attempts()).toBe(2)
130
+ expect(r.stderr).toMatch(/FAILING CLOSED/)
131
+ })
132
+ })