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
package/src/AgentTools.ts CHANGED
@@ -21,7 +21,9 @@ import { pipe } from "effect/Function"
21
21
  import * as Array from "effect/Array"
22
22
  import * as Data from "effect/Data"
23
23
  import * as Layer from "effect/Layer"
24
+ import * as Option from "effect/Option"
24
25
  import * as SemanticSearch from "./SemanticSearch/Service.ts"
26
+ import * as Image from "./Image.ts"
25
27
 
26
28
  /**
27
29
  * @since 1.0.0
@@ -59,6 +61,28 @@ export class SubagentExecutor extends Context.Service<
59
61
  (prompt: string) => Effect.Effect<string>
60
62
  >()("clanka/AgentTools/SubagentExecutor") {}
61
63
 
64
+ /**
65
+ * An image read by `readFile`, handed to the Agent so it can be spliced onto
66
+ * the Prompt for the next model turn.
67
+ *
68
+ * @since 1.0.0
69
+ * @category Models
70
+ */
71
+ export interface ImageAttachment {
72
+ readonly fileName: string
73
+ readonly mediaType: Image.ImageMediaType
74
+ readonly data: Uint8Array
75
+ }
76
+
77
+ /**
78
+ * @since 1.0.0
79
+ * @category Context
80
+ */
81
+ export class ImageAttacher extends Context.Service<
82
+ ImageAttacher,
83
+ (image: ImageAttachment) => Effect.Effect<void>
84
+ >()("clanka/AgentTools/ImageAttacher") {}
85
+
62
86
  /**
63
87
  * @since 1.0.0
64
88
  * @category Context
@@ -68,6 +92,7 @@ export const makeContextNoop = (cwd?: string) =>
68
92
  Context.add(CurrentDirectory, cwd ?? "/"),
69
93
  Context.add(DirectoryChanger, () => Effect.die("Not implemented")),
70
94
  Context.add(TaskCompleter, () => Effect.void),
95
+ Context.add(ImageAttacher, () => Effect.void),
71
96
  )
72
97
 
73
98
  class TodoItem extends Schema.Opaque<TodoItem>()(
@@ -92,14 +117,14 @@ export const AgentTools = Toolkit.make(
92
117
  }),
93
118
  Tool.make("readFile", {
94
119
  description:
95
- "Read a file and optionally filter the lines to return. Returns null if the file doesn't exist.",
120
+ "Read a file and optionally filter the lines to return. Returns null if the file doesn't exist. For png/jpeg/gif/webp files the image itself is attached to your next turn and a short marker is returned instead of the bytes; line ranges are not allowed on images.",
96
121
  parameters: Schema.Struct({
97
122
  path: Schema.String,
98
123
  startLine: Schema.optional(Schema.Number),
99
124
  endLine: Schema.optional(Schema.Number),
100
125
  }),
101
126
  success: Schema.NullOr(Schema.String),
102
- dependencies: [CurrentDirectory],
127
+ dependencies: [CurrentDirectory, ImageAttacher],
103
128
  }),
104
129
  Tool.make("rg", {
105
130
  description: "Search for a pattern in files using ripgrep",
@@ -300,6 +325,51 @@ export const AgentToolHandlersNoDeps = AgentToolsWithSearch.toLayer(
300
325
  )
301
326
  }, Effect.scoped)
302
327
 
328
+ /**
329
+ * Image branch of `readFile`: the bytes go to the `ImageAttacher` for
330
+ * the next model turn and the script only sees a marker.
331
+ */
332
+ const readImage = Effect.fnUntraced(function* (
333
+ path: string,
334
+ extensionType: Image.ImageMediaType,
335
+ options: {
336
+ readonly startLine?: number | undefined
337
+ readonly endLine?: number | undefined
338
+ },
339
+ ) {
340
+ if (options.startLine !== undefined || options.endLine !== undefined) {
341
+ return yield* Effect.die(
342
+ new Error(
343
+ `startLine / endLine are not supported for images: ${path}`,
344
+ ),
345
+ )
346
+ }
347
+ const bytes = yield* Image.readBoundedFile(fs, path).pipe(
348
+ Effect.catchReason("PlatformError", "NotFound", () =>
349
+ Effect.succeed(null),
350
+ ),
351
+ Effect.orDie,
352
+ )
353
+ if (bytes === null) return null
354
+ // The extension picks the branch; the magic bytes pick the real type.
355
+ const mediaType = Option.getOrElse(
356
+ Image.mediaTypeFromBytes(bytes),
357
+ () => extensionType,
358
+ )
359
+ const prepared = yield* Image.prepare({ data: bytes, mediaType }).pipe(
360
+ Effect.mapError((error) => new Error(`${error.message} (${path})`)),
361
+ Effect.orDie,
362
+ )
363
+ const fileName = pathService.basename(path)
364
+ const attach = yield* ImageAttacher
365
+ yield* attach({
366
+ fileName,
367
+ mediaType: prepared.mediaType,
368
+ data: prepared.data,
369
+ })
370
+ return `Image attached: ${fileName}`
371
+ })
372
+
303
373
  return AgentToolsWithSearch.of({
304
374
  changeDirectory: Effect.fn("AgentTools.changeDirectory")(
305
375
  function* (directory) {
@@ -315,11 +385,12 @@ export const AgentToolHandlersNoDeps = AgentToolsWithSearch.toLayer(
315
385
  Effect.annotateLogs(options),
316
386
  )
317
387
  const cwd = yield* CurrentDirectory
318
- let stream = pipe(
319
- fs.stream(pathService.resolve(cwd, options.path)),
320
- Stream.decodeText,
321
- Stream.splitLines,
322
- )
388
+ const path = pathService.resolve(cwd, options.path)
389
+ const imageType = Image.mediaTypeFromPath(path)
390
+ if (Option.isSome(imageType)) {
391
+ return yield* readImage(path, imageType.value, options)
392
+ }
393
+ let stream = pipe(fs.stream(path), Stream.decodeText, Stream.splitLines)
323
394
  if (options.startLine) {
324
395
  stream = Stream.drop(stream, options.startLine - 1)
325
396
  }
@@ -0,0 +1,302 @@
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 NodeHttpClient from "@effect/platform-node/NodeHttpClient"
5
+ import * as NodeServices from "@effect/platform-node/NodeServices"
6
+ import * as Duration from "effect/Duration"
7
+ import * as Effect from "effect/Effect"
8
+ import * as Exit from "effect/Exit"
9
+ import * as FileSystem from "effect/FileSystem"
10
+ import * as Layer from "effect/Layer"
11
+ import * as Path from "effect/Path"
12
+ import * as Stream from "effect/Stream"
13
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
14
+ import * as Model from "effect/unstable/ai/Model"
15
+ import type * as Prompt from "effect/unstable/ai/Prompt"
16
+ import type * as Response from "effect/unstable/ai/Response"
17
+ import * as Agent from "./Agent.ts"
18
+ import * as AgentExecutor from "./AgentExecutor.ts"
19
+ import type * as AgentOutput from "./AgentOutput.ts"
20
+ import {
21
+ bytesOf,
22
+ dimensions,
23
+ encodePng,
24
+ equalBytes,
25
+ tinyPng,
26
+ } from "./fixtures/TestImages.ts"
27
+
28
+ const svgText =
29
+ '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>'
30
+
31
+ /**
32
+ * A temp project directory holding the image fixtures, with a real local
33
+ * executor rooted in it.
34
+ */
35
+ const withProject = <A, E, R>(f: (project: string) => Effect.Effect<A, E, R>) =>
36
+ Effect.gen(function* () {
37
+ const fs = yield* FileSystem.FileSystem
38
+ const path = yield* Path.Path
39
+ const project = yield* fs.makeTempDirectoryScoped()
40
+ yield* fs.writeFile(path.join(project, "shot.png"), tinyPng)
41
+ yield* fs.writeFile(
42
+ path.join(project, "wide.png"),
43
+ encodePng({ width: 2500, height: 100 }),
44
+ )
45
+ yield* fs.writeFileString(path.join(project, "logo.svg"), svgText)
46
+ yield* fs.writeFile(
47
+ path.join(project, "broken.png"),
48
+ new TextEncoder().encode("this is not a png".repeat(20)),
49
+ )
50
+ return yield* f(project).pipe(
51
+ Effect.provide(
52
+ AgentExecutor.layerLocal({ directory: project }).pipe(
53
+ Layer.provide(NodeHttpClient.layerUndici),
54
+ ),
55
+ ),
56
+ )
57
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped)
58
+
59
+ const readFile = (params: {
60
+ readonly path: string
61
+ readonly startLine?: number
62
+ readonly endLine?: number
63
+ }) =>
64
+ Effect.gen(function* () {
65
+ const executor = yield* AgentExecutor.AgentExecutor
66
+ return yield* executor.executeUnsafe({ tool: "readFile", params })
67
+ })
68
+
69
+ describe("readFile images", () => {
70
+ it.effect("rejects startLine / endLine on an image", () =>
71
+ withProject(() =>
72
+ Effect.gen(function* () {
73
+ const withStart = yield* Effect.exit(
74
+ readFile({ path: "shot.png", startLine: 1 }),
75
+ )
76
+ assert.isTrue(Exit.isFailure(withStart))
77
+ const withEnd = yield* Effect.exit(
78
+ readFile({ path: "shot.png", endLine: 5 }),
79
+ )
80
+ assert.isTrue(Exit.isFailure(withEnd))
81
+ }),
82
+ ),
83
+ )
84
+
85
+ it.effect(
86
+ "rejects undecodable and over-ceiling images without decoding them",
87
+ () =>
88
+ withProject((project) =>
89
+ Effect.gen(function* () {
90
+ const fs = yield* FileSystem.FileSystem
91
+ const path = yield* Path.Path
92
+ // Sparse file one byte over the shared 20 MiB input ceiling.
93
+ const huge = path.join(project, "huge.png")
94
+ yield* fs.writeFile(huge, tinyPng)
95
+ yield* fs.truncate(huge, 20 * 1024 * 1024 + 1)
96
+
97
+ const decoder = vi.spyOn(Photon.PhotonImage, "new_from_byteslice")
98
+ try {
99
+ const broken = yield* Effect.exit(readFile({ path: "broken.png" }))
100
+ assert.isTrue(Exit.isFailure(broken))
101
+ const oversized = yield* Effect.exit(readFile({ path: "huge.png" }))
102
+ assert.isTrue(Exit.isFailure(oversized))
103
+ assert.strictEqual(
104
+ decoder.mock.calls.length,
105
+ 0,
106
+ "neither garbage nor an over-ceiling file may reach the decoder",
107
+ )
108
+ } finally {
109
+ decoder.mockRestore()
110
+ }
111
+ }),
112
+ ),
113
+ )
114
+ })
115
+
116
+ // =============================================================================
117
+ // Injection into the next model turn, through the real Agent loop
118
+ // =============================================================================
119
+
120
+ interface RecordedCall {
121
+ readonly prompt: Prompt.Prompt
122
+ }
123
+
124
+ const runAgent = (options: {
125
+ readonly respond: (
126
+ call: RecordedCall,
127
+ index: number,
128
+ ) => Stream.Stream<Response.StreamPartEncoded>
129
+ }) =>
130
+ Effect.gen(function* () {
131
+ const calls: Array<RecordedCall> = []
132
+ const outputs: Array<AgentOutput.Output> = []
133
+ const languageModel = yield* LanguageModel.make({
134
+ generateText: () => Effect.succeed([]),
135
+ streamText: (providerOptions) => {
136
+ const call = { prompt: providerOptions.prompt }
137
+ const index = calls.push(call) - 1
138
+ return options.respond(call, index)
139
+ },
140
+ })
141
+ const modelLayer = Layer.mergeAll(
142
+ Layer.succeed(LanguageModel.LanguageModel, languageModel),
143
+ Layer.succeed(Model.ProviderName, "test-provider"),
144
+ Layer.succeed(Model.ModelName, "test-model"),
145
+ )
146
+ const agent = yield* Agent.make
147
+ const exit = yield* agent.send({ prompt: "read the screenshot" }).pipe(
148
+ Effect.flatMap((stream) =>
149
+ stream.pipe(
150
+ Stream.runForEach((output) =>
151
+ Effect.sync(() => {
152
+ outputs.push(output)
153
+ }),
154
+ ),
155
+ Effect.as(""),
156
+ Effect.catchTag("AgentFinished", (finished) =>
157
+ Effect.succeed(finished.summary),
158
+ ),
159
+ ),
160
+ ),
161
+ Effect.provide(
162
+ Layer.mergeAll(
163
+ modelLayer,
164
+ Agent.ConversationMode.layer(true),
165
+ Agent.layerSubagentModel(modelLayer),
166
+ Layer.succeed(Agent.TurnTimeout, Duration.minutes(5)),
167
+ ),
168
+ ),
169
+ Effect.exit,
170
+ )
171
+ return { exit, calls, outputs }
172
+ }).pipe(Effect.scoped)
173
+
174
+ const toolCall = (id: string, script: string): Response.StreamPartEncoded => ({
175
+ type: "tool-call",
176
+ id,
177
+ name: "execute",
178
+ params: { script },
179
+ })
180
+
181
+ const text = (content: string): Array<Response.StreamPartEncoded> => [
182
+ { type: "text-start", id: "t" },
183
+ { type: "text-delta", id: "t", delta: content },
184
+ { type: "text-end", id: "t" },
185
+ ]
186
+
187
+ const toolResultText = (message: Prompt.Message): string | undefined => {
188
+ if (message.role !== "tool") return undefined
189
+ const part = message.content.find((p) => p.type === "tool-result")
190
+ return part && typeof part.result === "string" ? part.result : undefined
191
+ }
192
+
193
+ const fileParts = (message: Prompt.Message): Array<Prompt.FilePart> =>
194
+ message.role === "user"
195
+ ? message.content.filter((part) => part.type === "file")
196
+ : []
197
+
198
+ describe("readFile image injection", () => {
199
+ it.effect("injects an image as a file part after the execute result", () =>
200
+ withProject(() =>
201
+ Effect.gen(function* () {
202
+ const { exit, calls, outputs } = yield* runAgent({
203
+ respond: (_, index) =>
204
+ index === 0
205
+ ? Stream.make(
206
+ toolCall(
207
+ "call-1",
208
+ 'console.log(await readFile({ path: "shot.png" }))',
209
+ ),
210
+ )
211
+ : Stream.fromIterable(text("It is a red rectangle.")),
212
+ })
213
+ assert.isTrue(Exit.isSuccess(exit))
214
+ assert.strictEqual(calls.length, 2)
215
+
216
+ const messages = calls[1]!.prompt.content
217
+ const resultIndex = messages.findIndex((m) =>
218
+ toolResultText(m)?.includes("Image attached: shot.png"),
219
+ )
220
+ assert.notStrictEqual(resultIndex, -1, "script saw the marker")
221
+
222
+ const image = messages
223
+ .slice(resultIndex + 1)
224
+ .flatMap(fileParts)
225
+ .find((part) => part.mediaType === "image/png")
226
+ assert.isDefined(image, "a file part follows the tool result")
227
+ assert.strictEqual(
228
+ calls[1]!.prompt.content.flatMap(fileParts).length,
229
+ 1,
230
+ )
231
+ assert.strictEqual(image!.fileName, "shot.png")
232
+ assert.isTrue(equalBytes(bytesOf(image!.data), tinyPng))
233
+
234
+ // The first model call had no image: injection happens after the
235
+ // script runs, not before.
236
+ assert.strictEqual(
237
+ calls[0]!.prompt.content.flatMap(fileParts).length,
238
+ 0,
239
+ )
240
+
241
+ // The script output shown to the client is the marker, not bytes.
242
+ const scriptOutput = outputs.find(
243
+ (o): o is AgentOutput.ScriptOutput => o._tag === "ScriptOutput",
244
+ )
245
+ assert.isDefined(scriptOutput)
246
+ assert.include(scriptOutput!.output, "Image attached: shot.png")
247
+ assert.isBelow(scriptOutput!.output.length, 200)
248
+ }),
249
+ ),
250
+ )
251
+
252
+ it.effect("applies the image limits to readFile images", () =>
253
+ withProject(() =>
254
+ Effect.gen(function* () {
255
+ const { exit, calls } = yield* runAgent({
256
+ respond: (_, index) =>
257
+ index === 0
258
+ ? Stream.make(
259
+ toolCall(
260
+ "call-1",
261
+ 'console.log(await readFile({ path: "wide.png" }))',
262
+ ),
263
+ )
264
+ : Stream.fromIterable(text("done")),
265
+ })
266
+ assert.isTrue(Exit.isSuccess(exit))
267
+ const images = calls[1]!.prompt.content.flatMap(fileParts)
268
+ assert.strictEqual(images.length, 1)
269
+ const size = dimensions(bytesOf(images[0]!.data))
270
+ assert.isAtMost(size.width, 2000)
271
+ assert.isAtMost(size.height, 2000)
272
+ }),
273
+ ),
274
+ )
275
+
276
+ it.effect("reads SVG as text without injecting an image", () =>
277
+ withProject(() =>
278
+ Effect.gen(function* () {
279
+ const { calls } = yield* runAgent({
280
+ respond: (_, index) =>
281
+ index === 0
282
+ ? Stream.make(
283
+ toolCall(
284
+ "call-1",
285
+ 'console.log(await readFile({ path: "logo.svg" }))',
286
+ ),
287
+ )
288
+ : Stream.fromIterable(text("done")),
289
+ })
290
+ assert.strictEqual(calls.length, 2)
291
+ assert.strictEqual(
292
+ calls[1]!.prompt.content.flatMap(fileParts).length,
293
+ 0,
294
+ )
295
+ const result = calls[1]!.prompt.content
296
+ .map(toolResultText)
297
+ .find((r) => r !== undefined)
298
+ assert.include(result, "<svg")
299
+ }),
300
+ ),
301
+ )
302
+ })
package/src/Codex.ts CHANGED
@@ -67,7 +67,11 @@ const layerModel = (
67
67
  OpenAiLanguageModel.layer({
68
68
  model,
69
69
  config: {
70
- ...Struct.omit(options ?? {}, ["reasoning", "systemPromptTransform"]),
70
+ ...Struct.omit(options ?? {}, [
71
+ "reasoning",
72
+ "systemPromptTransform",
73
+ "supportsImages",
74
+ ]),
71
75
  store: false,
72
76
  reasoning: {
73
77
  effort: "medium",
@@ -82,6 +86,7 @@ const layerModel = (
82
86
  OpenAiLanguageModel.withConfigOverride(effect, {
83
87
  instructions: system,
84
88
  }),
89
+ supportsImages: options?.supportsImages,
85
90
  }),
86
91
  // No Compaction.SummarizerTransform: Codex rejects max_output_tokens,
87
92
  // so compaction summaries have no configured output-token cap.
package/src/Compaction.ts CHANGED
@@ -23,6 +23,7 @@ import * as AiError from "effect/unstable/ai/AiError"
23
23
  import * as LanguageModel from "effect/unstable/ai/LanguageModel"
24
24
  import * as Prompt from "effect/unstable/ai/Prompt"
25
25
  import type * as AgentOutput from "./AgentOutput.ts"
26
+ import * as Image from "./Image.ts"
26
27
 
27
28
  // =============================================================================
28
29
  // Configuration
@@ -531,8 +532,22 @@ const renderMessage = (message: Prompt.Message): string => {
531
532
  case "system":
532
533
  return ""
533
534
  case "user": {
535
+ // Image bytes never reach the summarizer: stub them in place.
534
536
  const text = message.content
535
- .flatMap((part) => (part.type === "text" ? [part.text] : []))
537
+ .flatMap((part) => {
538
+ switch (part.type) {
539
+ case "text":
540
+ return [part.text]
541
+ case "file":
542
+ return [
543
+ part.mediaType.startsWith("image/")
544
+ ? Image.omittedText(part.fileName)
545
+ : `[file: ${part.fileName ?? part.mediaType} omitted]`,
546
+ ]
547
+ default:
548
+ return []
549
+ }
550
+ })
536
551
  .join("\n")
537
552
  return `[user]\n${text}`
538
553
  }
@@ -0,0 +1,142 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Encoding from "effect/Encoding"
4
+ import * as Layer from "effect/Layer"
5
+ import * as Option from "effect/Option"
6
+ import * as Stream from "effect/Stream"
7
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
8
+ import * as Prompt from "effect/unstable/ai/Prompt"
9
+ import * as Compaction from "./Compaction.ts"
10
+ import { tinyPng } from "./fixtures/TestImages.ts"
11
+
12
+ const base64 = Encoding.encodeBase64(tinyPng)
13
+
14
+ const user = (text: string) =>
15
+ Prompt.makeMessage("user", {
16
+ content: [Prompt.makePart("text", { text })],
17
+ })
18
+
19
+ const userWithImage = (text: string, fileName: string, data: Uint8Array) =>
20
+ Prompt.makeMessage("user", {
21
+ content: [
22
+ Prompt.makePart("text", { text }),
23
+ Prompt.makePart("file", { mediaType: "image/png", fileName, data }),
24
+ ],
25
+ })
26
+
27
+ const assistantText = (text: string) =>
28
+ Prompt.makeMessage("assistant", {
29
+ content: [Prompt.makePart("text", { text })],
30
+ })
31
+
32
+ const system = Prompt.makeMessage("system", { content: "You are a test." })
33
+
34
+ const promptJson = (prompt: Prompt.Prompt): string =>
35
+ JSON.stringify(prompt.content)
36
+
37
+ const hasFileParts = (prompt: Prompt.Prompt): boolean =>
38
+ prompt.content.some(
39
+ (message) =>
40
+ message.role === "user" &&
41
+ message.content.some((part) => part.type === "file"),
42
+ )
43
+
44
+ describe("Compaction.compact with images", () => {
45
+ it.effect("never sends image parts to the summarizer", () =>
46
+ Effect.gen(function* () {
47
+ const summarizerPrompts: Array<Prompt.Prompt> = []
48
+ const languageModel = yield* LanguageModel.make({
49
+ generateText: () => Effect.succeed([]),
50
+ streamText: (options) => {
51
+ summarizerPrompts.push(options.prompt)
52
+ return Stream.fromIterable([
53
+ { type: "text-start", id: "s" },
54
+ { type: "text-delta", id: "s", delta: "SUMMARY" },
55
+ { type: "text-end", id: "s" },
56
+ ])
57
+ },
58
+ })
59
+ // Old image turn to summarize, then a fat text tail so the cut lands
60
+ // after the image.
61
+ const filler = "tail ".repeat(3_000)
62
+ const history = Prompt.fromMessages([
63
+ system,
64
+ userWithImage("look at this", "shot.png", tinyPng),
65
+ Prompt.makeMessage("user", {
66
+ content: [
67
+ Prompt.makePart("file", { mediaType: "image/png", data: base64 }),
68
+ ],
69
+ }),
70
+ assistantText("It is a red rectangle."),
71
+ user("now the next thing"),
72
+ assistantText(filler),
73
+ user("and again"),
74
+ assistantText(filler),
75
+ ])
76
+
77
+ const result = yield* Compaction.compact({
78
+ prompt: history,
79
+ reason: "threshold",
80
+ }).pipe(
81
+ Effect.provide(
82
+ Layer.mergeAll(
83
+ Layer.succeed(LanguageModel.LanguageModel, languageModel),
84
+ Compaction.CompactionConfig.layer({ keepRecentTokens: 2_000 }),
85
+ ),
86
+ ),
87
+ )
88
+ const compacted = Option.getOrThrow(result)
89
+ assert.strictEqual(summarizerPrompts.length, 1)
90
+ const sent = promptJson(summarizerPrompts[0]!)
91
+ assert.notInclude(sent, base64)
92
+ assert.isFalse(hasFileParts(summarizerPrompts[0]!))
93
+ assert.include(sent, "[image: shot.png omitted]")
94
+ assert.include(sent, "[image: unnamed omitted]")
95
+ assert.include(sent, "look at this")
96
+
97
+ // The rewritten prompt carries the summary and no stale image bytes
98
+ // from the summarized side.
99
+ const rewritten = promptJson(compacted.prompt)
100
+ assert.include(rewritten, "SUMMARY")
101
+ assert.notInclude(rewritten, base64)
102
+ }),
103
+ )
104
+
105
+ it.effect("keeps image parts in the kept tail", () =>
106
+ Effect.gen(function* () {
107
+ const languageModel = yield* LanguageModel.make({
108
+ generateText: () => Effect.succeed([]),
109
+ streamText: () =>
110
+ Stream.fromIterable([
111
+ { type: "text-start", id: "s" },
112
+ { type: "text-delta", id: "s", delta: "SUMMARY" },
113
+ { type: "text-end", id: "s" },
114
+ ]),
115
+ })
116
+ const filler = "old ".repeat(3_000)
117
+ const history = Prompt.fromMessages([
118
+ system,
119
+ user("start"),
120
+ assistantText(filler),
121
+ user("more"),
122
+ assistantText(filler),
123
+ // Recent image turn that must survive compaction verbatim.
124
+ userWithImage("latest screenshot", "latest.png", tinyPng),
125
+ ])
126
+ const result = yield* Compaction.compact({
127
+ prompt: history,
128
+ reason: "threshold",
129
+ }).pipe(
130
+ Effect.provide(
131
+ Layer.mergeAll(
132
+ Layer.succeed(LanguageModel.LanguageModel, languageModel),
133
+ Compaction.CompactionConfig.layer({ keepRecentTokens: 500 }),
134
+ ),
135
+ ),
136
+ )
137
+ const compacted = Option.getOrThrow(result)
138
+ assert.isTrue(hasFileParts(compacted.prompt))
139
+ assert.include(promptJson(compacted.prompt), "latest screenshot")
140
+ }),
141
+ )
142
+ })
package/src/Copilot.ts CHANGED
@@ -38,10 +38,14 @@ export const model = (
38
38
  Layer.mergeAll(
39
39
  OpenAiLanguageModel.layer({
40
40
  model,
41
- config: Struct.omit(options ?? {}, ["systemPromptTransform"]),
41
+ config: Struct.omit(options ?? {}, [
42
+ "systemPromptTransform",
43
+ "supportsImages",
44
+ ]),
42
45
  }),
43
46
  AgentModelConfig.layer({
44
47
  systemPromptTransform: options?.systemPromptTransform,
48
+ supportsImages: options?.supportsImages,
45
49
  }),
46
50
  // Cap compaction summaries; Copilot honours max_output_tokens.
47
51
  Layer.succeed(Compaction.SummarizerTransform, (effect) =>