feat(FN-2898): expand Claude model coverage and subprocess diagnostics

- Add missing Claude model entries and extend provider metadata handling for model extras
- Improve subprocess diagnostics in pi-claude-cli process management for clearer failure visibility
- Add targeted tests for provider model extras and process-manager diagnostic behavior
- Update Settings modal copy for project default model guidance and record changes in pi-claude-cli changelog

Fusion-Task-Id: FN-2898
This commit is contained in:
Fusion
2026-04-28 18:38:35 -07:00
committed by gsxdsm
parent f3d7eef2fe
commit ab910a2b0a
6 changed files with 237 additions and 11 deletions

View File

@@ -41,6 +41,7 @@ vi.mock("node:os", () => ({
import { spawn, execSync } from "node:child_process";
import {
spawnClaude,
buildClaudeSpawnArgs,
writeUserMessage,
cleanupProcess,
captureStderr,
@@ -52,6 +53,32 @@ import {
cleanupSystemPromptFile,
} from "../process-manager";
describe("buildClaudeSpawnArgs", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.writeFileSync.mockReset();
mocks.tmpdir.mockReset();
mocks.tmpdir.mockReturnValue("/mock-tmp");
});
it("builds args including model and optional session/mcp flags", () => {
const args = buildClaudeSpawnArgs("claude-sonnet-4-6", undefined, {
resumeSessionId: "sess-1",
effort: "high",
mcpConfigPath: "/tmp/mcp.json",
});
expect(args).toContain("--model");
expect(args).toContain("claude-sonnet-4-6");
expect(args).toContain("--resume");
expect(args).toContain("sess-1");
expect(args).toContain("--effort");
expect(args).toContain("high");
expect(args).toContain("--mcp-config");
expect(args).toContain("/tmp/mcp.json");
});
});
describe("spawnClaude", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -66,6 +66,7 @@ vi.mock("@mariozechner/pi-ai", () => ({
}));
import { spawn } from "node:child_process";
import { getModels } from "@mariozechner/pi-ai";
import { streamViaCli } from "../provider";
describe("provider registration (default export)", () => {
@@ -115,16 +116,71 @@ describe("provider registration (default export)", () => {
expect(firstModel.maxTokens).toBe(8192);
expect(firstModel.cost).toBeDefined();
});
it("includes all extra Claude model entries", async () => {
const registerProvider = vi.fn();
const mockPi = { registerProvider, on: vi.fn() } as any;
const mod = await import("../../index");
mod.default(mockPi);
const config = registerProvider.mock.calls[0][1];
const modelIds = new Set(config.models.map((m: { id: string }) => m.id));
for (const id of [
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-haiku-4-5",
]) {
expect(modelIds.has(id)).toBe(true);
}
});
it("deduplicates extra models when catalog already includes them", async () => {
const registerProvider = vi.fn();
const mockPi = { registerProvider, on: vi.fn() } as any;
const getModelsMock = vi.mocked(getModels);
getModelsMock.mockReturnValueOnce([
...mockModels,
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
api: "anthropic",
provider: "anthropic",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 16384,
} as any,
] as any);
const mod = await import("../../index");
mod.default(mockPi);
const config = registerProvider.mock.calls[0][1];
const matches = config.models.filter(
(m: { id: string }) => m.id === "claude-sonnet-4-6",
);
expect(matches).toHaveLength(1);
});
});
describe("streamViaCli", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
delete process.env.PI_CLAUDE_CLI_DEBUG;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
delete process.env.PI_CLAUDE_CLI_DEBUG;
});
it("returns an AssistantMessageEventStream", () => {
@@ -140,6 +196,24 @@ describe("streamViaCli", () => {
expect(result.end).toBeDefined();
});
it("logs PID and spawn args when debug mode is enabled", async () => {
process.env.PI_CLAUDE_CLI_DEBUG = "1";
const model = mockModels[0] as any;
const context = {
messages: [{ role: "user", content: "Hello" }],
};
const errorSpy = vi.spyOn(console, "error");
streamViaCli(model, context);
await vi.advanceTimersByTimeAsync(0);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("spawned claude subprocess pid=99999 args="),
);
});
it("spawns subprocess and writes user message to stdin", async () => {
const model = mockModels[0] as any;
const context = {
@@ -1179,6 +1253,50 @@ describe("streamViaCli", () => {
expect(doneEvent.message.content).toBeDefined();
});
it("logs stderr at warn level on close even with exit code 0", async () => {
const model = mockModels[0] as any;
const context = {
messages: [{ role: "user", content: "Hello" }],
};
const warnSpy = vi.spyOn(console, "warn");
streamViaCli(model, context);
await vi.advanceTimersByTimeAsync(0);
const proc = (spawn as any).mock.results[0].value;
proc.stderr.emit("data", Buffer.from("minor warning from cli"));
proc.emit("close", 0, null);
proc.stdout.end();
await vi.advanceTimersByTimeAsync(100);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("minor warning from cli"),
);
});
it("warns when subprocess closes successfully with no content events", async () => {
const model = mockModels[0] as any;
const context = {
messages: [{ role: "user", content: "Hello" }],
};
const warnSpy = vi.spyOn(console, "warn");
streamViaCli(model, context);
await vi.advanceTimersByTimeAsync(0);
const proc = (spawn as any).mock.results[0].value;
proc.emit("close", 0, null);
proc.stdout.end();
await vi.advanceTimersByTimeAsync(100);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("closed without content events"),
);
});
it("does not push error on normal close (code 0)", async () => {
const model = mockModels[0] as any;
const context = {