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>
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
/**
|
|
* Ticket #54 F1 — model-server download overlay on agent cells.
|
|
*
|
|
* When a pinned agent's OpenCode profile binds a local model server and that
|
|
* server emits `downloading` / `starting` / `probing` (via the existing
|
|
* `modelServerStatusChanged` stream), a full-cell overlay covers the cell — so
|
|
* the boot no longer looks like a hung/timed-out empty terminal. The overlay
|
|
* retracts at `ready`/`failed`.
|
|
*
|
|
* Correlation is `agentId → profileId → opencode.localModelServerId → serverId`:
|
|
* two cells whose agents share the same bound server are BOTH veiled by a single
|
|
* 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, 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 {
|
|
loadAddon() {}
|
|
open() {}
|
|
onData() {
|
|
return { dispose() {} };
|
|
}
|
|
onResize() {
|
|
return { dispose() {} };
|
|
}
|
|
write() {}
|
|
dispose() {}
|
|
get cols() {
|
|
return 80;
|
|
}
|
|
get rows() {
|
|
return 24;
|
|
}
|
|
},
|
|
}));
|
|
vi.mock("@xterm/addon-fit", () => ({
|
|
FitAddon: class {
|
|
fit() {}
|
|
},
|
|
}));
|
|
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
|
|
|
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
globalThis.ResizeObserver = class {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
} as unknown as typeof ResizeObserver;
|
|
}
|
|
|
|
import type { AgentProfile, ModelServerStatus } from "@/domain";
|
|
import type { Gateways } from "@/ports";
|
|
import {
|
|
MockAgentGateway,
|
|
MockInputGateway,
|
|
MockLayoutGateway,
|
|
MockProfileGateway,
|
|
MockSystemGateway,
|
|
MockTerminalGateway,
|
|
} from "@/adapters/mock";
|
|
import { DIProvider } from "@/app/di";
|
|
import { leaves } from "./layout";
|
|
import { LayoutGrid } from "./LayoutGrid";
|
|
|
|
const SERVER_ID = "srv-local-1";
|
|
|
|
/** A profile that binds the given local model server through OpenCode. */
|
|
function localModelProfile(id: string): AgentProfile {
|
|
return {
|
|
id,
|
|
name: "OpenCode + llama.cpp",
|
|
command: "opencode",
|
|
args: [],
|
|
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
|
detect: null,
|
|
cwdTemplate: "{projectRoot}",
|
|
structuredAdapter: "openCode",
|
|
opencode: {
|
|
baseURL: "http://localhost:8080/v1",
|
|
apiKey: "sk-no-key",
|
|
model: "qwen3-coder-30b",
|
|
localModelServerId: SERVER_ID,
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeGateways(
|
|
agentGateway: MockAgentGateway,
|
|
profileGateway: MockProfileGateway,
|
|
systemGateway: MockSystemGateway,
|
|
): Gateways {
|
|
return {
|
|
layout: new MockLayoutGateway(),
|
|
agent: agentGateway,
|
|
profile: profileGateway,
|
|
terminal: new MockTerminalGateway(),
|
|
system: systemGateway,
|
|
input: new MockInputGateway(),
|
|
} as unknown as Gateways;
|
|
}
|
|
|
|
function renderGrid(gateways: Gateways) {
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 waitFor(() => {
|
|
systemGateway.emit({
|
|
type: "modelServerStatusChanged",
|
|
serverId: SERVER_ID,
|
|
status,
|
|
});
|
|
check();
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
|
|
it("veils the cell while downloading, then retracts at ready", async () => {
|
|
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);
|
|
|
|
// Pin the agent onto the single default cell.
|
|
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);
|
|
// 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 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 emitUntil(systemGateway, { state: "ready", reused: false }, () =>
|
|
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it("shows 'Chargement du serveur…' for starting/probing and hides at failed", async () => {
|
|
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);
|
|
|
|
await emitUntil(systemGateway, { state: "probing" }, () =>
|
|
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 emitUntil(
|
|
systemGateway,
|
|
{ state: "failed", code: "spawn", message: "boom" },
|
|
() => expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it("veils EVERY cell whose agent references the downloading server", async () => {
|
|
const profileGateway = new MockProfileGateway();
|
|
await profileGateway.saveProfile(localModelProfile("opencode-local"));
|
|
const agentGateway = new MockAgentGateway();
|
|
const agentA = await agentGateway.createAgent("p1", {
|
|
name: "A",
|
|
profileId: "opencode-local",
|
|
});
|
|
const agentB = await agentGateway.createAgent("p1", {
|
|
name: "B",
|
|
profileId: "opencode-local",
|
|
});
|
|
const systemGateway = new MockSystemGateway();
|
|
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
|
|
|
|
// Split the root into two cells, pin one agent onto each.
|
|
const layout = gateways.layout as MockLayoutGateway;
|
|
const rootLeaf = leaves(await layout.loadLayout("p1"))[0].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "split",
|
|
target: rootLeaf,
|
|
direction: "row",
|
|
newLeaf: "cell-b",
|
|
container: "split-c",
|
|
});
|
|
await layout.mutateLayout("p1", {
|
|
type: "setCellAgent",
|
|
target: rootLeaf,
|
|
agent: agentA.id,
|
|
});
|
|
await layout.mutateLayout("p1", {
|
|
type: "setCellAgent",
|
|
target: "cell-b",
|
|
agent: agentB.id,
|
|
});
|
|
|
|
renderGrid(gateways);
|
|
|
|
// 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 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");
|
|
});
|
|
});
|