Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Merge remote-tracking branch 'origin/main' into ochafik/pdf-viewer-li…
…veness

# Conflicts:
#	examples/pdf-server/server.test.ts
#	examples/pdf-server/server.ts
  • Loading branch information
ochafik committed Apr 1, 2026
commit 60c9bbbead08db0296926e4e546d2c76596705b6
288 changes: 288 additions & 0 deletions examples/pdf-server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,294 @@ describe("interact tool", () => {
});
});

describe("save_as", () => {
// Roundtrip tests need: writable scope, kick off interact WITHOUT awaiting
// (it blocks until the view replies), poll → submit → await. The poll()
// call also registers the uuid in viewsPolled, satisfying
// ensureViewerIsPolling — without it interact would hang ~8s and fail.

let tmpDir: string;
let savedDirs: Set<string>;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pdf-saveas-"));
savedDirs = new Set(allowedLocalDirs);
allowedLocalDirs.add(tmpDir); // make tmpDir a directory root → writable
});

afterEach(() => {
allowedLocalDirs.clear();
for (const x of savedDirs) allowedLocalDirs.add(x);
viewSourcePaths.clear();
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("no path, no source tracked → tells model to provide a path", async () => {
// Fresh UUID never seen by display_pdf → viewSourcePaths has no entry.
// Same condition as a remote (https://) PDF or a stale viewUUID.
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "saveas-nosource", action: "save_as" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("no local source file");
expect(firstText(r)).toContain("Provide an explicit `path`");
await client.close();
await server.close();
});

it("no path, source tracked, overwrite omitted → asks for confirmation", async () => {
const { server, client } = await connect();
const source = path.join(tmpDir, "original.pdf");
fs.writeFileSync(source, "%PDF-1.4\noriginal");
viewSourcePaths.set("saveas-noconfirm", source);

const r = await client.callTool({
name: "interact",
arguments: { viewUUID: "saveas-noconfirm", action: "save_as" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("overwrites the original");
expect(firstText(r)).toContain(source);
expect(firstText(r)).toContain("overwrite: true");
// Nothing enqueued, file untouched
expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\noriginal");
await client.close();
await server.close();
});

it("no path, source not writable → same gate as save button", async () => {
const { server, client } = await connect();
// Source outside any directory root → isWritablePath false → save button
// would be hidden in the viewer. save_as should refuse for the same reason.
const outside = path.join(os.tmpdir(), "saveas-outside.pdf");
fs.writeFileSync(outside, "x");
viewSourcePaths.set("saveas-buttongate", outside);

try {
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-buttongate",
action: "save_as",
overwrite: true,
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("not writable");
expect(firstText(r)).toContain("save button is hidden");
} finally {
fs.rmSync(outside, { force: true });
}
await client.close();
await server.close();
});

it("no path, overwrite: true → roundtrip overwrites the original", async () => {
const { server, client } = await connect();
const uuid = "saveas-original";
const source = path.join(tmpDir, "report.pdf");
fs.writeFileSync(source, "%PDF-1.4\noriginal contents");
viewSourcePaths.set(uuid, source);

const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "save_as", overwrite: true },
});

const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("save_as");
await client.callTool({
name: "submit_save_data",
arguments: {
requestId: cmds[0].requestId as string,
data: Buffer.from("%PDF-1.4\nannotated").toString("base64"),
},
});

const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect(firstText(r)).toContain(source);
expect(fs.readFileSync(source, "utf8")).toBe("%PDF-1.4\nannotated");

await client.close();
await server.close();
});

it("rejects non-absolute path", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-rel",
action: "save_as",
path: "relative.pdf",
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("absolute");
await client.close();
await server.close();
});

it("rejects non-writable path", async () => {
const { server, client } = await connect();
// Path outside any directory root → not writable. Validation is sync,
// so nothing is enqueued and the queue stays empty.
const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-nowrite",
action: "save_as",
path: "/somewhere/else/out.pdf",
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("not under a mounted directory root");
await client.close();
await server.close();
});

it("rejects existing file when overwrite is false (default)", async () => {
const { server, client } = await connect();
const target = path.join(tmpDir, "exists.pdf");
fs.writeFileSync(target, "old contents");

const r = await client.callTool({
name: "interact",
arguments: {
viewUUID: "saveas-exists",
action: "save_as",
path: target,
},
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("already exists");
expect(firstText(r)).toContain("overwrite: true");
// Existence check is sync — nothing enqueued, file untouched.
expect(fs.readFileSync(target, "utf8")).toBe("old contents");
await client.close();
await server.close();
});

it("full roundtrip: enqueue → poll → submit → file written", async () => {
const { server, client } = await connect();
const uuid = "saveas-roundtrip";
const target = path.join(tmpDir, "out.pdf");
const pdfBytes = "%PDF-1.4\nfake-annotated-contents\n%%EOF";

// interact blocks in waitForSaveData until submit_save_data resolves it
const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "save_as", path: target },
});

// Viewer polls → receives the save_as command with a requestId
const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("save_as");
const requestId = cmds[0].requestId as string;
expect(typeof requestId).toBe("string");

// Viewer submits bytes
const submit = await client.callTool({
name: "submit_save_data",
arguments: {
requestId,
data: Buffer.from(pdfBytes).toString("base64"),
},
});
expect(submit.isError).toBeFalsy();

// interact now unblocks with success
const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect(firstText(r)).toContain("Saved");
expect(firstText(r)).toContain(target);
expect(fs.readFileSync(target, "utf8")).toBe(pdfBytes);

await client.close();
await server.close();
});

