clanka 0.5.2 → 0.6.0

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 (76) hide show
  1. package/dist/Acp.d.ts.map +1 -1
  2. package/dist/Acp.js +195 -16
  3. package/dist/Acp.js.map +1 -1
  4. package/dist/AcpImage.test.d.ts +2 -0
  5. package/dist/AcpImage.test.d.ts.map +1 -0
  6. package/dist/AcpImage.test.js +470 -0
  7. package/dist/AcpImage.test.js.map +1 -0
  8. package/dist/Agent.d.ts +9 -2
  9. package/dist/Agent.d.ts.map +1 -1
  10. package/dist/Agent.js +43 -6
  11. package/dist/Agent.js.map +1 -1
  12. package/dist/AgentExecutor.d.ts +27 -5
  13. package/dist/AgentExecutor.d.ts.map +1 -1
  14. package/dist/AgentExecutor.js +29 -4
  15. package/dist/AgentExecutor.js.map +1 -1
  16. package/dist/AgentExecutorImage.test.d.ts +2 -0
  17. package/dist/AgentExecutorImage.test.d.ts.map +1 -0
  18. package/dist/AgentExecutorImage.test.js +63 -0
  19. package/dist/AgentExecutorImage.test.js.map +1 -0
  20. package/dist/AgentImage.test.d.ts +2 -0
  21. package/dist/AgentImage.test.d.ts.map +1 -0
  22. package/dist/AgentImage.test.js +176 -0
  23. package/dist/AgentImage.test.js.map +1 -0
  24. package/dist/AgentTools.d.ts +25 -5
  25. package/dist/AgentTools.d.ts.map +1 -1
  26. package/dist/AgentTools.js +40 -4
  27. package/dist/AgentTools.js.map +1 -1
  28. package/dist/AgentToolsImage.test.d.ts +2 -0
  29. package/dist/AgentToolsImage.test.d.ts.map +1 -0
  30. package/dist/AgentToolsImage.test.js +158 -0
  31. package/dist/AgentToolsImage.test.js.map +1 -0
  32. package/dist/Codex.js +6 -1
  33. package/dist/Codex.js.map +1 -1
  34. package/dist/Compaction.d.ts.map +1 -1
  35. package/dist/Compaction.js +16 -1
  36. package/dist/Compaction.js.map +1 -1
  37. package/dist/CompactionImage.test.d.ts +2 -0
  38. package/dist/CompactionImage.test.d.ts.map +1 -0
  39. package/dist/CompactionImage.test.js +105 -0
  40. package/dist/CompactionImage.test.js.map +1 -0
  41. package/dist/Copilot.d.ts.map +1 -1
  42. package/dist/Copilot.js +5 -1
  43. package/dist/Copilot.js.map +1 -1
  44. package/dist/Image.d.ts +192 -0
  45. package/dist/Image.d.ts.map +1 -0
  46. package/dist/Image.js +586 -0
  47. package/dist/Image.js.map +1 -0
  48. package/dist/Image.test.d.ts +2 -0
  49. package/dist/Image.test.d.ts.map +1 -0
  50. package/dist/Image.test.js +142 -0
  51. package/dist/Image.test.js.map +1 -0
  52. package/dist/fixtures/TestImages.d.ts +73 -0
  53. package/dist/fixtures/TestImages.d.ts.map +1 -0
  54. package/dist/fixtures/TestImages.js +263 -0
  55. package/dist/fixtures/TestImages.js.map +1 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +4 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +2 -1
  61. package/src/Acp.ts +274 -21
  62. package/src/AcpImage.test.ts +676 -0
  63. package/src/Agent.ts +69 -7
  64. package/src/AgentExecutor.ts +51 -3
  65. package/src/AgentExecutorImage.test.ts +103 -0
  66. package/src/AgentImage.test.ts +295 -0
  67. package/src/AgentTools.ts +78 -7
  68. package/src/AgentToolsImage.test.ts +302 -0
  69. package/src/Codex.ts +6 -1
  70. package/src/Compaction.ts +16 -1
  71. package/src/CompactionImage.test.ts +142 -0
  72. package/src/Copilot.ts +5 -1
  73. package/src/Image.test.ts +220 -0
  74. package/src/Image.ts +704 -0
  75. package/src/fixtures/TestImages.ts +287 -0
  76. package/src/index.ts +5 -0
