Skip to content
Source
Sign inSign up
Open History to see the last commit

itemDetail.test.ts

184 lines6.0 KB

1import { describe, expect, it } from "vite-plus/test";
2import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4​
5import { isJsonToolText, turnItemOutputImages, turnItemOutputText } from "./itemDetail.ts";
6​
7describe("isJsonToolText", () => {
8 it.each([
9 ['{"screenshot":{"mimeType":"image/png"}}', true],
10 ['[{"threadId":"thread-1"}]', true],
11 ['{\n\n "status": "completed"\n}', true],
12 ['{\n "count": 1\n}\n\n{\n "count": 2\n}', true],
13 ["false", true],
14 ["null", true],
15 ["42", true],
16 ['"completed"', true],
17 ['{"partial":', false],
18 ['{"count":1}\n\nFinished.', false],
19 ["No output.", false],
20 ["", false],
21 ] as const)("classifies %j as JSON: %s", (text, expected) => {
22 expect(isJsonToolText(text)).toBe(expected);
23 });
24});
25​
26function toolItem(output: unknown): OrchestrationV2TurnItem {
27 return {
28 id: TurnItemId.make("images"),
29 threadId: ThreadId.make("thread"),
30 runId: null,
31 nodeId: null,
32 providerThreadId: null,
33 providerTurnId: null,
34 nativeItemRef: null,
35 parentItemId: null,
36 ordinal: 1,
37 status: "completed",
38 title: null,
39 startedAt: null,
40 completedAt: null,
41 updatedAt: DateTime.makeUnsafe("2026-10-01T00:00:00Z"),
42 type: "dynamic_tool",
43 toolName: "screenshots",
44 input: {},
45 output,
46 };
48​
49describe("tool image output parsing", () => {
50 it.each(["image-only", "summary", "json"] as const)(
51 "keeps structured fields beside %s content",
52 (shape) => {
53 const content = [
54 { type: "image", mimeType: "image/png" },
55 ...(shape === "image-only"
56 ? []
57 : [{ type: "text", text: shape === "summary" ? "Sunny" : '{"temp":21}' }]),
58 ];
59 for (const structuredContent of [
60 { temp: 21 },
61 { type: "text", text: "Sunny", temp: 21 },
62 { content: [{ type: "text", text: "Sunny" }], temp: 21 },
63 ]) {
64 const item = toolItem({ structuredContent, content });
65 expect(turnItemOutputText(item)).toBe(JSON.stringify(structuredContent, null, 2));
66 expect(turnItemOutputImages(item)).toHaveLength(1);
67 }
68 for (const structuredContent of [null, {}, [], ""]) {
69 const item = toolItem({ structuredContent, content: [{ type: "text", text: "Sunny" }] });
70 expect(turnItemOutputText(item)).toBe("Sunny");
71 }
72 expect(
73 turnItemOutputText(
74 toolItem({
75 content: [{ type: "text", text: "File contents" }],
76 structuredContent: { path: "read.ts", ready: true },
77 }),
78 ),
79 ).toBe("File contents");
80 },
81 );
82 it.each(["array", "content", "structured"] as const)(
83 "keeps markers for images beyond the served positions in %s output",
84 (shape) => {
85 // Detail responses retain image metadata after the server strips image bytes.
86 const image = { type: "image", mimeType: "image/png" };
87 const blocks = Array.from({ length: 8 }, () => ({ ...image }));
88 const wrap = (content: unknown[]) =>
89 shape === "array"
90 ? content
91 : shape === "structured"
92 ? { content, structuredContent: { temp: 21 } }
93 : { content };
94 const withinLimit = toolItem(wrap(blocks));
95 expect(turnItemOutputText(withinLimit)).toBe(
96 shape === "structured" ? '{\n "temp": 21\n}' : null,
97 );
98 expect(turnItemOutputImages(withinLimit)).toHaveLength(8);
99​
100 const overflow = toolItem(
101 wrap([
102 ...blocks,
103 { type: "text", text: "Two additional screenshots:" },
104 { ...image, sizeBytes: 100 },
105 { type: "image", source: { type: "base64", media_type: "image/jpeg" } },
106 ]),
107 );
108 expect(turnItemOutputImages(overflow)).toHaveLength(8);
109 expect(turnItemOutputText(overflow)).toBe(
110 (shape === "structured" ? '{\n "temp": 21\n}' : "Two additional screenshots:") +
111 "\n[image]\n[image]",
112 );
113 },
114 );
115});
116​
117describe("tool text output formatting", () => {
118 it.each([
119 '{"id":1234567890123456789,"name":"x"}',
120 '{"id":1}\n{"id":-9007199254740993}',
121 '{"id":9.007199254740993e15}',
122 '{"id":1e309}',
123 ])("keeps %j as returned when parsing would change a number", (text) => {
124 expect(turnItemOutputText(toolItem({ content: [{ type: "text", text }] }))).toBe(text);
125 });
126​
127 it.each(['{"id":42,"note":"12345678901234567890"}', '{"ratio":0.1234567890123456789}'])(
128 "still pretty-prints %j",
129 (text) => {
130 expect(turnItemOutputText(toolItem({ content: [{ type: "text", text }] }))).toBe(
131 JSON.stringify(JSON.parse(text), null, 2),
132 );
133 },
134 );
135});
136​
137describe("MCP app tool output", () => {
138 it("shows the tool result, not the app wrapper", () => {
139 const text = "Notice\nview\tcount\n/home\t42";
140 const item = toolItem({
141 t3McpApp: {
142 attachmentId: "app-html",
143 server: "repro",
144 tool: "aggregate_rum_events",
145 resourceUri: "ui://repro/dataviz",
146 },
147 result: { content: [{ type: "text", text }] },
148 });
149 expect(turnItemOutputText(item)).toBe(text);
150 });
151​
152 it("reads a result with metadata and marks images it cannot preview", () => {
153 const item = toolItem({
154 t3McpApp: {
155 attachmentId: "app-html",
156 server: "repro",
157 tool: "chart",
158 resourceUri: "ui://repro/chart",
159 },
160 result: {
161 content: [
162 { type: "text", text: "chart" },
163 { type: "image", mimeType: "image/png", data: "iVBORw0KGgo=" },
164 ],
165 _meta: { "ui/resourceUri": "ui://repro/chart" },
166 },
167 });
168 expect(turnItemOutputText(item)).toBe("chart\n[image]");
169​
170 const bounded = toolItem({
171 t3McpApp: {
172 attachmentId: "app-html",
173 server: "repro",
174 tool: "chart",
175 resourceUri: "ui://repro/chart",
176 },
177 result: [
178 { type: "text", text: "chart" },
179 { type: "image", mimeType: "image/png" },
180 ],
181 });
182 expect(turnItemOutputText(bounded)).toBe("chart\n[image]");
183 });
184});