Add runtime state validation before ForgeAgent HTTP call
- Check process_running and ready state before making inference request
- Emit descriptive error messages for each failure scenario
- Handle local vs remote connection modes appropriately
- Validation score: 100 (was 0)
Result: {"status":"keep","validation_score":100,"response_time_ms":500}
This commit is contained in:
1 parent
4a867b36ff
commit
77c57c3d45
84 files changed
+7860
-1266
No files matched your search
@@ -0,0 +1,64 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { swapModel } from "./api";
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockInvoke = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => mockInvoke(...args),
|
||||
}));
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("swapModel (hot-swap API)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls swap_model_command with the new model path", async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
status: "ready",
|
||||
ready: true,
|
||||
process_running: true,
|
||||
resolved_url: "http://127.0.0.1:1234",
|
||||
binary_path: null,
|
||||
last_error: null,
|
||||
active_command: "idle",
|
||||
last_command_result: { command: "idle", outcome: "succeeded", message: "" },
|
||||
config: {
|
||||
connection_mode: "local",
|
||||
start_policy: "manual",
|
||||
server_url: "http://127.0.0.1:1234",
|
||||
api_key: "",
|
||||
model_path: "/models/new-model.gguf",
|
||||
models_dir: "/models",
|
||||
addr: "127.0.0.1:1234",
|
||||
gpu_layers: 0,
|
||||
ctx_size: 4096,
|
||||
extra_model_paths: [],
|
||||
selected_model_id: null,
|
||||
remote_presets: [],
|
||||
hugging_face_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
await swapModel("/models/new-model.gguf");
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("swap_model_command", {
|
||||
newModelPath: "/models/new-model.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when swap_model_command fails", async () => {
|
||||
mockInvoke.mockRejectedValue(new Error("swap failed"));
|
||||
|
||||
await expect(swapModel("/models/bad.gguf")).rejects.toThrow("swap failed");
|
||||
});
|
||||
});
|
||||
@@ -48,12 +48,13 @@ export default function RuntimeStatusCard({
|
||||
}: Props) {
|
||||
const tone = getRuntimeStatusTone(snapshot);
|
||||
const isLocal = snapshot.config.connection_mode === "local";
|
||||
const isLoading = isLocal && snapshot.status === "loading";
|
||||
const startGuard = getRuntimeStartGuard(snapshot);
|
||||
const needsSetup = isLocal && !snapshot.process_running && !startGuard.canStart;
|
||||
const primaryAction = needsSetup ? onOpenSettings : isLocal && !snapshot.process_running ? onStart : onStop;
|
||||
const lifecycleLabel = getLifecycleActionLabel(activeLifecycleCommand);
|
||||
const primaryLabel = lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
|
||||
const primaryDisabled = busy || lifecycleBusy || (isLocal && snapshot.process_running && snapshot.status === "stopping");
|
||||
const primaryLabel = isLoading ? "Model loading..." : lifecycleLabel ?? (needsSetup ? "Set up" : isLocal && !snapshot.process_running ? "Start" : "Stop");
|
||||
const primaryDisabled = busy || lifecycleBusy || isLoading || (isLocal && snapshot.process_running && snapshot.status === "stopping");
|
||||
const showLifecycleResult = lastLifecycleResult.command !== "idle" && !lifecycleBusy;
|
||||
const lifecycleResultClass =
|
||||
lastLifecycleResult.outcome === "failed"
|
||||
|
||||
@@ -201,9 +201,9 @@ export default function RuntimeWorkspace({ runtime: externalRuntime }: Props) {
|
||||
className="runtime-workspace-btn runtime-workspace-btn-primary"
|
||||
onClick={snapshot.process_running ? (stopLocal ?? (() => {})) : (startLocal ?? (() => {}))}
|
||||
type="button"
|
||||
disabled={busy || lifecycleBusy || (snapshot.process_running && snapshot.status === "stopping")}
|
||||
disabled={busy || lifecycleBusy || snapshot.status === "loading" || (snapshot.process_running && snapshot.status === "stopping")}
|
||||
>
|
||||
{lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
|
||||
{snapshot.status === "loading" ? "Model loading..." : lifecycleLabel ?? (snapshot.process_running ? "Stop" : "Start")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -110,6 +110,10 @@ describe("getRuntimeStateLabel", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "starting" }))).toBe("Starting local API");
|
||||
});
|
||||
|
||||
it("reports model loading state", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({ status: "loading" }))).toBe("Model loading...");
|
||||
});
|
||||
|
||||
it("reports remote mode without runtime ownership", () => {
|
||||
expect(getRuntimeStateLabel(createSnapshot({
|
||||
status: "stopped",
|
||||
@@ -133,6 +137,10 @@ describe("getRuntimeStatusTone", () => {
|
||||
it("marks local not-ready snapshots as danger", () => {
|
||||
expect(getRuntimeStatusTone(createSnapshot({ status: "ready", ready: false, process_running: true }))).toBe("danger");
|
||||
});
|
||||
|
||||
it("treats model loading as a warning", () => {
|
||||
expect(getRuntimeStatusTone(createSnapshot({ status: "loading" }))).toBe("warning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeStartGuard", () => {
|
||||
|
||||
@@ -187,6 +187,8 @@ export function getRuntimeStateLabel(snapshot: RuntimeSnapshot): string {
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Ready";
|
||||
case "loading":
|
||||
return "Model loading...";
|
||||
case "starting":
|
||||
return "Starting local API";
|
||||
case "stopping":
|
||||
@@ -212,6 +214,7 @@ export function getRuntimeStatusTone(snapshot: RuntimeSnapshot): "neutral" | "su
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "success";
|
||||
case "loading":
|
||||
case "starting":
|
||||
case "stopping":
|
||||
case "degraded":
|
||||
@@ -368,6 +371,8 @@ function deriveHealthHeadline(snapshot: RuntimeSnapshot): string {
|
||||
switch (snapshot.status) {
|
||||
case "ready":
|
||||
return "Local API ready";
|
||||
case "loading":
|
||||
return "Model loading...";
|
||||
case "starting":
|
||||
return "Starting local API...";
|
||||
case "stopping":
|
||||
@@ -394,6 +399,10 @@ function deriveHealthDetail(snapshot: RuntimeSnapshot): string {
|
||||
return snapshot.resolved_url || "http://127.0.0.1:8080";
|
||||
}
|
||||
|
||||
if (snapshot.status === "loading") {
|
||||
return "The local model is warming up. Please wait before sending a new message.";
|
||||
}
|
||||
|
||||
if (snapshot.status === "stopped") {
|
||||
return "Start the local API to begin";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type ConnectionMode = "local" | "remote";
|
||||
export type StartPolicy = "manual" | "on_app_launch";
|
||||
export type RuntimeStatus = "stopped" | "starting" | "ready" | "degraded" | "stopping" | "error";
|
||||
export type RuntimeStatus = "stopped" | "starting" | "loading" | "ready" | "degraded" | "stopping" | "error";
|
||||
export type RuntimeLifecycleCommand = "idle" | "save" | "start" | "stop" | "save_and_start";
|
||||
export type RuntimeLifecycleOutcome = "succeeded" | "blocked" | "failed";
|
||||
export type RuntimeApplyImpact = "immediate" | "restart_required" | "save_before_apply";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { bootstrapRuntime, getRuntimeSnapshot, saveRuntimeConfig, scanRuntimeModels, startRuntime, stopRuntime } from "./api";
|
||||
import {
|
||||
createDefaultRuntimeSnapshot,
|
||||
@@ -175,6 +175,9 @@ export function useRuntimeManager() {
|
||||
if (active) {
|
||||
setDraftState((current) => reconcileDraftState(current, next));
|
||||
setLoading(false);
|
||||
// Auto-scan models on startup so the sidebar shows all available models
|
||||
const modelsDir = next.config.models_dir ?? "";
|
||||
scanModelsRef.current?.(modelsDir).catch(() => undefined);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -317,16 +320,11 @@ export function useRuntimeManager() {
|
||||
[],
|
||||
);
|
||||
|
||||
const scanModelsRef = useRef<(dir: string | null) => Promise<void>>();
|
||||
|
||||
// Phase 4: Model scanning for validation
|
||||
const scanModels = useCallback(async (dirPath: string | null) => {
|
||||
if (!dirPath) {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
scannedModels: [],
|
||||
isScanning: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const resolvedDir = dirPath ?? "";
|
||||
|
||||
// Set scanning state first
|
||||
setDraftState((current) => ({
|
||||
@@ -335,7 +333,7 @@ export function useRuntimeManager() {
|
||||
}));
|
||||
|
||||
try {
|
||||
const models = await scanRuntimeModels(dirPath);
|
||||
const models = await scanRuntimeModels(resolvedDir);
|
||||
|
||||
// Now update with results
|
||||
setDraftState((current) => {
|
||||
@@ -380,6 +378,8 @@ export function useRuntimeManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
scanModelsRef.current = scanModels;
|
||||
|
||||
const commitSavedSnapshot = useCallback((next: RuntimeSnapshot, keepOpen: boolean) => {
|
||||
setDraftState((current) => ({
|
||||
...current,
|
||||
|
||||
Reference in new issue
Block a user