clanka 0.5.2 → 0.6.1

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 (79) 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/cli.js +2 -1
  53. package/dist/cli.js.map +1 -1
  54. package/dist/fixtures/TestImages.d.ts +73 -0
  55. package/dist/fixtures/TestImages.d.ts.map +1 -0
  56. package/dist/fixtures/TestImages.js +263 -0
  57. package/dist/fixtures/TestImages.js.map +1 -0
  58. package/dist/index.d.ts +4 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +4 -0
  61. package/dist/index.js.map +1 -1
  62. package/package.json +2 -1
  63. package/src/Acp.ts +274 -21
  64. package/src/AcpImage.test.ts +676 -0
  65. package/src/Agent.ts +69 -7
  66. package/src/AgentExecutor.ts +51 -3
  67. package/src/AgentExecutorImage.test.ts +103 -0
  68. package/src/AgentImage.test.ts +295 -0
  69. package/src/AgentTools.ts +78 -7
  70. package/src/AgentToolsImage.test.ts +302 -0
  71. package/src/Codex.ts +6 -1
  72. package/src/Compaction.ts +16 -1
  73. package/src/CompactionImage.test.ts +142 -0
  74. package/src/Copilot.ts +5 -1
  75. package/src/Image.test.ts +220 -0
  76. package/src/Image.ts +704 -0
  77. package/src/cli.ts +2 -1
  78. package/src/fixtures/TestImages.ts +287 -0
  79. package/src/index.ts +5 -0
