kanna-code 0.16.0 → 0.18.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.
@@ -0,0 +1,282 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
6
+ import { getProjectUploadDir } from "./paths"
7
+ import { persistUploadedFiles, startKannaServer } from "./server"
8
+
9
+ const PNG_BASE64 =
10
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9sAAAAASUVORK5CYII="
11
+
12
+ const tempDirs: string[] = []
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
16
+ })
17
+
18
+ describe("uploads", () => {
19
+ test("stores uploads in .kanna/uploads and keeps duplicate filenames", async () => {
20
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-test-"))
21
+ tempDirs.push(projectDir)
22
+
23
+ const first = await persistProjectUpload({
24
+ projectId: "project-1",
25
+ localPath: projectDir,
26
+ fileName: "notes.txt",
27
+ bytes: new TextEncoder().encode("hello"),
28
+ fallbackMimeType: "text/plain",
29
+ })
30
+ const second = await persistProjectUpload({
31
+ projectId: "project-1",
32
+ localPath: projectDir,
33
+ fileName: "notes.txt",
34
+ bytes: new TextEncoder().encode("world"),
35
+ fallbackMimeType: "text/plain",
36
+ })
37
+
38
+ expect(first.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes.txt"))
39
+ expect(first.relativePath).toBe("./.kanna/uploads/notes.txt")
40
+ expect(first.contentUrl).toBe("/api/projects/project-1/uploads/notes.txt/content")
41
+ expect(second.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes-1.txt"))
42
+ expect(second.relativePath).toBe("./.kanna/uploads/notes-1.txt")
43
+ expect(second.contentUrl).toBe("/api/projects/project-1/uploads/notes-1.txt/content")
44
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes.txt")).text()).toBe("hello")
45
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes-1.txt")).text()).toBe("world")
46
+ })
47
+
48
+ test("stores concurrent same-name uploads without overwriting existing content", async () => {
49
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-concurrent-"))
50
+ tempDirs.push(projectDir)
51
+
52
+ const attachments = await Promise.all([
53
+ persistProjectUpload({
54
+ projectId: "project-1",
55
+ localPath: projectDir,
56
+ fileName: "notes.txt",
57
+ bytes: new TextEncoder().encode("first"),
58
+ fallbackMimeType: "text/plain",
59
+ }),
60
+ persistProjectUpload({
61
+ projectId: "project-1",
62
+ localPath: projectDir,
63
+ fileName: "notes.txt",
64
+ bytes: new TextEncoder().encode("second"),
65
+ fallbackMimeType: "text/plain",
66
+ }),
67
+ persistProjectUpload({
68
+ projectId: "project-1",
69
+ localPath: projectDir,
70
+ fileName: "notes.txt",
71
+ bytes: new TextEncoder().encode("third"),
72
+ fallbackMimeType: "text/plain",
73
+ }),
74
+ ])
75
+
76
+ const storedNames = attachments.map((attachment) => path.basename(attachment.absolutePath)).sort()
77
+ expect(storedNames).toEqual(["notes-1.txt", "notes-2.txt", "notes.txt"])
78
+
79
+ const contents = await Promise.all(attachments.map((attachment) => Bun.file(attachment.absolutePath).text()))
80
+ expect(new Set(contents)).toEqual(new Set(["first", "second", "third"]))
81
+ })
82
+
83
+ test("detects image uploads and returns absolute plus project-relative paths", async () => {
84
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-image-"))
85
+ tempDirs.push(projectDir)
86
+
87
+ const attachment = await persistProjectUpload({
88
+ projectId: "project-2",
89
+ localPath: projectDir,
90
+ fileName: "pixel.png",
91
+ bytes: Buffer.from(PNG_BASE64, "base64"),
92
+ })
93
+
94
+ expect(attachment.kind).toBe("image")
95
+ expect(attachment.mimeType).toBe("image/png")
96
+ expect(getProjectUploadDir(projectDir)).toBe(path.join(projectDir, ".kanna", "uploads"))
97
+ expect(attachment.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/pixel.png"))
98
+ expect(attachment.relativePath).toBe("./.kanna/uploads/pixel.png")
99
+ expect(attachment.contentUrl).toBe("/api/projects/project-2/uploads/pixel.png/content")
100
+ })
101
+
102
+ test("serves uploaded attachment content through the project content URL", async () => {
103
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-"))
104
+ tempDirs.push(projectDir)
105
+
106
+ const server = await startKannaServer({ port: 4310, strictPort: true })
107
+
108
+ try {
109
+ const project = await server.store.openProject(projectDir, "Project")
110
+ const attachment = await persistProjectUpload({
111
+ projectId: project.id,
112
+ localPath: projectDir,
113
+ fileName: "hello.txt",
114
+ bytes: new TextEncoder().encode("hello from upload"),
115
+ fallbackMimeType: "text/plain",
116
+ })
117
+
118
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
119
+ expect(response.status).toBe(200)
120
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
121
+ expect(await response.text()).toBe("hello from upload")
122
+ } finally {
123
+ await server.stop()
124
+ }
125
+ })
126
+
127
+ test("serves TypeScript uploads as text content", async () => {
128
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-typescript-"))
129
+ tempDirs.push(projectDir)
130
+
131
+ const server = await startKannaServer({ port: 4314, strictPort: true })
132
+
133
+ try {
134
+ const project = await server.store.openProject(projectDir, "Project")
135
+ const attachment = await persistProjectUpload({
136
+ projectId: project.id,
137
+ localPath: projectDir,
138
+ fileName: "main.ts",
139
+ bytes: new TextEncoder().encode("export const value = 1\n"),
140
+ fallbackMimeType: "video/mp2t",
141
+ })
142
+
143
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
144
+ expect(response.status).toBe(200)
145
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
146
+ expect(await response.text()).toContain("export const value = 1")
147
+ } finally {
148
+ await server.stop()
149
+ }
150
+ })
151
+
152
+ test("rejects non-GET requests for attachment content", async () => {
153
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-content-method-"))
154
+ tempDirs.push(projectDir)
155
+
156
+ const server = await startKannaServer({ port: 4312, strictPort: true })
157
+
158
+ try {
159
+ const project = await server.store.openProject(projectDir, "Project")
160
+ const attachment = await persistProjectUpload({
161
+ projectId: project.id,
162
+ localPath: projectDir,
163
+ fileName: "hello.txt",
164
+ bytes: new TextEncoder().encode("hello from upload"),
165
+ fallbackMimeType: "text/plain",
166
+ })
167
+
168
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`, { method: "POST" })
169
+ expect(response.status).toBe(405)
170
+ expect(response.headers.get("allow")).toBe("GET")
171
+ } finally {
172
+ await server.stop()
173
+ }
174
+ })
175
+
176
+ test("rejects oversized uploads before reading them into memory", async () => {
177
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-oversize-"))
178
+ tempDirs.push(projectDir)
179
+
180
+ const server = await startKannaServer({ port: 4313, strictPort: true })
181
+
182
+ try {
183
+ const project = await server.store.openProject(projectDir, "Project")
184
+ const formData = new FormData()
185
+ formData.append("files", new File([new Uint8Array(25 * 1024 * 1024 + 1)], "big.bin", { type: "application/octet-stream" }))
186
+
187
+ const response = await fetch(`http://localhost:${server.port}/api/projects/${project.id}/uploads`, {
188
+ method: "POST",
189
+ body: formData,
190
+ })
191
+
192
+ expect(response.status).toBe(413)
193
+ expect(await response.json()).toEqual({
194
+ error: "File \"big.bin\" exceeds the 25 MB limit.",
195
+ })
196
+ } finally {
197
+ await server.stop()
198
+ }
199
+ })
200
+
201
+ test("cleans up already-persisted files when a later file in the batch fails", async () => {
202
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-cleanup-"))
203
+ tempDirs.push(projectDir)
204
+
205
+ const files = [
206
+ new File(["first"], "first.txt", { type: "text/plain" }),
207
+ new File(["second"], "second.txt", { type: "text/plain" }),
208
+ ]
209
+
210
+ await expect(
211
+ persistUploadedFiles({
212
+ projectId: "project-4",
213
+ localPath: projectDir,
214
+ files,
215
+ persistUpload: async (args) => {
216
+ if (args.fileName === "second.txt") {
217
+ throw new Error("disk full")
218
+ }
219
+
220
+ return persistProjectUpload(args)
221
+ },
222
+ })
223
+ ).rejects.toThrow("disk full")
224
+
225
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/first.txt")).exists()).toBe(false)
226
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/second.txt")).exists()).toBe(false)
227
+ })
228
+
229
+ test("deletes uploaded attachments from the project uploads directory", async () => {
230
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-delete-"))
231
+ tempDirs.push(projectDir)
232
+
233
+ const attachment = await persistProjectUpload({
234
+ projectId: "project-3",
235
+ localPath: projectDir,
236
+ fileName: "delete-me.txt",
237
+ bytes: new TextEncoder().encode("bye"),
238
+ fallbackMimeType: "text/plain",
239
+ })
240
+
241
+ const deleted = await deleteProjectUpload({
242
+ localPath: projectDir,
243
+ storedName: "delete-me.txt",
244
+ })
245
+
246
+ expect(deleted).toBe(true)
247
+ expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
248
+ })
249
+
250
+ test("deletes uploaded attachment content through the project delete URL", async () => {
251
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-delete-"))
252
+ tempDirs.push(projectDir)
253
+
254
+ const server = await startKannaServer({ port: 4311, strictPort: true })
255
+
256
+ try {
257
+ const project = await server.store.openProject(projectDir, "Project")
258
+ const attachment = await persistProjectUpload({
259
+ projectId: project.id,
260
+ localPath: projectDir,
261
+ fileName: "bye.txt",
262
+ bytes: new TextEncoder().encode("delete over http"),
263
+ fallbackMimeType: "text/plain",
264
+ })
265
+
266
+ const deleteUrl = `http://localhost:${server.port}${attachment.contentUrl.replace(/\/content$/, "")}`
267
+ const response = await fetch(deleteUrl, { method: "DELETE" })
268
+ expect(response.status).toBe(200)
269
+ expect(await response.json()).toEqual({ ok: true })
270
+ expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
271
+ } finally {
272
+ await server.stop()
273
+ }
274
+ })
275
+
276
+ test("infers text-friendly content types for previewable source files", () => {
277
+ expect(inferAttachmentContentType("notes.txt")).toBe("text/plain; charset=utf-8")
278
+ expect(inferAttachmentContentType("README.md")).toBe("text/markdown; charset=utf-8")
279
+ expect(inferAttachmentContentType("main.ts", "video/mp2t")).toBe("text/plain; charset=utf-8")
280
+ expect(inferAttachmentContentType("archive.zip", "application/zip")).toBe("application/zip")
281
+ })
282
+ })
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdir, open, rm } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { fileTypeFromBuffer } from "file-type"
5
+ import type { ChatAttachment } from "../shared/types"
6
+ import { getProjectUploadDir } from "./paths"
7
+
8
+ const DEFAULT_BINARY_MIME_TYPE = "application/octet-stream"
9
+ const IMAGE_MIME_PREFIX = "image/"
10
+ const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8"
11
+
12
+ const TEXT_CONTENT_TYPE_BY_EXTENSION = new Map<string, string>([
13
+ [".csv", "text/csv; charset=utf-8"],
14
+ [".json", "application/json; charset=utf-8"],
15
+ [".jsonc", TEXT_PLAIN_CONTENT_TYPE],
16
+ [".md", "text/markdown; charset=utf-8"],
17
+ [".tsv", "text/tab-separated-values; charset=utf-8"],
18
+ ])
19
+
20
+ const TEXT_LIKE_EXTENSIONS = new Set([
21
+ ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".env", ".go", ".graphql", ".h", ".hpp", ".html",
22
+ ".ini", ".java", ".js", ".jsx", ".kt", ".lua", ".mjs", ".php", ".pl", ".properties", ".py", ".rb", ".rs",
23
+ ".scss", ".sh", ".sql", ".swift", ".toml", ".ts", ".tsx", ".txt", ".vue", ".xml", ".yaml", ".yml", ".zsh",
24
+ ])
25
+
26
+ function sanitizeFileName(fileName: string) {
27
+ const baseName = path.basename(fileName).trim()
28
+ const cleaned = baseName.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "")
29
+ return cleaned || "upload"
30
+ }
31
+
32
+ function getUploadCandidateNames(originalName: string) {
33
+ const sanitizedName = sanitizeFileName(originalName)
34
+ const parsed = path.parse(sanitizedName)
35
+ const extension = parsed.ext
36
+ const name = parsed.name || "upload"
37
+
38
+ return {
39
+ first: sanitizedName,
40
+ withCounter(counter: number) {
41
+ return `${name}-${counter}${extension}`
42
+ },
43
+ }
44
+ }
45
+
46
+ export async function persistProjectUpload(args: {
47
+ projectId: string
48
+ localPath: string
49
+ fileName: string
50
+ bytes: Uint8Array
51
+ fallbackMimeType?: string
52
+ }): Promise<ChatAttachment> {
53
+ const uploadDir = getProjectUploadDir(args.localPath)
54
+ await mkdir(uploadDir, { recursive: true })
55
+
56
+ const detectedType = await fileTypeFromBuffer(args.bytes)
57
+ const mimeType = detectedType?.mime ?? args.fallbackMimeType ?? DEFAULT_BINARY_MIME_TYPE
58
+ const candidates = getUploadCandidateNames(args.fileName)
59
+
60
+ let storedName = candidates.first
61
+ let absolutePath = path.join(uploadDir, storedName)
62
+ let counter = 1
63
+
64
+ while (true) {
65
+ try {
66
+ const handle = await open(absolutePath, "wx")
67
+ try {
68
+ await handle.writeFile(args.bytes)
69
+ } finally {
70
+ await handle.close()
71
+ }
72
+ break
73
+ } catch (error) {
74
+ const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined
75
+ if (code !== "EEXIST") {
76
+ throw error
77
+ }
78
+
79
+ storedName = candidates.withCounter(counter)
80
+ absolutePath = path.join(uploadDir, storedName)
81
+ counter += 1
82
+ }
83
+ }
84
+
85
+ return {
86
+ id: randomUUID(),
87
+ kind: mimeType.startsWith(IMAGE_MIME_PREFIX) ? "image" : "file",
88
+ displayName: args.fileName,
89
+ absolutePath,
90
+ relativePath: `./.kanna/uploads/${storedName}`,
91
+ contentUrl: `/api/projects/${args.projectId}/uploads/${encodeURIComponent(storedName)}/content`,
92
+ mimeType,
93
+ size: args.bytes.byteLength,
94
+ }
95
+ }
96
+
97
+ export function inferAttachmentContentType(fileName: string, fallbackType?: string): string {
98
+ const extension = path.extname(fileName).toLowerCase()
99
+ const mappedType = TEXT_CONTENT_TYPE_BY_EXTENSION.get(extension)
100
+ if (mappedType) {
101
+ return mappedType
102
+ }
103
+
104
+ if (TEXT_LIKE_EXTENSIONS.has(extension)) {
105
+ return TEXT_PLAIN_CONTENT_TYPE
106
+ }
107
+
108
+ return fallbackType || DEFAULT_BINARY_MIME_TYPE
109
+ }
110
+
111
+ export async function deleteProjectUpload(args: {
112
+ localPath: string
113
+ storedName: string
114
+ }): Promise<boolean> {
115
+ const storedName = args.storedName
116
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
117
+ return false
118
+ }
119
+
120
+ const absolutePath = path.join(getProjectUploadDir(args.localPath), storedName)
121
+ try {
122
+ await rm(absolutePath, { force: true })
123
+ return true
124
+ } catch {
125
+ return false
126
+ }
127
+ }
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
2
2
  import {
3
3
  DEFAULT_DEV_CLIENT_PORT,
4
4
  getDefaultDevServerPort,
5
+ parseDevArgs,
5
6
  resolveDevPorts,
6
7
  stripPortArg,
7
8
  } from "./dev-ports"
