| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | compactDynamicToolOutput, |
| 5 | MAX_TOOL_OUTPUT_IMAGES, |
| 6 | omitToolOutputImageData, |
| 7 | toolOutputImages, |
| 8 | toolOutputIndicatesFailure, |
| 9 | } from "./toolOutput.ts"; |
| 10 | |
| 11 | describe("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 | |
| 150 | describe("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 | |
| 183 | describe("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 | }); |