Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- CLI: reject junk between duration tokens and warn when malformed browser duration flags fall back to defaults. Thanks @devYRPauli!
- Browser: run long local Pro consultations in a detached worker while the CLI remains attached to its session log, so unexpected foreground termination cannot stop answer capture; Ctrl-C still cancels the worker. Thanks @Rokurolize!
- Browser: recognize ChatGPT's separate Pro effort control as the selected maximum effort for GPT-5.6 Sol when `--browser-thinking-time heavy` is requested. Thanks @Rokurolize!
- Gemini: type `.mp4`, `.mov`, and `.webm` uploads as video so Gemini receives them instead of silently discarding generic binary uploads. Thanks @mkubenka!
- Browser: wait for saved conversation turns to hydrate before retrying capture after a reload or reattach, and reject shell-only stop controls as recovery evidence. Thanks @pdurlej!

## 0.16.1 — 2026-07-23
Expand Down
24 changes: 22 additions & 2 deletions src/gemini-web/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,33 @@ const GEMINI_UPLOAD_MIME_TYPES: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".mov": "video/quicktime",
".mp4": "video/mp4",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".webm": "video/webm",
".webp": "image/webp",
};

/**
* Resolve the MIME type Gemini should be told an upload carries.
*
* Gemini silently discards uploads it cannot type: the run still reports the file as
* attached, but the model never receives it and answers as though nothing was sent.
* Anything falling back to `application/octet-stream` is therefore invisible to the model.
*
* Only formats confirmed to work against the Gemini web upload endpoint are listed. The
* endpoint also gates on the file extension, not just the declared type — an `.m4v` byte
* for byte identical to a working `.mp4`, and declared `video/mp4`, is still dropped — so
* entries here cannot be extrapolated from what the Gemini API documents.
*/
export function resolveGeminiUploadMimeType(filePath: string): string {
return (
GEMINI_UPLOAD_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"
);
}

function getNestedValue<T>(value: unknown, pathParts: Array<string | number>, fallback: T): T {
let current: unknown = value;
for (const part of pathParts) {
Expand Down Expand Up @@ -187,8 +208,7 @@ async function uploadGeminiFile(
const absPath = path.resolve(process.cwd(), filePath);
const data = await readFile(absPath);
const fileName = path.basename(absPath);
const mimeType =
GEMINI_UPLOAD_MIME_TYPES[path.extname(absPath).toLowerCase()] ?? "application/octet-stream";
const mimeType = resolveGeminiUploadMimeType(absPath);

const form = new FormData();
form.append("file", new Blob([data], { type: mimeType }), fileName);
Expand Down
45 changes: 45 additions & 0 deletions tests/gemini-upload-mime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import { resolveGeminiUploadMimeType } from "../src/gemini-web/client.js";

describe("resolveGeminiUploadMimeType", () => {
it("types the video formats Gemini can actually read", () => {
// Untyped uploads are silently discarded: the run reports the file as attached
// while the model answers as though nothing was sent.
expect(resolveGeminiUploadMimeType("/tmp/clip.mp4")).toBe("video/mp4");
expect(resolveGeminiUploadMimeType("/tmp/clip.mov")).toBe("video/quicktime");
expect(resolveGeminiUploadMimeType("/tmp/clip.webm")).toBe("video/webm");
});

it("keeps existing image and document types unchanged", () => {
expect(resolveGeminiUploadMimeType("/tmp/shot.png")).toBe("image/png");
expect(resolveGeminiUploadMimeType("/tmp/shot.jpg")).toBe("image/jpeg");
expect(resolveGeminiUploadMimeType("/tmp/shot.webp")).toBe("image/webp");
expect(resolveGeminiUploadMimeType("/tmp/doc.pdf")).toBe("application/pdf");
});

it("is case insensitive", () => {
expect(resolveGeminiUploadMimeType("/tmp/CLIP.MP4")).toBe("video/mp4");
expect(resolveGeminiUploadMimeType("/tmp/SHOT.PNG")).toBe("image/png");
});

it("leaves formats the endpoint rejects on the octet-stream fallback", () => {
// Audio and plain text are never delivered by this endpoint whatever type is
// declared, and .m4v/.mkv/.3gp are dropped or error out, so mapping them would
// only advertise support that does not exist.
for (const p of [
"/tmp/a.wav",
"/tmp/a.mp3",
"/tmp/a.m4a",
"/tmp/n.txt",
"/tmp/c.m4v",
"/tmp/c.mkv",
]) {
expect(resolveGeminiUploadMimeType(p)).toBe("application/octet-stream");
}
});

it("falls back to octet-stream for unknown extensions", () => {
expect(resolveGeminiUploadMimeType("/tmp/archive.xyz")).toBe("application/octet-stream");
expect(resolveGeminiUploadMimeType("/tmp/noext")).toBe("application/octet-stream");
});
});