@@ -50,3 +51,34 @@ describe("stripPortArg", () => {
50
51
  ])
51
52
  })
52
53
  })
54
+
55
+ describe("parseDevArgs", () => {
56
+ test("derives dev share options from the client port", () => {
57
+ expect(parseDevArgs(["--share", "--port", "3333"], "dev-machine")).toEqual({
58
+ clientPort: 3333,
59
+ serverPort: 3334,
60
+ share: true,
61
+ backendTargetHost: "127.0.0.1",
62
+ allowedHosts: true,
63
+ serverArgs: [],
64
+ })
65
+ })
66
+
67
+ test("rejects combining --share with --host or --remote", () => {
68
+ expect(() => parseDevArgs(["--share", "--host", "dev-box"], "dev-machine")).toThrow("--share cannot be used with --host")
69
+ expect(() => parseDevArgs(["--host", "dev-box", "--share"], "dev-machine")).toThrow("--share cannot be used with --host")
70
+ expect(() => parseDevArgs(["--share", "--remote"], "dev-machine")).toThrow("--share cannot be used with --remote")
71
+ expect(() => parseDevArgs(["--remote", "--share"], "dev-machine")).toThrow("--share cannot be used with --remote")
72
+ })
73
+
74
+ test("preserves existing remote host behavior when share is off", () => {
75
+ expect(parseDevArgs(["--remote", "--port", "3333"], "dev-machine")).toEqual({
76
+ clientPort: 3333,
77
+ serverPort: 3334,
78
+ share: false,
79
+ backendTargetHost: "127.0.0.1",
80
+ allowedHosts: true,
81
+ serverArgs: ["--remote"],
82
+ })
83
+ })
84
+ })
@@ -4,6 +4,15 @@ export function getDefaultDevServerPort(clientPort = DEFAULT_DEV_CLIENT_PORT) {
4
4
  return clientPort + 1
5
5
  }
