@vellumai/assistant 0.4.48 → 0.4.49

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 (252) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/README.md +2 -23
  3. package/docs/architecture/integrations.md +45 -41
  4. package/docs/architecture/keychain-broker.md +3 -3
  5. package/docs/runbook-trusted-contacts.md +3 -8
  6. package/hook-templates/debug-prompt-logger/hook.json +1 -1
  7. package/hook-templates/debug-prompt-logger/run.sh +1 -3
  8. package/package.json +1 -1
  9. package/src/__tests__/actor-token-service.test.ts +0 -1
  10. package/src/__tests__/anthropic-provider.test.ts +156 -0
  11. package/src/__tests__/approval-cascade.test.ts +810 -0
  12. package/src/__tests__/approval-primitive.test.ts +0 -1
  13. package/src/__tests__/approval-routes-http.test.ts +2 -0
  14. package/src/__tests__/assistant-attachments.test.ts +12 -34
  15. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +76 -0
  16. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -1
  17. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  18. package/src/__tests__/channel-guardian.test.ts +0 -2
  19. package/src/__tests__/channel-readiness-routes.test.ts +15 -6
  20. package/src/__tests__/channel-readiness-service.test.ts +10 -9
  21. package/src/__tests__/checker.test.ts +9 -29
  22. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
  23. package/src/__tests__/computer-use-tools.test.ts +2 -19
  24. package/src/__tests__/config-watcher.test.ts +0 -1
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
  26. package/src/__tests__/context-image-dimensions.test.ts +332 -0
  27. package/src/__tests__/context-token-estimator.test.ts +196 -13
  28. package/src/__tests__/conversation-attention-store.test.ts +0 -1
  29. package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
  30. package/src/__tests__/conversation-routes-guardian-reply.test.ts +144 -0
  31. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  32. package/src/__tests__/credential-metadata-store.test.ts +64 -73
  33. package/src/__tests__/credential-security-invariants.test.ts +13 -7
  34. package/src/__tests__/credential-vault-unit.test.ts +280 -49
  35. package/src/__tests__/credential-vault.test.ts +138 -16
  36. package/src/__tests__/credentials-cli.test.ts +71 -0
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  38. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  39. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  40. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -1
  41. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +0 -1
  42. package/src/__tests__/guardian-routing-invariants.test.ts +0 -1
  43. package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -1
  44. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -39
  45. package/src/__tests__/heartbeat-service.test.ts +0 -1
  46. package/src/__tests__/host-cu-proxy.test.ts +629 -0
  47. package/src/__tests__/host-shell-tool.test.ts +27 -15
  48. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  49. package/src/__tests__/ingress-url-consistency.test.ts +14 -21
  50. package/src/__tests__/integration-status.test.ts +32 -51
  51. package/src/__tests__/intent-routing.test.ts +0 -1
  52. package/src/__tests__/invite-routes-http.test.ts +10 -9
  53. package/src/__tests__/keychain-broker-client.test.ts +11 -43
  54. package/src/__tests__/notification-routing-intent.test.ts +0 -1
  55. package/src/__tests__/oauth-cli.test.ts +373 -14
  56. package/src/__tests__/oauth-provider-profiles.test.ts +9 -9
  57. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  58. package/src/__tests__/oauth-store.test.ts +756 -0
  59. package/src/__tests__/onboarding-starter-tasks.test.ts +0 -1
  60. package/src/__tests__/provider-error-scenarios.test.ts +0 -1
  61. package/src/__tests__/provider-streaming.benchmark.test.ts +0 -1
  62. package/src/__tests__/public-ingress-urls.test.ts +15 -21
  63. package/src/__tests__/recording-handler.test.ts +3 -4
  64. package/src/__tests__/registry.test.ts +2 -2
  65. package/src/__tests__/runtime-events-sse.test.ts +55 -7
  66. package/src/__tests__/schedule-store.test.ts +0 -1
  67. package/src/__tests__/scheduler-recurrence.test.ts +0 -1
  68. package/src/__tests__/scoped-approval-grants.test.ts +0 -1
  69. package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -1
  70. package/src/__tests__/secret-ingress-handler.test.ts +0 -1
  71. package/src/__tests__/send-endpoint-busy.test.ts +21 -6
  72. package/src/__tests__/sequence-store.test.ts +0 -1
  73. package/src/__tests__/session-init.benchmark.test.ts +4 -5
  74. package/src/__tests__/skill-include-graph.test.ts +66 -0
  75. package/src/__tests__/skill-load-feature-flag.test.ts +0 -1
  76. package/src/__tests__/skill-load-tool.test.ts +149 -1
  77. package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
  78. package/src/__tests__/skills-uninstall.test.ts +1 -1
  79. package/src/__tests__/skills.test.ts +3 -3
  80. package/src/__tests__/slack-channel-config.test.ts +67 -3
  81. package/src/__tests__/slack-share-routes.test.ts +17 -19
  82. package/src/__tests__/system-prompt.test.ts +0 -1
  83. package/src/__tests__/telegram-invite-adapter.test.ts +18 -22
  84. package/src/__tests__/terminal-tools.test.ts +4 -3
  85. package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
  86. package/src/__tests__/tool-approval-handler.test.ts +0 -1
  87. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -1
  88. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  90. package/src/__tests__/tool-executor.test.ts +0 -1
  91. package/src/__tests__/tool-grant-request-escalation.test.ts +0 -1
  92. package/src/__tests__/trust-store-pattern-matches.test.ts +29 -0
  93. package/src/__tests__/trust-store.test.ts +1 -22
  94. package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
  95. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  96. package/src/__tests__/twilio-routes.test.ts +0 -16
  97. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  98. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  99. package/src/agent/ax-tree-compaction.test.ts +235 -0
  100. package/src/agent/loop.ts +76 -130
  101. package/src/calls/call-domain.ts +1 -6
  102. package/src/calls/relay-server.ts +9 -13
  103. package/src/calls/twilio-config.ts +2 -7
  104. package/src/calls/twilio-routes.ts +1 -2
  105. package/src/calls/voice-ingress-preflight.ts +1 -1
  106. package/src/cli/commands/browser-relay.ts +18 -12
  107. package/src/cli/commands/completions.ts +0 -3
  108. package/src/cli/commands/credentials.ts +101 -15
  109. package/src/cli/commands/oauth/apps.ts +255 -0
  110. package/src/cli/commands/oauth/connections.ts +299 -0
  111. package/src/cli/commands/oauth/index.ts +52 -0
  112. package/src/cli/commands/oauth/providers.ts +242 -0
  113. package/src/cli/commands/skills.ts +4 -338
  114. package/src/cli/program.ts +1 -5
  115. package/src/cli/reference.ts +1 -3
  116. package/src/config/assistant-feature-flags.ts +0 -3
  117. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  118. package/src/config/bundled-skills/computer-use/SKILL.md +3 -6
  119. package/src/config/bundled-skills/computer-use/TOOLS.json +22 -4
  120. package/src/config/bundled-skills/google-calendar/calendar-client.ts +21 -16
  121. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -4
  122. package/src/config/bundled-skills/settings/SKILL.md +1 -1
  123. package/src/config/bundled-skills/settings/TOOLS.json +2 -8
  124. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +5 -33
  125. package/src/config/env-registry.ts +14 -83
  126. package/src/config/env.ts +11 -50
  127. package/src/config/feature-flag-registry.json +16 -16
  128. package/src/config/loader.ts +0 -6
  129. package/src/config/schema.ts +3 -1
  130. package/src/config/skills.ts +21 -2
  131. package/src/context/image-dimensions.ts +229 -0
  132. package/src/context/token-estimator.ts +75 -12
  133. package/src/context/window-manager.ts +49 -10
  134. package/src/daemon/assistant-attachments.ts +1 -13
  135. package/src/daemon/handlers/config-ingress.ts +8 -33
  136. package/src/daemon/handlers/config-slack-channel.ts +49 -46
  137. package/src/daemon/handlers/config-telegram.ts +32 -16
  138. package/src/daemon/handlers/sessions.ts +10 -24
  139. package/src/daemon/handlers/shared.ts +0 -130
  140. package/src/daemon/host-cu-proxy.ts +401 -0
  141. package/src/daemon/lifecycle.ts +36 -68
  142. package/src/daemon/message-protocol.ts +3 -0
  143. package/src/daemon/message-types/computer-use.ts +2 -119
  144. package/src/daemon/message-types/host-cu.ts +19 -0
  145. package/src/daemon/message-types/messages.ts +3 -0
  146. package/src/daemon/server.ts +14 -21
  147. package/src/daemon/session-agent-loop-handlers.ts +2 -0
  148. package/src/daemon/session-attachments.ts +1 -2
  149. package/src/daemon/session-slash.ts +1 -1
  150. package/src/daemon/session-surfaces.ts +40 -28
  151. package/src/daemon/session-tool-setup.ts +2 -9
  152. package/src/daemon/session.ts +138 -15
  153. package/src/daemon/tool-side-effects.ts +2 -8
  154. package/src/daemon/watch-handler.ts +2 -2
  155. package/src/events/tool-metrics-listener.ts +2 -2
  156. package/src/hooks/manager.ts +1 -4
  157. package/src/inbound/public-ingress-urls.ts +7 -7
  158. package/src/logfire.ts +16 -5
  159. package/src/memory/conversation-key-store.ts +21 -0
  160. package/src/memory/db-init.ts +4 -0
  161. package/src/memory/migrations/149-oauth-tables.ts +60 -0
  162. package/src/memory/migrations/index.ts +1 -0
  163. package/src/memory/schema/index.ts +1 -0
  164. package/src/memory/schema/oauth.ts +65 -0
  165. package/src/messaging/provider.ts +4 -4
  166. package/src/messaging/providers/gmail/client.ts +82 -2
  167. package/src/messaging/providers/gmail/people-client.ts +10 -10
  168. package/src/messaging/providers/telegram-bot/adapter.ts +17 -17
  169. package/src/messaging/providers/whatsapp/adapter.ts +11 -8
  170. package/src/messaging/registry.ts +2 -32
  171. package/src/notifications/copy-composer.ts +0 -5
  172. package/src/notifications/signal.ts +4 -5
  173. package/src/oauth/byo-connection.test.ts +126 -25
  174. package/src/oauth/byo-connection.ts +22 -6
  175. package/src/oauth/connect-orchestrator.ts +113 -57
  176. package/src/oauth/connect-types.ts +17 -23
  177. package/src/oauth/connection-resolver.ts +35 -11
  178. package/src/oauth/connection.ts +1 -1
  179. package/src/oauth/manual-token-connection.ts +104 -0
  180. package/src/oauth/oauth-store.ts +496 -0
  181. package/src/oauth/platform-connection.test.ts +29 -0
  182. package/src/oauth/platform-connection.ts +6 -5
  183. package/src/oauth/provider-behaviors.ts +124 -0
  184. package/src/oauth/scope-policy.ts +9 -2
  185. package/src/oauth/seed-providers.ts +161 -0
  186. package/src/oauth/token-persistence.ts +74 -78
  187. package/src/permissions/checker.ts +3 -3
  188. package/src/permissions/defaults.ts +0 -1
  189. package/src/permissions/prompter.ts +10 -1
  190. package/src/permissions/trust-store.ts +13 -0
  191. package/src/prompts/__tests__/build-cli-reference-section.test.ts +3 -1
  192. package/src/prompts/system-prompt.ts +28 -40
  193. package/src/providers/anthropic/client.ts +133 -24
  194. package/src/providers/retry.ts +1 -27
  195. package/src/runtime/auth/route-policy.ts +0 -3
  196. package/src/runtime/channel-reply-delivery.ts +0 -40
  197. package/src/runtime/gateway-client.ts +0 -7
  198. package/src/runtime/http-server.ts +8 -6
  199. package/src/runtime/http-types.ts +2 -2
  200. package/src/runtime/middleware/twilio-validation.ts +1 -11
  201. package/src/runtime/pending-interactions.ts +14 -12
  202. package/src/runtime/routes/channel-delivery-routes.ts +0 -1
  203. package/src/runtime/routes/conversation-routes.ts +73 -19
  204. package/src/runtime/routes/events-routes.ts +21 -11
  205. package/src/runtime/routes/host-cu-routes.ts +97 -0
  206. package/src/runtime/routes/inbound-stages/background-dispatch.ts +12 -111
  207. package/src/runtime/routes/integrations/slack/share.ts +6 -7
  208. package/src/runtime/routes/log-export-routes.ts +126 -8
  209. package/src/runtime/routes/settings-routes.ts +55 -48
  210. package/src/runtime/routes/surface-action-routes.ts +1 -1
  211. package/src/runtime/routes/watch-routes.ts +128 -0
  212. package/src/schedule/integration-status.ts +10 -9
  213. package/src/security/credential-key.ts +0 -156
  214. package/src/security/keychain-broker-client.ts +5 -6
  215. package/src/security/oauth2.ts +1 -1
  216. package/src/security/token-manager.ts +119 -46
  217. package/src/skills/catalog-install.ts +358 -0
  218. package/src/skills/include-graph.ts +32 -0
  219. package/src/telegram/bot-username.ts +2 -3
  220. package/src/tools/browser/network-recorder.ts +1 -1
  221. package/src/tools/browser/network-recording-types.ts +1 -1
  222. package/src/tools/computer-use/definitions.ts +46 -11
  223. package/src/tools/computer-use/registry.ts +4 -5
  224. package/src/tools/credentials/broker.ts +1 -2
  225. package/src/tools/credentials/metadata-store.ts +17 -121
  226. package/src/tools/credentials/vault.ts +94 -167
  227. package/src/tools/registry.ts +2 -7
  228. package/src/tools/skills/load.ts +62 -3
  229. package/src/tools/watch/watch-state.ts +0 -12
  230. package/src/util/logger.ts +7 -41
  231. package/src/util/platform.ts +9 -28
  232. package/src/watcher/providers/google-calendar.ts +2 -1
  233. package/src/__tests__/computer-use-session-compaction.test.ts +0 -143
  234. package/src/__tests__/computer-use-session-lifecycle.test.ts +0 -322
  235. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -166
  236. package/src/__tests__/computer-use-skill-baseline.test.ts +0 -78
  237. package/src/__tests__/computer-use-skill-endstate.test.ts +0 -105
  238. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +0 -249
  239. package/src/__tests__/ride-shotgun-handler.test.ts +0 -452
  240. package/src/cli/commands/dev.ts +0 -129
  241. package/src/cli/commands/map.ts +0 -391
  242. package/src/cli/commands/oauth.ts +0 -77
  243. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +0 -16
  244. package/src/daemon/computer-use-session.ts +0 -1026
  245. package/src/daemon/ride-shotgun-handler.ts +0 -569
  246. package/src/oauth/provider-base-urls.ts +0 -21
  247. package/src/oauth/provider-profiles.ts +0 -192
  248. package/src/prompts/computer-use-prompt.ts +0 -98
  249. package/src/runtime/routes/computer-use-routes.ts +0 -641
  250. package/src/runtime/telegram-streaming-delivery.test.ts +0 -729
  251. package/src/runtime/telegram-streaming-delivery.ts +0 -393
  252. package/src/tools/computer-use/request-computer-control.ts +0 -56
