1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 compactDynamicToolOutput,
5 MAX_TOOL_OUTPUT_IMAGES,
6 omitToolOutputImageData,
7 toolOutputImages,
8 toolOutputIndicatesFailure,
9} from "./toolOutput.ts";
10
11describe("compactDynamicToolOutput", () => {
12 it("extracts IDs through the MCP result envelopes used by T3 summaries", () => {
13 const metadata = { threadId: "thread-1", messageId: "message-1" };
14 const json = JSON.stringify({ ...metadata, response: "Private response body" });
15 for (const value of [
16 { ...metadata, response: "Private response body" },
17 { structuredContent: JSON.parse(json), content: "Ignored alternative body" },
18 [{ type: "text", text: json }],
19 { content: [{ text: { text: json } }], isError: false },
20 json,
21 ]) {
22 expect(compactDynamicToolOutput(value)).toEqual(metadata);
23 }
24 });
25
26 it("keeps nested thread identity and scalar task IDs without result bodies", () => {
27 const output = {
28 thread: { threadId: "thread-1", messages: ["Private message"] },
29 taskId: "task-1",
30 scheduledTaskId: "scheduled-1",
31 status: "failed",
32 summary: "A child failed; the tool itself succeeded.",
33 };
34 expect(compactDynamicToolOutput(output)).toEqual({
35 thread: { threadId: "thread-1" },
36 taskId: "task-1",
37 scheduledTaskId: "scheduled-1",
38 });
39 expect(output.thread.messages).toEqual(["Private message"]);
40 });
41
42 it("keeps explicit envelope failure while dropping error text and arbitrary output", () => {
43 for (const failure of [
44 { isError: true },
45 { is_error: true },
46 { _tag: "OrchestratorMcpFailure" },
47 { error: { message: "Private error" } },
48 ]) {
49 expect(
50 compactDynamicToolOutput({
51 ...failure,
52 structuredContent: { threadId: "thread-1", error: "Private details" },
53 }),
54 ).toEqual({ isError: true, threadId: "thread-1" });
55 }
56 expect(compactDynamicToolOutput("Private plain text")).toBeUndefined();
57 expect(compactDynamicToolOutput({ result: { body: "Private result" } })).toBeUndefined();
58 });
59
60 it("uses the first result data but combines explicit failure flags across content blocks", () => {
61 expect(
62 compactDynamicToolOutput([
63 { text: JSON.stringify({ threadId: "first" }) },
64 { text: JSON.stringify({ threadId: "second", isError: true, error: "Private error" }) },
65 ]),
66 ).toEqual({ threadId: "first", isError: true });
67 expect(
68 compactDynamicToolOutput([{ text: "{}" }, { text: JSON.stringify({ threadId: "second" }) }]),
69 ).toBeUndefined();
70 });
71
72 it("keeps complete thread creation batches and rollback markers without row bodies", () => {
73 expect(
74 compactDynamicToolOutput({
75 threads: [
76 { threadId: "created", status: "running", messages: ["Private message"] },
77 { threadId: "reverted", status: "rolled_back", error: "Private error" },
78 { status: "rolled_back" },
79 ],
80 }),
81 ).toEqual({
82 threads: [
83 { threadId: "created" },
84 { threadId: "reverted", status: "rolled_back" },
85 { status: "rolled_back" },
86 ],
87 });
88 expect(compactDynamicToolOutput({ threadId: "reverted", status: "rolled_back" })).toEqual({
89 threadId: "reverted",
90 status: "rolled_back",
91 });
92 expect(compactDynamicToolOutput({ threads: [] })).toEqual({ threads: [] });
93 });
94
95 it("omits incomplete or oversized creation evidence instead of returning a partial count", () => {
96 for (const threads of [
97 [{ threadId: "known" }, { title: "No confirmed ID" }],
98 Array.from({ length: 101 }, (_, index) => ({ threadId: `thread-${index}` })),
99 Array.from({ length: 100 }, (_, index) => ({ threadId: `${index}`.padEnd(256, "x") })),
100 [{ threadId: "x".repeat(257) }],
101 ]) {
102 expect(
103 compactDynamicToolOutput({
104 threadId: "must-not-be-counted-as-one",
105 status: "rolled_back",
106 taskId: "task-1",
107 threads,
108 }),
109 ).toEqual({ taskId: "task-1" });
110 }
111 const normalBatch = {
112 threads: Array.from({ length: 100 }, (_, index) => ({
113 threadId: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
114 })),
115 };
116 expect(compactDynamicToolOutput(normalBatch)).toEqual(normalBatch);
117 expect(
118 new TextEncoder().encode(JSON.stringify(compactDynamicToolOutput(normalBatch))).byteLength,
119 ).toBeLessThanOrEqual(8_192);
120 });
121
122 it("bounds parsing and envelope scanning while preserving an outer failure marker", () => {
123 const oversizedJson = JSON.stringify({ threadId: "hidden", body: "😄".repeat(5_000) });
124 for (const content of [
125 "x".repeat(1_000_000),
126 oversizedJson,
127 Array.from({ length: 33 }, () => ({ text: JSON.stringify({ threadId: "hidden" }) })),
128 { content: { content: { content: { content: { threadId: "too-deep" } } } } },
129 ]) {
130 expect(compactDynamicToolOutput({ isError: true, content })).toEqual({ isError: true });
131 }
132 expect(compactDynamicToolOutput({ threadId: "known", body: "x".repeat(1_000_000) })).toEqual({
133 threadId: "known",
134 });
135 });
136
137 it("is idempotent for compact metadata", () => {
138 const compact = compactDynamicToolOutput({
139 isError: true,
140 structuredContent: {
141 threadId: "thread-1",
142 threads: [{ threadId: "thread-1" }, { status: "rolled_back" }],
143 output: "Private output",
144 },
145 });
146 expect(compactDynamicToolOutput(compact)).toEqual(compact);
147 });
148});
149
150describe("toolOutputIndicatesFailure", () => {
151 it("preserves the command failure phrases across shells without copying the output", () => {
152 for (const text of [
153 "FILE NOT FOUND",
154 "No files found",
155 "ENOENT",
156 "No such file or directory",
157 "CommandNotFoundException",
158 "command not found",
159 "Cannot find path 'a' because it does not exist",
160 "The term 'example' is not recognized",
161 "is not recognized as the name of a cmdlet",
162 "A parameter cannot be found that matches parameter name",
163 "<exited with exit code 2>",
164 "Exited with exit code 1",
165 "exit code: 127",
166 ])
167 expect(toolOutputIndicatesFailure(text)).toBe(true);
168 });
169
170 it("does not mark successful exit codes or incomplete failure phrases", () => {
171 for (const text of [
172 "Done",
173 "exit code: 0",
174 "Exited with exit code 0",
175 "cannot find path",
176 "is not recognized",
177 ]) {
178 expect(toolOutputIndicatesFailure(text)).toBe(false);
179 }
180 });
181});
182
183describe("toolOutputImages", () => {
184 const mcpImage = { type: "image", data: "AAAA", mimeType: "image/png" };
185 const claudeImage = {
186 type: "image",
187 source: { type: "base64", media_type: "image/JPEG", data: "BBBB" },
188 };
189 const text = { type: "text", text: "{}" };
190
191 it("finds images in each place providers store them, in order", () => {
192 expect(toolOutputImages({ content: [text, claudeImage, mcpImage] })).toEqual([
193 { mimeType: "image/jpeg", data: "BBBB" },
194 { mimeType: "image/png", data: "AAAA" },
195 ]);
196 expect(toolOutputImages([mcpImage])).toEqual([{ mimeType: "image/png", data: "AAAA" }]);
197 expect(toolOutputImages(mcpImage)).toEqual([{ mimeType: "image/png", data: "AAAA" }]);
198 });
199
200 it("skips types the server must not serve inline", () => {
201 expect(
202 toolOutputImages([
203 { ...mcpImage, mimeType: "image/svg+xml" },
204 { ...mcpImage, mimeType: "text/html" },
205 { type: "image", source: { type: "url", url: "https://example.com/a.png" } },
206 ]),
207 ).toEqual([]);
208 });
209
210 it("stops after the first few images", () => {
211 const images = toolOutputImages(Array.from({ length: 10_000 }, () => mcpImage));
212 expect(images).toHaveLength(MAX_TOOL_OUTPUT_IMAGES);
213 });
214
215 it("drops the bytes but keeps each image's position", () => {
216 const omitted = omitToolOutputImageData({ content: [text, claudeImage, mcpImage] });
217 expect(omitted).toEqual({
218 content: [
219 text,
220 { type: "image", mimeType: "image/jpeg" },
221 { type: "image", mimeType: "image/png" },
222 ],
223 });
224 expect(toolOutputImages(omitted)).toEqual([
225 { mimeType: "image/jpeg" },
226 { mimeType: "image/png" },
227 ]);
228 });
229});