fix: restore claude-cli work silently dropped by FN-2370 squash and add audit guardrails
The FN-2370 auto-resolved squash (7c1a1c36c) reverted three commits' worth
of refinements to the claude-cli provider because the branch contained
rebased duplicates of commits already on main. The auto-resolver picked
the older side line-by-line and dropped the newer.
Restored:
- /api/models filter logic (was inverted; emptied every model picker)
- Claude Opus 4.7 catalog entry in pi-claude-cli
- Provider card status text and toast messages (no longer claim a restart
is needed — the extension is always-loaded now)
- POST /api/auth/claude-cli returns restartRequired: false
Prevention:
- Regression tests on the /api/models useClaudeCli filter
- scripts/audit-squash-merge.mjs flags duplicate-cherry-pick risk and
touched-file overlap on any squash commit
- AGENTS.md documents the rebase-before-squash rule and requires the
merging agent to run the audit and triage every flagged item itself
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -291,11 +291,10 @@ function ClaudeCliStatusLine({
|
||||
</small>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<small className="settings-muted">
|
||||
Enabled. Restart Fusion to complete activation.
|
||||
</small>
|
||||
);
|
||||
// Enabled but `ready` is false and we have no specific reason — usually a
|
||||
// transient state after flipping the toggle before the first probe
|
||||
// completes.
|
||||
return <small className="settings-muted">Enabled. Validating…</small>;
|
||||
}
|
||||
|
||||
function ClaudeCliActionToast({
|
||||
@@ -317,9 +316,9 @@ function ClaudeCliActionToast({
|
||||
return (
|
||||
<p className="onboarding-helper-text">
|
||||
{verb}.{" "}
|
||||
{action.restartRequired
|
||||
? "Restart Fusion to activate the routing change."
|
||||
: "No further action needed."}
|
||||
{action.kind === "enabled"
|
||||
? "Claude-CLI-routed models are now visible in the model picker."
|
||||
: "Claude-CLI-routed models are hidden from the model picker."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -700,24 +700,21 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
describe("Experimental Features section", () => {
|
||||
const openExperimentalFeaturesSection = async () => {
|
||||
const sectionLabel = await screen.findByText("Experimental Features");
|
||||
await userEvent.click(sectionLabel);
|
||||
};
|
||||
|
||||
it("renders the Experimental Features section in the sidebar", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Experimental Features")).toBeDefined();
|
||||
expect(await screen.findByText("Experimental Features")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows known experimental features (Insights, Roadmaps) even when no custom features are configured", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Known features should always be shown
|
||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
||||
@@ -732,11 +729,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
expect(screen.getByText("my-feature")).toBeInTheDocument();
|
||||
expect(screen.getByText("another-feature")).toBeInTheDocument();
|
||||
@@ -750,11 +743,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
@@ -768,11 +757,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
@@ -786,11 +771,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
@@ -808,11 +789,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Toggle the feature
|
||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
||||
@@ -832,11 +809,7 @@ describe("SettingsModal", () => {
|
||||
it("shows project scope banner in Experimental Features section", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Should show project scope indicator
|
||||
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
|
||||
@@ -850,11 +823,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Known features should always be shown regardless of settings
|
||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
||||
@@ -869,11 +838,7 @@ describe("SettingsModal", () => {
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("Experimental Features"));
|
||||
await openExperimentalFeaturesSection();
|
||||
|
||||
// Toggle feature-b to true
|
||||
const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement;
|
||||
|
||||
@@ -171,12 +171,17 @@ describe("TaskCard", () => {
|
||||
expect(dots[3]?.className).toContain("card-step-dot--failed");
|
||||
expect(dots[4]?.className).toContain("card-step-dot--pending");
|
||||
|
||||
const workflowBadges = Array.from(container.querySelectorAll(".card-step-workflow-badge")).map((el) => el.textContent);
|
||||
expect(workflowBadges).toEqual([
|
||||
"Pre-merge",
|
||||
"Post-merge",
|
||||
"Pre-merge",
|
||||
]);
|
||||
const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge");
|
||||
const workflowBadges = Array.from(workflowBadgeElements).map((el) => el.textContent);
|
||||
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow"]);
|
||||
|
||||
expect(workflowBadgeElements[0]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
expect(workflowBadgeElements[1]?.className).toContain("card-step-workflow-badge--post-merge");
|
||||
expect(workflowBadgeElements[2]?.className).toContain("card-step-workflow-badge--pre-merge");
|
||||
|
||||
workflowBadgeElements.forEach((badge) => {
|
||||
expect(badge.getAttribute("title")).toBe("Workflow check");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to workflow result name, then raw ID when lookup names are unavailable", () => {
|
||||
|
||||
@@ -1058,9 +1058,9 @@ function TaskCardComponent({
|
||||
{step.source === "workflow" && (
|
||||
<span
|
||||
className={`card-step-workflow-badge card-step-workflow-badge--${step.phase}`}
|
||||
title={step.phase === "post-merge" ? "Post-merge workflow check" : "Pre-merge workflow check"}
|
||||
title="Workflow check"
|
||||
>
|
||||
{step.phase === "post-merge" ? "Post-merge" : "Pre-merge"}
|
||||
workflow
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3718,6 +3718,53 @@ describe("GET /models", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.models).toEqual([]);
|
||||
});
|
||||
|
||||
// Regression guard: FN-2370's auto-resolved squash inverted this filter,
|
||||
// emptying every model picker in the UI. The filter is small but the
|
||||
// failure mode is silent and project-wide — keep these tests close to the
|
||||
// route so any future flip flips CI red immediately.
|
||||
describe("useClaudeCli filter", () => {
|
||||
function buildAppWithSetting(useClaudeCli: boolean | undefined, modelRegistry: ModelRegistryLike) {
|
||||
const globalStore = createMockGlobalSettingsStore();
|
||||
(globalStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
useClaudeCli === undefined ? {} : { useClaudeCli },
|
||||
);
|
||||
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(globalStore);
|
||||
return buildApp(modelRegistry);
|
||||
}
|
||||
|
||||
function registryWithCli(): ModelRegistryLike {
|
||||
return createMockModelRegistry({
|
||||
getAvailable: vi.fn().mockReturnValue([
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
|
||||
{ id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 200000 },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
it("hides pi-claude-cli entries when useClaudeCli is false", async () => {
|
||||
const res = await GET(buildAppWithSetting(false, registryWithCli()), "/api/models");
|
||||
expect(res.status).toBe(200);
|
||||
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
||||
expect(providers).not.toContain("pi-claude-cli");
|
||||
expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai"]));
|
||||
});
|
||||
|
||||
it("hides pi-claude-cli entries when setting is unset (default off)", async () => {
|
||||
const res = await GET(buildAppWithSetting(undefined, registryWithCli()), "/api/models");
|
||||
expect(res.status).toBe(200);
|
||||
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
||||
expect(providers).not.toContain("pi-claude-cli");
|
||||
});
|
||||
|
||||
it("includes pi-claude-cli entries alongside other providers when useClaudeCli is true", async () => {
|
||||
const res = await GET(buildAppWithSetting(true, registryWithCli()), "/api/models");
|
||||
expect(res.status).toBe(200);
|
||||
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
||||
expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai", "pi-claude-cli"]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auth route tests ---
|
||||
|
||||
@@ -19367,15 +19367,14 @@ function registerModelsRoute(
|
||||
}
|
||||
}
|
||||
|
||||
// When the user has opted to route AI through pi-claude-cli, only
|
||||
// Anthropic Claude models are reachable — pi-claude-cli wraps the
|
||||
// local Claude CLI and does not bridge other providers. Surface only
|
||||
// those models so every picker in the app (settings, onboarding, per
|
||||
// lane overrides) stays honest about what'll actually run.
|
||||
// OpenRouter-proxied Claude (provider: "openrouter") is excluded on
|
||||
// purpose: it hits OpenRouter's API, not the local CLI.
|
||||
if (useClaudeCli) {
|
||||
models = models.filter((m) => m.provider === "anthropic");
|
||||
// The vendored pi-claude-cli extension registers its provider as
|
||||
// "pi-claude-cli" (distinct from "anthropic") whenever it loads.
|
||||
// When the toggle is OFF, hide those entries from pickers so users
|
||||
// don't see CLI-routed models they haven't opted into. When ON,
|
||||
// surface everything so the CLI-routed entries appear alongside any
|
||||
// direct provider auth the user has connected.
|
||||
if (!useClaudeCli) {
|
||||
models = models.filter((m) => m.provider !== "pi-claude-cli");
|
||||
}
|
||||
|
||||
res.json({ models, favoriteProviders, favoriteModels });
|
||||
@@ -19592,12 +19591,10 @@ function registerAuthRoutes(
|
||||
|
||||
res.json({
|
||||
enabled: next,
|
||||
// Pi extension registrations can't be added/removed mid-process,
|
||||
// so flipping on/off requires a restart for the model routing
|
||||
// itself to take effect. Skill install/backfill happens
|
||||
// immediately either way. Surface this so the UI can show a
|
||||
// "Restart Fusion to activate" prompt when next !== prev.
|
||||
restartRequired: prev !== next,
|
||||
// The pi-claude-cli extension is now always loaded; toggling
|
||||
// this setting only flips the /api/models filter, which takes
|
||||
// effect on the next picker fetch. No restart needed.
|
||||
restartRequired: false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -36,13 +36,13 @@ export function createLogger(prefix) {
|
||||
const tag = `[${prefix}]`;
|
||||
return {
|
||||
log(message, ...args) {
|
||||
console.error(withSeverityMarker("info", `${tag} ${message}`), ...args);
|
||||
globalThis.console.error(withSeverityMarker("info", `${tag} ${message}`), ...args);
|
||||
},
|
||||
warn(message, ...args) {
|
||||
console.warn(withSeverityMarker("warn", `${tag} ${message}`), ...args);
|
||||
globalThis.console.warn(withSeverityMarker("warn", `${tag} ${message}`), ...args);
|
||||
},
|
||||
error(message, ...args) {
|
||||
console.error(withSeverityMarker("error", `${tag} ${message}`), ...args);
|
||||
globalThis.console.error(withSeverityMarker("error", `${tag} ${message}`), ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) {
|
||||
validateCliPresence(); // throws if CLI not on PATH
|
||||
validateCliAuth(); // warns if not authenticated
|
||||
|
||||
const models = getModels("anthropic").map((model) => ({
|
||||
const catalogModels = getModels("anthropic").map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
@@ -81,6 +81,28 @@ export default function (pi: ExtensionAPI) {
|
||||
maxTokens: model.maxTokens,
|
||||
}));
|
||||
|
||||
// Newer models released after the pinned @mariozechner/pi-ai catalog
|
||||
// was generated. Dedupe by id so this list is harmless once the upstream
|
||||
// catalog catches up.
|
||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
const extraModels: typeof catalogModels = [
|
||||
{
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
];
|
||||
|
||||
const seen = new Set(catalogModels.map((m) => m.id));
|
||||
const models = [
|
||||
...catalogModels,
|
||||
...extraModels.filter((m) => !seen.has(m.id)),
|
||||
];
|
||||
|
||||
// Ensure all registered tools are active so pi can execute them.
|
||||
// Some tools (find, grep, ls) are registered but not activated by default.
|
||||
pi.on("session_start", async () => {
|
||||
|
||||
Reference in New Issue
Block a user