@@ -0,0 +1,676 @@
1
+ import * as Photon from "@silvia-odwyer/photon-node"
2
+ import { assert, describe, it } from "@effect/vitest"
3
+ import { vi } from "vitest"
4
+ import * as NodeServices from "@effect/platform-node/NodeServices"
5
+ import * as NodePath from "@effect/platform-node/NodePath"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Fiber from "effect/Fiber"
8
+ import { TestClock } from "effect/testing"
9
+ import * as Encoding from "effect/Encoding"
10
+ import * as FileSystem from "effect/FileSystem"
11
+ import * as Layer from "effect/Layer"
12
+ import * as Option from "effect/Option"
13
+ import * as Path from "effect/Path"
14
+ import * as Stream from "effect/Stream"
15
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
16
+ import * as Model from "effect/unstable/ai/Model"
17
+ import type * as Prompt from "effect/unstable/ai/Prompt"
18
+ import type * as AiResponse from "effect/unstable/ai/Response"
19
+ import * as HttpClient from "effect/unstable/http/HttpClient"
20
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
21
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
22
+ import * as Acp from "./Acp.ts"
23
+ import * as Agent from "./Agent.ts"
24
+ import * as AgentExecutor from "./AgentExecutor.ts"
25
+ import {
26
+ bytesOf,
27
+ dimensions,
28
+ encodePng,
29
+ equalBytes,
30
+ tinyPng,
31
+ } from "./fixtures/TestImages.ts"
32
+
33
+ type Message = {
34
+ readonly id?: number
35
+ readonly method?: string
36
+ readonly result?: any
37
+ readonly error?: { readonly code: number; readonly message: string }
38
+ readonly params?: any
39
+ }
40
+
41
+ const capabilities = new AgentExecutor.Capabilities({
42
+ toolsDts: "",
43
+ agentsMd: Option.none(),
44
+ supportsSearch: false,
45
+ skills: [],
46
+ })
47
+
48
+ const executor = AgentExecutor.AgentExecutor.of({
49
+ capabilities: Effect.succeed(capabilities),
50
+ execute: () => Stream.make("script output"),
51
+ executeUnsafe: () => Effect.die("executeUnsafe not implemented"),
52
+ })
53
+
54
+ const assistantSays = (
55
+ text: string,
56
+ ): Stream.Stream<AiResponse.StreamPartEncoded> =>
57
+ Stream.fromIterable([
58
+ { type: "text-start", id: "1" },
59
+ { type: "text-delta", id: "1", delta: text },
60
+ { type: "text-end", id: "1" },
61
+ ])
62
+
63
+ /**
64
+ * A server whose model records every prompt it is sent. `prompts[i]` is the
65
+ * prompt of the i-th model call.
66
+ */
67
+ const makeServer = Effect.fnUntraced(function* () {
68
+ const sent: Array<Message> = []
69
+ const prompts: Array<Prompt.Prompt> = []
70
+ const languageModel = yield* LanguageModel.make({
71
+ generateText: () => Effect.succeed([]),
72
+ streamText: (options) => {
73
+ prompts.push(options.prompt)
74
+ return assistantSays("I see it")
75
+ },
76
+ })
77
+ const modelLayer = Layer.mergeAll(
78
+ Layer.succeed(LanguageModel.LanguageModel, languageModel),
79
+ Layer.succeed(Model.ProviderName, "test"),
80
+ Layer.succeed(Model.ModelName, "model"),
81
+ )
82
+ const server = yield* Acp.make({
83
+ version: "test",
84
+ defaultModel: "test/model",
85
+ send: (message) =>
86
+ Effect.sync(() => {
87
+ sent.push(message as Message)
88
+ }),
89
+ makeAgent: () =>
90
+ Agent.make.pipe(
91
+ Effect.provideService(AgentExecutor.AgentExecutor, executor),
92
+ ),
93
+ makeModel: (modelId) =>
94
+ modelId === "test/model"
95
+ ? Option.some(
96
+ Layer.merge(modelLayer, Agent.layerSubagentModel(modelLayer)),
97
+ )
98
+ : Option.none(),
99
+ })
100
+ const request = (id: number, method: string, params?: unknown) =>
101
+ server
102
+ .handle(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
103
+ .pipe(Effect.map(() => sent.find((m) => m.id === id)!))
104
+ const newSession = (cwd: string) =>
105
+ request(1, "session/new", { cwd, mcpServers: [] }).pipe(
106
+ Effect.map((created) => created.result.sessionId as string),
107
+ )
108
+ return { sent, prompts, request, newSession }
109
+ })
110
+
111
+ /** Every `file` part in the last user message of a prompt. */
112
+ const lastUserMessage = (prompt: Prompt.Prompt): Prompt.UserMessage => {
113
+ const message = prompt.content.findLast((m) => m.role === "user")
114
+ assert.isDefined(message, "expected a user message")
115
+ return message as Prompt.UserMessage
116
+ }
117
+
118
+ const fileParts = (message: Prompt.UserMessage): Array<Prompt.FilePart> =>
119
+ message.content.filter((part) => part.type === "file")
120
+
121
+ const textOf = (message: Prompt.UserMessage): string =>
122
+ message.content
123
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
124
+ .join("\n")
125
+
126
+ /** An HttpClient that serves `bytes` as `image/png` and records the URLs. */
127
+ const makeHttp = (bytes: Uint8Array | ((url: URL) => Uint8Array)) => {
128
+ const urls: Array<string> = []
129
+ const client = HttpClient.make((request, url) =>
130
+ Effect.sync(() => {
131
+ urls.push(url.toString())
132
+ return HttpClientResponse.fromWeb(
133
+ request,
134
+ new Response(
135
+ new Uint8Array(typeof bytes === "function" ? bytes(url) : bytes),
136
+ {
137
+ headers: { "content-type": "image/png" },
138
+ },
139
+ ),
140
+ )
141
+ }),
142
+ )
143
+ return { urls, layer: Layer.succeed(HttpClient.HttpClient, client) }
144
+ }
145
+
146
+ const withServices = <A, E, R>(
147
+ effect: Effect.Effect<A, E, R>,
148
+ http: Layer.Layer<HttpClient.HttpClient> = makeHttp(tinyPng).layer,
149
+ ) =>
150
+ effect.pipe(
151
+ Effect.provide(
152
+ Layer.mergeAll(
153
+ KeyValueStore.layerMemory,
154
+ Agent.ConversationMode.layer(true),
155
+ NodeServices.layer,
156
+ http,
157
+ ),
158
+ ),
159
+ Effect.scoped,
160
+ )
161
+
162
+ const withTempDir = <A, E, R>(f: (cwd: string) => Effect.Effect<A, E, R>) =>
163
+ Effect.gen(function* () {
164
+ const fs = yield* FileSystem.FileSystem
165
+ const cwd = yield* fs.makeTempDirectoryScoped()
166
+ return yield* f(cwd)
167
+ })
168
+
169
+ const base64 = (bytes: Uint8Array) => Encoding.encodeBase64(bytes)
170
+
171
+ describe("Acp images", () => {
172
+ it.effect("maps an image block onto a file part instead of text", () =>
173
+ withServices(
174
+ Effect.gen(function* () {
175
+ const server = yield* makeServer()
176
+ const init = yield* server.request(0, "initialize", {
177
+ protocolVersion: 1,
178
+ })
179
+ assert.deepStrictEqual(
180
+ init.result.agentCapabilities.promptCapabilities,
181
+ { image: true, audio: false, embeddedContext: true },
182
+ )
183
+ const sessionId = yield* server.newSession("/tmp")
184
+ const done = yield* server.request(2, "session/prompt", {
185
+ sessionId,
186
+ prompt: [
187
+ { type: "text", text: "describe this screenshot" },
188
+ { type: "image", data: base64(tinyPng), mimeType: "image/png" },
189
+ ],
190
+ })
191
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
192
+ assert.strictEqual(server.prompts.length, 1)
193
+
194
+ const message = lastUserMessage(server.prompts[0]!)
195
+ const images = fileParts(message)
196
+ assert.strictEqual(images.length, 1)
197
+ assert.strictEqual(images[0]!.mediaType, "image/png")
198
+ assert.isTrue(equalBytes(bytesOf(images[0]!.data), tinyPng))
199
+
200
+ const text = textOf(message)
201
+ assert.include(text, "describe this screenshot")
202
+ assert.notInclude(text, base64(tinyPng))
203
+ // Text comes first, the image after, matching the block order.
204
+ assert.strictEqual(message.content[0]!.type, "text")
205
+ assert.strictEqual(message.content[1]!.type, "file")
206
+ }),
207
+ ),
208
+ )
209
+
210
+ it.effect("keeps image parts in the persisted session across a load", () =>
211
+ withServices(
212
+ Effect.gen(function* () {
213
+ const kvs = yield* KeyValueStore.KeyValueStore
214
+ const store = KeyValueStore.toSchemaStore(
215
+ KeyValueStore.prefix(kvs, "session-"),
216
+ Acp.SessionRecord,
217
+ )
218
+
219
+ const first = yield* makeServer()
220
+ const sessionId = yield* first.newSession("/tmp")
221
+ yield* first.request(2, "session/prompt", {
222
+ sessionId,
223
+ prompt: [
224
+ { type: "text", text: "remember this" },
225
+ { type: "image", data: base64(tinyPng), mimeType: "image/png" },
226
+ ],
227
+ })
228
+
229
+ const persisted = Option.getOrThrow(yield* store.get(sessionId))
230
+ const persistedImages = persisted.history.content.flatMap((m) =>
231
+ m.role === "user" ? fileParts(m) : [],
232
+ )
233
+ assert.strictEqual(persistedImages.length, 1)
234
+ assert.isTrue(equalBytes(bytesOf(persistedImages[0]!.data), tinyPng))
235
+
236
+ const second = yield* makeServer()
237
+ const loaded = yield* second.request(1, "session/load", {
238
+ sessionId,
239
+ cwd: "/tmp",
240
+ })
241
+ assert.isUndefined(loaded.error)
242
+ const replay = second.sent.filter(
243
+ (message) =>
244
+ message.method === "session/update" &&
245
+ message.params.sessionId === sessionId &&
246
+ message.params.update.sessionUpdate === "user_message_chunk",
247
+ )
248
+ assert.deepStrictEqual(
249
+ replay.map((message) => message.params.update.content),
250
+ [
251
+ { type: "text", text: "remember this" },
252
+ { type: "image", data: base64(tinyPng), mimeType: "image/png" },
253
+ ],
254
+ )
255
+ assert.strictEqual(
256
+ second.prompts.length,
257
+ 0,
258
+ "load must not call the model",
259
+ )
260
+ yield* second.request(2, "session/prompt", {
261
+ sessionId,
262
+ prompt: [{ type: "text", text: "what did I show you?" }],
263
+ })
264
+ assert.strictEqual(second.prompts.length, 1)
265
+ const images = second.prompts[0]!.content.flatMap((m) =>
266
+ m.role === "user" ? fileParts(m) : [],
267
+ )
268
+ assert.strictEqual(images.length, 1)
269
+ assert.isTrue(equalBytes(bytesOf(images[0]!.data), tinyPng))
270
+ }),
271
+ ),
272
+ )
273
+
274
+ it.effect("fetches an https resource_link with an image mime type", () => {
275
+ const http = makeHttp((url) =>
276
+ url.pathname.endsWith(".svg")
277
+ ? new TextEncoder().encode("<svg/>")
278
+ : tinyPng,
279
+ )
280
+ return withServices(
281
+ Effect.gen(function* () {
282
+ const server = yield* makeServer()
283
+ const sessionId = yield* server.newSession("/tmp")
284
+ const done = yield* server.request(2, "session/prompt", {
285
+ sessionId,
286
+ prompt: [
287
+ { type: "text", text: "look" },
288
+ {
289
+ type: "resource_link",
290
+ uri: "https://attachments.example/shot.png",
291
+ name: "shot.png",
292
+ mimeType: "image/png",
293
+ },
294
+ {
295
+ type: "resource_link",
296
+ uri: "https://docs.example/diagram.svg",
297
+ name: "diagram.svg",
298
+ mimeType: "image/svg+xml",
299
+ },
300
+ ],
301
+ })
302
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
303
+ assert.include(
304
+ textOf(lastUserMessage(server.prompts[0]!)),
305
+ "https://docs.example/diagram.svg",
306
+ )
307
+ assert.deepStrictEqual(http.urls, [
308
+ "https://attachments.example/shot.png",
309
+ ])
310
+ const images = fileParts(lastUserMessage(server.prompts[0]!))
311
+ assert.strictEqual(images.length, 1)
312
+ assert.strictEqual(images[0]!.mediaType, "image/png")
313
+ assert.isTrue(equalBytes(bytesOf(images[0]!.data), tinyPng))
314
+ }),
315
+ http.layer,
316
+ )
317
+ })
318
+
319
+ it.effect(
320
+ "rejects an oversized streamed image before consuming the whole body",
321
+ () => {
322
+ let pulled = 0
323
+ let cancelled = false
324
+ const chunk = new Uint8Array(1024 * 1024)
325
+ chunk.set(tinyPng)
326
+ const client = HttpClient.make((request) =>
327
+ Effect.succeed(
328
+ HttpClientResponse.fromWeb(
329
+ request,
330
+ new Response(
331
+ new ReadableStream<Uint8Array>({
332
+ pull(controller) {
333
+ if (pulled === 64) controller.close()
334
+ else {
335
+ pulled++
336
+ controller.enqueue(chunk)
337
+ }
338
+ },
339
+ cancel() {
340
+ cancelled = true
341
+ },
342
+ }),
343
+ { headers: { "content-type": "image/png" } },
344
+ ),
345
+ ),
346
+ ),
347
+ )
348
+ return withServices(
349
+ Effect.gen(function* () {
350
+ const server = yield* makeServer()
351
+ const sessionId = yield* server.newSession("/tmp")
352
+ const done = yield* server.request(2, "session/prompt", {
353
+ sessionId,
354
+ prompt: [
355
+ {
356
+ type: "resource_link",
357
+ uri: "https://attachments.example/large.png",
358
+ mimeType: "image/png",
359
+ },
360
+ ],
361
+ })
362
+ assert.strictEqual(done.error?.code, -32602)
363
+ assert.strictEqual(server.prompts.length, 0)
364
+ // No Content-Length: the actual stream must be bounded, not just headers.
365
+ assert.isBelow(pulled, 64, "must stop before buffering 64 MiB")
366
+ assert.isTrue(cancelled, "rejection must cancel the remaining body")
367
+ }),
368
+ Layer.succeed(HttpClient.HttpClient, client),
369
+ )
370
+ },
371
+ )
372
+
373
+ it.effect("times out a stalled image body without calling the model", () => {
374
+ const client = HttpClient.make((request) =>
375
+ Effect.succeed(
376
+ HttpClientResponse.fromWeb(
377
+ request,
378
+ new Response(
379
+ new ReadableStream<Uint8Array>({
380
+ start(controller) {
381
+ controller.enqueue(tinyPng)
382
+ },
383
+ }),
384
+ { headers: { "content-type": "image/png" } },
385
+ ),
386
+ ),
387
+ ),
388
+ )
389
+ return withServices(
390
+ Effect.gen(function* () {
391
+ const server = yield* makeServer()
392
+ const sessionId = yield* server.newSession("/tmp")
393
+ const fiber = yield* server
394
+ .request(2, "session/prompt", {
395
+ sessionId,
396
+ prompt: [
397
+ {
398
+ type: "resource_link",
399
+ uri: "https://attachments.example/stalled.png",
400
+ mimeType: "image/png",
401
+ },
402
+ ],
403
+ })
404
+ .pipe(Effect.forkChild)
405
+ // A generous upper bound, not a configurable production deadline.
406
+ yield* TestClock.adjust("1 minute")
407
+ assert.isDefined(
408
+ fiber.pollUnsafe(),
409
+ "image ingestion must finish within one minute",
410
+ )
411
+ const done = yield* Fiber.join(fiber)
412
+ assert.strictEqual(done.error?.code, -32602)
413
+ assert.strictEqual(server.prompts.length, 0)
414
+ }),
415
+ Layer.succeed(HttpClient.HttpClient, client),
416
+ )
417
+ })
418
+
419
+ it.effect(
420
+ "rejects inline and file: images over the input ceiling before decoding",
421
+ () =>
422
+ withServices(
423
+ withTempDir((cwd) =>
424
+ Effect.gen(function* () {
425
+ const fs = yield* FileSystem.FileSystem
426
+ const path = yield* Path.Path
427
+ // The same 20 MiB ceiling the HTTP fetch already enforces.
428
+ const ceiling = 20 * 1024 * 1024
429
+ // Sparse: a valid PNG header, then a hole up to one byte over.
430
+ const huge = path.join(cwd, "huge.png")
431
+ yield* fs.writeFile(huge, tinyPng)
432
+ yield* fs.truncate(huge, ceiling + 1)
433
+ // Base64 one quantum longer than the ceiling encodes to.
434
+ const inline =
435
+ base64(tinyPng) + "A".repeat(Math.ceil(ceiling / 3) * 4)
436
+
437
+ const decoder = vi.spyOn(Photon.PhotonImage, "new_from_byteslice")
438
+ try {
439
+ const server = yield* makeServer()
440
+ const sessionId = yield* server.newSession(cwd)
441
+ const fromInline = yield* server.request(2, "session/prompt", {
442
+ sessionId,
443
+ prompt: [
444
+ { type: "image", data: inline, mimeType: "image/png" },
445
+ ],
446
+ })
447
+ assert.strictEqual(fromInline.error?.code, -32602)
448
+ const fromFile = yield* server.request(3, "session/prompt", {
449
+ sessionId,
450
+ prompt: [
451
+ {
452
+ type: "resource_link",
453
+ uri: "file://" + huge,
454
+ mimeType: "image/png",
455
+ },
456
+ ],
457
+ })
458
+ assert.strictEqual(fromFile.error?.code, -32602)
459
+ assert.strictEqual(server.prompts.length, 0, "no model call")
460
+ assert.strictEqual(
461
+ decoder.mock.calls.length,
462
+ 0,
463
+ "nothing over the ceiling may reach the decoder",
464
+ )
465
+ } finally {
466
+ decoder.mockRestore()
467
+ }
468
+ }),
469
+ ),
470
+ ),
471
+ )
472
+
473
+ it.effect("rejects a file: resource_link outside the cwd", () =>
474
+ withServices(
475
+ withTempDir((base) =>
476
+ Effect.gen(function* () {
477
+ const fs = yield* FileSystem.FileSystem
478
+ const path = yield* Path.Path
479
+ const cwd = path.join(base, "project")
480
+ yield* fs.makeDirectory(cwd)
481
+ const outside = path.join(base, "secret.png")
482
+ yield* fs.writeFile(outside, tinyPng)
483
+
484
+ const server = yield* makeServer()
485
+ const sessionId = yield* server.newSession(cwd)
486
+ const done = yield* server.request(2, "session/prompt", {
487
+ sessionId,
488
+ prompt: [
489
+ {
490
+ type: "resource_link",
491
+ // `..` must not escape the cwd either.
492
+ uri: `file://${path.join(cwd, "..", "secret.png")}`,
493
+ name: "secret.png",
494
+ mimeType: "image/png",
495
+ },
496
+ ],
497
+ })
498
+ assert.isDefined(done.error, "prompt must be rejected")
499
+ assert.strictEqual(done.error!.code, -32602)
500
+ assert.strictEqual(server.prompts.length, 0, "no model call")
501
+ }),
502
+ ),
503
+ ),
504
+ )
505
+
506
+ it.effect("maps an embedded resource blob with an image mime type", () =>
507
+ withServices(
508
+ Effect.gen(function* () {
509
+ const server = yield* makeServer()
510
+ const sessionId = yield* server.newSession("/tmp")
511
+ const done = yield* server.request(2, "session/prompt", {
512
+ sessionId,
513
+ prompt: [
514
+ {
515
+ type: "resource",
516
+ resource: {
517
+ uri: "file:///attached/shot.png",
518
+ mimeType: "image/png",
519
+ blob: base64(tinyPng),
520
+ },
521
+ },
522
+ {
523
+ type: "resource",
524
+ resource: {
525
+ uri: "file:///attached/diagram.svg",
526
+ mimeType: "image/svg+xml",
527
+ blob: base64(new TextEncoder().encode("<svg/>")),
528
+ },
529
+ },
530
+ ],
531
+ })
532
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
533
+ const message = lastUserMessage(server.prompts[0]!)
534
+ assert.include(textOf(message), "file:///attached/diagram.svg")
535
+ const images = fileParts(message)
536
+ assert.strictEqual(images.length, 1)
537
+ assert.isTrue(equalBytes(bytesOf(images[0]!.data), tinyPng))
538
+ assert.notInclude(textOf(message), base64(tinyPng))
539
+ }),
540
+ ),
541
+ )
542
+
543
+ it.effect("applies the image limits to ACP images", () =>
544
+ withServices(
545
+ Effect.gen(function* () {
546
+ const wide = encodePng({ width: 2500, height: 100 })
547
+ const server = yield* makeServer()
548
+ const sessionId = yield* server.newSession("/tmp")
549
+ const done = yield* server.request(2, "session/prompt", {
550
+ sessionId,
551
+ prompt: [
552
+ { type: "image", data: base64(wide), mimeType: "image/png" },
553
+ ],
554
+ })
555
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
556
+ const images = fileParts(lastUserMessage(server.prompts[0]!))
557
+ assert.strictEqual(images.length, 1)
558
+ const size = dimensions(bytesOf(images[0]!.data))
559
+ assert.isAtMost(size.width, 2000)
560
+ assert.isAtMost(size.height, 2000)
561
+ }),
562
+ ),
563
+ )
564
+
565
+ it.effect("fetches the uri when an image block has empty data", () => {
566
+ const http = makeHttp(tinyPng)
567
+ return withServices(
568
+ Effect.gen(function* () {
569
+ const server = yield* makeServer()
570
+ const sessionId = yield* server.newSession("/tmp")
571
+ const uri = "https://attachments.example/shot.png"
572
+ const done = yield* server.request(2, "session/prompt", {
573
+ sessionId,
574
+ prompt: [{ type: "image", data: "", uri, mimeType: "image/png" }],
575
+ })
576
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
577
+ assert.deepStrictEqual(http.urls, [uri])
578
+ const images = fileParts(lastUserMessage(server.prompts[0]!))
579
+ assert.strictEqual(images.length, 1)
580
+ assert.strictEqual(images[0]!.mediaType, "image/png")
581
+ assert.isTrue(equalBytes(bytesOf(images[0]!.data), tinyPng))
582
+ }),
583
+ http.layer,
584
+ )
585
+ })
586
+
587
+ for (const outside of [false, true]) {
588
+ it.effect(
589
+ outside
590
+ ? "rejects a symlink to an image outside the cwd"
591
+ : "accepts an image symlink inside a symlinked cwd",
592
+ () =>
593
+ withServices(
594
+ withTempDir((base) =>
595
+ Effect.gen(function* () {
596
+ const fs = yield* FileSystem.FileSystem
597
+ const path = yield* Path.Path
598
+ const realCwd = path.join(base, "project")
599
+ const cwd = path.join(base, "project-link")
600
+ yield* fs.makeDirectory(realCwd)
601
+ yield* fs.symlink(realCwd, cwd)
602
+ const target = path.join(outside ? base : realCwd, "target.png")
603
+ yield* fs.writeFile(target, tinyPng)
604
+ const link = path.join(cwd, "shot.png")
605
+ yield* fs.symlink(target, link)
606
+
607
+ const server = yield* makeServer()
608
+ const sessionId = yield* server.newSession(cwd)
609
+ const done = yield* server.request(2, "session/prompt", {
610
+ sessionId,
611
+ prompt: [
612
+ {
613
+ type: "resource_link",
614
+ uri: "file://" + link,
615
+ name: "shot.png",
616
+ mimeType: "image/png",
617
+ },
618
+ ],
619
+ })
620
+ if (outside) {
621
+ assert.isDefined(done.error, "prompt must be rejected")
622
+ assert.strictEqual(done.error!.code, -32602)
623
+ assert.include(
624
+ done.error!.message,
625
+ "outside the session directory",
626
+ )
627
+ assert.strictEqual(server.prompts.length, 0, "no model call")
628
+ } else {
629
+ assert.deepStrictEqual(done.result, { stopReason: "end_turn" })
630
+ const images = fileParts(lastUserMessage(server.prompts[0]!))
631
+ assert.strictEqual(images.length, 1)
632
+ assert.strictEqual(images[0]!.mediaType, "image/png")
633
+ assert.isTrue(equalBytes(bytesOf(images[0]!.data), tinyPng))
634
+ }
635
+ }),
636
+ ),
637
+ ),
638
+ )
639
+ }
640
+
641
+ for (const [uri, missing] of [
642
+ ["https://attachments.example/shot.png", "HttpClient"],
643
+ ["file:///tmp/shot.png", "FileSystem"],
644
+ ] as const) {
645
+ it.effect("rejects an image URI without a " + missing + " service", () =>
646
+ Effect.gen(function* () {
647
+ const server = yield* makeServer()
648
+ const sessionId = yield* server.newSession("/tmp")
649
+ const done = yield* server.request(2, "session/prompt", {
650
+ sessionId,
651
+ prompt: [
652
+ {
653
+ type: "resource_link",
654
+ uri,
655
+ name: "shot.png",
656
+ mimeType: "image/png",
657
+ },
658
+ ],
659
+ })
660
+ assert.isDefined(done.error, "prompt must be rejected")
661
+ assert.strictEqual(done.error!.code, -32602)
662
+ assert.include(done.error!.message, "no " + missing + " available")
663
+ assert.strictEqual(server.prompts.length, 0, "no model call")
664
+ }).pipe(
665
+ Effect.provide(
666
+ Layer.mergeAll(
667
+ KeyValueStore.layerMemory,
668
+ Agent.ConversationMode.layer(true),
669
+ NodePath.layer,
670
+ ),
671
+ ),
672
+ Effect.scoped,
673
+ ),
674
+ )
675
+ }
676
+ })