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
69 changes: 69 additions & 0 deletions src/tools/create-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,75 @@ describe("createGmailTools", () => {
await tools.dispose();
});

test("skips a thread that 404s instead of failing the page", async () => {
const tools = createGmailTools({
capabilities: testCapabilities(async (input) => {
const url = new URL(String(input));
if (url.pathname === "/gmail/v1/users/me/threads") {
return new Response(
JSON.stringify({ threads: [{ id: "thread-1" }, { id: "gone" }] }),
);
}
if (url.pathname.endsWith("/gone")) {
return new Response("{}", { status: 404, statusText: "Not Found" });
}
return new Response(JSON.stringify({ id: "thread-1", messages: [] }));
}),
});
const result = await tools.run(
{ id: "call-gone", name: "gmail_search_threads", arguments: {} },
new AbortController().signal,
);
expect(result.isError).toBeUndefined();
expect(result.content).toEqual({
data: { threads: [{ id: "thread-1", messages: [] }] },
});
await tools.dispose();
});

test("skips malformed base64 parts and decodes the declared charset", async () => {
const tools = createGmailTools({
capabilities: testCapabilities(
async () =>
new Response(
JSON.stringify({
id: "message-1",
payload: {
mimeType: "multipart/alternative",
parts: [
{
mimeType: "text/plain",
headers: [
{
name: "Content-Type",
value: 'text/plain; charset="ISO-8859-1"',
},
],
body: {
data: Buffer.from("café", "latin1").toString("base64url"),
},
},
{ mimeType: "text/html", body: { data: "!!!not-base64!!!" } },
],
},
}),
),
),
});
const result = await tools.run(
{
id: "call-charset",
name: "gmail_get_message",
arguments: { messageId: "message-1" },
},
new AbortController().signal,
);
expect(result.content).toEqual({
data: { id: "message-1", labelIds: [], plaintextBody: "café" },
});
await tools.dispose();
});

test("returns complete decoded text, HTML, and attachment metadata", async () => {
const plaintextBody = "a".repeat(70_000);
const fetchImpl = createFetchImpl(async (input) => {
Expand Down
16 changes: 11 additions & 5 deletions src/tools/create-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities";

import {
createGmailClient,
GmailApiError,
type GmailClient,
type GmailDraft,
type GmailFetch,
Expand Down Expand Up @@ -223,10 +224,13 @@ async function lookupThreads(
view: GmailThreadView,
signal: AbortSignal,
) {
const threads = Array.from<{
id: string;
messages: ReturnType<typeof toThreadListMessage>[];
}>({
const threads = Array.from<
| {
id: string;
messages: ReturnType<typeof toThreadListMessage>[];
}
| undefined
>({
length: threadIds.length,
});
let nextIndex = 0;
Expand All @@ -252,6 +256,8 @@ async function lookupThreads(
),
};
} catch (error) {
// A thread deleted between list and lookup drops out of the page.
if (error instanceof GmailApiError && error.status === 404) continue;
failed = true;
throw error;
}
Expand All @@ -263,7 +269,7 @@ async function lookupThreads(
worker,
),
);
return threads;
return threads.filter((thread) => thread !== undefined);
}

const HANDLERS = new Map<string, GmailToolHandler>([
Expand Down
26 changes: 21 additions & 5 deletions src/tools/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,27 @@ function headersByName(
return result;
}

function decodeBase64Url(value: string): string {
function decodeBase64Url(value: string): Uint8Array | undefined {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return new TextDecoder().decode(
Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)),
);
try {
return Uint8Array.from(atob(padded), (character) =>
character.charCodeAt(0),
);
} catch {
return undefined;
}
}

function textDecoder(part: GmailMessagePart): TextDecoder {
const contentType = headersByName(part.headers).get("content-type") ?? "";
const charset = /charset\s*=\s*"?([^";\s]+)/i.exec(contentType)?.[1];
try {
// bun-types narrows the label to three encodings; runtimes accept any WHATWG label.
return new TextDecoder(charset as Bun.Encoding | undefined);
} catch {
return new TextDecoder();
}
}

function collectParts(part: GmailMessagePart | undefined): GmailMessagePart[] {
Expand All @@ -96,7 +111,8 @@ function bodyForMimeType(
) {
return [];
}
return [decodeBase64Url(part.body.data)];
const bytes = decodeBase64Url(part.body.data);
return bytes === undefined ? [] : [textDecoder(part).decode(bytes)];
});
return values.length === 0 ? undefined : values.join("\n\n");
}
Expand Down
Loading