it("overwrite: true replaces an existing file", async () => {
const { server, client } = await connect();
const uuid = "saveas-overwrite";
const target = path.join(tmpDir, "replace.pdf");
fs.writeFileSync(target, "old contents");

const interactPromise = client.callTool({
name: "interact",
arguments: {
viewUUID: uuid,
action: "save_as",
path: target,
overwrite: true,
},
});

const cmds = await poll(client, uuid);
const requestId = cmds[0].requestId as string;
await client.callTool({
name: "submit_save_data",
arguments: {
requestId,
data: Buffer.from("%PDF-1.4\nnew").toString("base64"),
},
});

const r = await interactPromise;
expect(r.isError).toBeFalsy();
expect(fs.readFileSync(target, "utf8")).toBe("%PDF-1.4\nnew");

await client.close();
await server.close();
});

it("propagates viewer-reported errors to the model", async () => {
const { server, client } = await connect();
const uuid = "saveas-viewerr";
const target = path.join(tmpDir, "wontwrite.pdf");

const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "save_as", path: target },
});

const cmds = await poll(client, uuid);
// Viewer hit an error building bytes → reports it instead of timing out
await client.callTool({
name: "submit_save_data",
arguments: {
requestId: cmds[0].requestId as string,
error: "pdf-lib choked on a comb field",
},
});

const r = await interactPromise;
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("pdf-lib choked on a comb field");
expect(fs.existsSync(target)).toBe(false);

await client.close();
await server.close();
});

it("submit_save_data with unknown requestId returns isError", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "submit_save_data",
arguments: { requestId: "never-created", data: "AAAA" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("No pending request");
await client.close();
await server.close();
});
});

describe("viewer liveness", () => {
// get_screenshot/get_text fail fast when the iframe never polled, instead
// of waiting 45s for a viewer that isn't there. Reproduces the case where
Expand Down
51 changes: 48 additions & 3 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,9 @@ function waitForPageData(
/**
* Wait for the viewer's first poll_pdf_commands call.
*
* Called before waitForPageData() so a viewer that never mounted fails in ~8s
* with a specific message instead of a generic 45s "Timeout waiting for page
* data" that gives no hint why.
* Called before waitForPageData() / waitForSaveData() so a viewer that never
* mounted fails in ~8s with a specific message instead of a generic 45s
* "Timeout waiting for ..." that gives no hint why.
*
* Intentionally does NOT touch pollWaiters: piggybacking on that single-slot
* Map races with poll_pdf_commands' batch-wait branch (which never cancels the
Expand All @@ -267,6 +267,41 @@ async function ensureViewerIsPolling(uuid: string): Promise<void> {
}
}

// =============================================================================
// Pending save_as Requests (request-response bridge via client)
// =============================================================================
//
// Same shape as get_pages: model's interact call blocks while the viewer
// builds annotated bytes and posts them back. Reuses GET_PAGES_TIMEOUT_MS
// (45s) — generous because pdf-lib reflow on a large doc can take seconds.

const pendingSaveRequests = new Map<string, (v: string | Error) => void>();

/**
* Wait for the viewer to build annotated PDF bytes and submit them as base64.
* Rejects on timeout, abort, or when the viewer reports an error.
*/
function waitForSaveData(
requestId: string,
signal?: AbortSignal,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const settle = (v: string | Error) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
pendingSaveRequests.delete(requestId);
v instanceof Error ? reject(v) : resolve(v);
};
const onAbort = () => settle(new Error("interact request cancelled"));
const timer = setTimeout(
() => settle(new Error("Timeout waiting for PDF bytes from viewer")),
GET_PAGES_TIMEOUT_MS,
);
signal?.addEventListener("abort", onAbort);
pendingSaveRequests.set(requestId, settle);
});
}

interface QueueEntry {
commands: PdfCommand[];
/** Timestamp of the most recent enqueue or dequeue */
Expand All @@ -287,6 +322,15 @@ const pollWaiters = new Map<string, () => void>();
*/
const viewsPolled = new Set<string>();

/**
* Resolved local file path per viewer UUID, for save_as without an explicit
* target. Only set for local files (remote PDFs have nothing to overwrite).
* Populated during display_pdf, cleared by the heartbeat sweep.
*
* Exported for tests.
*/
export const viewSourcePaths = new Map<string, string>();

/** Valid form field names per viewer UUID (populated during display_pdf) */
const viewFieldNames = new Map<string, Set<string>>();

Expand Down Expand Up @@ -333,6 +377,7 @@ function pruneStaleQueues(): void {
viewFieldNames.delete(uuid);
viewFieldInfo.delete(uuid);
viewsPolled.delete(uuid);
viewSourcePaths.delete(uuid);
stopFileWatch(uuid);
}
}
Expand Down
You are viewing a condensed version of this merge commit. You can view the full changes here.