switchroom 0.18.8 → 0.18.10

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 (93) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +198 -13
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +28 -4
  6. package/dist/host-control/main.js +3 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -1
  8. package/dist/vault/broker/server.js +2 -1
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +119 -37
  11. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  12. package/skills/dev-protocol/SKILL.md +131 -0
  13. package/telegram-plugin/README.md +2 -1
  14. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  15. package/telegram-plugin/admin-commands/index.ts +6 -1
  16. package/telegram-plugin/bridge/bridge.ts +23 -1
  17. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  18. package/telegram-plugin/chat-lock.ts +13 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
  21. package/telegram-plugin/dist/server.js +29 -2
  22. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  23. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  24. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  27. package/telegram-plugin/gateway/effort-command.ts +47 -3
  28. package/telegram-plugin/gateway/gateway.ts +2051 -3180
  29. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  30. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  31. package/telegram-plugin/gateway/model-command.ts +94 -8
  32. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  33. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  34. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  35. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  36. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  37. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  38. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  39. package/telegram-plugin/gateway/stop-command.ts +56 -0
  40. package/telegram-plugin/photo-precheck.ts +201 -0
  41. package/telegram-plugin/quota-watch.ts +141 -2
  42. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  43. package/telegram-plugin/registry/subagents.test.ts +67 -0
  44. package/telegram-plugin/retry-api-call.ts +31 -0
  45. package/telegram-plugin/subagent-watcher.ts +392 -1
  46. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  47. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  48. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  49. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  50. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  51. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  52. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  53. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  54. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  55. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  56. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  57. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  58. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  59. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  60. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  61. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  62. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  63. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  64. package/telegram-plugin/tests/model-command.test.ts +46 -3
  65. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  66. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  67. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  68. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  69. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  70. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  71. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  72. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  73. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  74. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  75. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  76. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  77. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  78. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  79. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  80. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  82. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  83. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  84. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  85. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  86. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  87. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  88. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  89. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  90. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  91. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  92. package/telegram-plugin/welcome-text.ts +4 -3
  93. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Covers the photo→document graceful fallback contract (#klanker
3
+ * 2026-07-10 incident: a tall phone screenshot sent via the reply tool's
4
+ * `files` param threw PHOTO_INVALID_DIMENSIONS).
5
+ *
6
+ * Two production pieces make the gateway's fallback work:
7
+ * 1. `isPhotoDimensionRejectError` classifies the 400s that mean "not
8
+ * usable AS a photo" (dimensions / save-file / too-large).
9
+ * 2. `retryApiCall` must NOT swallow or retry those 400s — they have to
10
+ * reach the caller's catch so it can re-send as a document.
11
+ *
12
+ * The final block exercises the EXACT try/catch shape the gateway's reply
13
+ * file-send loop uses (`retryWithThreadFallback` + the predicate), proving
14
+ * the file is still delivered as a document.
15
+ */
16
+
17
+ import { describe, it, expect, vi } from 'vitest'
18
+ import {
19
+ createRetryApiCall,
20
+ retryWithThreadFallback,
21
+ isPhotoDimensionRejectError,
22
+ } from '../retry-api-call.js'
23
+ import { errors } from './fake-bot-api.js'
24
+
25
+ describe('isPhotoDimensionRejectError', () => {
26
+ it('is true for PHOTO_INVALID_DIMENSIONS', () => {
27
+ expect(isPhotoDimensionRejectError(errors.badRequest('PHOTO_INVALID_DIMENSIONS', 'sendPhoto'))).toBe(true)
28
+ })
29
+
30
+ it('is true for PHOTO_SAVE_FILE_INVALID', () => {
31
+ expect(isPhotoDimensionRejectError(errors.badRequest('PHOTO_SAVE_FILE_INVALID', 'sendPhoto'))).toBe(true)
32
+ })
33
+
34
+ it('is true for the mediagroup-wrapped variant', () => {
35
+ const err = errors.badRequest(
36
+ 'failed to send message #2 with the error message "PHOTO_INVALID_DIMENSIONS"',
37
+ 'sendMediaGroup',
38
+ )
39
+ expect(isPhotoDimensionRejectError(err)).toBe(true)
40
+ })
41
+
42
+ it('is true for photo-too-big size rejections', () => {
43
+ expect(isPhotoDimensionRejectError(errors.badRequest('PHOTO_SAVE_FILE_INVALID: photo is too big', 'sendPhoto'))).toBe(true)
44
+ expect(isPhotoDimensionRejectError(errors.badRequest('Image is too big', 'sendPhoto'))).toBe(true)
45
+ })
46
+
47
+ it('is false for unrelated 400s', () => {
48
+ expect(isPhotoDimensionRejectError(errors.badRequest('chat not found', 'sendPhoto'))).toBe(false)
49
+ expect(isPhotoDimensionRejectError(errors.threadNotFound('sendPhoto'))).toBe(false)
50
+ })
51
+
52
+ it('is false for non-400 and non-grammy errors', () => {
53
+ expect(isPhotoDimensionRejectError(errors.forbidden('sendPhoto'))).toBe(false)
54
+ expect(isPhotoDimensionRejectError(errors.floodWait(3, 'sendPhoto'))).toBe(false)
55
+ expect(isPhotoDimensionRejectError(new Error('PHOTO_INVALID_DIMENSIONS'))).toBe(false)
56
+ expect(isPhotoDimensionRejectError(null)).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('retryApiCall does not swallow photo-dimension rejects', () => {
61
+ it('rethrows PHOTO_INVALID_DIMENSIONS so the caller can fall back', async () => {
62
+ const retry = createRetryApiCall()
63
+ const err = errors.badRequest('PHOTO_INVALID_DIMENSIONS', 'sendPhoto')
64
+ await expect(retry(() => Promise.reject(err))).rejects.toBe(err)
65
+ })
66
+
67
+ it('propagates through retryWithThreadFallback (not treated as THREAD_NOT_FOUND)', async () => {
68
+ const retry = createRetryApiCall()
69
+ const err = errors.badRequest('PHOTO_INVALID_DIMENSIONS', 'sendPhoto')
70
+ await expect(
71
+ retryWithThreadFallback(retry, () => Promise.reject(err), {
72
+ threadId: 42,
73
+ chat_id: '123',
74
+ verb: 'sendPhoto',
75
+ }),
76
+ ).rejects.toBe(err)
77
+ })
78
+ })
79
+
80
+ describe('photo→document fallback (gateway reply-loop shape)', () => {
81
+ it('re-sends as a document when sendPhoto is rejected for dimensions', async () => {
82
+ const retry = createRetryApiCall()
83
+ const err = errors.badRequest('PHOTO_INVALID_DIMENSIONS', 'sendPhoto')
84
+ const sendPhoto = vi.fn(() => Promise.reject(err))
85
+ const sendDocument = vi.fn(() => Promise.resolve({ message_id: 555 }))
86
+
87
+ // Mirror gateway.ts executeReply's per-file try/catch.
88
+ let sent: { message_id: number }
89
+ try {
90
+ sent = await retryWithThreadFallback(retry, () => sendPhoto(), {
91
+ threadId: undefined,
92
+ chat_id: '123',
93
+ verb: 'sendPhoto',
94
+ })
95
+ } catch (caught) {
96
+ expect(isPhotoDimensionRejectError(caught)).toBe(true)
97
+ sent = await retryWithThreadFallback(retry, () => sendDocument(), {
98
+ threadId: undefined,
99
+ chat_id: '123',
100
+ verb: 'sendDocument',
101
+ })
102
+ }
103
+
104
+ expect(sendPhoto).toHaveBeenCalledTimes(1)
105
+ expect(sendDocument).toHaveBeenCalledTimes(1)
106
+ expect(sent!.message_id).toBe(555)
107
+ })
108
+
109
+ it('does NOT fall back for non-dimension errors (they propagate)', async () => {
110
+ const retry = createRetryApiCall()
111
+ const err = errors.forbidden('sendPhoto')
112
+ const sendDocument = vi.fn(() => Promise.resolve({ message_id: 1 }))
113
+
114
+ let threw = false
115
+ try {
116
+ await retryWithThreadFallback(retry, () => Promise.reject(err), {
117
+ threadId: undefined,
118
+ chat_id: '123',
119
+ verb: 'sendPhoto',
120
+ })
121
+ } catch (caught) {
122
+ threw = true
123
+ // The gateway only falls back when this predicate holds.
124
+ expect(isPhotoDimensionRejectError(caught)).toBe(false)
125
+ }
126
+ expect(threw).toBe(true)
127
+ expect(sendDocument).not.toHaveBeenCalled()
128
+ })
129
+ })
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Pins the #3033 layer-2 pre-send photo validation:
3
+ * 1. header-only dimension probes for PNG / JPEG / GIF / WebP
4
+ * 2. the photo-path bounds (width+height cap, aspect-ratio cap, size cap)
5
+ * 3. classifyPhotoFile routing (violation → document; probe miss → photo)
6
+ *
7
+ * Incident anchor: clerk 2026-07-11 sent a 600x8717 newsletter screenshot
8
+ * in a media group — ratio 14.5:1, WITHIN the documented 20:1 Bot API
9
+ * limit — and Telegram rejected it with PHOTO_INVALID_DIMENSIONS, failing
10
+ * the whole album. The bounds here must classify that image as a document.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { mkdtempSync, writeFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { tmpdir } from 'node:os'
17
+ import {
18
+ probeImageDimensions,
19
+ probeImageFile,
20
+ photoSendViolation,
21
+ classifyPhotoFile,
22
+ rerouteResultSuffix,
23
+ PHOTO_MAX_BYTES,
24
+ } from '../photo-precheck.js'
25
+
26
+ function pngBuffer(width: number, height: number): Buffer {
27
+ const buf = Buffer.alloc(33)
28
+ buf.writeUInt32BE(0x89504e47, 0)
29
+ buf.writeUInt32BE(0x0d0a1a0a, 4)
30
+ buf.writeUInt32BE(13, 8) // IHDR length
31
+ buf.write('IHDR', 12, 'ascii')
32
+ buf.writeUInt32BE(width, 16)
33
+ buf.writeUInt32BE(height, 20)
34
+ return buf
35
+ }
36
+
37
+ function jpegBuffer(width: number, height: number): Buffer {
38
+ // SOI, APP0 (JFIF stub), SOF0 with dimensions.
39
+ const app0 = Buffer.from([0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00])
40
+ const sof0 = Buffer.alloc(2 + 2 + 5 + 3)
41
+ sof0[0] = 0xff; sof0[1] = 0xc0
42
+ sof0.writeUInt16BE(10, 2) // segment length
43
+ sof0[4] = 8 // precision
44
+ sof0.writeUInt16BE(height, 5)
45
+ sof0.writeUInt16BE(width, 7)
46
+ sof0[9] = 1
47
+ return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof0])
48
+ }
49
+
50
+ function gifBuffer(width: number, height: number): Buffer {
51
+ const buf = Buffer.alloc(13)
52
+ buf.write('GIF89a', 0, 'ascii')
53
+ buf.writeUInt16LE(width, 6)
54
+ buf.writeUInt16LE(height, 8)
55
+ return buf
56
+ }
57
+
58
+ function webpVp8xBuffer(width: number, height: number): Buffer {
59
+ const buf = Buffer.alloc(30)
60
+ buf.write('RIFF', 0, 'ascii')
61
+ buf.writeUInt32LE(22, 4)
62
+ buf.write('WEBP', 8, 'ascii')
63
+ buf.write('VP8X', 12, 'ascii')
64
+ buf.writeUInt32LE(10, 16)
65
+ const w = width - 1
66
+ const h = height - 1
67
+ buf[24] = w & 0xff; buf[25] = (w >> 8) & 0xff; buf[26] = (w >> 16) & 0xff
68
+ buf[27] = h & 0xff; buf[28] = (h >> 8) & 0xff; buf[29] = (h >> 16) & 0xff
69
+ return buf
70
+ }
71
+
72
+ describe('probeImageDimensions', () => {
73
+ it('reads PNG IHDR dimensions', () => {
74
+ expect(probeImageDimensions(pngBuffer(600, 8717))).toEqual({ width: 600, height: 8717 })
75
+ })
76
+
77
+ it('reads JPEG SOF0 dimensions past an APP0 segment', () => {
78
+ expect(probeImageDimensions(jpegBuffer(1179, 2556))).toEqual({ width: 1179, height: 2556 })
79
+ })
80
+
81
+ it('reads GIF logical screen dimensions', () => {
82
+ expect(probeImageDimensions(gifBuffer(320, 240))).toEqual({ width: 320, height: 240 })
83
+ })
84
+
85
+ it('reads WebP VP8X canvas dimensions', () => {
86
+ expect(probeImageDimensions(webpVp8xBuffer(1920, 1080))).toEqual({ width: 1920, height: 1080 })
87
+ })
88
+
89
+ it('returns null for unknown formats and truncated buffers', () => {
90
+ expect(probeImageDimensions(Buffer.from('not an image at all, sorry'))).toBeNull()
91
+ expect(probeImageDimensions(Buffer.from([0x89, 0x50]))).toBeNull()
92
+ expect(probeImageDimensions(Buffer.alloc(0))).toBeNull()
93
+ })
94
+ })
95
+
96
+ describe('photoSendViolation bounds', () => {
97
+ it('accepts a normal phone screenshot (1179x2556)', () => {
98
+ expect(photoSendViolation({ width: 1179, height: 2556, bytes: 500_000 })).toBeNull()
99
+ })
100
+
101
+ it('rejects the clerk incident image 600x8717 (ratio 14.5:1, within documented 20:1)', () => {
102
+ const v = photoSendViolation({ width: 600, height: 8717, bytes: 400_000 })
103
+ expect(v).toMatch(/aspect ratio/)
104
+ })
105
+
106
+ it('rejects width+height over 10000', () => {
107
+ const v = photoSendViolation({ width: 6000, height: 5000, bytes: 400_000 })
108
+ expect(v).toMatch(/width\+height/)
109
+ })
110
+
111
+ it('rejects files over the 10MB photo ceiling', () => {
112
+ const v = photoSendViolation({ width: 1000, height: 1000, bytes: PHOTO_MAX_BYTES + 1 })
113
+ expect(v).toMatch(/exceeds 10MB/)
114
+ })
115
+
116
+ it('accepts exactly-at-bound values', () => {
117
+ expect(photoSendViolation({ width: 5000, height: 5000, bytes: PHOTO_MAX_BYTES })).toBeNull()
118
+ expect(photoSendViolation({ width: 800, height: 8800, bytes: 1 })).toMatch(/aspect ratio/) // 11:1
119
+ expect(photoSendViolation({ width: 900, height: 9000, bytes: 1 })).toBeNull() // exactly 10:1
120
+ })
121
+ })
122
+
123
+ describe('probeImageFile + classifyPhotoFile', () => {
124
+ const dir = mkdtempSync(join(tmpdir(), 'photo-precheck-'))
125
+
126
+ it('probes a PNG file on disk', () => {
127
+ const p = join(dir, 'tall.png')
128
+ writeFileSync(p, pngBuffer(600, 8717))
129
+ expect(probeImageFile(p)).toEqual({ width: 600, height: 8717, bytes: 33 })
130
+ })
131
+
132
+ it('routes an out-of-bounds photo to document with a reason', () => {
133
+ const p = join(dir, 'tall2.png')
134
+ writeFileSync(p, pngBuffer(600, 8717))
135
+ const cls = classifyPhotoFile(p)
136
+ expect(cls.route).toBe('document')
137
+ if (cls.route === 'document') expect(cls.reason).toMatch(/aspect ratio 14\.5:1/)
138
+ })
139
+
140
+ it('keeps an in-bounds photo on the photo route', () => {
141
+ const p = join(dir, 'ok.png')
142
+ writeFileSync(p, pngBuffer(1179, 2556))
143
+ expect(classifyPhotoFile(p)).toEqual({ route: 'photo' })
144
+ })
145
+
146
+ // #3038 item 3 — probeImageFile now does fixed-size header reads
147
+ // (openSync + 32 bytes; 64KB window for JPEG) instead of readFileSync.
148
+ // Pin correctness on the same fixture set across every format.
149
+ it('probes JPEG / GIF / WebP files on disk (fixed-size read path)', () => {
150
+ const j = join(dir, 'shot.jpg')
151
+ writeFileSync(j, jpegBuffer(1179, 2556))
152
+ expect(probeImageFile(j)).toMatchObject({ width: 1179, height: 2556 })
153
+
154
+ const g = join(dir, 'anim.gif')
155
+ writeFileSync(g, gifBuffer(320, 240))
156
+ expect(probeImageFile(g)).toMatchObject({ width: 320, height: 240 })
157
+
158
+ const w = join(dir, 'pic.webp')
159
+ writeFileSync(w, webpVp8xBuffer(1920, 1080))
160
+ expect(probeImageFile(w)).toMatchObject({ width: 1920, height: 1080 })
161
+ })
162
+
163
+ it('probes the 600x8717 incident image without reading the whole multi-MB file', () => {
164
+ // Header + 5MB of body: the probe must report full file size but
165
+ // still read dimensions from the fixed-size header window.
166
+ const p = join(dir, 'incident.png')
167
+ const body = Buffer.alloc(5 * 1024 * 1024)
168
+ writeFileSync(p, Buffer.concat([pngBuffer(600, 8717), body]))
169
+ const probe = probeImageFile(p)
170
+ expect(probe).toEqual({ width: 600, height: 8717, bytes: 33 + body.length })
171
+ expect(classifyPhotoFile(p).route).toBe('document')
172
+ })
173
+
174
+ it('finds JPEG SOFn past a large metadata segment (within the 64KB window)', () => {
175
+ // SOI + a 40KB APP1 (EXIF-sized) segment, then SOF0. The 32-byte
176
+ // sniff alone cannot see SOF0; the JPEG re-read must.
177
+ const segLen = 40 * 1024
178
+ const app1 = Buffer.alloc(2 + 2 + segLen)
179
+ app1[0] = 0xff; app1[1] = 0xe1
180
+ app1.writeUInt16BE(2 + segLen, 2)
181
+ const sof0 = Buffer.alloc(10)
182
+ sof0[0] = 0xff; sof0[1] = 0xc0
183
+ sof0.writeUInt16BE(8, 2)
184
+ sof0[4] = 8
185
+ sof0.writeUInt16BE(2556, 5)
186
+ sof0.writeUInt16BE(1179, 7)
187
+ const p = join(dir, 'exif.jpg')
188
+ writeFileSync(p, Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sof0]))
189
+ expect(probeImageFile(p)).toMatchObject({ width: 1179, height: 2556 })
190
+ })
191
+
192
+ it('returns null (photo route kept) when JPEG SOFn is beyond the 64KB window', () => {
193
+ // Two 40KB APPn segments push SOF0 past the 64KB probe window
194
+ // (a single segment length is 16-bit capped at 65535).
195
+ const segLen = 40 * 1024
196
+ const app1 = Buffer.alloc(2 + 2 + segLen)
197
+ app1[0] = 0xff; app1[1] = 0xe1
198
+ app1.writeUInt16BE(2 + segLen, 2)
199
+ const sof0 = Buffer.alloc(10)
200
+ sof0[0] = 0xff; sof0[1] = 0xc0
201
+ sof0.writeUInt16BE(8, 2)
202
+ sof0[4] = 8
203
+ sof0.writeUInt16BE(8717, 5)
204
+ sof0.writeUInt16BE(600, 7)
205
+ const p = join(dir, 'deep-exif.jpg')
206
+ writeFileSync(p, Buffer.concat([Buffer.from([0xff, 0xd8]), app1, app1, sof0]))
207
+ expect(probeImageFile(p)).toBeNull()
208
+ // Probe miss → photo route → #3022 reactive fallback backstops.
209
+ expect(classifyPhotoFile(p)).toEqual({ route: 'photo' })
210
+ })
211
+
212
+ it('keeps the photo route when the file is unreadable or unrecognized (reactive fallback backstops)', () => {
213
+ expect(classifyPhotoFile(join(dir, 'missing.png'))).toEqual({ route: 'photo' })
214
+ const junk = join(dir, 'junk.png')
215
+ writeFileSync(junk, Buffer.from('definitely not a png'))
216
+ expect(classifyPhotoFile(junk)).toEqual({ route: 'photo' })
217
+ })
218
+ })
219
+
220
+ describe('rerouteResultSuffix (#3038 — reply result tells the agent about reroutes)', () => {
221
+ it('returns empty string when nothing was rerouted', () => {
222
+ expect(rerouteResultSuffix([])).toBe('')
223
+ })
224
+
225
+ it('formats a single reroute with basename and reason', () => {
226
+ const s = rerouteResultSuffix([
227
+ { path: '/tmp/x/tall.png', reason: 'aspect ratio 14.5:1 (600x8717) exceeds 10:1 photo bound' },
228
+ ])
229
+ expect(s).toBe(' (1 file(s) sent as document, not inline photo: tall.png: aspect ratio 14.5:1 (600x8717) exceeds 10:1 photo bound)')
230
+ })
231
+
232
+ it('joins multiple reroutes and counts them', () => {
233
+ const s = rerouteResultSuffix([
234
+ { path: '/a/one.png', reason: 'r1' },
235
+ { path: '/b/two.jpg', reason: 'r2' },
236
+ ])
237
+ expect(s).toMatch(/^ \(2 file\(s\) sent as document/)
238
+ expect(s).toContain('one.png: r1; two.jpg: r2')
239
+ })
240
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * #3038 polish — pins the gateway wiring for the photo→document reroute
3
+ * honesty surfaces added after PR #3037:
4
+ *
5
+ * 1. executeReply's tool result appends `rerouteResultSuffix(...)` so
6
+ * the agent learns files went out as documents, not inline photos
7
+ * (it otherwise only sees "sent (id: N)" and may tell the user an
8
+ * inline screenshot rendered).
9
+ * 2. attachment_kinds history records what was ACTUALLY sent: a
10
+ * photo-extension file in the `sentAsDocument` set is recorded as
11
+ * 'document', covering both the precheck reroute and the #3022
12
+ * reactive fallback paths.
13
+ *
14
+ * The gateway IIFE is too entangled to instantiate in-process; source-
15
+ * level assertions follow the `buffer-gate-broadened.test.ts` pattern.
16
+ * The pure formatting/probe logic is unit-tested in
17
+ * `photo-precheck.test.ts`.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import { readFileSync } from 'node:fs'
22
+ import { resolve } from 'node:path'
23
+
24
+ const gatewaySrc = readFileSync(
25
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
26
+ 'utf-8',
27
+ )
28
+
29
+ // Slice executeReply's file-send + result + history block: from the
30
+ // photo-precheck anchor to the recordOutbound call for replies.
31
+ const sliceStart = gatewaySrc.indexOf('const photoPrecheck = new Map')
32
+ const sliceEnd = gatewaySrc.indexOf('history recordOutbound (reply) failed')
33
+ const slice = gatewaySrc.slice(sliceStart, sliceEnd)
34
+
35
+ describe('reroute signal reaches the reply tool result (#3038 item 1)', () => {
36
+ it('slices resolve (anchors present in executeReply)', () => {
37
+ expect(sliceStart).toBeGreaterThan(-1)
38
+ expect(sliceEnd).toBeGreaterThan(sliceStart)
39
+ })
40
+
41
+ it('imports rerouteResultSuffix from photo-precheck', () => {
42
+ expect(gatewaySrc).toMatch(/import \{ classifyPhotoFile, rerouteResultSuffix \} from '\.\.\/photo-precheck\.js'/)
43
+ })
44
+
45
+ it('appends rerouteResultSuffix(documentReroutes) to the result text', () => {
46
+ // The suffix must be part of the `result` expression the tool returns.
47
+ const resultIdx = slice.indexOf('const result =')
48
+ expect(resultIdx).toBeGreaterThan(-1)
49
+ const resultExpr = slice.slice(resultIdx, slice.indexOf('\n\n', resultIdx))
50
+ expect(resultExpr).toContain('rerouteResultSuffix(documentReroutes)')
51
+ })
52
+
53
+ it('precheck reroutes populate documentReroutes AND sentAsDocument', () => {
54
+ const precheckBlock = slice.slice(0, slice.indexOf('sendableAsPhoto ='))
55
+ expect(precheckBlock).toContain("if (cls.route === 'document')")
56
+ expect(precheckBlock).toContain('documentReroutes.push({ path: f, reason: cls.reason })')
57
+ expect(precheckBlock).toContain('sentAsDocument.add(f)')
58
+ })
59
+
60
+ it('reactive fallbacks (album + per-file, #3022) also record the reroute', () => {
61
+ // Album-wide fallback loop.
62
+ const albumIdx = slice.indexOf('albumReason')
63
+ expect(albumIdx).toBeGreaterThan(-1)
64
+ // Per-file sendPhoto→sendDocument fallback: sendAsDocument followed by
65
+ // both bookkeeping calls.
66
+ const perFile = slice.indexOf('sent = await sendAsDocument(f)')
67
+ expect(perFile).toBeGreaterThan(-1)
68
+ const afterPerFile = slice.slice(perFile, perFile + 400)
69
+ expect(afterPerFile).toContain('sentAsDocument.add(f)')
70
+ expect(afterPerFile).toContain('documentReroutes.push')
71
+ })
72
+ })
73
+
74
+ describe('attachment_kinds records what was actually sent (#3038 item 2)', () => {
75
+ it('history kind consults sentAsDocument, not just the raw extension', () => {
76
+ const historyIdx = slice.indexOf('const attachKinds')
77
+ expect(historyIdx).toBeGreaterThan(-1)
78
+ const historyBlock = slice.slice(historyIdx)
79
+ expect(historyBlock).toContain("PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? 'photo' : 'document'")
80
+ // Both the human-readable text label and the kinds array use the
81
+ // resolved kind.
82
+ expect(historyBlock).toMatch(/texts\.push\(`\(\$\{kind\}/)
83
+ expect(historyBlock).toContain('attachKinds.push(kind)')
84
+ })
85
+ })
@@ -20,6 +20,11 @@ import {
20
20
  emptyAccountState,
21
21
  isLiveCorroboration,
22
22
  type CorroborationProbe,
23
+ evaluateFleetRollAnnounce,
24
+ buildFleetRollMessage,
25
+ FLEET_ROLL_MAX_AGE_MS,
26
+ QUOTA_WATCH_ROLL_DEDUP_MS,
27
+ type FleetRollInfo,
23
28
  } from "../quota-watch.js";
24
29
  import type { AccountSnapshot } from "../auth-snapshot-format.js";
25
30
  import type { QuotaUtilization } from "../quota-check.js";
@@ -946,3 +951,223 @@ describe("evaluateQuotaWatchAccount — total enumeration (144 cells)", () => {
946
951
  expect(cells).toBe(144);
947
952
  });
948
953
  });
954
+
955
+ // ── #3031 PR 3 — broker-roll announcement ────────────────────────────────────
956
+
957
+ describe("evaluateFleetRollAnnounce (#3031 PR 3)", () => {
958
+ const ROLL: FleetRollInfo = {
959
+ from: "alice@example.com",
960
+ to: "bob@example.com",
961
+ at: NOW - 60_000,
962
+ exhausted_until: NOW + 2 * 3600_000,
963
+ window: "5h",
964
+ pct: 99.8,
965
+ };
966
+
967
+ it("fires once for a fresh, never-announced roll (latched on roll.at)", () => {
968
+ const d = evaluateFleetRollAnnounce({ roll: ROLL, prev: emptyAccountState(), now: NOW });
969
+ expect(d.kind).toBe("notify");
970
+ if (d.kind !== "notify") return;
971
+ expect(d.newState.lastNotifiedAt).toBe(ROLL.at);
972
+ });
973
+
974
+ it("skips when already announced (edge trigger — no steady-state re-notify)", () => {
975
+ const d1 = evaluateFleetRollAnnounce({ roll: ROLL, prev: emptyAccountState(), now: NOW });
976
+ expect(d1.kind).toBe("notify");
977
+ if (d1.kind !== "notify") return;
978
+ const d2 = evaluateFleetRollAnnounce({ roll: ROLL, prev: d1.newState, now: NOW + 15 * 60_000 });
979
+ expect(d2.kind).toBe("skip");
980
+ if (d2.kind !== "skip") return;
981
+ expect(d2.reason).toBe("already-announced");
982
+ });
983
+
984
+ it("re-fires for a NEW roll event (later at) after a prior announce", () => {
985
+ const d1 = evaluateFleetRollAnnounce({ roll: ROLL, prev: emptyAccountState(), now: NOW });
986
+ if (d1.kind !== "notify") throw new Error("expected notify");
987
+ const later: FleetRollInfo = { ...ROLL, at: NOW + 6 * 3600_000, from: "bob@example.com", to: "carol@example.com" };
988
+ const d2 = evaluateFleetRollAnnounce({ roll: later, prev: d1.newState, now: NOW + 6 * 3600_000 + 5_000 });
989
+ expect(d2.kind).toBe("notify");
990
+ });
991
+
992
+ it("skips a stale roll (boot long after the event resurrects no old news)", () => {
993
+ const d = evaluateFleetRollAnnounce({
994
+ roll: { ...ROLL, at: NOW - FLEET_ROLL_MAX_AGE_MS - 1 },
995
+ prev: emptyAccountState(),
996
+ now: NOW,
997
+ });
998
+ expect(d.kind).toBe("skip");
999
+ if (d.kind !== "skip") return;
1000
+ expect(d.reason).toBe("stale-roll");
1001
+ });
1002
+
1003
+ it("skips when the broker reports no roll (old broker / never rolled)", () => {
1004
+ expect(evaluateFleetRollAnnounce({ roll: null, prev: emptyAccountState(), now: NOW }).kind).toBe("skip");
1005
+ expect(evaluateFleetRollAnnounce({ roll: undefined, prev: emptyAccountState(), now: NOW }).kind).toBe("skip");
1006
+ });
1007
+ });
1008
+
1009
+ describe("buildFleetRollMessage (#3031 PR 3) — causal + reassuring shape", () => {
1010
+ it("names the new account, the binding window + pct on the old account, and the reset", () => {
1011
+ const msg = buildFleetRollMessage(
1012
+ {
1013
+ from: "alice@example.com",
1014
+ to: "bob@example.com",
1015
+ at: NOW - 60_000,
1016
+ exhausted_until: NOW + 2 * 3600_000,
1017
+ window: "5h",
1018
+ pct: 99.8,
1019
+ },
1020
+ NOW,
1021
+ );
1022
+ expect(msg).toContain("Switched fleet to");
1023
+ expect(msg).toContain("bob@example.com");
1024
+ expect(msg).toContain("alice@example.com");
1025
+ expect(msg).toContain("5-hour window");
1026
+ expect(msg).toContain("100%"); // fmtPct rounds 99.8
1027
+ expect(msg).toContain("resets");
1028
+ expect(msg).toContain("Work continues uninterrupted");
1029
+ });
1030
+
1031
+ it("degrades gracefully without window/pct/reset data", () => {
1032
+ const msg = buildFleetRollMessage(
1033
+ { from: "alice@example.com", to: "bob@example.com", at: NOW - 60_000 },
1034
+ NOW,
1035
+ );
1036
+ expect(msg).toContain("quota window");
1037
+ expect(msg).not.toContain("resets");
1038
+ expect(msg).toContain("Work continues uninterrupted");
1039
+ });
1040
+ });
1041
+
1042
+ describe("evaluateQuotaWatchAccount — roll-announcement dedupe (#3031 PR 3)", () => {
1043
+ it("latches entered-throttling SILENTLY when a fresh roll off the same account was announced", () => {
1044
+ const d = evaluateQuotaWatchAccount({
1045
+ agentName: "lawgpt",
1046
+ snap: THROTTLING_5H,
1047
+ prev: PREV_NEVER_NOTIFIED,
1048
+ now: NOW,
1049
+ lastRoll: { from: "alice@example.com", at: NOW - 60_000 },
1050
+ });
1051
+ expect(d.kind).toBe("reconcile");
1052
+ if (d.kind !== "reconcile") return;
1053
+ expect(d.reason).toBe("roll-announced");
1054
+ expect(d.transition).toBe("entered-throttling");
1055
+ // Latch advances — no steady-state re-notify on the next tick.
1056
+ expect(d.newAccountState.lastNotifiedHealth).toBe("throttling");
1057
+ const next = evaluateQuotaWatchAccount({
1058
+ agentName: "lawgpt",
1059
+ snap: THROTTLING_5H,
1060
+ prev: d.newAccountState,
1061
+ now: NOW + 15 * 60_000,
1062
+ lastRoll: { from: "alice@example.com", at: NOW - 60_000 },
1063
+ });
1064
+ expect(next.kind).toBe("skip");
1065
+ });
1066
+
1067
+ it("does NOT dedupe a roll for a DIFFERENT account", () => {
1068
+ const d = evaluateQuotaWatchAccount({
1069
+ agentName: "lawgpt",
1070
+ snap: THROTTLING_5H,
1071
+ prev: PREV_NEVER_NOTIFIED,
1072
+ now: NOW,
1073
+ lastRoll: { from: "someone-else@example.com", at: NOW - 60_000 },
1074
+ });
1075
+ expect(d.kind).toBe("notify");
1076
+ });
1077
+
1078
+ it("does NOT dedupe a roll older than the dedup window", () => {
1079
+ const d = evaluateQuotaWatchAccount({
1080
+ agentName: "lawgpt",
1081
+ snap: THROTTLING_5H,
1082
+ prev: PREV_NEVER_NOTIFIED,
1083
+ now: NOW,
1084
+ lastRoll: { from: "alice@example.com", at: NOW - QUOTA_WATCH_ROLL_DEDUP_MS - 1 },
1085
+ });
1086
+ expect(d.kind).toBe("notify");
1087
+ });
1088
+
1089
+ it("throttling message says what happens next (early warning, not incident)", () => {
1090
+ const d = evaluateQuotaWatchAccount({
1091
+ agentName: "lawgpt",
1092
+ snap: makeSnap("alice@example.com", makeQuota(85, 40), /*isActive*/ true),
1093
+ prev: PREV_NEVER_NOTIFIED,
1094
+ now: NOW,
1095
+ });
1096
+ expect(d.kind).toBe("notify");
1097
+ if (d.kind !== "notify") return;
1098
+ expect(d.message).toContain("No action needed");
1099
+ expect(d.message).toContain("fleet will prefer another account");
1100
+ });
1101
+ });
1102
+
1103
+ // ── #3035 review fixes ───────────────────────────────────────────────────────
1104
+
1105
+ describe("#3035 review fixes", () => {
1106
+ it("finding 1 — roll announce ceiling never exceeds the broker claim window (no duplicate-card gap)", async () => {
1107
+ const mod = await import("../quota-watch.js");
1108
+ expect(FLEET_ROLL_MAX_AGE_MS).toBeLessThanOrEqual(mod.QUOTA_WATCH_CLAIM_WINDOW_MS);
1109
+ });
1110
+
1111
+ it("finding 3 — roll-dedupe suppresses ONLY the first post-roll episode: a recover-then-re-throttle after the roll notifies", () => {
1112
+ const rollAt = NOW - 20 * 60_000; // inside the 30-min dedup window
1113
+ // Post-roll, the account recovered to healthy and the watch state
1114
+ // advanced (lastNotifiedAt AFTER roll.at). A fresh throttling entry is
1115
+ // a genuinely NEW episode the roll card said nothing about.
1116
+ const prevRecoveredPostRoll = {
1117
+ lastNotifiedHealth: "healthy" as const,
1118
+ lastNotifiedAt: rollAt + 5 * 60_000,
1119
+ };
1120
+ const d = evaluateQuotaWatchAccount({
1121
+ agentName: "lawgpt",
1122
+ snap: THROTTLING_5H,
1123
+ prev: prevRecoveredPostRoll,
1124
+ now: NOW,
1125
+ lastRoll: { from: "alice@example.com", at: rollAt },
1126
+ });
1127
+ expect(d.kind).toBe("notify");
1128
+ if (d.kind !== "notify") return;
1129
+ expect(d.transition).toBe("entered-throttling");
1130
+ });
1131
+
1132
+ it("finding 3 — first post-roll episode (state pre-dates the roll) still latches silently", () => {
1133
+ const rollAt = NOW - 60_000;
1134
+ const d = evaluateQuotaWatchAccount({
1135
+ agentName: "lawgpt",
1136
+ snap: THROTTLING_5H,
1137
+ prev: { lastNotifiedHealth: "healthy" as const, lastNotifiedAt: rollAt - 3600_000 },
1138
+ now: NOW,
1139
+ lastRoll: { from: "alice@example.com", at: rollAt },
1140
+ });
1141
+ expect(d.kind).toBe("reconcile");
1142
+ if (d.kind !== "reconcile") return;
1143
+ expect(d.reason).toBe("roll-announced");
1144
+ });
1145
+ });
1146
+
1147
+ describe("buildFleetRollMessage — reason attribution (#3031 PR 2 reason field)", () => {
1148
+ const BASE: FleetRollInfo = {
1149
+ from: "alice@example.com",
1150
+ to: "bob@example.com",
1151
+ at: NOW - 60_000,
1152
+ window: "7d",
1153
+ pct: 96,
1154
+ };
1155
+
1156
+ it("soft-avoid reads as proactive 'approaching limits', not exhaustion", () => {
1157
+ const msg = buildFleetRollMessage({ ...BASE, reason: "soft-avoid" }, NOW);
1158
+ expect(msg).toContain("Proactive switch");
1159
+ expect(msg).toContain("approaching its limits");
1160
+ expect(msg).toContain("proactive, before exhaustion");
1161
+ expect(msg).toContain("7-day window at 96%");
1162
+ expect(msg).toContain("Work continues uninterrupted");
1163
+ });
1164
+
1165
+ it("hard-exhaustion (explicit or absent reason — pre-PR-2 broker) keeps the walled framing", () => {
1166
+ for (const roll of [{ ...BASE, reason: "hard-exhaustion" as const }, BASE]) {
1167
+ const msg = buildFleetRollMessage(roll, NOW);
1168
+ expect(msg).not.toContain("Proactive switch");
1169
+ expect(msg).not.toContain("approaching its limits");
1170
+ expect(msg).toContain("7-day window at 96% on");
1171
+ }
1172
+ });
1173
+ });