@@ -0,0 +1,332 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "bun:test";
4
+
5
+ import { parseImageDimensions } from "../context/image-dimensions.js";
6
+
7
+ /**
8
+ * Helper: build a Buffer of given bytes and return its base64 encoding.
9
+ */
10
+ function toBase64(bytes: number[]): string {
11
+ return Buffer.from(bytes).toString("base64");
12
+ }
13
+
14
+ /**
15
+ * Minimal valid PNG IHDR: 8-byte signature + 13-byte IHDR chunk.
16
+ * Width = 320, Height = 240.
17
+ */
18
+ function minimalPngHeader(width: number, height: number): number[] {
19
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
20
+ // IHDR chunk: length (13 = 0x0000000D), "IHDR", width(4), height(4), bitDepth, colorType, compression, filter, interlace
21
+ const ihdrLength = [0x00, 0x00, 0x00, 0x0d];
22
+ const ihdrType = [0x49, 0x48, 0x44, 0x52]; // "IHDR"
23
+ const w = [
24
+ (width >> 24) & 0xff,
25
+ (width >> 16) & 0xff,
26
+ (width >> 8) & 0xff,
27
+ width & 0xff,
28
+ ];
29
+ const h = [
30
+ (height >> 24) & 0xff,
31
+ (height >> 16) & 0xff,
32
+ (height >> 8) & 0xff,
33
+ height & 0xff,
34
+ ];
35
+ const rest = [0x08, 0x06, 0x00, 0x00, 0x00]; // bit depth, color type RGBA, compression, filter, interlace
36
+ const crc = [0x00, 0x00, 0x00, 0x00]; // dummy CRC (not validated by parser)
37
+ return [...sig, ...ihdrLength, ...ihdrType, ...w, ...h, ...rest, ...crc];
38
+ }
39
+
40
+ /**
41
+ * Minimal valid JPEG with SOF0 marker.
42
+ * Structure: SOI + APP0 (short) + SOF0 with given dimensions.
43
+ */
44
+ function minimalJpegHeader(width: number, height: number): number[] {
45
+ const soi = [0xff, 0xd8]; // Start of image
46
+ // APP0 marker (JFIF) - minimal
47
+ const app0 = [
48
+ 0xff,
49
+ 0xe0, // APP0 marker
50
+ 0x00,
51
+ 0x10, // length = 16
52
+ 0x4a,
53
+ 0x46,
54
+ 0x49,
55
+ 0x46,
56
+ 0x00, // "JFIF\0"
57
+ 0x01,
58
+ 0x01, // version 1.1
59
+ 0x00, // aspect ratio units
60
+ 0x00,
61
+ 0x01, // X density
62
+ 0x00,
63
+ 0x01, // Y density
64
+ 0x00,
65
+ 0x00, // no thumbnail
66
+ ];
67
+ // SOF0 marker
68
+ const sof0 = [
69
+ 0xff,
70
+ 0xc0, // SOF0 marker
71
+ 0x00,
72
+ 0x0b, // length = 11
73
+ 0x08, // precision = 8 bits
74
+ (height >> 8) & 0xff,
75
+ height & 0xff, // height
76
+ (width >> 8) & 0xff,
77
+ width & 0xff, // width
78
+ 0x03, // number of components
79
+ 0x01,
80
+ 0x11,
81
+ 0x00, // Y component
82
+ ];
83
+ return [...soi, ...app0, ...sof0];
84
+ }
85
+
86
+ /**
87
+ * Minimal valid GIF89a header with given dimensions.
88
+ */
89
+ function minimalGifHeader(width: number, height: number): number[] {
90
+ // "GIF89a"
91
+ const sig = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
92
+ const w = [width & 0xff, (width >> 8) & 0xff]; // little-endian uint16
93
+ const h = [height & 0xff, (height >> 8) & 0xff]; // little-endian uint16
94
+ return [...sig, ...w, ...h, 0x00, 0x00]; // pad to 12 bytes
95
+ }
96
+
97
+ /**
98
+ * Minimal valid WebP VP8 (lossy) header with given dimensions.
99
+ */
100
+ function minimalWebpVP8Header(width: number, height: number): number[] {
101
+ const riff = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
102
+ const fileSize = [0x00, 0x00, 0x00, 0x00]; // dummy file size
103
+ const webp = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
104
+ const vp8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
105
+ const chunkSize = [0x00, 0x00, 0x00, 0x00]; // dummy chunk size
106
+ // VP8 bitstream header (bytes 20-25): frame tag + start code
107
+ const frameTag = [0x9d, 0x01, 0x2a]; // key frame tag bytes
108
+ const padding = [0x00, 0x00, 0x00]; // padding to reach offset 26
109
+ // Width at byte 26 (LE uint16), height at byte 28 (LE uint16)
110
+ const w = [width & 0xff, (width >> 8) & 0x3f]; // little-endian uint16, upper bits masked
111
+ const h = [height & 0xff, (height >> 8) & 0x3f]; // little-endian uint16, upper bits masked
112
+ return [
113
+ ...riff,
114
+ ...fileSize,
115
+ ...webp,
116
+ ...vp8,
117
+ ...chunkSize,
118
+ ...frameTag,
119
+ ...padding,
120
+ ...w,
121
+ ...h,
122
+ 0x00,
123
+ 0x00,
124
+ ];
125
+ }
126
+
127
+ /**
128
+ * Minimal valid WebP VP8L (lossless) header with given dimensions.
129
+ */
130
+ function minimalWebpVP8LHeader(width: number, height: number): number[] {
131
+ const riff = [0x52, 0x49, 0x46, 0x46];
132
+ const fileSize = [0x00, 0x00, 0x00, 0x00];
133
+ const webp = [0x57, 0x45, 0x42, 0x50];
134
+ const vp8l = [0x56, 0x50, 0x38, 0x4c]; // "VP8L"
135
+ const chunkSize = [0x00, 0x00, 0x00, 0x00];
136
+ // Signature byte at offset 20
137
+ const sigByte = [0x2f];
138
+ // At offset 21: LE uint32 encoding width-1 in bits 0-13 and height-1 in bits 14-27
139
+ const bits = ((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14);
140
+ const bitsBytes = [
141
+ bits & 0xff,
142
+ (bits >> 8) & 0xff,
143
+ (bits >> 16) & 0xff,
144
+ (bits >> 24) & 0xff,
145
+ ];
146
+ return [
147
+ ...riff,
148
+ ...fileSize,
149
+ ...webp,
150
+ ...vp8l,
151
+ ...chunkSize,
152
+ ...sigByte,
153
+ ...bitsBytes,
154
+ ];
155
+ }
156
+
157
+ /**
158
+ * Minimal valid WebP VP8X (extended) header with given dimensions.
159
+ */
160
+ function minimalWebpVP8XHeader(width: number, height: number): number[] {
161
+ const riff = [0x52, 0x49, 0x46, 0x46];
162
+ const fileSize = [0x00, 0x00, 0x00, 0x00];
163
+ const webp = [0x57, 0x45, 0x42, 0x50];
164
+ const vp8x = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
165
+ const chunkSize = [0x0a, 0x00, 0x00, 0x00]; // chunk size = 10
166
+ const flags = [0x00, 0x00, 0x00, 0x00]; // flags (bytes 20-23)
167
+ // Width-1 as LE uint24 at offset 24
168
+ const w1 = width - 1;
169
+ const wBytes = [w1 & 0xff, (w1 >> 8) & 0xff, (w1 >> 16) & 0xff];
170
+ // Height-1 as LE uint24 at offset 27
171
+ const h1 = height - 1;
172
+ const hBytes = [h1 & 0xff, (h1 >> 8) & 0xff, (h1 >> 16) & 0xff];
173
+ return [
174
+ ...riff,
175
+ ...fileSize,
176
+ ...webp,
177
+ ...vp8x,
178
+ ...chunkSize,
179
+ ...flags,
180
+ ...wBytes,
181
+ ...hBytes,
182
+ ];
183
+ }
184
+
185
+ describe("parseImageDimensions", () => {
186
+ describe("PNG", () => {
187
+ it("extracts dimensions from a valid PNG header", () => {
188
+ const base64 = toBase64(minimalPngHeader(320, 240));
189
+ const result = parseImageDimensions(base64, "image/png");
190
+ expect(result).toEqual({ width: 320, height: 240 });
191
+ });
192
+
193
+ it("extracts dimensions from a large PNG", () => {
194
+ const base64 = toBase64(minimalPngHeader(3840, 2160));
195
+ const result = parseImageDimensions(base64, "image/png");
196
+ expect(result).toEqual({ width: 3840, height: 2160 });
197
+ });
198
+
199
+ it("returns null for truncated PNG data", () => {
200
+ const bytes = minimalPngHeader(320, 240);
201
+ const truncated = toBase64(bytes.slice(0, 10));
202
+ expect(parseImageDimensions(truncated, "image/png")).toBeNull();
203
+ });
204
+
205
+ it("returns null for corrupt PNG signature", () => {
206
+ const bytes = minimalPngHeader(320, 240);
207
+ bytes[0] = 0x00; // corrupt signature
208
+ expect(parseImageDimensions(toBase64(bytes), "image/png")).toBeNull();
209
+ });
210
+ });
211
+
212
+ describe("JPEG", () => {
213
+ it("extracts dimensions from a valid JPEG with SOF0", () => {
214
+ const base64 = toBase64(minimalJpegHeader(640, 480));
215
+ const result = parseImageDimensions(base64, "image/jpeg");
216
+ expect(result).toEqual({ width: 640, height: 480 });
217
+ });
218
+
219
+ it("extracts dimensions from a JPEG with SOF2 (progressive)", () => {
220
+ const bytes = minimalJpegHeader(800, 600);
221
+ // Change SOF0 (0xC0) to SOF2 (0xC2)
222
+ const sof0Idx = bytes.indexOf(0xc0, 2);
223
+ bytes[sof0Idx] = 0xc2;
224
+ const result = parseImageDimensions(toBase64(bytes), "image/jpeg");
225
+ expect(result).toEqual({ width: 800, height: 600 });
226
+ });
227
+
228
+ it("returns null for truncated JPEG data", () => {
229
+ const truncated = toBase64([0xff, 0xd8, 0xff, 0xc0]);
230
+ expect(parseImageDimensions(truncated, "image/jpeg")).toBeNull();
231
+ });
232
+
233
+ it("returns null for corrupt JPEG (missing SOI)", () => {
234
+ const bytes = minimalJpegHeader(640, 480);
235
+ bytes[0] = 0x00;
236
+ expect(parseImageDimensions(toBase64(bytes), "image/jpeg")).toBeNull();
237
+ });
238
+ });
239
+
240
+ describe("GIF", () => {
241
+ it("extracts dimensions from a valid GIF89a header", () => {
242
+ const base64 = toBase64(minimalGifHeader(100, 50));
243
+ const result = parseImageDimensions(base64, "image/gif");
244
+ expect(result).toEqual({ width: 100, height: 50 });
245
+ });
246
+
247
+ it("extracts dimensions from GIF87a header", () => {
248
+ const bytes = minimalGifHeader(256, 128);
249
+ bytes[4] = 0x37; // Change '9' to '7' for GIF87a — signature check is GIF8 only
250
+ bytes[5] = 0x61;
251
+ const result = parseImageDimensions(toBase64(bytes), "image/gif");
252
+ expect(result).toEqual({ width: 256, height: 128 });
253
+ });
254
+
255
+ it("returns null for truncated GIF data", () => {
256
+ const truncated = toBase64([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
257
+ expect(parseImageDimensions(truncated, "image/gif")).toBeNull();
258
+ });
259
+
260
+ it("returns null for corrupt GIF signature", () => {
261
+ const bytes = minimalGifHeader(100, 50);
262
+ bytes[0] = 0x00;
263
+ expect(parseImageDimensions(toBase64(bytes), "image/gif")).toBeNull();
264
+ });
265
+ });
266
+
267
+ describe("WebP", () => {
268
+ it("extracts dimensions from a VP8 (lossy) WebP", () => {
269
+ const base64 = toBase64(minimalWebpVP8Header(400, 300));
270
+ const result = parseImageDimensions(base64, "image/webp");
271
+ expect(result).toEqual({ width: 400, height: 300 });
272
+ });
273
+
274
+ it("extracts dimensions from a VP8L (lossless) WebP", () => {
275
+ const base64 = toBase64(minimalWebpVP8LHeader(500, 250));
276
+ const result = parseImageDimensions(base64, "image/webp");
277
+ expect(result).toEqual({ width: 500, height: 250 });
278
+ });
279
+
280
+ it("extracts dimensions from a VP8X (extended) WebP", () => {
281
+ const base64 = toBase64(minimalWebpVP8XHeader(1920, 1080));
282
+ const result = parseImageDimensions(base64, "image/webp");
283
+ expect(result).toEqual({ width: 1920, height: 1080 });
284
+ });
285
+
286
+ it("returns null for truncated WebP data", () => {
287
+ const truncated = toBase64([
288
+ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
289
+ ]);
290
+ expect(parseImageDimensions(truncated, "image/webp")).toBeNull();
291
+ });
292
+
293
+ it("returns null for corrupt RIFF signature", () => {
294
+ const bytes = minimalWebpVP8Header(400, 300);
295
+ bytes[0] = 0x00;
296
+ expect(parseImageDimensions(toBase64(bytes), "image/webp")).toBeNull();
297
+ });
298
+ });
299
+
300
+ describe("unknown media type", () => {
301
+ it("returns null for unsupported media type", () => {
302
+ expect(parseImageDimensions("AAAA", "image/bmp")).toBeNull();
303
+ });
304
+
305
+ it("returns null for non-image media type", () => {
306
+ expect(parseImageDimensions("AAAA", "application/pdf")).toBeNull();
307
+ });
308
+ });
309
+
310
+ describe("empty/invalid data", () => {
311
+ it("returns null for empty base64 string", () => {
312
+ expect(parseImageDimensions("", "image/png")).toBeNull();
313
+ expect(parseImageDimensions("", "image/jpeg")).toBeNull();
314
+ expect(parseImageDimensions("", "image/gif")).toBeNull();
315
+ expect(parseImageDimensions("", "image/webp")).toBeNull();
316
+ });
317
+ });
318
+
319
+ describe("real image file", () => {
320
+ it("parses dimensions from an actual PNG file in the repo", () => {
321
+ const pngPath = join(
322
+ import.meta.dir,
323
+ "../../..",
324
+ "clients/chrome-extension/icons/icon16.png",
325
+ );
326
+ const pngData = readFileSync(pngPath);
327
+ const base64 = pngData.toString("base64");
328
+ const result = parseImageDimensions(base64, "image/png");
329
+ expect(result).toEqual({ width: 16, height: 16 });
330
+ });
331
+ });
332
+ });
@@ -104,7 +104,7 @@ describe("token estimator", () => {
104
104
  expect(largeFileTokens - smallFileTokens).toBeGreaterThan(1000);
105
105
  });
106
106
 
107
- test("does not count file base64 payload for OpenAI/Anthropic-style file fallback", () => {
107
+ test("does not count file base64 payload for OpenAI-style file fallback", () => {
108
108
  const sharedSource = {
109
109
  type: "base64" as const,
110
110
  filename: "report.pdf",
@@ -130,6 +130,47 @@ describe("token estimator", () => {
130
130
  expect(largeFileTokens).toBe(smallFileTokens);
131
131
  });
132
132
 
133
+ test("estimates Anthropic PDF tokens from file size", () => {
134
+ // ~14.8 MB PDF => ~20M base64 chars
135
+ const base64Length = 20_000_000;
136
+ const tokens = estimateContentBlockTokens(
137
+ {
138
+ type: "file",
139
+ source: {
140
+ type: "base64",
141
+ filename: "large-report.pdf",
142
+ media_type: "application/pdf",
143
+ data: "a".repeat(base64Length),
144
+ },
145
+ extracted_text: "",
146
+ },
147
+ { providerName: "anthropic" },
148
+ );
149
+
150
+ // Raw bytes = 20_000_000 * 3/4 = 15_000_000
151
+ // Estimated tokens = 15_000_000 * 0.016 = 240_000 (plus overhead)
152
+ expect(tokens).toBeGreaterThan(200_000);
153
+ });
154
+
155
+ test("Anthropic PDF minimum is one page", () => {
156
+ const tokens = estimateContentBlockTokens(
157
+ {
158
+ type: "file",
159
+ source: {
160
+ type: "base64",
161
+ filename: "tiny.pdf",
162
+ media_type: "application/pdf",
163
+ data: "a".repeat(16),
164
+ },
165
+ extracted_text: "",
166
+ },
167
+ { providerName: "anthropic" },
168
+ );
169
+
170
+ // Should be at least ANTHROPIC_PDF_MIN_TOKENS (1600) plus overhead
171
+ expect(tokens).toBeGreaterThanOrEqual(1600);
172
+ });
173
+
133
174
  test("does not count non-inline file base64 payload for Gemini", () => {
134
175
  const sharedSource = {
135
176
  type: "base64" as const,
@@ -156,21 +197,163 @@ describe("token estimator", () => {
156
197
  expect(largeFileTokens).toBe(smallFileTokens);
157
198
  });
158
199
 
159
- test("scales image token estimate with base64 payload size", () => {
160
- const smallImageTokens = estimateContentBlockTokens({
161
- type: "image",
162
- source: { type: "base64", media_type: "image/png", data: "a".repeat(64) },
163
- });
164
- const largeImageTokens = estimateContentBlockTokens({
165
- type: "image",
166
- source: {
167
- type: "base64",
168
- media_type: "image/png",
169
- data: "a".repeat(60_000),
200
+ // Non-Anthropic providers use base64 payload size for image estimation
201
+ test("scales image token estimate with base64 payload size (non-Anthropic)", () => {
202
+ const smallImageTokens = estimateContentBlockTokens(
203
+ {
204
+ type: "image",
205
+ source: {
206
+ type: "base64",
207
+ media_type: "image/png",
208
+ data: "a".repeat(64),
209
+ },
170
210
  },
171
- });
211
+ { providerName: "openai" },
212
+ );
213
+ const largeImageTokens = estimateContentBlockTokens(
214
+ {
215
+ type: "image",
216
+ source: {
217
+ type: "base64",
218
+ media_type: "image/png",
219
+ data: "a".repeat(60_000),
220
+ },
221
+ },
222
+ { providerName: "openai" },
223
+ );
172
224
 
173
225
  expect(largeImageTokens).toBeGreaterThan(smallImageTokens);
174
226
  expect(largeImageTokens - smallImageTokens).toBeGreaterThan(1000);
175
227
  });
228
+
229
+ test("estimates Anthropic image tokens from dimensions, not base64 size", () => {
230
+ // Build a minimal valid PNG header encoding 1920x1080 dimensions.
231
+ // PNG header: 8-byte signature + 4-byte IHDR length + 4-byte "IHDR" + 4-byte width + 4-byte height = 24 bytes minimum
232
+ const pngHeader = Buffer.alloc(24);
233
+ // PNG signature: 89 50 4E 47 0D 0A 1A 0A
234
+ pngHeader[0] = 0x89;
235
+ pngHeader[1] = 0x50;
236
+ pngHeader[2] = 0x4e;
237
+ pngHeader[3] = 0x47;
238
+ pngHeader[4] = 0x0d;
239
+ pngHeader[5] = 0x0a;
240
+ pngHeader[6] = 0x1a;
241
+ pngHeader[7] = 0x0a;
242
+ // IHDR chunk length (13 bytes)
243
+ pngHeader.writeUInt32BE(13, 8);
244
+ // "IHDR"
245
+ pngHeader[12] = 0x49;
246
+ pngHeader[13] = 0x48;
247
+ pngHeader[14] = 0x44;
248
+ pngHeader[15] = 0x52;
249
+ // Width: 1920
250
+ pngHeader.writeUInt32BE(1920, 16);
251
+ // Height: 1080
252
+ pngHeader.writeUInt32BE(1080, 20);
253
+
254
+ // Pad with ~200 KB of data to simulate a real screenshot payload
255
+ const padding = Buffer.alloc(200_000, 0x42);
256
+ const fullPayload = Buffer.concat([pngHeader, padding]);
257
+ const base64Data = fullPayload.toString("base64");
258
+
259
+ const anthropicTokens = estimateContentBlockTokens(
260
+ {
261
+ type: "image",
262
+ source: { type: "base64", media_type: "image/png", data: base64Data },
263
+ },
264
+ { providerName: "anthropic" },
265
+ );
266
+
267
+ // 1920x1080 scaled to fit 1568x1568: scale = 1568/1920 = 0.8167
268
+ // scaledWidth = round(1920 * 0.8167) = 1568, scaledHeight = round(1080 * 0.8167) = 882
269
+ // tokens = ceil(1568 * 882 / 750) = ceil(1843.968) = ~1844
270
+ // With IMAGE_BLOCK_OVERHEAD_TOKENS and media_type overhead, still well under 5000
271
+ expect(anthropicTokens).toBeLessThan(5_000);
272
+
273
+ // Verify it's NOT using base64 size (which would be ~50,000+ tokens)
274
+ const nonAnthropicTokens = estimateContentBlockTokens(
275
+ {
276
+ type: "image",
277
+ source: { type: "base64", media_type: "image/png", data: base64Data },
278
+ },
279
+ { providerName: "openai" },
280
+ );
281
+ expect(nonAnthropicTokens).toBeGreaterThan(50_000);
282
+ });
283
+
284
+ test("falls back to max tokens when Anthropic image dimensions can't be parsed", () => {
285
+ // Corrupted base64 that won't parse as a valid image header
286
+ const corruptedData = Buffer.from(
287
+ "not-a-valid-image-header-at-all",
288
+ ).toString("base64");
289
+
290
+ const tokens = estimateContentBlockTokens(
291
+ {
292
+ type: "image",
293
+ source: {
294
+ type: "base64",
295
+ media_type: "image/png",
296
+ data: corruptedData,
297
+ },
298
+ },
299
+ { providerName: "anthropic" },
300
+ );
301
+
302
+ // Should fall back to ANTHROPIC_IMAGE_MAX_TOKENS (~3,277)
303
+ // The total will include IMAGE_BLOCK_OVERHEAD_TOKENS + media_type overhead,
304
+ // but the max is applied at the outer Math.max(IMAGE_BLOCK_TOKENS, ...) level
305
+ // ANTHROPIC_IMAGE_MAX_TOKENS = ceil(1568*1568/750) = 3277
306
+ // Total = max(1024, 16 + ceil(9/4) + 3277) = max(1024, 3296) = 3296
307
+ expect(tokens).toBeGreaterThanOrEqual(3_277);
308
+ expect(tokens).toBeLessThan(4_000);
309
+ });
310
+
311
+ test("Anthropic image tokens are the same for same-dimension images regardless of payload size", () => {
312
+ // Build two PNG headers with the same dimensions (800x600) but different payload sizes
313
+ function makePng(
314
+ width: number,
315
+ height: number,
316
+ paddingSize: number,
317
+ ): string {
318
+ const header = Buffer.alloc(24);
319
+ header[0] = 0x89;
320
+ header[1] = 0x50;
321
+ header[2] = 0x4e;
322
+ header[3] = 0x47;
323
+ header[4] = 0x0d;
324
+ header[5] = 0x0a;
325
+ header[6] = 0x1a;
326
+ header[7] = 0x0a;
327
+ header.writeUInt32BE(13, 8);
328
+ header[12] = 0x49;
329
+ header[13] = 0x48;
330
+ header[14] = 0x44;
331
+ header[15] = 0x52;
332
+ header.writeUInt32BE(width, 16);
333
+ header.writeUInt32BE(height, 20);
334
+ const padding = Buffer.alloc(paddingSize, 0x42);
335
+ return Buffer.concat([header, padding]).toString("base64");
336
+ }
337
+
338
+ const smallPayload = makePng(800, 600, 1_000);
339
+ const largePayload = makePng(800, 600, 200_000);
340
+
341
+ const smallTokens = estimateContentBlockTokens(
342
+ {
343
+ type: "image",
344
+ source: { type: "base64", media_type: "image/png", data: smallPayload },
345
+ },
346
+ { providerName: "anthropic" },
347
+ );
348
+ const largeTokens = estimateContentBlockTokens(
349
+ {
350
+ type: "image",
351
+ source: { type: "base64", media_type: "image/png", data: largePayload },
352
+ },
353
+ { providerName: "anthropic" },
354
+ );
355
+
356
+ // For Anthropic, same dimensions should produce the same estimate
357
+ expect(largeTokens).toBe(smallTokens);
358
+ });
176
359
  });
@@ -21,7 +21,6 @@ mock.module("../util/logger.js", () => ({
21
21
  new Proxy({} as Record<string, unknown>, {
22
22
  get: () => () => {},
23
23
  }),
24
- isDebug: () => false,
25
24
  truncateForLog: (value: string) => value,
26
25
  }));
27
26
 
@@ -28,7 +28,6 @@ mock.module("../util/logger.js", () => ({
28
28
  new Proxy({} as Record<string, unknown>, {
29
29
  get: () => () => {},
30
30
  }),
31
- isDebug: () => false,
32
31
  truncateForLog: (value: string) => value,
33
32
  }));
34
33