From b1a360ca0d793154d8dd57682f039bbc1e62be0f Mon Sep 17 00:00:00 2001 From: imrik Date: Fri, 30 Oct 2026 11:29:27 +0200 Subject: [PATCH 1/3] feat(sessions): send the session's artifacts with an import An imported session showed the chat but not the deliverable. The import now finds the files the session presented (Claude Code Artifact publishes, Codex app open_in_codex of a file), reads only those paths, and sends them as `artifacts` parts with an `artifact_manifest`, kept under the server's 95 MB request cap. Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 1 + .../cli/src/cli/commands/sessions/import.ts | 25 +- packages/cli/src/core/session-import/api.ts | 29 ++- .../cli/src/core/session-import/artifacts.ts | 216 ++++++++++++++++++ .../cli/tests/cli/sessions_import.spec.ts | 155 +++++++++++++ .../core/session-import-artifacts.spec.ts | 52 +++++ 6 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/core/session-import/artifacts.ts create mode 100644 packages/cli/tests/core/session-import-artifacts.spec.ts diff --git a/docs/commands.md b/docs/commands.md index e5d64b3c..80351297 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -54,6 +54,7 @@ Omitting the flag preserves existing main-app behavior. - A claude.ai link runs `claude --teleport -p "Reply with OK."` in a temp clone of this checkout's `origin`, strips that exchange, then deletes the clone and its `~/.claude/projects` folder. Tests swap the binary with the `claudeBin` test override. - **Push check.** For `--current` and the picker, the checkout's work must be on GitHub, because Base Code starts the branch from there. Uncommitted changes, unpushed commits, a branch with no upstream or a deleted one (`[gone]`) stop the command before the upload, so the one-time key stays valid for the re-run. `--skip-push-check` imports anyway and says the local changes aren't included. Outside a git checkout there is no check. A link (the changes live in the cloud session) and `--file` skip it. - **`source_branch`.** The current branch (`git rev-parse --abbrev-ref HEAD`, left out when detached) goes with the upload as a multipart field. The backend starts the new branch from it when it's on GitHub, otherwise from the branch recorded in the file, then the repo default. +- **Artifacts.** `core/session-import/artifacts.ts` finds the session's Claude Code `Artifact` publishes (`file_path`, no `action` or `publish`, no `asset`/`type_url`) and Codex `exec` calls that `open_in_codex` a `type: "file"` target, and reads only the paths those calls name (regular files). A deleted Claude Code file falls back to its last `Write` with no later `Edit`/`MultiEdit`; Codex has no fallback. In HTML, an `` that is a key of the publish's `files` becomes a `data:` URI of the mapped image. One file per path (the last call's id), newest 10, 10 MB each, html/htm/pdf/png/jpg/jpeg/gif/webp/txt/md/csv/json. Cloudflare refuses bodies over 100 MB and the server caps the request at 95 MB, so the oldest are dropped until session + artifacts + a 1 MB margin fit. They go as repeated `artifacts` parts plus an `artifact_manifest` JSON (`tool_use_id`, `file_name` per part, in order), and neither is sent when there are none. The command prints what it sends and each skip with its reason; the response's `imported_artifacts`/`skipped_artifacts` are printed when non-zero and are in the `--json` output with `sentArtifacts`/`unsentArtifacts`. - **`Idempotency-Key`.** Each run sends one fresh UUID on the upload, shared by any retry within the run, so a retried upload gets the first response back instead of creating a second branch. 409 `idempotency_key_in_use` means that first upload is still running. - **Per-command key auth.** `--token` is a one-time `b44imp_` key. The command is `requireAuth: false` and calls `ensureAuth` only without a key. The key goes through a bare `ky` call, never `base44Client`: its auth hook would replace the header, and its 401 handler would refresh and retry as the stored login. - Errors are rebuilt as plain messages an agent can relay, with no request or response body (the body is the conversation). The action replaces the reported command context so the link and `--token` never reach telemetry. diff --git a/packages/cli/src/cli/commands/sessions/import.ts b/packages/cli/src/cli/commands/sessions/import.ts index e77bd577..18fe712f 100644 --- a/packages/cli/src/cli/commands/sessions/import.ts +++ b/packages/cli/src/cli/commands/sessions/import.ts @@ -12,6 +12,7 @@ import { import { getBase44ApiUrl } from "@/core/config.js"; import { InvalidInputError } from "@/core/errors.js"; import { MAX_SESSION_BYTES, uploadSession } from "@/core/session-import/api.js"; +import { collectArtifacts } from "@/core/session-import/artifacts.js"; import { prepareSessionImages } from "@/core/session-import/images.js"; import { findCurrentSession, @@ -139,22 +140,44 @@ async function importAction( if (!options.token) await ensureAuth(ctx); + const { artifacts, skipped } = await collectArtifacts(content); ctx.log.info("This uploads the full conversation to Base Code."); + if (artifacts.length) { + ctx.log.info( + `Sending ${artifacts.length} artifact${artifacts.length === 1 ? "" : "s"}: ${artifacts.map((a) => a.fileName).join(", ")}`, + ); + } + for (const { fileName, reason } of skipped) { + ctx.log.warn(`Not sending ${fileName}: ${reason}.`); + } const imported = await ctx.runTask("Importing session", () => uploadSession(appId, content, { token: options.token, sourceBranch: git?.branch, + artifacts, }), ); const url = `${getBase44ApiUrl()}${imported.branchUrl}`; + const { importedArtifacts: saved, skippedArtifacts: refused } = imported; + const artifactLine = + saved || refused + ? `Artifacts: ${saved} saved${refused ? `, ${refused} not saved` : ""}.` + : undefined; return { outroMessage: [ outcome(imported), + ...(artifactLine ? [artifactLine] : []), ...(note ? [note] : []), `Open it: ${url}`, ].join("\n"), stdout: ctx.jsonMode - ? toJsonStdout({ ...imported, url, ...(note ? { note } : {}) }) + ? toJsonStdout({ + ...imported, + url, + sentArtifacts: artifacts.map((a) => a.fileName), + unsentArtifacts: skipped, + ...(note ? { note } : {}), + }) : undefined, }; } diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts index d195f1a4..56e5cce0 100644 --- a/packages/cli/src/core/session-import/api.ts +++ b/packages/cli/src/core/session-import/api.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { getAppClient } from "@/core/clients/index.js"; import { getBase44ApiUrl } from "@/core/config.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import type { SessionArtifact } from "@/core/session-import/artifacts.js"; export const MAX_SESSION_BYTES = 50 * 1024 * 1024; @@ -18,6 +19,8 @@ const SessionImportResponseSchema = z source_ref: z.string(), // Servers before re-import support leave it out. reimported: z.boolean().default(false), + imported_artifacts: z.number().default(0), + skipped_artifacts: z.number().default(0), }) .transform((data) => ({ branchId: data.branch_id, @@ -28,6 +31,8 @@ const SessionImportResponseSchema = z continued: data.continued, sourceRef: data.source_ref, reimported: data.reimported, + importedArtifacts: data.imported_artifacts, + skippedArtifacts: data.skipped_artifacts, })); type SessionImport = z.infer; @@ -135,12 +140,34 @@ async function toImportError( export async function uploadSession( appId: string, content: Uint8Array, - { token, sourceBranch }: { token?: string; sourceBranch?: string } = {}, + { + token, + sourceBranch, + artifacts = [], + }: { + token?: string; + sourceBranch?: string; + artifacts?: SessionArtifact[]; + } = {}, ): Promise { const form = new FormData(); form.append("file", new Blob([content]), "session.jsonl"); // The backend starts the new branch from this one when it's on GitHub. if (sourceBranch) form.append("source_branch", sourceBranch); + for (const { data, fileName } of artifacts) { + form.append("artifacts", new Blob([data]), fileName); + } + if (artifacts.length) { + form.append( + "artifact_manifest", + JSON.stringify( + artifacts.map((a) => ({ + tool_use_id: a.toolUseId, + file_name: a.fileName, + })), + ), + ); + } // One key per run, shared by any retry of it: a retried upload gets the first // response back instead of creating a second branch. const idempotencyKey = randomUUID(); diff --git a/packages/cli/src/core/session-import/artifacts.ts b/packages/cli/src/core/session-import/artifacts.ts new file mode 100644 index 00000000..1249f99a --- /dev/null +++ b/packages/cli/src/core/session-import/artifacts.ts @@ -0,0 +1,216 @@ +import { readFile, stat } from "node:fs/promises"; +import { basename, extname, resolve } from "node:path"; + +export interface SessionArtifact { + toolUseId: string; + fileName: string; + data: Buffer; +} + +interface SkippedArtifact { + fileName: string; + reason: string; +} + +type Entry = Record; + +interface Found { + toolUseId: string; + path: string; + // Claude Code only: the Artifact's supporting files, and a Write to fall back on. + claude: boolean; + files?: Record; +} + +const ALLOWED = new Set( + "html htm pdf png jpg jpeg gif webp txt md csv json".split(" "), +); +const IMAGE_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", +}; +const MAX_FILES = 10; +const MAX_BYTES = 10 * 1024 * 1024; +// Cloudflare refuses bodies over 100 MB; the server caps the request at 95 MB. +const MAX_REQUEST_BYTES = 95 * 1024 * 1024; +const MULTIPART_MARGIN = 1024 * 1024; +const OPENS_FILE = /open_in_codex\(\s*\{\s*["']?target["']?\s*:\s*\{([^}]*)\}/g; +const FILE_TARGET = /["']?type["']?\s*:\s*["']file["']/; +const TARGET_PATH = + /(?:^|[\s,])["']?path["']?\s*:\s*(["'])((?:\\.|(?!\1).)*)\1/; +const IMG_SRC = /(]*?\bsrc\s*=\s*)(["'])(.*?)\2/gi; + +const asRecord = (value: unknown): Entry => + value && typeof value === "object" ? (value as Entry) : {}; + +function toolUses(entry: Entry): Entry[] { + const content = asRecord(entry.message).content; + return (Array.isArray(content) ? content : []) + .map(asRecord) + .filter((block) => block.type === "tool_use"); +} + +function findArtifacts(entries: Entry[]): Found[] { + const found: Found[] = []; + for (const entry of entries) { + const cwd = typeof entry.cwd === "string" ? entry.cwd : ""; + if (entry.type === "assistant" && !entry.isSidechain) { + for (const block of toolUses(entry)) { + const input = asRecord(block.input); + if ( + block.name === "Artifact" && + typeof block.id === "string" && + typeof input.file_path === "string" && + (input.action ?? "publish") === "publish" && + !input.asset && + !input.type_url + ) { + const files = Object.fromEntries( + Object.entries(asRecord(input.files)) + .filter(([, local]) => typeof local === "string") + .map(([key, local]) => [key, resolve(cwd, local as string)]), + ); + found.push({ + toolUseId: block.id, + path: input.file_path, + claude: true, + files, + }); + } + } + } + const payload = asRecord(entry.payload); + if ( + entry.type === "response_item" && + payload.type === "custom_tool_call" && + payload.name === "exec" && + typeof payload.input === "string" && + typeof payload.call_id === "string" + ) { + for (const [, target = ""] of payload.input.matchAll(OPENS_FILE)) { + if (!FILE_TARGET.test(target)) continue; + const path = TARGET_PATH.exec(target)?.[2]?.replace(/\\(.)/g, "$1"); + if (path) + found.push({ toolUseId: payload.call_id, path, claude: false }); + } + } + } + return found; +} + +/** The last Write of `path` that no Edit changed afterwards. */ +function lastCleanWrite(entries: Entry[], path: string): Buffer | undefined { + let content: string | undefined; + for (const entry of entries) { + for (const block of toolUses(entry)) { + const input = asRecord(block.input); + if (input.file_path !== path) continue; + if (block.name === "Write" && typeof input.content === "string") { + content = input.content; + } else if (block.name === "Edit" || block.name === "MultiEdit") { + content = undefined; + } + } + } + return content === undefined ? undefined : Buffer.from(content); +} + +const readRegularFile = (path: string) => + stat(path).then( + (info) => (info.isFile() ? readFile(path) : undefined), + () => undefined, + ); + +async function inlineImages( + html: Buffer, + files: Record, +): Promise { + const text = html.toString("utf8"); + const uris = new Map(); + for (const [, , , src = ""] of text.matchAll(IMG_SRC)) { + const local = files[src]; + const mediaType = local && IMAGE_TYPES[extname(local).toLowerCase()]; + const data = mediaType && (await readRegularFile(local)); + if (data) + uris.set(src, `data:${mediaType};base64,${data.toString("base64")}`); + } + if (!uris.size) return html; + return Buffer.from( + text.replace(IMG_SRC, (match, prefix, quote, src) => { + const uri = uris.get(src); + return uri ? `${prefix}${quote}${uri}${quote}` : match; + }), + ); +} + +/** + * The files of the artifacts a session presented (Claude Code `Artifact` + * publishes, Codex app `open_in_codex` of a file), read from the paths those + * calls name and nowhere else. `budget` is what the request has left for them. + */ +export async function collectArtifacts( + session: Uint8Array, + budget = MAX_REQUEST_BYTES - session.length - MULTIPART_MARGIN, +): Promise<{ artifacts: SessionArtifact[]; skipped: SkippedArtifact[] }> { + const entries = Buffer.from(session) + .toString("utf8") + .split("\n") + .map((line) => { + try { + return asRecord(JSON.parse(line)); + } catch { + return {}; + } + }); + // The newest call per path, ordered by when it was last presented. + const byPath = new Map(); + for (const found of findArtifacts(entries)) { + byPath.delete(found.path); + byPath.set(found.path, found); + } + const newest = [...byPath.values()]; + const skipped: SkippedArtifact[] = newest + .slice(0, -MAX_FILES) + .map(({ path }) => ({ + fileName: basename(path), + reason: `only the newest ${MAX_FILES} are sent`, + })); + + const artifacts: SessionArtifact[] = []; + for (const found of newest.slice(-MAX_FILES)) { + const fileName = basename(found.path); + if (!ALLOWED.has(extname(fileName).slice(1).toLowerCase())) { + skipped.push({ fileName, reason: "its file type isn't supported" }); + continue; + } + let data = + (await readRegularFile(found.path)) ?? + (found.claude ? lastCleanWrite(entries, found.path) : undefined); + if (!data) { + skipped.push({ fileName, reason: "the file isn't on disk anymore" }); + continue; + } + if (found.files && /\.html?$/i.test(fileName)) { + data = await inlineImages(data, found.files); + } + if (data.length > MAX_BYTES) { + skipped.push({ fileName, reason: "it's larger than 10 MB" }); + continue; + } + artifacts.push({ toolUseId: found.toolUseId, fileName, data }); + } + + let total = artifacts.reduce((sum, { data }) => sum + data.length, 0); + while (total > budget) { + const oldest = artifacts.shift() as SessionArtifact; + total -= oldest.data.length; + skipped.push({ + fileName: oldest.fileName, + reason: "the upload would be over 95 MB", + }); + } + return { artifacts, skipped }; +} diff --git a/packages/cli/tests/cli/sessions_import.spec.ts b/packages/cli/tests/cli/sessions_import.spec.ts index 668f0c78..5fd1ee12 100644 --- a/packages/cli/tests/cli/sessions_import.spec.ts +++ b/packages/cli/tests/cli/sessions_import.spec.ts @@ -209,6 +209,7 @@ describe("sessions import command", () => { new RegExp(`/apps/${APP_ID}/editor/preview\\?branch=add-coupon-codes$`), ), }); + expect(uploads[0]?.body).not.toContain('name="artifact_manifest"'); }); it("tells a reused key to copy a new prompt, and leaves the stored login alone", async () => { @@ -563,6 +564,160 @@ describe("sessions import command", () => { expect(uploads[0]?.body).not.toContain(base64("mentioned-only")); }); + describe("artifacts", () => { + const lines = (entries: unknown[]) => + entries.map((entry) => JSON.stringify(entry)).join("\n"); + const assistant = (...content: unknown[]) => ({ + type: "assistant", + cwd: "/repo", + message: { role: "assistant", content }, + }); + const toolUse = (id: string, name: string, input: unknown) => ({ + type: "tool_use", + id, + name, + input, + }); + const publish = (id: string, file_path: string, extra = {}) => + toolUse(id, "Artifact", { + file_path, + description: "The checkout redesign.", + icon: "design", + ...extra, + }); + const user = { + type: "user", + cwd: "/repo", + message: { role: "user", content: "Design the checkout page" }, + }; + + it("sends a published file with its images inlined, and reports the counts", async () => { + const page = await plant( + "scratchpad/checkout.html", + '

Checkout

', + ); + const hero = await plant("scratchpad/hero.png", "hero-bytes"); + const file = await plant( + "s.jsonl", + lines([ + user, + assistant( + toolUse("toolu_01Q", "Artifact", { action: "quickstart" }), + publish("toolu_01A", page, { files: { "hero.png": hero } }), + ), + ]), + ); + mockUpload(200, { ...SUCCESS, imported_artifacts: 1 }); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(result.stderr).toContain("Sending 1 artifact: checkout.html"); + const body = uploads[0]?.body ?? ""; + expect(body).toContain('name="artifacts"; filename="checkout.html"'); + expect(body).toContain( + ``, + ); + expect(body).toContain( + '[{"tool_use_id":"toolu_01A","file_name":"checkout.html"}]', + ); + expect(JSON.parse(result.stdout)).toMatchObject({ + importedArtifacts: 1, + skippedArtifacts: 0, + sentArtifacts: ["checkout.html"], + unsentArtifacts: [], + }); + }); + + it("falls back to the last Write of a deleted file, but not to one edited after", async () => { + const written = join(home(), "gone/pricing.html"); + const edited = join(home(), "gone/plan.html"); + const file = await plant( + "s.jsonl", + lines([ + user, + assistant( + toolUse("w1", "Write", { file_path: written, content: "old" }), + toolUse("w2", "Write", { + file_path: written, + content: "

v2

", + }), + toolUse("w3", "Write", { file_path: edited, content: "draft" }), + toolUse("e1", "Edit", { + file_path: edited, + old_string: "draft", + new_string: "final", + }), + publish("toolu_pricing", written), + publish("toolu_plan", edited), + ), + ]), + ); + mockUpload(); + + const result = await importFile(file); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + "Not sending plan.html: the file isn't on disk anymore.", + ); + const body = uploads[0]?.body ?? ""; + expect(body).toContain("

v2

"); + expect(body).not.toContain('filename="plan.html"'); + expect(body).toContain( + '[{"tool_use_id":"toolu_pricing","file_name":"pricing.html"}]', + ); + }); + + it("sends a file the Codex app opened, once, with the last call's id", async () => { + const page = await plant( + ".codex/visualizations/2026/10/30/thread-a/mcp-connections/index.html", + "

MCP connections

", + ); + const exec = (call_id: string, input: string) => ({ + type: "response_item", + payload: { type: "custom_tool_call", name: "exec", call_id, input }, + }); + const file = await plant( + "rollout-artifacts.jsonl", + lines([ + { type: "session_meta", payload: { cwd: "/repo" } }, + exec( + "call_1", + `text(await tools.mcp__codex_app__open_in_codex({target:{type:"file",path:"${page}"}}));`, + ), + exec( + "call_2", + `text(await tools.mcp__codex_app__open_in_codex({ "target": { "path": "${page}", "type": "file" } }));`, + ), + exec( + "call_3", + `text(await tools.mcp__codex_app__open_in_codex({target:{type:"browser",url:"http://localhost:3000"}}));`, + ), + ]), + ); + mockUpload(200, { ...SUCCESS, source: "codex" }); + + t.expectResult(await importFile(file)).toSucceed(); + + const body = uploads[0]?.body ?? ""; + expect(body).toContain("

MCP connections

"); + expect(body).toContain( + '[{"tool_use_id":"call_2","file_name":"index.html"}]', + ); + }); + }); + it("rejects a claude.ai 'Copy link' address", async () => { const result = await t.run( "sessions", diff --git a/packages/cli/tests/core/session-import-artifacts.spec.ts b/packages/cli/tests/core/session-import-artifacts.spec.ts new file mode 100644 index 00000000..55d8405e --- /dev/null +++ b/packages/cli/tests/core/session-import-artifacts.spec.ts @@ -0,0 +1,52 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { collectArtifacts } from "@/core/session-import/artifacts.js"; + +describe("collectArtifacts", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "b44-artifacts-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("drops the oldest artifacts until the upload fits the request budget", async () => { + const entries = []; + for (const [i, name] of ["a.html", "b.pdf", "c.png"].entries()) { + const path = join(dir, name); + await writeFile(path, "x".repeat(400)); + entries.push({ + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: `toolu_${i}`, + name: "Artifact", + input: { file_path: path, description: name }, + }, + ], + }, + }); + } + const session = Buffer.from( + entries.map((entry) => JSON.stringify(entry)).join("\n"), + ); + + const { artifacts, skipped } = await collectArtifacts(session, 900); + + expect(artifacts.map((a) => [a.toolUseId, a.fileName])).toEqual([ + ["toolu_1", "b.pdf"], + ["toolu_2", "c.png"], + ]); + expect(skipped).toEqual([ + { fileName: "a.html", reason: "the upload would be over 95 MB" }, + ]); + }); +}); From ba6ae1e05a54e5e477e15816ce6882187de66ab2 Mon Sep 17 00:00:00 2001 From: imrik Date: Fri, 30 Oct 2026 11:42:33 +0200 Subject: [PATCH 2/3] test(sessions): escape the Codex fixture path like a real JS string Raw Windows paths in the fixture's JS string lost their backslashes. Co-Authored-By: Claude Opus 5.5 --- packages/cli/tests/cli/sessions_import.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/cli/sessions_import.spec.ts b/packages/cli/tests/cli/sessions_import.spec.ts index 5fd1ee12..7b9bbf19 100644 --- a/packages/cli/tests/cli/sessions_import.spec.ts +++ b/packages/cli/tests/cli/sessions_import.spec.ts @@ -694,11 +694,11 @@ describe("sessions import command", () => { { type: "session_meta", payload: { cwd: "/repo" } }, exec( "call_1", - `text(await tools.mcp__codex_app__open_in_codex({target:{type:"file",path:"${page}"}}));`, + `text(await tools.mcp__codex_app__open_in_codex({target:{type:"file",path:${JSON.stringify(page)}}}));`, ), exec( "call_2", - `text(await tools.mcp__codex_app__open_in_codex({ "target": { "path": "${page}", "type": "file" } }));`, + `text(await tools.mcp__codex_app__open_in_codex({ "target": { "path": ${JSON.stringify(page)}, "type": "file" } }));`, ), exec( "call_3", From 345fab13e9e7721a88ad5281a282a3795be777e0 Mon Sep 17 00:00:00 2001 From: imrik Date: Sun, 1 Nov 2026 09:21:24 +0200 Subject: [PATCH 3/3] fix(sessions): say which artifact images aren't on disk anymore They were dropped from the inlined HTML without a word. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/core/session-import/artifacts.ts | 10 +++++++++- packages/cli/tests/cli/sessions_import.spec.ts | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/core/session-import/artifacts.ts b/packages/cli/src/core/session-import/artifacts.ts index 1249f99a..bea31edd 100644 --- a/packages/cli/src/core/session-import/artifacts.ts +++ b/packages/cli/src/core/session-import/artifacts.ts @@ -127,6 +127,7 @@ const readRegularFile = (path: string) => async function inlineImages( html: Buffer, files: Record, + missing: string[], ): Promise { const text = html.toString("utf8"); const uris = new Map(); @@ -136,6 +137,7 @@ async function inlineImages( const data = mediaType && (await readRegularFile(local)); if (data) uris.set(src, `data:${mediaType};base64,${data.toString("base64")}`); + else if (local) missing.push(src); } if (!uris.size) return html; return Buffer.from( @@ -194,7 +196,13 @@ export async function collectArtifacts( continue; } if (found.files && /\.html?$/i.test(fileName)) { - data = await inlineImages(data, found.files); + const missing: string[] = []; + data = await inlineImages(data, found.files, missing); + for (const src of new Set(missing)) + skipped.push({ + fileName: src, + reason: `this image in ${fileName} isn't on disk anymore`, + }); } if (data.length > MAX_BYTES) { skipped.push({ fileName, reason: "it's larger than 10 MB" }); diff --git a/packages/cli/tests/cli/sessions_import.spec.ts b/packages/cli/tests/cli/sessions_import.spec.ts index 7b9bbf19..60ea6445 100644 --- a/packages/cli/tests/cli/sessions_import.spec.ts +++ b/packages/cli/tests/cli/sessions_import.spec.ts @@ -594,7 +594,7 @@ describe("sessions import command", () => { it("sends a published file with its images inlined, and reports the counts", async () => { const page = await plant( "scratchpad/checkout.html", - '

Checkout

', + '

Checkout

', ); const hero = await plant("scratchpad/hero.png", "hero-bytes"); const file = await plant( @@ -603,7 +603,9 @@ describe("sessions import command", () => { user, assistant( toolUse("toolu_01Q", "Artifact", { action: "quickstart" }), - publish("toolu_01A", page, { files: { "hero.png": hero } }), + publish("toolu_01A", page, { + files: { "hero.png": hero, "gone.png": `${hero}.deleted` }, + }), ), ]), ); @@ -623,6 +625,9 @@ describe("sessions import command", () => { t.expectResult(result).toSucceed(); expect(result.stderr).toContain("Sending 1 artifact: checkout.html"); + expect(result.stderr).toContain( + "Not sending gone.png: this image in checkout.html isn't on disk anymore.", + ); const body = uploads[0]?.body ?? ""; expect(body).toContain('name="artifacts"; filename="checkout.html"'); expect(body).toContain( @@ -635,7 +640,12 @@ describe("sessions import command", () => { importedArtifacts: 1, skippedArtifacts: 0, sentArtifacts: ["checkout.html"], - unsentArtifacts: [], + unsentArtifacts: [ + { + fileName: "gone.png", + reason: "this image in checkout.html isn't on disk anymore", + }, + ], }); });