feat(FN-3338): fix project root resolution in registerExtensionProviders an
The merge adds an auto-reload setting (FN-3334) with UI controls in the settings modal, documentation, and a new version-check module, while also fixing a bug (FN-3338) where extension providers incorrectly resolved the project root when invoked from git worktrees — moving the project-root resolutio Fusion-Task-Id: FN-3338
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix extension provider registration using wrong directory when project runs outside engine's working directory.
|
||||||
@@ -22,6 +22,7 @@ const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
|||||||
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
||||||
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
||||||
const readCustomProvidersMock = vi.fn(() => []);
|
const readCustomProvidersMock = vi.fn(() => []);
|
||||||
|
const packageManagerCwdCapture = vi.fn();
|
||||||
|
|
||||||
// Route async `exec` through the `execSync` mock so the promisify bridge works.
|
// Route async `exec` through the `execSync` mock so the promisify bridge works.
|
||||||
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
|
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
|
||||||
@@ -96,6 +97,9 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
DefaultPackageManager: class {
|
DefaultPackageManager: class {
|
||||||
|
constructor(options: any) {
|
||||||
|
packageManagerCwdCapture(options?.cwd);
|
||||||
|
}
|
||||||
async resolve() {
|
async resolve() {
|
||||||
return packageManagerResolveMock();
|
return packageManagerResolveMock();
|
||||||
}
|
}
|
||||||
@@ -486,6 +490,46 @@ describe("createFnAgent", () => {
|
|||||||
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
|
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-3338: registerExtensionProviders receives resolved project root when cwd is a subdirectory", async () => {
|
||||||
|
// Simulate cwd being a subdirectory of the project. resolvePiExtensionProjectRoot
|
||||||
|
// walks up from /project/src/components checking each dir for .fusion.
|
||||||
|
existsSyncMock.mockImplementation((path) => {
|
||||||
|
const value = String(path);
|
||||||
|
return value === "/project/.fusion";
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createFnAgent } = await import("../pi.js");
|
||||||
|
|
||||||
|
await createFnAgent({
|
||||||
|
cwd: "/project/src/components",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
});
|
||||||
|
|
||||||
|
// registerExtensionProviders should receive the resolved project root,
|
||||||
|
// not the raw subdirectory cwd. This is verified by checking the
|
||||||
|
// DefaultPackageManager constructor received "/project" as cwd.
|
||||||
|
expect(packageManagerCwdCapture).toHaveBeenCalledWith("/project");
|
||||||
|
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3338: registerExtensionProviders falls back to cwd when no .fusion is found", async () => {
|
||||||
|
// No .fusion directory exists anywhere above cwd.
|
||||||
|
existsSyncMock.mockImplementation(() => false);
|
||||||
|
|
||||||
|
const { createFnAgent } = await import("../pi.js");
|
||||||
|
|
||||||
|
await createFnAgent({
|
||||||
|
cwd: "/unrelated/directory",
|
||||||
|
systemPrompt: "test",
|
||||||
|
tools: "readonly",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Falls back to the raw cwd when no .fusion is found
|
||||||
|
expect(packageManagerCwdCapture).toHaveBeenCalledWith("/unrelated/directory");
|
||||||
|
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers extension providers before resolving configured models", async () => {
|
it("registers extension providers before resolving configured models", async () => {
|
||||||
packageManagerResolveMock.mockResolvedValueOnce({
|
packageManagerResolveMock.mockResolvedValueOnce({
|
||||||
extensions: [{ enabled: true, path: "/extensions/zai-provider" }],
|
extensions: [{ enabled: true, path: "/extensions/zai-provider" }],
|
||||||
@@ -983,11 +1027,13 @@ describe("createFnAgent", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
DefaultPackageManager: class {
|
DefaultPackageManager: class {
|
||||||
|
constructor(options: any) {
|
||||||
|
packageManagerCwdCapture(options?.cwd);
|
||||||
|
}
|
||||||
async resolve() {
|
async resolve() {
|
||||||
return packageManagerResolveMock();
|
return packageManagerResolveMock();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
|
|
||||||
getAgentDir: () => "/mock-agent-dir",
|
getAgentDir: () => "/mock-agent-dir",
|
||||||
ModelRegistry: class {
|
ModelRegistry: class {
|
||||||
static create(...args: unknown[]) {
|
static create(...args: unknown[]) {
|
||||||
@@ -1068,11 +1114,13 @@ describe("createFnAgent", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
DefaultPackageManager: class {
|
DefaultPackageManager: class {
|
||||||
|
constructor(options: any) {
|
||||||
|
packageManagerCwdCapture(options?.cwd);
|
||||||
|
}
|
||||||
async resolve() {
|
async resolve() {
|
||||||
return packageManagerResolveMock();
|
return packageManagerResolveMock();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
|
|
||||||
getAgentDir: () => "/mock-agent-dir",
|
getAgentDir: () => "/mock-agent-dir",
|
||||||
ModelRegistry: class {
|
ModelRegistry: class {
|
||||||
static create(...args: unknown[]) {
|
static create(...args: unknown[]) {
|
||||||
@@ -1150,11 +1198,13 @@ describe("createFnAgent", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
DefaultPackageManager: class {
|
DefaultPackageManager: class {
|
||||||
|
constructor(options: any) {
|
||||||
|
packageManagerCwdCapture(options?.cwd);
|
||||||
|
}
|
||||||
async resolve() {
|
async resolve() {
|
||||||
return packageManagerResolveMock();
|
return packageManagerResolveMock();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
|
|
||||||
getAgentDir: () => "/mock-agent-dir",
|
getAgentDir: () => "/mock-agent-dir",
|
||||||
ModelRegistry: class {
|
ModelRegistry: class {
|
||||||
static create(...args: unknown[]) {
|
static create(...args: unknown[]) {
|
||||||
|
|||||||
@@ -1043,7 +1043,12 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
piLog.log(`createFnAgent called (tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
|
piLog.log(`createFnAgent called (tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
|
||||||
const authStorage = createFusionAuthStorage();
|
const authStorage = createFusionAuthStorage();
|
||||||
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||||
await registerExtensionProviders(options.cwd, modelRegistry);
|
|
||||||
|
// Resolve the project root early so extension providers, skill discovery,
|
||||||
|
// and resource loading all use the correct root when cwd is a worktree,
|
||||||
|
// subdirectory, or any path other than the project root itself.
|
||||||
|
const resolvedProjectRoot = getProjectRootFromWorktree(options.cwd) ?? resolvePiExtensionProjectRoot(options.cwd);
|
||||||
|
await registerExtensionProviders(resolvedProjectRoot, modelRegistry);
|
||||||
|
|
||||||
for (const provider of readCustomProviders()) {
|
for (const provider of readCustomProviders()) {
|
||||||
try {
|
try {
|
||||||
@@ -1109,11 +1114,8 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
}
|
}
|
||||||
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, worktreeProjectRoot);
|
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, worktreeProjectRoot);
|
||||||
|
|
||||||
// Resolve the project root for resource discovery (skills, settings, extensions).
|
// resolvedProjectRoot was computed above (before registerExtensionProviders)
|
||||||
// When cwd is a worktree (e.g., /project/.worktrees/task-branch) or any other
|
// and is reused here for resource loader and skill discovery.
|
||||||
// subdirectory, we walk up to find the project root containing .fusion/.
|
|
||||||
// This ensures skill/settings discovery works regardless of session cwd.
|
|
||||||
const resolvedProjectRoot = worktreeProjectRoot ?? resolvePiExtensionProjectRoot(options.cwd);
|
|
||||||
|
|
||||||
// Compaction is explicitly enabled to prevent context-window overflow during
|
// Compaction is explicitly enabled to prevent context-window overflow during
|
||||||
// long-running agent conversations (triage, execution, review, merge).
|
// long-running agent conversations (triage, execution, review, merge).
|
||||||
|
|||||||
Reference in New Issue
Block a user