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.
- package/dist/Acp.d.ts.map +1 -1
- package/dist/Acp.js +195 -16
- package/dist/Acp.js.map +1 -1
- package/dist/AcpImage.test.d.ts +2 -0
- package/dist/AcpImage.test.d.ts.map +1 -0
- package/dist/AcpImage.test.js +470 -0
- package/dist/AcpImage.test.js.map +1 -0
- package/dist/Agent.d.ts +9 -2
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +43 -6
- package/dist/Agent.js.map +1 -1
- package/dist/AgentExecutor.d.ts +27 -5
- package/dist/AgentExecutor.d.ts.map +1 -1
- package/dist/AgentExecutor.js +29 -4
- package/dist/AgentExecutor.js.map +1 -1
- package/dist/AgentExecutorImage.test.d.ts +2 -0
- package/dist/AgentExecutorImage.test.d.ts.map +1 -0
- package/dist/AgentExecutorImage.test.js +63 -0
- package/dist/AgentExecutorImage.test.js.map +1 -0
- package/dist/AgentImage.test.d.ts +2 -0
- package/dist/AgentImage.test.d.ts.map +1 -0
- package/dist/AgentImage.test.js +176 -0
- package/dist/AgentImage.test.js.map +1 -0
- package/dist/AgentTools.d.ts +25 -5
- package/dist/AgentTools.d.ts.map +1 -1
- package/dist/AgentTools.js +40 -4
- package/dist/AgentTools.js.map +1 -1
- package/dist/AgentToolsImage.test.d.ts +2 -0
- package/dist/AgentToolsImage.test.d.ts.map +1 -0
- package/dist/AgentToolsImage.test.js +158 -0
- package/dist/AgentToolsImage.test.js.map +1 -0
- package/dist/Codex.js +6 -1
- package/dist/Codex.js.map +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +16 -1
- package/dist/Compaction.js.map +1 -1
- package/dist/CompactionImage.test.d.ts +2 -0
- package/dist/CompactionImage.test.d.ts.map +1 -0
- package/dist/CompactionImage.test.js +105 -0
- package/dist/CompactionImage.test.js.map +1 -0
- package/dist/Copilot.d.ts.map +1 -1
- package/dist/Copilot.js +5 -1
- package/dist/Copilot.js.map +1 -1
- package/dist/Image.d.ts +192 -0
- package/dist/Image.d.ts.map +1 -0
- package/dist/Image.js +586 -0
- package/dist/Image.js.map +1 -0
- package/dist/Image.test.d.ts +2 -0
- package/dist/Image.test.d.ts.map +1 -0
- package/dist/Image.test.js +142 -0
- package/dist/Image.test.js.map +1 -0
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/fixtures/TestImages.d.ts +73 -0
- package/dist/fixtures/TestImages.d.ts.map +1 -0
- package/dist/fixtures/TestImages.js +263 -0
- package/dist/fixtures/TestImages.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/Acp.ts +274 -21
- package/src/AcpImage.test.ts +676 -0
- package/src/Agent.ts +69 -7
- package/src/AgentExecutor.ts +51 -3
- package/src/AgentExecutorImage.test.ts +103 -0
- package/src/AgentImage.test.ts +295 -0
- package/src/AgentTools.ts +78 -7
- package/src/AgentToolsImage.test.ts +302 -0
- package/src/Codex.ts +6 -1
- package/src/Compaction.ts +16 -1
- package/src/CompactionImage.test.ts +142 -0
- package/src/Copilot.ts +5 -1
- package/src/Image.test.ts +220 -0
- package/src/Image.ts +704 -0
- package/src/cli.ts +2 -1
- package/src/fixtures/TestImages.ts +287 -0
- 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
|
+
})
|