diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad20be261..4594e96ce 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Session load now skips the recursive async blob-ref resolver for entries with no `blob:sha256:` references. A cheap synchronous precheck gates the walk per entry (preserving the previous per-entry initiation order under synchronous store mutation), so text-heavy histories no longer pay the `Promise.all` tree descent for every non-session entry ([#5922](https://github.com/can1357/oh-my-pi/issues/5922)). + ## [17.0.3] - 2026-07-17 ### Changed diff --git a/packages/coding-agent/src/session/session-loader.ts b/packages/coding-agent/src/session/session-loader.ts index 9950568ac..58e4a301f 100644 --- a/packages/coding-agent/src/session/session-loader.ts +++ b/packages/coding-agent/src/session/session-loader.ts @@ -271,10 +271,38 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke ); } +/** + * Cheap synchronous precheck: does this value's tree contain any `blob:sha256:` string? + * Early-exits on the first hit and allocates no promises, so blob-free entries skip the + * async {@link resolvePersistedBlobRefs} descent entirely. Conservative — a blob ref in a + * non-resolved position still returns true, which only costs an extra (no-op) walk. + */ +function containsBlobRef(value: unknown): boolean { + if (typeof value === "string") return isBlobRef(value); + if (Array.isArray(value)) { + for (const item of value) { + if (containsBlobRef(item)) return true; + } + return false; + } + if (typeof value !== "object" || value === null) return false; + for (const key in value) { + if (containsBlobRef((value as Record)[key])) return true; + } + return false; +} + export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise { - await Promise.all( - entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)), - ); + const pending: Promise[] = []; + // Interleave precheck + initiation per entry so a positive entry begins resolution at the same + // relative point as the old filter+map schedule (no scan-all-first pass that could observe a + // later entry before an earlier resolution mutates it). + for (const entry of entries) { + if (entry.type === "session") continue; + if (!containsBlobRef(entry)) continue; + pending.push(resolvePersistedBlobRefs(entry, blobStore)); + } + await Promise.all(pending); } /** diff --git a/packages/coding-agent/test/session-persistence-images.test.ts b/packages/coding-agent/test/session-persistence-images.test.ts index 4b1c39441..81d3a48f0 100644 --- a/packages/coding-agent/test/session-persistence-images.test.ts +++ b/packages/coding-agent/test/session-persistence-images.test.ts @@ -125,4 +125,46 @@ describe("session image persistence", () => { expect(resolvedImage?.data).toBe(data); expect(resolvedItem?.result).toBe(data); }); + + it("skips the async resolver for entries without blob refs while still resolving blob-ref entries", async () => { + using tempDir = TempDir.createSync("@session-blob-precheck-"); + const blobStore = new BlobStore(tempDir.path()); + let getCalls = 0; + const origGet = blobStore.get.bind(blobStore); + blobStore.get = async (hash: string) => { + getCalls++; + return origGet(hash); + }; + + const imageData = Buffer.alloc(1500, 7).toString("base64"); + const withImage = messageEntry({ + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [png(imageData)], + isError: false, + timestamp: 0, + } as unknown as ToolResultMessage); + const persistedWithImage = prepareEntryForPersistence(withImage, blobStore); + + const textOnly: FileEntry[] = Array.from({ length: 50 }, (_, i) => ({ + type: "message", + id: `text-${i}`, + parentId: i === 0 ? null : `text-${i - 1}`, + timestamp: new Date(0).toISOString(), + message: { role: "user", content: [text(`plain body ${i}`)], timestamp: 0 }, + })) as unknown as FileEntry[]; + + const loaded: FileEntry[] = [ + ...textOnly.map(entry => structuredClone(entry)), + structuredClone(persistedWithImage), + ]; + await resolveBlobRefsInEntries(loaded, blobStore); + + // The blob-ref entry resolves through BlobStore.get exactly once; the 50 text entries never touch it. + expect(getCalls).toBe(1); + const resolved = loaded[loaded.length - 1] as ToolResultEntry; + const resolvedImage = resolved.message.content.find((block): block is ImageContent => block.type === "image"); + expect(resolvedImage?.data).toBe(imageData); + }); });