6
6
 
7
+ export interface DevArgResolution {
8
+ clientPort: number
9
+ serverPort: number
10
+ share: boolean
11
+ backendTargetHost: string
12
+ allowedHosts: true | string[]
13
+ serverArgs: string[]
14
+ }
15
+
7
16
  export function resolveDevPorts(args: string[]) {
8
17
  let clientPort = DEFAULT_DEV_CLIENT_PORT
9
18
 
@@ -41,3 +50,49 @@ export function stripPortArg(args: string[]) {
41
50
 
42
51
  return stripped
43
52
  }
53
+
54
+ export function parseDevArgs(args: string[], localHostname: string): DevArgResolution {
55
+ const { clientPort, serverPort } = resolveDevPorts(args)
56
+ const serverArgs = stripPortArg(args).filter((arg) => arg !== "--share")
57
+ let share = false
58
+ let sawHost = false
59
+ let sawRemote = false
60
+ let backendTargetHost = "127.0.0.1"
61
+ let allowAllHosts = false
62
+ const hosts = new Set<string>(["localhost", "127.0.0.1", "0.0.0.0", localHostname])
63
+
64
+ for (let index = 0; index < args.length; index += 1) {
65
+ const arg = args[index]
66
+ if (arg === "--share") {
67
+ if (sawHost) throw new Error("--share cannot be used with --host")
68
+ if (sawRemote) throw new Error("--share cannot be used with --remote")
69
+ share = true
70
+ continue
71
+ }
72
+ if (arg === "--remote") {
73
+ if (share) throw new Error("--share cannot be used with --remote")
74
+ sawRemote = true
75
+ backendTargetHost = "127.0.0.1"
76
+ allowAllHosts = true
77
+ continue
78
+ }
79
+ if (arg !== "--host") continue
80
+
81
+ const next = args[index + 1]
82
+ if (!next || next.startsWith("-")) continue
83
+ if (share) throw new Error("--share cannot be used with --host")
84
+ sawHost = true
85
+ hosts.add(next)
86
+ backendTargetHost = next === "0.0.0.0" ? "127.0.0.1" : next
87
+ index += 1
88
+ }
89
+
90
+ return {
91
+ clientPort,
92
+ serverPort,
93
+ share,
94
+ backendTargetHost,
95
+ allowedHosts: share || allowAllHosts ? true : [...hosts],
96
+ serverArgs,
97
+ }
98
+ }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AgentProvider,
3
+ ChatAttachment,
3
4
  ChatSnapshot,
4
5
  KeybindingsSnapshot,
5
6
  LocalProjectsSnapshot,
@@ -67,6 +68,7 @@ export type ClientCommand =
67
68
  projectId?: string
68
69
  provider?: AgentProvider
69
70
  content: string
71
+ attachments?: ChatAttachment[]
70
72
  model?: string
71
73
  modelOptions?: ModelOptions
72
74
  effort?: string
@@ -96,4 +96,69 @@ describe("hydrateToolResult", () => {
96
96
 
97
97
  expect(hydrateToolResult(tool, "line 1\nline 2")).toBe("line 1\nline 2")
98
98
  })
99
+
100
+ test("hydrates read image results with canonical image blocks intact", () => {
101
+ const tool = normalizeToolCall({
102
+ toolName: "Read",
103
+ toolId: "tool-image",
104
+ input: { file_path: "/tmp/example.png" },
105
+ })
106
+
107
+ expect(hydrateToolResult(tool, {
108
+ content: [
109
+ {
110
+ type: "text",
111
+ text: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
112
+ },
113
+ {
114
+ type: "image",
115
+ data: "ZmFrZS1pbWFnZS1kYXRh",
116
+ mimeType: "image/png",
117
+ },
118
+ ],
119
+ })).toEqual({
120
+ content: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
121
+ blocks: [
122
+ {
123
+ type: "text",
124
+ text: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
125
+ },
126
+ {
127
+ type: "image",
128
+ data: "ZmFrZS1pbWFnZS1kYXRh",
129
+ mimeType: "image/png",
130
+ },
131
+ ],
132
+ })
133
+ })
134
+
135
+ test("hydrates Claude read image results with source.base64 into canonical image blocks", () => {
136
+ const tool = normalizeToolCall({
137
+ toolName: "Read",
138
+ toolId: "tool-image-claude",
139
+ input: { file_path: "/tmp/example.png" },
140
+ })
141
+
142
+ expect(hydrateToolResult(tool, {
143
+ content: [
144
+ {
145
+ type: "image",
146
+ source: {
147
+ type: "base64",
148
+ data: "ZmFrZS1pbWFnZS1kYXRh",
149
+ media_type: "image/png",
150
+ },
151
+ },
152
+ ],
153
+ })).toEqual({
154
+ content: "",
155
+ blocks: [
156
+ {
157
+ type: "image",
158
+ data: "ZmFrZS1pbWFnZS1kYXRh",
159
+ mimeType: "image/png",
160
+ },
161
+ ],
162
+ })
163
+ })
99
164
  })
