fix(pdf-server): viewer liveness, 1:1 batch results, fullscreen jitter (#579)
* fix(pdf-server): fail fast on never-polled viewer + surface batch errors
interact's get_screenshot/get_text waited the full 45s for a viewer that
never mounted (host went idle before iframe reached startPolling), then
timed out with a generic message. Now tracks viewsPolled and fails in ~8s
with a recovery hint. Batch errors were also ordered success-first —
hosts that flatten isError to content[0] showed "Queued: Filled N fields"
and dropped the actual failure entirely. Viewer side: poll even when PDF
load throws, and catch connect() rejection instead of silently blanking.
* fix(pdf-server): squash batch errors into one block, warn on phantom annotation updates
Three issues from a hung interact in production:
1. Batch error swallowed by host flattening. The earlier fix unshifted the
error before successes, but the prefix went on top of THAT — so
content[0] was just "Batch failed at step 2/2 (get_screenshot):" with
the actual timeout text in content[1], which the host dropped. Now all
text joins into one block; images survive after.
2. update_annotations silent no-op. The viewer's processCommands does
`if (!existing) continue` for unknown ids. If a prior add_annotations
failed mid-batch the model gets a happy "Queued" while nothing renders.
Track ids server-side; warn (not error — user might have drawn the
annotation manually in the iframe).
3. Per-def isolation in the viewer. A throw in addAnnotation killed the
whole processCommands batch including any get_pages later in the queue,
so submit_page_data never fired and the server waited the full 45s.
Now each def gets its own try/catch.
Also: stderr logging on interact failure (only — not chatty per-call) so
the next hang shows step timing in mcp-server-*.log instead of nowhere.
* fix(pdf-server): images first on success too, not just on error
The host's content[0] flattening drops images on successful batches just
the same: [add_annotations, get_screenshot] → content = ["Queued: ...",
<image>] → model sees only the text. Observed in audit logs where the
exact same iframe + viewUUID returned an image when the batch was
[get_screenshot, get_screenshot] but only text when add_annotations
came first.
Stable-sort images to the front. The Queued lines still arrive, just
after what the model actually asked to see.
* Revert "fix(pdf-server): images first on success too, not just on error"
This reverts commit 129a2dcd7a04e5bd116e59c4d6fc211db4203e2e.
* refactor(pdf-server): 1:1 batch results, drop isError when there's something to lose
LocalAgentMode SDK collapses isError:true results to a bare string of
content[0].text. The squash-everything-into-one-block defense from
0dbb8ed3 worked but lost the positional contract — the model couldn't
tell which command produced which result.
Cleaner: content[i] is the result of commands[i]. If a step fails the
batch stops there with that slot's text starting with "ERROR", and
content.length tells the model how far it got. No isError on multi-step
failures — there's no way to set it without the SDK eating the prior
images. Single-command failures keep isError:true since there's nothing
before content[0] to lose; the SDK's flatten-to-content[0].text is
exactly the ERROR text we want shown.
Tool description documents the contract.
* fix(pdf-server): isError only for single-command failures, not failedAt===0
failedAt===0 in a multi-step batch still has a positional contract to
honor — content.length=1 vs commandList.length=3 is a signal the model
loses if the SDK flattens the array to a string. The ERROR text encodes
"step 1/3" but the simpler rule ("batches never set isError") is
easier to document and reason about.
* fix(pdf-viewer): read live displayMode in requestFitToContent, guard tiny measurements
currentDisplayMode defaults to "inline" and handleHostContextChanged
only writes it `if (ctx.displayMode)`. If the host's initial context
omits the field — or the update lands one tick after a renderPage() —
requestFitToContent measures a near-empty pageWrapper and sends ~85px
(toolbar + padding only), shrinking a fullscreen iframe to a sliver.
Workaround was exit + re-enter fullscreen to force a context update.
Read app.getHostContext().displayMode directly (always fresh), and
separately refuse to send when pageWrapper measures smaller than the
toolbar — that's never a real layout, just an early render.
* revert(pdf-server): drop viewAnnotationIds — wrong about source of truth
The viewer's annotationMap is baseline (loadBaselineAnnotations from the
PDF) + diff restored from localStorage + manual edits + model adds.
viewAnnotationIds only tracks the last category, so the warning fires
for any update to an annotation the model didn't itself add — including
the entire restored state.
That's not "best-effort" miss rate, it's structurally wrong about
where the data lives. Model would learn to ignore the warning. The
viewer-side log.error on the silent skip (mcp-app.ts:4251) is the right
layer for this.
This commit is contained in:
parent
9d55050a00
commit
800f6d08fd
1 changed files with 139 additions and 66 deletions
|
|
@ -2254,7 +2254,7 @@ URL: ${normalized}`,
|
|||
description: `Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms.
|
||||
IMPORTANT: viewUUID must be the exact UUID returned by display_pdf (e.g. "a1b2c3d4-..."). Do NOT use arbitrary strings.
|
||||
|
||||
**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially. TIP: End with \`get_screenshot\` to verify your changes.
|
||||
**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially; results are returned in the same order, one content item per command. If a command fails, the batch stops there and that command's slot contains text starting with \`ERROR\` — content.length tells you how far it got. TIP: End with \`get_screenshot\` to verify your changes.
|
||||
|
||||
**ANNOTATION** — add_annotations with array of annotation objects. Each needs: id (unique string), type, page (1-indexed).
|
||||
|
||||
|
|
@ -2449,9 +2449,21 @@ Example — add a signature image and a stamp, then screenshot to verify:
|
|||
};
|
||||
}
|
||||
|
||||
// Process commands sequentially, collecting all content parts
|
||||
// 1:1 content array — content[i] is the result of commands[i].
|
||||
//
|
||||
// For multi-step batches we do NOT set isError on a step failure:
|
||||
// LocalAgentMode SDK 2.1.87 collapses isError:true results to a
|
||||
// bare string of content[0].text, which would drop any images
|
||||
// from earlier successful steps. Instead, the failed step's slot
|
||||
// is text starting with "ERROR", and the batch stops there — the
|
||||
// model reads content.length to see how far it got.
|
||||
//
|
||||
// Single-command calls have no prior results to lose, so they
|
||||
// keep isError:true. The SDK's flatten-to-content[0].text is
|
||||
// exactly the ERROR text we want it to see.
|
||||
const allContent: ContentPart[] = [];
|
||||
let failedAt = -1;
|
||||
const t0 = Date.now();
|
||||
|
||||
for (let i = 0; i < commandList.length; i++) {
|
||||
const result = await processInteractCommand(
|
||||
|
|
@ -2460,27 +2472,48 @@ Example — add a signature image and a stamp, then screenshot to verify:
|
|||
extra.signal,
|
||||
);
|
||||
if (result.isError) {
|
||||
// Error content first. Some hosts flatten isError results to
|
||||
// content[0].text — if we push the error after prior successes,
|
||||
// the user sees "Queued: Filled 7 fields" with isError:true and
|
||||
// the actual failure is silently dropped.
|
||||
allContent.unshift(...result.content);
|
||||
const errText = result.content
|
||||
.map((c) => (c.type === "text" ? c.text : null))
|
||||
.filter((t) => t != null)
|
||||
.join(" — ");
|
||||
// Normalize the prefix — processInteractCommand's catch blocks
|
||||
// are inconsistent ("Error: ...", "add_annotations: ...", etc.)
|
||||
const stripped = errText.replace(/^error:\s*/i, "");
|
||||
allContent.push({
|
||||
type: "text",
|
||||
text:
|
||||
commandList.length > 1
|
||||
? `ERROR at step ${i + 1}/${commandList.length} (${commandList[i].action}): ${stripped}`
|
||||
: `ERROR: ${stripped}`,
|
||||
});
|
||||
failedAt = i;
|
||||
console.error(
|
||||
`[interact] uuid=${uuid} step ${i + 1}/${commandList.length} ` +
|
||||
`(${commandList[i].action}) failed after ${Date.now() - t0}ms: ${stripped}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
allContent.push(...result.content);
|
||||
}
|
||||
|
||||
if (failedAt >= 0 && commandList.length > 1) {
|
||||
allContent.unshift({
|
||||
type: "text",
|
||||
text: `Batch failed at step ${failedAt + 1}/${commandList.length} (${commandList[failedAt].action}):`,
|
||||
});
|
||||
// Squash multi-part successes (e.g. get_text returning multiple
|
||||
// text blocks) into one slot so 1:1 indexing holds. Preserve a
|
||||
// lone image as-is — that's the screenshot the model wants.
|
||||
if (result.content.length === 1) {
|
||||
allContent.push(result.content[0]);
|
||||
} else {
|
||||
const texts = result.content
|
||||
.map((c) => (c.type === "text" ? c.text : null))
|
||||
.filter((t) => t != null);
|
||||
allContent.push({ type: "text", text: texts.join("\n") });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: allContent,
|
||||
...(failedAt >= 0 ? { isError: true } : {}),
|
||||
// isError flattens to a string in some SDKs, losing the array
|
||||
// shape. Only set it when there was never going to be an array
|
||||
// — single command, no positional contract to break.
|
||||
...(failedAt >= 0 && commandList.length === 1
|
||||
? { isError: true }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue