fix: harden multi-file response detection so merge can't fail silently (#6516)

Co-authored-by: Reece Browne <[email protected]>
This commit is contained in:
Anthony Stirling
2026-06-04 18:17:36 +01:00
committed by GitHub
co-authored by Reece Browne
parent cb687fbf99
commit bd9ef0586b
9 changed files with 518 additions and 63 deletions
@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import { processMultiFileResponse } from "@app/utils/automationExecutor";
// Regression coverage for the automation-side mirror of the merge bug:
// non-canonical Content-Types previously misrouted a PDF into ZIP extraction
// and yielded a bogus `automation_*.zip`.
const PDF_BYTES = new Uint8Array([
0x25,
0x50,
0x44,
0x46,
0x2d,
0x31,
0x2e,
0x37, // "%PDF-1.7"
0x0a,
0x25,
0xe2,
0xe3,
0xcf,
0xd3,
0x0a,
]);
const inputFiles = [
new File(["fake"], "input.pdf", { type: "application/pdf" }),
];
async function run(contentType: string) {
return processMultiFileResponse(
new Blob([PDF_BYTES]),
{ "content-type": contentType },
inputFiles,
"automated_",
false,
);
}
describe("processMultiFileResponse (automation execution)", () => {
test('PDF body + "application/octet-stream" -> PDF, not .zip', async () => {
const result = await run("application/octet-stream");
expect(result.length).toBe(1);
expect(result[0].name).not.toMatch(/\.zip$/);
});
test('PDF body + "application/pdf;charset=UTF-8" -> PDF, not .zip', async () => {
const result = await run("application/pdf;charset=UTF-8");
expect(result.length).toBe(1);
expect(result[0].name).not.toMatch(/\.zip$/);
});
test('PDF body + "APPLICATION/PDF" -> PDF, not .zip', async () => {
const result = await run("APPLICATION/PDF");
expect(result.length).toBe(1);
expect(result[0].name).not.toMatch(/\.zip$/);
});
});
@@ -4,54 +4,48 @@ import { ToolId } from "@app/types/toolId";
import { AUTOMATION_CONSTANTS } from "@app/constants/automation";
import { AutomationFileProcessor } from "@app/utils/automationFileProcessor";
import { ToolType } from "@app/hooks/tools/shared/useToolOperation";
import { zipFileService } from "@app/services/zipFileService";
import { processResponse } from "@app/utils/toolResponseProcessor";
/**
* Process multi-file tool response (handles ZIP or single PDF responses)
*/
const processMultiFileResponse = async (
export const processMultiFileResponse = async (
responseData: Blob,
responseHeaders: any,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean,
): Promise<File[]> => {
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
if (
responseData.type === "application/pdf" ||
(responseHeaders && responseHeaders["content-type"] === "application/pdf")
) {
// Single PDF response - use processResponse to respect preserveBackendFilename
const processedFiles = await processResponse(
const contentTypeHeader = responseHeaders?.["content-type"];
const looksLikeZip = await zipFileService.isZipResponse(
responseData,
typeof contentTypeHeader === "string" ? contentTypeHeader : undefined,
);
if (!looksLikeZip) {
return processResponse(
responseData,
files,
filePrefix,
undefined,
preserveBackendFilename ? responseHeaders : undefined,
);
return processedFiles;
} else {
// ZIP response
const result =
await AutomationFileProcessor.extractAutomationZipFiles(responseData);
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
// Apply prefix to files, replacing any existing prefix
const processedFiles =
filePrefix && !preserveBackendFilename
? result.files.map((file) => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, "");
return new File([file], `${filePrefix}${nameWithoutPrefix}`, {
type: file.type,
});
})
: result.files;
return processedFiles;
}
const result =
await AutomationFileProcessor.extractAutomationZipFiles(responseData);
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
if (!filePrefix || preserveBackendFilename) {
return result.files;
}
return result.files.map((file) => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, "");
return new File([file], `${filePrefix}${nameWithoutPrefix}`, {
type: file.type,
});
});
};
/**
@@ -19,18 +19,6 @@ export interface AutomationProcessingResult {
}
export class AutomationFileProcessor {
/**
* Check if a blob is a ZIP file by examining its header
*/
static isZipFile(blob: Blob): boolean {
// This is a simple check - in a real implementation you might want to read the first few bytes
// For now, we'll rely on the extraction attempt and fallback
return (
blob.type === "application/zip" ||
blob.type === "application/x-zip-compressed"
);
}
/**
* Extract files from a ZIP blob during automation execution, with fallback for non-ZIP files
* Extracts all file types (PDFs, images, etc.) except HTML files which stay zipped