feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router): - server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/ - dashboard_runs.router renamed in place to proxy: an older build on the same DB fails on that column, so dev and prod sharing one DB update together - a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages); Test probes Claude /v1/messages and Codex /v1/responses - Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker Updates: - every tab reloads onto the new UI build when its WebSocket reconnects to a server of another version (lib/newBuildReload.ts, once per version) - Update now stops waiting on a plugin chain that failed or pulled nothing (the old server never goes down), and reloads as soon as a new version answers Also: - a pane whose command exits at once says so (EPANEEXITED) instead of "Command failed: tmux set-option … mouse on" - the pane drag preview draws where the pane actually lands
This commit is contained in:
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
action: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file PaneGrid.test.ts
|
||||
* @description The pane drop preview: it draws where the dragged pane will
|
||||
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||
* current box, and nothing for a drop that changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dropPreview } from "../PaneGrid";
|
||||
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||
|
||||
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
|
||||
describe("dropPreview", () => {
|
||||
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
|
||||
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
|
||||
});
|
||||
|
||||
it("a pane from another tab splits the target's current box", () => {
|
||||
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||
});
|
||||
|
||||
it("the center swaps into the target's box", () => {
|
||||
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
|
||||
});
|
||||
|
||||
it("nothing on its own pane or on a refused move", () => {
|
||||
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
|
||||
const full: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
|
||||
focus: 0,
|
||||
};
|
||||
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||
});
|
||||
});
|
||||
+33
-28
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* @file RouterPicker.test.tsx
|
||||
* @description The per-session AI Router picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
||||
* @file ProxyPicker.test.tsx
|
||||
* @description The per-session AI Proxy picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a proxy,
|
||||
* remembering the pick, and falling back to Native when the remembered
|
||||
* profile is gone; plus `routerArg`, which never sends native.
|
||||
* profile is gone; plus `proxyArg`, which never sends native.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type { AiRouter } from "../../../lib/api";
|
||||
import type { AiProxy } from "../../../lib/api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", routerCapable: true },
|
||||
{ id: "gemini", label: "Gemini", routerCapable: false },
|
||||
{ id: "claude", label: "Claude Code", proxyCapable: true },
|
||||
{ id: "gemini", label: "Gemini", proxyCapable: false },
|
||||
].map((x) => ({ ...x, installed: true, enabled: true })),
|
||||
}),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiProxies: { list: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(api.aiRouters.list)
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native, nine] } as never);
|
||||
});
|
||||
|
||||
describe("RouterPicker", () => {
|
||||
describe("ProxyPicker", () => {
|
||||
it("lists the saved profiles for a tool that can use one", async () => {
|
||||
render(
|
||||
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
|
||||
render(
|
||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
const picker = screen.getByTestId("router-picker");
|
||||
const picker = screen.getByTestId("proxy-picker");
|
||||
expect(picker.querySelector("button")).toBeDisabled();
|
||||
expect(picker).not.toHaveTextContent("9Router");
|
||||
});
|
||||
|
||||
it("falls back to Native when the remembered profile is gone", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
|
||||
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||
});
|
||||
|
||||
it("useAiRouters reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiRouters());
|
||||
it("useAiProxies reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
||||
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionRouter / routerArg", () => {
|
||||
describe("useSessionProxy / proxyArg", () => {
|
||||
it("remembers the last pick for the next session", () => {
|
||||
const first = renderHook(() => useSessionRouter());
|
||||
const first = renderHook(() => useSessionProxy());
|
||||
expect(first.result.current[0]).toBe("native");
|
||||
act(() => first.result.current[1]("9router"));
|
||||
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
|
||||
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||
});
|
||||
|
||||
it("sends a router only for a capable tool and never native", () => {
|
||||
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
|
||||
expect(routerArg(true, "native")).toEqual({});
|
||||
expect(routerArg(false, "9router")).toEqual({});
|
||||
it("still reads the pick remembered before the rename", () => {
|
||||
localStorage.setItem("agenthub.session.router", "9router");
|
||||
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||
});
|
||||
|
||||
it("sends a proxy only for a capable tool and never native", () => {
|
||||
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
|
||||
expect(proxyArg(true, "native")).toEqual({});
|
||||
expect(proxyArg(false, "9router")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
* same list as one chip: aliases up front, pinned versions under More models,
|
||||
* plus the effort slider.
|
||||
@@ -211,7 +211,7 @@ describe("ModelPicker", () => {
|
||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
||||
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
|
||||
render(
|
||||
<ModelPicker
|
||||
value=""
|
||||
|
||||
Reference in New Issue
Block a user