@@ -203,6 +203,72 @@ function parseJsonValue(value: unknown): unknown {
203
203
  }
204
204
  }
205
205
 
206
+ type ReadStructuredTextBlock = {
207
+ type: "text"
208
+ text: string
209
+ }
210
+
211
+ type ReadStructuredImageBlock = {
212
+ type: "image"
213
+ data: string
214
+ mimeType?: string
215
+ }
216
+
217
+ function normalizeReadBlocks(value: unknown): Array<ReadStructuredTextBlock | ReadStructuredImageBlock> {
218
+ const blocks = (
219
+ value
220
+ && typeof value === "object"
221
+ && "content" in value
222
+ && Array.isArray((value as { content?: unknown }).content)
223
+ )
224
+ ? (value as { content: unknown[] }).content
225
+ : Array.isArray(value)
226
+ ? value
227
+ : []
228
+
229
+ const normalized: Array<ReadStructuredTextBlock | ReadStructuredImageBlock> = []
230
+
231
+ for (const block of blocks) {
232
+ if (!block || typeof block !== "object" || !("type" in block)) {
233
+ continue
234
+ }
235
+
236
+ if (block.type === "text" && typeof block.text === "string") {
237
+ normalized.push({ type: "text", text: block.text })
238
+ continue
239
+ }
240
+
241
+ if (block.type === "image") {
242
+ if ("data" in block && typeof block.data === "string") {
243
+ normalized.push({
244
+ type: "image",
245
+ data: block.data,
246
+ mimeType: typeof block.mimeType === "string" ? block.mimeType : undefined,
247
+ })
248
+ continue
249
+ }
250
+
251
+ if (
252
+ "source" in block
253
+ && block.source
254
+ && typeof block.source === "object"
255
+ && "type" in block.source
256
+ && block.source.type === "base64"
257
+ && "data" in block.source
258
+ && typeof block.source.data === "string"
259
+ ) {
260
+ normalized.push({
261
+ type: "image",
262
+ data: block.source.data,
263
+ mimeType: typeof block.source.media_type === "string" ? block.source.media_type : undefined,
264
+ })
265
+ }
266
+ }
267
+ }
268
+
269
+ return normalized
270
+ }
271
+
206
272
  export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): HydratedToolCall["result"] {
207
273
  const parsed = parseJsonValue(raw)
208
274
 
@@ -241,6 +307,16 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
241
307
  if (typeof parsed === "string") {
242
308
  return parsed
243
309
  }
310
+ const blocks = normalizeReadBlocks(parsed)
311
+ if (blocks.length > 0) {
312
+ return {
313
+ content: blocks
314
+ .flatMap((block) => block.type === "text" ? [block.text] : [])
315
+ .join(""),
316
+ blocks,
317
+ } satisfies ReadFileToolResult
318
+ }
319
+
244
320
  const record = asRecord(parsed)
245
321
  return {
246
322
  content: typeof record?.content === "string" ? record.content : JSON.stringify(parsed, null, 2),