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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user