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 fc2b391c32
commit 0c9d9be856
6 changed files with 237 additions and 11 deletions

View File

@@ -1,5 +1,15 @@
# @fusion/pi-claude-cli
## Unreleased
### Patch Changes
- Add missing Anthropic model metadata entries to provider registration: `claude-sonnet-4-6`, `claude-sonnet-4-5`, and `claude-haiku-4-5` (alongside existing `claude-opus-4-7`) so they appear in the model picker even before upstream catalog updates.
- Improve subprocess diagnostics in `streamViaCli` by:
- logging Claude stderr on close at warn level even when exit code is 0,
- logging debug spawn correlation details (PID + effective args) when `PI_CLAUDE_CLI_DEBUG=1`,
- warning when a subprocess closes without producing any content events.
## 0.7.1
### Patch Changes

View File

@@ -144,6 +144,33 @@ export default function (pi: ExtensionAPI) {
contextWindow: 1_000_000,
maxTokens: 128_000,
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200_000,
maxTokens: 16_384,
},
{
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200_000,
maxTokens: 8_192,
},
{
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
contextWindow: 200_000,
maxTokens: 8_192,
},
];
const seen = new Set(catalogModels.map((m) => m.id));

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 = {

View File

@@ -19,18 +19,16 @@ import { tmpdir } from "node:os";
* @param options - Optional cwd, AbortSignal, and effort level
* @returns The spawned ChildProcess with piped stdin/stdout/stderr
*/
export function spawnClaude(
export function buildClaudeSpawnArgs(
modelId: string,
systemPrompt?: string,
options?: {
cwd?: string;
signal?: AbortSignal;
effort?: string;
mcpConfigPath?: string;
resumeSessionId?: string;
newSessionId?: string;
},
): ChildProcess {
): string[] {
const args = [
"-p",
"--input-format",
@@ -72,6 +70,28 @@ export function spawnClaude(
args.push("--mcp-config", options.mcpConfigPath);
}
return args;
}
export function spawnClaude(
modelId: string,
systemPrompt?: string,
options?: {
cwd?: string;
signal?: AbortSignal;
effort?: string;
mcpConfigPath?: string;
resumeSessionId?: string;
newSessionId?: string;
},
): ChildProcess {
const args = buildClaudeSpawnArgs(modelId, systemPrompt, {
effort: options?.effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId: options?.resumeSessionId,
newSessionId: options?.newSessionId,
});
const proc = spawn("claude", args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: options?.cwd ?? process.cwd(),

View File

@@ -38,6 +38,7 @@ import {
forceKillProcess,
registerProcess,
cleanupSystemPromptFile,
buildClaudeSpawnArgs,
} from "./process-manager.js";
import { parseLine } from "./stream-parser.js";
import { createEventBridge } from "./event-bridge.js";
@@ -61,10 +62,12 @@ import { isPiKnownClaudeTool } from "./tool-mapping.js";
* arrives (e.g. someone embeds pi-claude-cli without a stuck detector).
*/
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
const DEBUG_STREAM = process.env.PI_CLAUDE_CLI_DEBUG === "1";
function isDebugStreamEnabled(): boolean {
return process.env.PI_CLAUDE_CLI_DEBUG === "1";
}
function debugLog(message: string): void {
if (!DEBUG_STREAM) return;
if (!isDebugStreamEnabled()) return;
console.error(`[pi-claude-cli] ${message}`);
}
@@ -131,19 +134,30 @@ export function streamViaCli(
options?.thinkingBudgets,
);
// Spawn subprocess
proc = spawnClaude(model.id, systemPrompt || undefined, {
const spawnOptions = {
cwd,
signal: options?.signal,
effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId,
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
});
};
// Spawn subprocess
proc = spawnClaude(model.id, systemPrompt || undefined, spawnOptions);
const getStderr = captureStderr(proc);
// Register in global process registry for teardown cleanup
registerProcess(proc);
const spawnArgs = buildClaudeSpawnArgs(model.id, undefined, {
effort,
mcpConfigPath: options?.mcpConfigPath,
resumeSessionId,
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
});
debugLog(
`spawned claude subprocess pid=${proc.pid ?? "unknown"} args=${JSON.stringify(spawnArgs)}`,
);
// Write user message to subprocess stdin
writeUserMessage(proc, prompt);
@@ -234,10 +248,13 @@ export function streamViaCli(
proc.on("close", (code: number | null, _signal: string | null) => {
clearTimeout(inactivityTimer);
if (broken) return; // Break-early kill, expected
const stderr = getStderr().trim();
if (stderr) {
console.warn(`[pi-claude-cli] Claude CLI stderr on close: ${stderr}`);
}
if (code !== 0 && code !== null) {
const stderr = getStderr();
const message = stderr
? `Claude CLI exited with code ${code}: ${stderr.trim()}`
? `Claude CLI exited with code ${code}: ${stderr}`
: `Claude CLI exited unexpectedly with code ${code}`;
endStreamWithError(message);
}
@@ -324,6 +341,13 @@ export function streamViaCli(
// Guard with streamEnded to avoid pushing done after an error was already pushed.
if (!streamEnded) {
const output = bridge.getOutput();
const contentEvents = output.content || [];
if (contentEvents.length === 0) {
console.warn(
`[pi-claude-cli] Claude CLI closed without content events (model=${model.id}, sessionId=${options?.sessionId ?? "none"})`,
);
}
// If stopReason is toolUse but there are no pi-known tool calls in content,
// it means only user MCP tools were called (filtered by event bridge).