





















@@ -5395,7 +5395,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
53955395expect(mockState.lastDispatchCtx?.MediaStaged).toBe(true);
53965396});
539753975398-it("wraps stageSandboxMedia infrastructure errors as 5xx UNAVAILABLE and cleans up media-store files", async () => {
5398+it("wraps stageSandboxMedia infrastructure errors as 5xx UNAVAILABLE for non-fallback refs and cleans up media-store files", async () => {
5399+// A non-PDF managed offload cannot fall back to a managed path, so an infra
5400+// staging error stays a retryable 5xx. (Managed PDFs fall back instead — see
5401+// the staging-throw fallback test below.) #90097
53995402createTranscriptFixture("openclaw-chat-send-stage-unavailable-");
54005403mockState.finalText = "ok";
54015404mockState.sessionEntry = {
@@ -5411,7 +5414,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
54115414},
54125415];
54135416mockState.savedMediaResults = [
5414-{ path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" },
5417+{
5418+path: "/home/user/.openclaw/media/inbound/report.bin",
5419+contentType: "application/octet-stream",
5420+},
54155421];
54165422mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" };
54175423const stageError = Object.assign(new Error("ENOSPC: no space left on device"), {
@@ -5422,7 +5428,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
54225428mockState.stageSandboxMediaError = stageError;
54235429const respond = vi.fn();
54245430const context = createChatContext();
5425-const pdf = Buffer.from("%PDF-1.4\n%µ¶\n1 0 obj\n<<>>\nendobj\n").toString("base64");
5431+const binPayload = Buffer.from("OPENCLAW-BINARY\n").toString("base64");
5426543254275433await runNonStreamingChatSend({
54285434 context,
@@ -5433,9 +5439,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
54335439attachments: [
54345440{
54355441type: "file",
5436-mimeType: "application/pdf",
5437-fileName: "report.pdf",
5438-content: pdf,
5442+mimeType: "application/octet-stream",
5443+fileName: "report.bin",
5444+content: binPayload,
54395445},
54405446],
54415447},
@@ -5518,7 +5524,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
55185524// absolute path, so a simple `stagedPaths.length === nonImage.length`
55195525// check could not detect when one of the files silently fell out (e.g. a
55205526// file between the RPC cap and the staging cap). Prestage must compare
5521-// the returned `staged` map against the input refs.
5527+// the returned `staged` map against the input refs. Non-PDF refs cannot fall
5528+// back to a managed path, so an incomplete stage stays a 5xx. (Managed PDFs
5529+// fall back instead — see the staging-skip fallback test below.) #90097
55225530createTranscriptFixture("openclaw-chat-send-partial-stage-");
55235531mockState.finalText = "ok";
55245532mockState.sessionEntry = {
@@ -5534,15 +5542,21 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
55345542},
55355543];
55365544mockState.savedMediaResults = [
5537-{ path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" },
5538-{ path: "/home/user/.openclaw/media/inbound/oversize.pdf", contentType: "application/pdf" },
5545+{
5546+path: "/home/user/.openclaw/media/inbound/report.bin",
5547+contentType: "application/octet-stream",
5548+},
5549+{
5550+path: "/home/user/.openclaw/media/inbound/data.bin",
5551+contentType: "application/octet-stream",
5552+},
55395553];
55405554mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" };
5541-mockState.stagedRelativePaths = ["media/inbound/report.pdf", "media/inbound/oversize.pdf"];
5542-mockState.unstagedSources = ["/home/user/.openclaw/media/inbound/oversize.pdf"];
5555+mockState.stagedRelativePaths = ["media/inbound/report.bin", "media/inbound/data.bin"];
5556+mockState.unstagedSources = ["/home/user/.openclaw/media/inbound/data.bin"];
55435557const respond = vi.fn();
55445558const context = createChatContext();
5545-const pdf = Buffer.from("%PDF-1.4\n").toString("base64");
5559+const binPayload = Buffer.from("OPENCLAW-BINARY\n").toString("base64");
5546556055475561await runNonStreamingChatSend({
55485562 context,
@@ -5551,8 +5565,18 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
55515565message: "read these",
55525566requestParams: {
55535567attachments: [
5554-{ type: "file", mimeType: "application/pdf", fileName: "report.pdf", content: pdf },
5555-{ type: "file", mimeType: "application/pdf", fileName: "oversize.pdf", content: pdf },
5568+{
5569+type: "file",
5570+mimeType: "application/octet-stream",
5571+fileName: "report.bin",
5572+content: binPayload,
5573+},
5574+{
5575+type: "file",
5576+mimeType: "application/octet-stream",
5577+fileName: "data.bin",
5578+content: binPayload,
5579+},
55565580],
55575581},
55585582expectBroadcast: false,
@@ -5634,6 +5658,188 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
56345658expect(mockState.deleteMediaBufferCalls).toEqual([]);
56355659});
563656605661+it("falls back to the managed path when sandbox staging throws for an already-managed PDF", async () => {
5662+// #90097: an already-managed inbound PDF below the staging cap normally
5663+// stages into the sandbox, but if staging throws (e.g. workspace mkdir
5664+// ENOSPC) the PDF must still reach the agent via its managed media path
5665+// instead of failing the send — host-side media-understanding reads it from
5666+// the media-store root.
5667+createTranscriptFixture("openclaw-chat-send-managed-pdf-stage-throw-");
5668+mockState.finalText = "ok";
5669+mockState.sessionEntry = {
5670+modelProvider: "test-provider",
5671+model: "vision-model",
5672+};
5673+mockState.modelCatalog = [
5674+{
5675+provider: "test-provider",
5676+id: "vision-model",
5677+name: "Vision model",
5678+input: ["text", "image"],
5679+},
5680+];
5681+mockState.savedMediaResults = [
5682+{ path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" },
5683+];
5684+mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" };
5685+mockState.stageSandboxMediaError = Object.assign(new Error("ENOSPC: no space left on device"), {
5686+code: "ENOSPC",
5687+});
5688+const respond = vi.fn();
5689+const context = createChatContext();
5690+// Small PDF (below the 5MB staging cap) so it takes the staging path, not the
5691+// oversized pass-through path.
5692+const pdf = Buffer.from("%PDF-1.4\n%µ¶\nendobj\n").toString("base64");
5693+5694+await runNonStreamingChatSend({
5695+ context,
5696+ respond,
5697+idempotencyKey: "idem-managed-pdf-stage-throw",
5698+message: "read this",
5699+requestParams: {
5700+attachments: [
5701+{ type: "file", mimeType: "application/pdf", fileName: "report.pdf", content: pdf },
5702+],
5703+},
5704+expectBroadcast: false,
5705+});
5706+5707+// Falls back to the absolute managed path; nothing staged (so no workspace
5708+// dir) and the media-store entry is preserved for host-side extraction.
5709+expect(mockState.lastDispatchCtx?.MediaPath).toBe(
5710+"/home/user/.openclaw/media/inbound/report.pdf",
5711+);
5712+expect(mockState.lastDispatchCtx?.MediaPaths).toEqual([
5713+"/home/user/.openclaw/media/inbound/report.pdf",
5714+]);
5715+expect(mockState.lastDispatchCtx?.MediaType).toBe("application/pdf");
5716+expect(mockState.lastDispatchCtx?.MediaWorkspaceDir).toBeUndefined();
5717+expect(mockState.lastDispatchCtx?.MediaStaged).toBe(true);
5718+expect(mockState.deleteMediaBufferCalls).toEqual([]);
5719+});
5720+5721+it("falls back to the managed path when sandbox staging silently skips an already-managed PDF", async () => {
5722+// #90097: stageSandboxMedia can silently skip a file (keeping its absolute
5723+// path) and return it absent from the staged map. An already-managed PDF in
5724+// that state falls back to its managed media path rather than failing the
5725+// send; the staged workspace dir is still carried for any files that landed.
5726+createTranscriptFixture("openclaw-chat-send-managed-pdf-stage-skip-");
5727+mockState.finalText = "ok";
5728+mockState.sessionEntry = {
5729+modelProvider: "test-provider",
5730+model: "vision-model",
5731+};
5732+mockState.modelCatalog = [
5733+{
5734+provider: "test-provider",
5735+id: "vision-model",
5736+name: "Vision model",
5737+input: ["text", "image"],
5738+},
5739+];
5740+mockState.savedMediaResults = [
5741+{ path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" },
5742+];
5743+mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" };
5744+// No stagedRelativePaths → staged map is empty and ctx.MediaPaths keeps the
5745+// absolute path, mirroring stageSandboxMedia silently skipping the file.
5746+const respond = vi.fn();
5747+const context = createChatContext();
5748+const pdf = Buffer.from("%PDF-1.4\n%µ¶\nendobj\n").toString("base64");
5749+5750+await runNonStreamingChatSend({
5751+ context,
5752+ respond,
5753+idempotencyKey: "idem-managed-pdf-stage-skip",
5754+message: "read this",
5755+requestParams: {
5756+attachments: [
5757+{ type: "file", mimeType: "application/pdf", fileName: "report.pdf", content: pdf },
5758+],
5759+},
5760+expectBroadcast: false,
5761+});
5762+5763+expect(mockState.lastDispatchCtx?.MediaPath).toBe(
5764+"/home/user/.openclaw/media/inbound/report.pdf",
5765+);
5766+expect(mockState.lastDispatchCtx?.MediaPaths).toEqual([
5767+"/home/user/.openclaw/media/inbound/report.pdf",
5768+]);
5769+expect(mockState.lastDispatchCtx?.MediaType).toBe("application/pdf");
5770+expect(mockState.lastDispatchCtx?.MediaWorkspaceDir).toBe("/sandbox/workspace");
5771+expect(mockState.lastDispatchCtx?.MediaStaged).toBe(true);
5772+expect(mockState.deleteMediaBufferCalls).toEqual([]);
5773+});
5774+5775+it("still fails the send when staging skips a non-PDF in a mixed managed batch", async () => {
5776+// #90097: the PDF fallback is per-ref. A managed PDF that stages does not
5777+// rescue a sibling non-PDF that silently fell out of staging; that batch must
5778+// still surface a retryable 5xx and clean up every offloaded entry.
5779+createTranscriptFixture("openclaw-chat-send-mixed-stage-skip-");
5780+mockState.finalText = "ok";
5781+mockState.sessionEntry = {
5782+modelProvider: "test-provider",
5783+model: "vision-model",
5784+};
5785+mockState.modelCatalog = [
5786+{
5787+provider: "test-provider",
5788+id: "vision-model",
5789+name: "Vision model",
5790+input: ["text", "image"],
5791+},
5792+];
5793+mockState.savedMediaResults = [
5794+{ path: "/home/user/.openclaw/media/inbound/report.pdf", contentType: "application/pdf" },
5795+{
5796+path: "/home/user/.openclaw/media/inbound/data.bin",
5797+contentType: "application/octet-stream",
5798+},
5799+];
5800+mockState.sandboxWorkspace = { workspaceDir: "/sandbox/workspace" };
5801+mockState.stagedRelativePaths = ["media/inbound/report.pdf", "media/inbound/data.bin"];
5802+mockState.unstagedSources = ["/home/user/.openclaw/media/inbound/data.bin"];
5803+const respond = vi.fn();
5804+const context = createChatContext();
5805+const pdf = Buffer.from("%PDF-1.4\n").toString("base64");
5806+const bin = Buffer.from("OPENCLAW-BINARY\n").toString("base64");
5807+5808+await runNonStreamingChatSend({
5809+ context,
5810+ respond,
5811+idempotencyKey: "idem-mixed-stage-skip",
5812+message: "read these",
5813+requestParams: {
5814+attachments: [
5815+{ type: "file", mimeType: "application/pdf", fileName: "report.pdf", content: pdf },
5816+{
5817+type: "file",
5818+mimeType: "application/octet-stream",
5819+fileName: "data.bin",
5820+content: bin,
5821+},
5822+],
5823+},
5824+expectBroadcast: false,
5825+waitFor: "none",
5826+});
5827+5828+expect(mockState.lastDispatchCtx).toBeUndefined();
5829+expect(respond).toHaveBeenCalledTimes(1);
5830+const [ok, payload, error] = lastRespondCall(respond) ?? [];
5831+expect(ok).toBe(false);
5832+expect(payload).toBeUndefined();
5833+expect(error?.code).toBe(ErrorCodes.UNAVAILABLE);
5834+expect(responseErrorMessage(error)).toMatch(/staging incomplete/i);
5835+// The whole batch is cleaned up — including the PDF that would have fallen
5836+// back on its own — because the non-PDF cannot be delivered.
5837+expect(mockState.deleteMediaBufferCalls.map((c) => c.id).toSorted()).toEqual([
5838+"saved-media",
5839+"saved-media",
5840+]);
5841+});
5842+56375843it("rejects sandbox-oversized non-image attachments as 4xx before staging", async () => {
56385844// Regression: resolveChatAttachmentMaxBytes defaults to 20MB, but
56395845// stageSandboxMedia caps each file at STAGED_MEDIA_MAX_BYTES (5MB) and
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。