diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 92dc08e2e..337f845cc 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -1005,10 +1005,14 @@ async function collectExtensionModules(entryRealPath: string): Promise { +async function ensureExtensionGraphHook(entryRealPath: string): Promise | undefined> { if (hookedExtensionEntries.has(entryRealPath)) { - return; + return undefined; } hookedExtensionEntries.add(entryRealPath); @@ -1032,6 +1036,7 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise { }); }, }); + return modules; } /** @@ -1050,9 +1055,16 @@ export async function loadLegacyPiModule(resolvedPath: string): Promise // `bun link`/pnpm installs) so the rewrite filter matches the path Bun // actually hands the hook. const entryRealPath = await realpathOrSelf(path.resolve(resolvedPath)); - await ensureExtensionGraphHook(entryRealPath); - // `?mtime` busts Bun's module cache so repeat loads pick up edited source. - return import(`${toImportSpecifier(entryRealPath)}?mtime=${Date.now()}`); + const pendingSources = await ensureExtensionGraphHook(entryRealPath); + try { + // `?mtime` busts Bun's module cache so repeat loads pick up edited source. + return await import(`${toImportSpecifier(entryRealPath)}?mtime=${Date.now()}`); + } finally { + // Drop whatever the initial import didn't consume: graph modules only + // reached by lazy dynamic imports must be read from disk at their actual + // import time, not served from this load-time snapshot. + pendingSources?.clear(); + } } function getLoader(path: string): "js" | "jsx" | "ts" | "tsx" { diff --git a/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts b/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts index 794a4ad14..6383ce7b4 100644 --- a/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts +++ b/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, spyOn } from "b import * as fs from "node:fs"; import * as path from "node:path"; import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { loadLegacyPiModule } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat"; import { TempDir } from "@oh-my-pi/pi-utils"; import type { BunFile } from "bun"; @@ -98,4 +99,32 @@ export default function(pi) { checkReadCount(path.join(extDir, `mod-${i}.ts`)); } }); + + it("should read graph modules skipped by the initial import from disk at import time", async () => { + const cwd = tempDir.absolute(); + const extDir = path.join(cwd, "ext"); + fs.mkdirSync(extDir, { recursive: true }); + + const lazyPath = path.join(extDir, "lazy.ts"); + fs.writeFileSync(lazyPath, `export const value = "before";\n`, "utf-8"); + + // The fixture's dynamic import is the loading boundary under test: the + // graph scan collects `./lazy.ts` at load time, but nothing imports it + // until `readLazy()` runs. + const entryPath = path.join(extDir, "index.ts"); + const entryContent = `export async function readLazy(): Promise { + const mod = await import("./lazy.ts"); + return mod.value; +} +`; + fs.writeFileSync(entryPath, entryContent, "utf-8"); + + const ns = (await loadLegacyPiModule(entryPath)) as { readLazy(): Promise }; + + // Edit the module after load but before its first import: the loader + // must serve the on-disk content, not a stale load-time snapshot. + fs.writeFileSync(lazyPath, `export const value = "after";\n`, "utf-8"); + + expect(await ns.readLazy()).toBe("after"); + }); });