Skip to content

Commit 29d018f

Browse files
authored
fix(ui): detect encoded media URL extensions (#103466)
1 parent bcf2798 commit 29d018f

7 files changed

Lines changed: 142 additions & 39 deletions

File tree

ui/src/lib/chat/message-normalizer.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,36 @@ describe("message-normalizer", () => {
377377
]);
378378
});
379379

380+
it("classifies encoded assistant MEDIA extensions", () => {
381+
const imageUrl = "https://cdn.example/render%2Epng?download=1";
382+
const videoUrl = "https://cdn.example/clip%2Emp4";
383+
const result = normalizeMessage({
384+
role: "assistant",
385+
content: `MEDIA:${imageUrl}\nMEDIA:${videoUrl}`,
386+
});
387+
388+
expect(result.content).toEqual([
389+
{
390+
type: "attachment",
391+
attachment: {
392+
url: imageUrl,
393+
kind: "image",
394+
label: "render%2Epng",
395+
mimeType: "image/png",
396+
},
397+
},
398+
{
399+
type: "attachment",
400+
attachment: {
401+
url: videoUrl,
402+
kind: "video",
403+
label: "clip%2Emp4",
404+
mimeType: "video/mp4",
405+
},
406+
},
407+
]);
408+
});
409+
380410
it("keeps valid local MEDIA paths as assistant attachments", () => {
381411
const result = normalizeMessage({
382412
role: "assistant",

ui/src/lib/chat/message-normalizer.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "../../../../src/chat/tool-content.js";
1313
import { splitMediaFromOutput } from "../../../../src/media/parse.js";
1414
import { parseInlineDirectives } from "../../../../src/utils/directive-tags.js";
15+
import { getMediaFileExtension } from "../media-file-extension.ts";
1516
import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts";
1617

1718
export function normalizeRoleForGrouping(role: string): string {
@@ -158,26 +159,8 @@ const MIME_BY_EXT: Record<string, string> = {
158159
zip: "application/zip",
159160
};
160161

161-
function getFileExtension(url: string): string | undefined {
162-
const trimmed = url.trim();
163-
if (!trimmed) {
164-
return undefined;
165-
}
166-
const source = (() => {
167-
try {
168-
if (/^https?:\/\//i.test(trimmed)) {
169-
return new URL(trimmed).pathname;
170-
}
171-
} catch {}
172-
return trimmed;
173-
})();
174-
const fileName = source.split(/[\\/]/).pop() ?? source;
175-
const match = /\.([a-zA-Z0-9]+)$/.exec(fileName);
176-
return match?.[1]?.toLowerCase();
177-
}
178-
179162
function mimeTypeFromUrl(url: string): string | undefined {
180-
const ext = getFileExtension(url);
163+
const ext = getMediaFileExtension(url);
181164
return ext ? MIME_BY_EXT[ext] : undefined;
182165
}
183166

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Control UI tests cover browser-safe media extension parsing.
2+
import { describe, expect, it } from "vitest";
3+
import { getMediaFileExtension } from "./media-file-extension.ts";
4+
5+
describe("getMediaFileExtension", () => {
6+
it.each([
7+
{ value: "https://cdn.example/render%2Emp4?download=1#preview", expected: "mp4" },
8+
{ value: "https://cdn.example/render%2Em%70%34", expected: "mp4" },
9+
{ value: "https://cdn.example/bad%ZZ/render%2Emp4", expected: "mp4" },
10+
{ value: "https://cdn.example/archive%2Fclip%2Emp4", expected: "mp4" },
11+
{ value: "https://cdn.example/archive%5Cclip%2Emp4", expected: "mp4" },
12+
{ value: "https://cdn.example/render.mp4%2Fpreview", expected: undefined },
13+
{ value: "https://cdn.example/render.mp4%5Cpreview", expected: undefined },
14+
{ value: "https://cdn.example/bad%ZZ%2Emp4", expected: undefined },
15+
{ value: "https://cdn.example/render%2Emp4/", expected: undefined },
16+
{ value: String.raw`C:\media\clip.MP4`, expected: "mp4" },
17+
] as const)("extracts $expected from $value", ({ value, expected }) => {
18+
expect(getMediaFileExtension(value)).toBe(expected);
19+
});
20+
});

ui/src/lib/media-file-extension.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Browser-safe media filename extension parsing shared by Control UI renderers.
2+
3+
/** Returns a lowercase extension without the leading dot. */
4+
export function getMediaFileExtension(value: string): string | undefined {
5+
const trimmed = value.trim();
6+
if (!trimmed) {
7+
return undefined;
8+
}
9+
let filename: string;
10+
try {
11+
if (/^https?:\/\//i.test(trimmed)) {
12+
const pathname = new URL(trimmed).pathname;
13+
filename = pathname.slice(pathname.lastIndexOf("/") + 1);
14+
try {
15+
// Match media-core: decode only the filename and keep encoded path
16+
// separators as filename data instead of turning them into boundaries.
17+
const decodable = filename.replace(/%2f/gi, "%252F").replace(/%5c/gi, "%255C");
18+
filename = decodeURIComponent(decodable);
19+
} catch {
20+
// Preserve the raw filename when its own percent encoding is malformed.
21+
}
22+
} else {
23+
filename = trimmed.split(/[\\/]/).pop() ?? trimmed;
24+
}
25+
} catch {
26+
filename = trimmed.split(/[\\/]/).pop() ?? trimmed;
27+
}
28+
return /\.([a-zA-Z0-9]+)$/.exec(filename)?.[1]?.toLowerCase();
29+
}

ui/src/pages/chat/chat-responsive.browser.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,43 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
644644
}
645645
});
646646

647+
it("renders encoded media extensions from assistant output and transcript fields", async () => {
648+
if (!realChatServer) {
649+
throw new Error("Expected the Control UI server to be ready");
650+
}
651+
const imageUrl = "https://cdn.example/render%2Epng?download=1";
652+
const videoUrl = "https://cdn.example/clip%2Emp4?download=1";
653+
const page = await openBrowserPage(1366, 900);
654+
try {
655+
await page.route("https://cdn.example/**", (route) => route.abort());
656+
await installMockGateway(page, {
657+
historyMessages: [
658+
{
659+
content: `MEDIA:${imageUrl}`,
660+
role: "assistant",
661+
timestamp: Date.UTC(2026, 6, 9, 10, 0),
662+
},
663+
{
664+
content: "Encoded transcript video",
665+
MediaPath: videoUrl,
666+
role: "user",
667+
timestamp: Date.UTC(2026, 6, 9, 10, 1),
668+
},
669+
],
670+
});
671+
await page.goto(`${realChatServer.baseUrl}chat`);
672+
673+
const image = page.locator("img.chat-message-image");
674+
const video = page.locator("video");
675+
await image.waitFor({ timeout: 10_000 });
676+
await video.waitFor({ timeout: 10_000 });
677+
expect(await image.getAttribute("src")).toBe(imageUrl);
678+
expect(await video.getAttribute("src")).toBe(videoUrl);
679+
} finally {
680+
await closeBrowserPage(page);
681+
}
682+
});
683+
647684
it.each([
648685
[393, 852],
649686
[1366, 900],

ui/src/pages/chat/components/chat-message.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,6 +1868,26 @@ describe("grouped chat rendering", () => {
18681868
expect(onAssistantAttachmentLoaded).toHaveBeenCalledTimes(2);
18691869
});
18701870

1871+
it("renders transcript video URLs with encoded extensions", () => {
1872+
const container = document.createElement("div");
1873+
const mediaUrl = "https://cdn.example/clip%2Emp4?download=1";
1874+
1875+
renderGroupedMessage(
1876+
container,
1877+
{
1878+
id: "user-encoded-video",
1879+
role: "user",
1880+
content: "",
1881+
MediaPath: mediaUrl,
1882+
timestamp: Date.now(),
1883+
},
1884+
"user",
1885+
{ showToolCalls: false },
1886+
);
1887+
1888+
expect(expectElement(container, "video", HTMLVideoElement).src).toBe(mediaUrl);
1889+
});
1890+
18711891
it("renders allowed transcript and content image variants", async () => {
18721892
resetAssistantAttachmentAvailabilityCacheForTest();
18731893
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {

ui/src/pages/chat/components/chat-message.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { resolveToolDisplay } from "../../../lib/chat/tool-display.ts";
4040
import { resolveUiHourCycleOptions } from "../../../lib/format.ts";
4141
import { formatCompactTokenCount } from "../../../lib/format.ts";
4242
import "../../../components/tooltip.ts";
43+
import { getMediaFileExtension } from "../../../lib/media-file-extension.ts";
4344
import { openExternalUrlSafe } from "../../../lib/open-external-url.ts";
4445
import { stripThinkingTags } from "../../../lib/strip-thinking-tags.ts";
4546
import { detectTextDirection } from "../../../lib/text-direction.ts";
@@ -250,23 +251,6 @@ function buildBase64ImageUrl(params: { data: string; mediaType?: string }): stri
250251
: `data:${params.mediaType ?? "image/png"};base64,${params.data}`;
251252
}
252253

253-
function getFileExtension(url: string): string | undefined {
254-
const source = (() => {
255-
try {
256-
const trimmed = url.trim();
257-
if (/^https?:\/\//i.test(trimmed)) {
258-
return new URL(trimmed).pathname;
259-
}
260-
} catch {
261-
// Fall back to the raw path when URL parsing fails.
262-
}
263-
return url;
264-
})();
265-
const fileName = source.split(/[\\/]/).pop() ?? source;
266-
const match = /\.([a-zA-Z0-9]+)$/.exec(fileName);
267-
return match?.[1]?.toLowerCase();
268-
}
269-
270254
function isImageTranscriptMediaPath(path: string, mediaType: unknown): boolean {
271255
if (typeof mediaType === "string" && mediaType.trim()) {
272256
const normalized = mediaType.trim().toLowerCase();
@@ -277,7 +261,7 @@ function isImageTranscriptMediaPath(path: string, mediaType: unknown): boolean {
277261
return false;
278262
}
279263
}
280-
const ext = getFileExtension(path);
264+
const ext = getMediaFileExtension(path);
281265
return (
282266
ext !== undefined &&
283267
["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic", "heif", "avif"].includes(ext)
@@ -288,7 +272,7 @@ function isAudioTranscriptMediaPath(path: string, mediaType: unknown): boolean {
288272
if (typeof mediaType === "string" && mediaType.trim().toLowerCase().startsWith("audio/")) {
289273
return true;
290274
}
291-
const ext = getFileExtension(path);
275+
const ext = getMediaFileExtension(path);
292276
return (
293277
ext !== undefined &&
294278
["aac", "flac", "m2a", "m4a", "mp3", "oga", "ogg", "opus", "wav"].includes(ext)
@@ -299,7 +283,7 @@ function isVideoTranscriptMediaPath(path: string, mediaType: unknown): boolean {
299283
if (typeof mediaType === "string" && mediaType.trim().toLowerCase().startsWith("video/")) {
300284
return true;
301285
}
302-
const ext = getFileExtension(path);
286+
const ext = getMediaFileExtension(path);
303287
return ext !== undefined && ["m4v", "mov", "mp4", "webm"].includes(ext);
304288
}
305289

0 commit comments

Comments
 (0)