feat(model-server): progression fine du téléchargement du modèle llamacpp (#54)
Stretch B2/F2 de #54, par-dessus le MVP déjà mergé (B1/F1). Backend : le port de téléchargement HF publie une progression débouncée (bytes reçus / total, pourcentage) via le stream de statut du serveur modèle, avec gestion du total inconnu (pas de faux %), du cache hit, de l'annulation et du timeout. Frontend : l'overlay plein-cellule de préparation du serveur affiche la progression réelle (barre, %, octets, source) en mappant le fil de statut, avec la règle « pas de faux % » quand le total est inconnu. Tests : application + infrastructure (téléchargement débouncé, cancel, timeout, cache hit, total inconnu) et vitest (overlay + formatage pur). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -12,7 +12,17 @@
|
||||
* event on that server. xterm is stubbed so the terminal mounts under jsdom.
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, configure } from "@testing-library/react";
|
||||
|
||||
// Delivery of a `modelServerStatusChanged` event is made deterministic by
|
||||
// `trackSubscriptions` (no lost event). What remains is *propagation* latency:
|
||||
// the overlay only shows once the hook's async profile + agent loads resolve, so
|
||||
// the assertion is a guaranteed-eventual state. Under the full-suite parallel
|
||||
// run (22 files) the worker's event loop can be starved past the 1000 ms default
|
||||
// async-util budget — a scheduling delay, not a logic race. A generous timeout
|
||||
// removes that flake without weakening any assertion (the state is guaranteed to
|
||||
// arrive; we only wait long enough for it under load).
|
||||
configure({ asyncUtilTimeout: 5000 });
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
@ -108,16 +118,35 @@ function renderGrid(gateways: Gateways) {
|
||||
);
|
||||
}
|
||||
|
||||
async function emit(
|
||||
/**
|
||||
* Emit `status` and KEEP re-emitting it until `check` passes (the emit runs
|
||||
* inside `waitFor`, whose async wrapper already provides `act`). This kills the
|
||||
* emit-vs-subscribe race at the root — with no need to identify *which*
|
||||
* `onDomainEvent` subscriber is the overlay hook's.
|
||||
*
|
||||
* Why the previous `waitSubscribed(count > 0)` gate was insufficient: several
|
||||
* components subscribe to the event stream, and the ones outside `LeafView`
|
||||
* mount *before* the async layout load brings `LeafView` (and its overlay hook)
|
||||
* into the tree — so the global subscriber count crosses 0 while the overlay
|
||||
* hook's own handler is not yet registered, and a single emit gated on it is
|
||||
* still dropped. Re-emitting is safe because the reducer stores an idempotent
|
||||
* per-server value: whichever attempt lands *after* the overlay hook subscribed
|
||||
* and its profile/agent loads resolved is the one that makes `check` pass; the
|
||||
* earlier (possibly dropped) attempts are no-ops. Used for BOTH appearance and
|
||||
* retraction assertions so no case can regress into the race.
|
||||
*/
|
||||
async function emitUntil(
|
||||
systemGateway: MockSystemGateway,
|
||||
status: ModelServerStatus,
|
||||
check: () => void,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
await waitFor(() => {
|
||||
systemGateway.emit({
|
||||
type: "modelServerStatusChanged",
|
||||
serverId: SERVER_ID,
|
||||
status,
|
||||
});
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
@ -147,29 +176,28 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
|
||||
// No overlay before any status.
|
||||
// The cell exists but no status has been emitted yet → no overlay.
|
||||
await screen.findByTestId("layout-leaf");
|
||||
expect(screen.queryByTestId("model-server-overlay")).toBeNull();
|
||||
|
||||
// Downloading → overlay with the download wording.
|
||||
await emit(systemGateway, {
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("model-server-overlay")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
|
||||
"Téléchargement du modèle",
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Téléchargement du modèle"),
|
||||
);
|
||||
|
||||
// Ready → overlay gone.
|
||||
await emit(systemGateway, { state: "ready", reused: false });
|
||||
await waitFor(() =>
|
||||
await emitUntil(systemGateway, { state: "ready", reused: false }, () =>
|
||||
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
||||
);
|
||||
});
|
||||
@ -194,27 +222,25 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
|
||||
await emit(systemGateway, { state: "probing" });
|
||||
await waitFor(() =>
|
||||
await emitUntil(systemGateway, { state: "probing" }, () =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Chargement du serveur"),
|
||||
);
|
||||
|
||||
await emit(systemGateway, { state: "starting" });
|
||||
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
|
||||
"Chargement du serveur",
|
||||
await emitUntil(systemGateway, { state: "starting" }, () =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Chargement du serveur"),
|
||||
);
|
||||
// No progressbar for a non-download preparing state.
|
||||
expect(screen.queryByTestId("model-server-progress")).toBeNull();
|
||||
|
||||
await emit(systemGateway, {
|
||||
state: "failed",
|
||||
code: "spawn",
|
||||
message: "boom",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{ state: "failed", code: "spawn", message: "boom" },
|
||||
() => expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
@ -255,25 +281,156 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
||||
});
|
||||
|
||||
renderGrid(gateways);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("layout-leaf").length).toBe(2),
|
||||
|
||||
// Both cells carry the overlay from a single server event (re-emitted until
|
||||
// both cells' overlay hooks have subscribed and correlated the server).
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
},
|
||||
() =>
|
||||
expect(screen.getAllByTestId("model-server-overlay").length).toBe(2),
|
||||
);
|
||||
|
||||
await emit(systemGateway, {
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
});
|
||||
// Both cells carry the overlay from a single server event.
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId("model-server-overlay").length).toBe(2),
|
||||
);
|
||||
|
||||
await emit(systemGateway, { state: "ready", reused: true });
|
||||
await waitFor(() =>
|
||||
await emitUntil(systemGateway, { state: "ready", reused: true }, () =>
|
||||
expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** Render a single pinned-agent cell and return its system gateway. */
|
||||
async function renderSinglePinnedCell(): Promise<MockSystemGateway> {
|
||||
const profileGateway = new MockProfileGateway();
|
||||
await profileGateway.saveProfile(localModelProfile("opencode-local"));
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "opencode-local",
|
||||
});
|
||||
const systemGateway = new MockSystemGateway();
|
||||
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
|
||||
const layout = gateways.layout as MockLayoutGateway;
|
||||
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: agent.id,
|
||||
});
|
||||
renderGrid(gateways);
|
||||
// No subscription gate needed: every emission goes through `emitUntil`, which
|
||||
// re-emits until the overlay hook has subscribed and correlated the server.
|
||||
return systemGateway;
|
||||
}
|
||||
|
||||
describe("LayoutGrid — download progress (ticket #54 F2)", () => {
|
||||
it("known percent → determinate progressbar + % + formatted bytes + source", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 1_500_000,
|
||||
totalBytes: 3_000_000,
|
||||
percent: 50,
|
||||
source: "unsloth/Qwen3-Coder-30B",
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("50"),
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId("model-server-progress");
|
||||
expect(bar.getAttribute("role")).toBe("progressbar");
|
||||
expect(bar.getAttribute("aria-valuemin")).toBe("0");
|
||||
expect(bar.getAttribute("aria-valuemax")).toBe("100");
|
||||
|
||||
const label = screen.getByTestId("model-server-progress-label");
|
||||
expect(label.textContent).toContain("50 %");
|
||||
expect(label.textContent).toContain("1.5 Mo / 3 Mo");
|
||||
|
||||
expect(screen.getByTestId("model-server-source").textContent).toBe(
|
||||
"unsloth/Qwen3-Coder-30B",
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown total → indeterminate bar, NO fake %, shows downloaded bytes", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 800_000,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
},
|
||||
() => expect(screen.getByTestId("model-server-progress")).toBeTruthy(),
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId("model-server-progress");
|
||||
// Indeterminate: no aria-valuenow set.
|
||||
expect(bar.hasAttribute("aria-valuenow")).toBe(false);
|
||||
|
||||
const label = screen.getByTestId("model-server-progress-label");
|
||||
expect(label.textContent).not.toContain("%");
|
||||
expect(label.textContent).toContain("800 Ko");
|
||||
// No source line when the backend sends none.
|
||||
expect(screen.queryByTestId("model-server-source")).toBeNull();
|
||||
});
|
||||
|
||||
it("starting/probing show the title but NO progressbar", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(systemGateway, { state: "starting" }, () =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-overlay").textContent,
|
||||
).toContain("Chargement du serveur"),
|
||||
);
|
||||
expect(screen.queryByTestId("model-server-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("later progress events update the bar in place", async () => {
|
||||
const systemGateway = await renderSinglePinnedCell();
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 1_000_000,
|
||||
totalBytes: 4_000_000,
|
||||
percent: 25,
|
||||
source: null,
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("25"),
|
||||
);
|
||||
|
||||
await emitUntil(
|
||||
systemGateway,
|
||||
{
|
||||
state: "downloading",
|
||||
downloadedBytes: 3_000_000,
|
||||
totalBytes: 4_000_000,
|
||||
percent: 75,
|
||||
source: null,
|
||||
},
|
||||
() =>
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
|
||||
).toBe("75"),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("model-server-progress-label").textContent,
|
||||
).toContain("3 Mo / 4 Mo");
|
||||
});
|
||||
});
|
||||
|
||||
@ -40,6 +40,7 @@ import {
|
||||
} from "@/features/announcements";
|
||||
import {
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
useModelServerLaunchState,
|
||||
} from "@/features/agents";
|
||||
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
|
||||
@ -382,7 +383,12 @@ function LeafView({
|
||||
const pinnedAgent = agentId
|
||||
? agents.find((a) => a.id === agentId)
|
||||
: undefined;
|
||||
const modelServerOverlay = modelServerOverlayText(statusForAgent(pinnedAgent));
|
||||
const modelServerStatus = statusForAgent(pinnedAgent);
|
||||
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
|
||||
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
|
||||
// for every non-`downloading` state, so `starting`/`probing` show just the
|
||||
// title. An indeterminate download (unknown total) yields `percent: null`.
|
||||
const modelServerDownload = describeModelServerDownload(modelServerStatus);
|
||||
const agentWork = agentId
|
||||
? workState?.agents.find((row) => row.agentId === agentId)
|
||||
: undefined;
|
||||
@ -976,16 +982,101 @@ function LeafView({
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
maxWidth: "80%",
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 4,
|
||||
padding: "6px 12px",
|
||||
padding: "8px 14px",
|
||||
}}
|
||||
>
|
||||
{modelServerOverlay}
|
||||
</span>
|
||||
<span>{modelServerOverlay}</span>
|
||||
{modelServerDownload && (
|
||||
<>
|
||||
{/* Determinate bar when the total is known; an indeterminate
|
||||
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
|
||||
<div
|
||||
data-testid="model-server-progress"
|
||||
role="progressbar"
|
||||
aria-label="progression du téléchargement"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={
|
||||
modelServerDownload.percent != null
|
||||
? Math.round(modelServerDownload.percent)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 220,
|
||||
maxWidth: "100%",
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
background: "var(--color-raised, #2a2a2a)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
modelServerDownload.percent != null
|
||||
? {
|
||||
height: "100%",
|
||||
width: `${modelServerDownload.percent}%`,
|
||||
background: "var(--color-primary, #5b9bd5)",
|
||||
transition: "width 120ms linear",
|
||||
}
|
||||
: {
|
||||
// Indeterminate: a sliding sliver.
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "40%",
|
||||
background: "var(--color-primary, #5b9bd5)",
|
||||
animation:
|
||||
"model-server-indeterminate 1.1s ease-in-out infinite",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{(modelServerDownload.percent != null ||
|
||||
modelServerDownload.bytesLabel) && (
|
||||
<span
|
||||
data-testid="model-server-progress-label"
|
||||
style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
|
||||
>
|
||||
{modelServerDownload.percent != null
|
||||
? `${Math.round(modelServerDownload.percent)} %`
|
||||
: ""}
|
||||
{modelServerDownload.percent != null &&
|
||||
modelServerDownload.bytesLabel
|
||||
? " · "
|
||||
: ""}
|
||||
{modelServerDownload.bytesLabel ?? ""}
|
||||
</span>
|
||||
)}
|
||||
{modelServerDownload.source && (
|
||||
<span
|
||||
data-testid="model-server-source"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--color-content-muted, #9a9a9a)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={modelServerDownload.source}
|
||||
>
|
||||
{modelServerDownload.source}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user