From bfe75ef6f779e519c34bf3a2a415336d4f21ab76 Mon Sep 17 00:00:00 2001 From: serhiizghama Date: Fri, 26 Jun 2026 08:45:16 +0700 Subject: [PATCH 1/2] fix(eve): degrade missing attachment bytes on resume instead of failing the turn Signed-off-by: serhiizghama --- .../honor-missing-attachment-on-resume.md | 5 +++ .../eve/src/harness/attachment-staging.ts | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 .changeset/honor-missing-attachment-on-resume.md diff --git a/.changeset/honor-missing-attachment-on-resume.md b/.changeset/honor-missing-attachment-on-resume.md new file mode 100644 index 000000000..74806fa1d --- /dev/null +++ b/.changeset/honor-missing-attachment-on-resume.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Resuming a durable session that referenced an inbound file attachment no longer fails the turn when the staging sandbox has been torn down. The missing attachment now degrades to a text reference noting the file is unavailable, so the run continues instead of ending in `session.failed`. diff --git a/packages/eve/src/harness/attachment-staging.ts b/packages/eve/src/harness/attachment-staging.ts index 44858d7d1..e064f1f44 100644 --- a/packages/eve/src/harness/attachment-staging.ts +++ b/packages/eve/src/harness/attachment-staging.ts @@ -11,6 +11,7 @@ import { SandboxKey } from "#context/keys.js"; import { ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { fileDataToBytes } from "#internal/attachments/data.js"; import { EveAttachmentError } from "#internal/attachments/errors.js"; +import { createLogger } from "#internal/logging.js"; import { deserializeUrlFilePart, isSerializedUrlFilePart } from "#internal/attachments/url-refs.js"; import { decodeSandboxRef, @@ -28,6 +29,8 @@ import { toErrorMessage } from "#shared/errors.js"; */ export const ATTACHMENTS_ROOT = "/workspace/attachments"; +const log = createLogger("harness.attachment-staging"); + const UNSAFE_FILENAME_CHARS = /[^\w.-]+/g; const SHA_PREFIX_LENGTH = 16; @@ -229,10 +232,22 @@ async function hydrateMessageContent(content: unknown, sandbox: SandboxSession): } const bytes = await sandbox.readBinaryFile({ path: ref.path }); if (bytes === null) { - throw new Error( - `Sandbox-ref FilePart references missing file: "${ref.path}". ` + - "The staging pipeline invariant (every eve-sandbox: ref has bytes on disk) was violated.", + // Resuming a durable session can outlive the sandbox that staged + // an earlier turn's attachment: an ephemeral backend tears the + // sandbox down between turns, so the bytes behind a historical + // ref are gone. Degrade to a text reference instead of failing + // the whole turn — the run continues and the model is told the + // attachment is no longer reachable rather than chasing a path + // that has nothing behind it. + log.warn( + "sandbox-ref attachment bytes missing on hydration — degrading to text reference", + { + mediaType: ref.mediaType, + path: ref.path, + size: ref.size, + }, ); + return renderMissingSandboxRefAsTextPart(ref); } return { ...filePart, data: bytes, mediaType: ref.mediaType }; }), @@ -273,6 +288,20 @@ function renderSandboxRefAsTextPart(ref: SandboxRef): TextPart { return { text: `Attached file ${ref.path} (${ref.mediaType})`, type: "text" }; } +/** + * Renders a sandbox-resident attachment whose staged bytes are gone (the + * staging sandbox was torn down before the session resumed) as a + * {@link TextPart}. Unlike {@link renderSandboxRefAsTextPart}, this states + * the file is unreachable so the model does not try to read a path that no + * longer resolves. + */ +function renderMissingSandboxRefAsTextPart(ref: SandboxRef): TextPart { + return { + text: `Attached file ${ref.path} (${ref.mediaType}) is no longer available in this session.`, + type: "text", + }; +} + async function stageFilePart( part: FilePart, sandbox: SandboxSession, From b1be521f22dd7fc32ec6eecde35e7020c26f911c Mon Sep 17 00:00:00 2001 From: serhiizghama Date: Fri, 26 Jun 2026 08:45:18 +0700 Subject: [PATCH 2/2] test(eve): cover attachment hydration when staged bytes are gone on resume Signed-off-by: serhiizghama --- .../attachment-staging.integration.test.ts | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/eve/src/harness/attachment-staging.integration.test.ts b/packages/eve/src/harness/attachment-staging.integration.test.ts index a7243420c..76d058d31 100644 --- a/packages/eve/src/harness/attachment-staging.integration.test.ts +++ b/packages/eve/src/harness/attachment-staging.integration.test.ts @@ -611,12 +611,16 @@ describe("hydrateSandboxAttachments (integration)", () => { expect(hydrated).toBe(messages); }); - it("throws a descriptive error when an inlinable sandbox ref points at a missing file", async () => { + it("degrades to a text reference when an inlinable sandbox ref points at a missing file", async () => { + // Resuming a durable session whose staging sandbox was torn down + // leaves historical attachment refs pointing at bytes that are gone. + // Hydration must not fail the whole turn over it — it degrades to a + // text part so the run survives (#276). const sandbox = mockSandbox({ id: "sbx_missing" }); const runtime = createTestRuntime(); // Ref pointing at a path that was never written. Use an - // inlinable media type so the error path (byte read) fires — + // inlinable media type so the byte-read path fires — // non-inlinable refs never touch the sandbox. const danglingRef = new URL( "eve-sandbox:?path=%2Fworkspace%2Fattachments%2Fdeadbeefdeadbeef%2Fghost.png&size=5&type=image%2Fpng", @@ -624,6 +628,7 @@ describe("hydrateSandboxAttachments (integration)", () => { const messages = [ { content: [ + { type: "text", text: "what's in the image?" }, { data: danglingRef, filename: "/workspace/attachments/deadbeefdeadbeef/ghost.png", @@ -635,9 +640,53 @@ describe("hydrateSandboxAttachments (integration)", () => { }, ]; - await expect( - runtime.runAsSession({ sandbox }, async () => hydrateSandboxAttachments(messages)), - ).rejects.toThrow(/references missing file/); + const hydrated = await runtime.runAsSession({ sandbox }, async () => + hydrateSandboxAttachments(messages), + ); + + const hydratedContent = hydrated[0]?.content as Exclude; + // The leading text survives and the dangling file ref is replaced by + // an unavailability notice — no file part reaches the model. + expect(hydratedContent[0]).toEqual({ type: "text", text: "what's in the image?" }); + expect(hydratedContent[1]).toEqual({ + text: "Attached file /workspace/attachments/deadbeefdeadbeef/ghost.png (image/png) is no longer available in this session.", + type: "text", + }); + const fileParts = hydratedContent.filter((p) => (p as FilePart).type === "file"); + expect(fileParts).toHaveLength(0); + }); + + it("survives a resume after the staging sandbox was torn down (#276)", async () => { + // Stage an image into one sandbox, then hydrate the resulting + // ref-only message against a fresh sandbox — the same shape as + // resuming a durable session whose ephemeral sandbox is gone. The + // bytes are unreachable, but the turn must not fail. + const stagingSandbox = mockSandbox({ id: "sbx_resume_stage" }); + const runtime = createTestRuntime(); + + const stagedContent = (await runtime.runAsSession({ sandbox: stagingSandbox }, async () => + stageAttachmentsToSandbox([ + { type: "text", text: "describe the image" }, + { data: smallImageBytes, filename: "logo.png", mediaType: "image/png", type: "file" }, + ] as UserContent), + )) as UserContent; + + const refPart = (stagedContent as FilePart[]).find((p) => p.type === "file") as FilePart; + const stagedPath = refPart.filename as string; + + const messages = [{ content: stagedContent, role: "user" as const }]; + const freshSandbox = mockSandbox({ id: "sbx_resume_fresh" }); + const hydrated = await runtime.runAsSession({ sandbox: freshSandbox }, async () => + hydrateSandboxAttachments(messages), + ); + + const hydratedContent = hydrated[0]?.content as Exclude; + expect(hydratedContent[0]).toEqual({ type: "text", text: "describe the image" }); + expect(hydratedContent[1]).toEqual({ + text: `Attached file ${stagedPath} (image/png) is no longer available in this session.`, + type: "text", + }); + expect(hydratedContent.filter((p) => (p as FilePart).type === "file")).toHaveLength(0); }); it("does not touch the sandbox when every ref is non-inlinable — text references carry all the info", async () => {