| 1 | import { ThreadId, TurnItemId } from "@t3tools/contracts"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | |
| 5 | import { turnItemOutputImages, turnItemOutputText } from "./itemDetail.ts"; |
| 6 | |
| 7 | const screenshot = { |
| 8 | id: TurnItemId.make("tool-screenshot"), |
| 9 | type: "dynamic_tool" as const, |
| 10 | threadId: ThreadId.make("thread-1"), |
| 11 | runId: null, |
| 12 | nodeId: null, |
| 13 | providerThreadId: null, |
| 14 | providerTurnId: null, |
| 15 | nativeItemRef: null, |
| 16 | parentItemId: null, |
| 17 | ordinal: 1, |
| 18 | status: "completed" as const, |
| 19 | title: null, |
| 20 | toolName: "mcp__t3-code__device_screenshot", |
| 21 | input: { deviceId: "phone" }, |
| 22 | // What a detail read returns: the image's position without its bytes. |
| 23 | output: { |
| 24 | content: [ |
| 25 | { type: "text", text: "Captured the home screen." }, |
| 26 | { type: "image", mimeType: "image/png" }, |
| 27 | ], |
| 28 | }, |
| 29 | startedAt: DateTime.makeUnsafe("2026-10-05T00:00:00.000Z"), |
| 30 | completedAt: DateTime.makeUnsafe("2026-10-05T00:00:01.000Z"), |
| 31 | updatedAt: DateTime.makeUnsafe("2026-10-05T00:00:01.000Z"), |
| 32 | }; |
| 33 | |
| 34 | describe("tool output images", () => { |
| 35 | it("shows a screenshot as an image asset, not as text", () => { |
| 36 | expect(turnItemOutputImages(screenshot)).toEqual([ |
| 37 | { |
| 38 | _tag: "tool-output-image", |
| 39 | threadId: screenshot.threadId, |
| 40 | itemId: screenshot.id, |
| 41 | index: 0, |
| 42 | }, |
| 43 | ]); |
| 44 | expect(turnItemOutputText(screenshot)).toBe("Captured the home screen."); |
| 45 | expect( |
| 46 | turnItemOutputText({ ...screenshot, output: { content: [screenshot.output.content[1]] } }), |
| 47 | ).toBeNull(); |
| 48 | }); |
| 49 | |
| 50 | it("keeps a placeholder for images it cannot show", () => { |
| 51 | const output = { content: [{ type: "image", mimeType: "image/svg+xml" }] }; |
| 52 | expect(turnItemOutputImages({ ...screenshot, output })).toEqual([]); |
| 53 | expect(turnItemOutputText({ ...screenshot, output })).toBe("[image]"); |
| 54 | }); |
| 55 | |
| 56 | it("omits Claude Read's image placeholder when the file has a preview", () => { |
| 57 | const output = { |
| 58 | type: "image", |
| 59 | file: { filePath: "/tmp/blk.png", type: "image/png", sizeBytes: 1024 }, |
| 60 | }; |
| 61 | const item = { ...screenshot, toolName: "Read", viewedImagePath: "/tmp/blk.png", output }; |
| 62 | |
| 63 | expect(turnItemOutputImages(item)).toEqual([]); |
| 64 | expect(turnItemOutputText(item)).toBeNull(); |
| 65 | expect(turnItemOutputText({ ...item, viewedImagePath: undefined })).toBe("[image]"); |
| 66 | }); |
| 67 | |
| 68 | it("keeps accompanying text and literal image markers when the file has a preview", () => { |
| 69 | const output = { |
| 70 | content: [ |
| 71 | { type: "text", text: "Read /tmp/blk.png. Literal marker: [image]" }, |
| 72 | screenshot.output.content[1], |
| 73 | ], |
| 74 | }; |
| 75 | expect(turnItemOutputText({ ...screenshot, viewedImagePath: "/tmp/blk.png", output })).toBe( |
| 76 | "Read /tmp/blk.png. Literal marker: [image]", |
| 77 | ); |
| 78 | }); |
| 79 | |
| 80 | it("keeps placeholders for unserved images in a list despite a file preview", () => { |
| 81 | const blocks = [ |
| 82 | { type: "text", text: "Read /tmp/blk.png." }, |
| 83 | { type: "image", file: { type: "image/png", sizeBytes: 1024 } }, |
| 84 | screenshot.output.content[1], |
| 85 | { type: "image", mimeType: "image/svg+xml" }, |
| 86 | ]; |
| 87 | for (const output of [blocks, { content: blocks }, { content: blocks, isError: false }]) { |
| 88 | const item = { ...screenshot, viewedImagePath: "/tmp/blk.png", output }; |
| 89 | expect(turnItemOutputText(item)).toBe("Read /tmp/blk.png.\n[image]\n[image]"); |
| 90 | expect(turnItemOutputImages(item)).toHaveLength(1); |
| 91 | } |
| 92 | }); |
| 93 | }); |