@@ -0,0 +1,220 @@
1
+ import * as Photon from "@silvia-odwyer/photon-node"
2
+ import { assert, describe, it } from "@effect/vitest"
3
+ import * as Effect from "effect/Effect"
4
+ import * as Encoding from "effect/Encoding"
5
+ import * as Option from "effect/Option"
6
+ import { vi } from "vitest"
7
+ import * as Image from "./Image.ts"
8
+ import {
9
+ dimensions,
10
+ encodePng,
11
+ noisePixel,
12
+ oversizedFrameHeaders,
13
+ oversizedHeaders,
14
+ supportedImages,
15
+ tinyPng,
16
+ } from "./fixtures/TestImages.ts"
17
+
18
+ /** Optionally block decoding so crafted headers can never reach Photon. */
19
+ const withDecoderSpy = <A, E, R>(
20
+ f: (calls: () => number) => Effect.Effect<A, E, R>,
21
+ blockDecoding = false,
22
+ ) =>
23
+ Effect.suspend(() => {
24
+ const spy = vi.spyOn(Photon.PhotonImage, "new_from_byteslice")
25
+ if (blockDecoding) {
26
+ spy.mockImplementation(() => {
27
+ throw new Error("Header-only fixture must not reach the decoder")
28
+ })
29
+ }
30
+ return f(() => spy.mock.calls.length).pipe(
31
+ Effect.ensuring(Effect.sync(() => spy.mockRestore())),
32
+ )
33
+ })
34
+
35
+ const base64Length = (bytes: Uint8Array) => Encoding.encodeBase64(bytes).length
36
+
37
+ describe("Image.prepare", () => {
38
+ it.effect("passes an in-limit image through unchanged", () =>
39
+ Effect.gen(function* () {
40
+ assert.strictEqual(Image.maxDimension, 2000)
41
+ assert.strictEqual(Image.maxBytes, 5 * 1024 * 1024)
42
+ const prepared = yield* Image.prepare({
43
+ data: tinyPng,
44
+ mediaType: "image/png",
45
+ })
46
+ assert.deepStrictEqual(prepared, {
47
+ data: tinyPng,
48
+ mediaType: "image/png",
49
+ })
50
+ }),
51
+ )
52
+
53
+ it.effect("resizes to the dimension cap while preserving aspect ratio", () =>
54
+ Effect.gen(function* () {
55
+ const prepared = yield* Image.prepare({
56
+ data: encodePng({ width: 2500, height: 100 }),
57
+ mediaType: "image/png",
58
+ })
59
+ assert.deepStrictEqual(dimensions(prepared.data), {
60
+ width: 2000,
61
+ height: 80,
62
+ })
63
+ assert.isAtMost(base64Length(prepared.data), Image.maxBytes)
64
+ }),
65
+ )
66
+
67
+ it.effect("re-encodes an image over the base64 byte cap", () =>
68
+ Effect.gen(function* () {
69
+ // Exercise the byte budget independently of the dimension cap, without a multi-MB fixture.
70
+ const data = encodePng({ width: 64, height: 64, pixel: noisePixel })
71
+ const limits = { maxDimension: 64, maxBytes: 2000 }
72
+ assert.isAbove(base64Length(data), limits.maxBytes)
73
+ const prepared = yield* Image.prepare({
74
+ data,
75
+ mediaType: "image/png",
76
+ limits,
77
+ })
78
+ assert.isAtMost(base64Length(prepared.data), limits.maxBytes)
79
+ const size = dimensions(prepared.data)
80
+ assert.isAtMost(size.width, limits.maxDimension)
81
+ assert.isAtMost(size.height, limits.maxDimension)
82
+ }),
83
+ )
84
+
85
+ it.effect("rejects an image that cannot fit even after resizing", () =>
86
+ Effect.gen(function* () {
87
+ const error = yield* Effect.flip(
88
+ Image.prepare({
89
+ data: tinyPng,
90
+ mediaType: "image/png",
91
+ limits: { maxDimension: 2, maxBytes: 40 },
92
+ }),
93
+ )
94
+ assert.strictEqual(error._tag, "ImageError")
95
+ assert.strictEqual(error.reason, "TooLarge")
96
+ }),
97
+ )
98
+
99
+ it.effect("rejects undecodable bytes with a typed error", () =>
100
+ Effect.gen(function* () {
101
+ const error = yield* Effect.flip(
102
+ Image.prepare({
103
+ data: new TextEncoder().encode("not a png"),
104
+ mediaType: "image/png",
105
+ }),
106
+ )
107
+ assert.strictEqual(error._tag, "ImageError")
108
+ assert.strictEqual(error.reason, "Decode")
109
+ }),
110
+ )
111
+ })
112
+
113
+ // =============================================================================
114
+ // Input budget: what may reach the decoder at all
115
+ //
116
+ // Two bounds sit in front of Photon on every path. `maxInputBytes` caps the
117
+ // encoded input (20 MiB, the same number the HTTP fetch already uses), and
118
+ // `maxInputPixels` caps the canvas declared in the image header, because a
119
+ // small, highly compressible file can expand to gigabytes of RGBA inside the
120
+ // decoder before `prepare` ever reads a width. Both checks run before any
121
+ // decoding; the fixtures below have no pixels, so a decoder call would fail.
122
+ // =============================================================================
123
+
124
+ describe("Image input budget", () => {
125
+ it("pins the shared ceilings", () => {
126
+ assert.strictEqual(Image.maxInputBytes, 20 * 1024 * 1024)
127
+ assert.strictEqual(Image.maxInputPixels, 50_000_000)
128
+ })
129
+
130
+ it.effect("reads dimensions from the header without decoding", () =>
131
+ withDecoderSpy((decoderCalls) =>
132
+ Effect.sync(() => {
133
+ for (const image of supportedImages) {
134
+ assert.deepStrictEqual(
135
+ Option.getOrThrow(Image.dimensions(image.data)),
136
+ dimensions(image.data),
137
+ image.mediaType,
138
+ )
139
+ }
140
+ // `dimensions` above is the Photon-backed fixture helper; reset.
141
+ const baseline = decoderCalls()
142
+ for (const header of oversizedHeaders) {
143
+ assert.deepStrictEqual(
144
+ Image.dimensions(header.data),
145
+ Option.some({ width: header.width, height: header.height }),
146
+ header.mediaType,
147
+ )
148
+ }
149
+ assert.deepStrictEqual(
150
+ Image.dimensions(new TextEncoder().encode("not an image")),
151
+ Option.none(),
152
+ )
153
+ assert.strictEqual(decoderCalls(), baseline)
154
+ }),
155
+ ),
156
+ )
157
+
158
+ it.effect("rejects an oversized canvas in every format before decoding", () =>
159
+ withDecoderSpy(
160
+ (decoderCalls) =>
161
+ Effect.gen(function* () {
162
+ const headers = [...oversizedHeaders, ...oversizedFrameHeaders]
163
+ const results = []
164
+ for (const header of headers) {
165
+ assert.isAbove(header.width * header.height, Image.maxInputPixels)
166
+ const baseline = decoderCalls()
167
+ const error = yield* Effect.flip(
168
+ Image.prepare({ data: header.data, mediaType: header.mediaType }),
169
+ )
170
+ results.push({
171
+ mediaType: header.mediaType,
172
+ tag: error._tag,
173
+ reason: error.reason,
174
+ decoderCalls: decoderCalls() - baseline,
175
+ })
176
+ }
177
+ assert.deepStrictEqual(
178
+ results,
179
+ headers.map((header) => ({
180
+ mediaType: header.mediaType,
181
+ tag: "ImageError",
182
+ reason: "TooLarge",
183
+ decoderCalls: 0,
184
+ })),
185
+ )
186
+ }),
187
+ true,
188
+ ),
189
+ )
190
+
191
+ it.effect("rejects input over the byte ceiling before decoding", () =>
192
+ withDecoderSpy((decoderCalls) =>
193
+ Effect.gen(function* () {
194
+ // A real PNG header followed by padding: over the ceiling by one byte.
195
+ const data = new Uint8Array(20 * 1024 * 1024 + 1)
196
+ data.set(tinyPng)
197
+ const error = yield* Effect.flip(
198
+ Image.prepare({ data, mediaType: "image/png" }),
199
+ )
200
+ assert.strictEqual(error._tag, "ImageError")
201
+ assert.strictEqual(error.reason, "TooLarge")
202
+ assert.strictEqual(decoderCalls(), 0, "the decoder must not run")
203
+ }),
204
+ ),
205
+ )
206
+
207
+ it.effect("does not send undecodable bytes to the decoder", () =>
208
+ withDecoderSpy((decoderCalls) =>
209
+ Effect.gen(function* () {
210
+ yield* Effect.flip(
211
+ Image.prepare({
212
+ data: new TextEncoder().encode("not a png"),
213
+ mediaType: "image/png",
214
+ }),
215
+ )
216
+ assert.strictEqual(decoderCalls(), 0)
217
+ }),
218
+ ),
219
+ )
220
+ })