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:
2026-10-06 22:48:09 +07:00
parent dd9b23106c
commit 5fe5d51b1b
82 changed files with 1792 additions and 1412 deletions
@@ -0,0 +1,288 @@
/**
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiProxyConfig } from "../AiProxyConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
aiProxies: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "",
models: {},
};
const nineRouter = {
...native,
name: "9router",
label: "9Router",
builtin: false,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
vi.mocked(api.aiProxies.test)
.mockReset()
.mockResolvedValue({
claude: { ok: true, status: 200, message: "Highest" },
codex: { ok: true, status: 200, message: "Highest" },
});
});
describe("AiProxyConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiProxyConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiProxy\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiProxy\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the proxy's model names in Claude Code (#56)", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiProxy.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiProxy.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() =>
expect(api.aiProxies.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiProxy.edit 9Router"));
expect(screen.getByLabelText("aiProxy.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiProxy.fieldLabel"));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
await waitFor(() => expect(api.aiProxies.update).toHaveBeenCalled());
const call = vi.mocked(api.aiProxies.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiProxies.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("AiProxyConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
await waitFor(() =>
expect(api.aiProxies.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
const lines = await screen.findAllByTestId("proxy-test-result");
expect(lines[0]).toHaveTextContent("Claude Code: aiProxy.testOk");
expect(lines[1]).toHaveTextContent("Codex: aiProxy.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiProxy\.edit 9Router/));
await user.click(screen.getByTestId("proxy-test"));
await waitFor(() =>
expect(api.aiProxies.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the proxy's own refusal per CLI rather than claiming it works", async () => {
vi.mocked(api.aiProxies.test).mockResolvedValue({
claude: { ok: false, status: 401, message: "invalid api key" },
codex: { ok: true, status: 200, message: "Highest" },
});
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
const [claude, codex] = await screen.findAllByTestId("proxy-test-result");
expect(claude).toHaveTextContent("Claude Code: aiProxy.testFailed");
expect(codex).toHaveTextContent("Codex: aiProxy.testOk");
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiProxyConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "Highest");
await user.click(screen.getByTestId("proxy-test"));
expect(await screen.findAllByTestId("proxy-test-result")).toHaveLength(2);
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryAllByTestId("proxy-test-result")).toHaveLength(0));
});
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, nineRouter] });
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiProxyConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
expect(screen.queryByRole("menu")).toBeNull();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiProxy.remove" }));
await waitFor(() => expect(pending?.title).toBe("aiProxy.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiProxies.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiProxy.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
off();
});
});
@@ -1,290 +0,0 @@
/**
* @file Tests for the AI Router settings tab — listing profiles, creating one,
* and the two rules that keep a key safe: the form never shows one, and an
* edit that leaves the field blank must not send an empty key that would wipe
* the stored one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AiRouterConfig } from "../AiRouterConfig";
import { api } from "../../lib/api";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
vi.mock("../../lib/api", () => ({
api: {
aiRouters: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
test: vi.fn(),
},
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.label === "string" ? `${key}:${opts.label}` : key,
}),
}));
const native = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
const nineRouter = {
...native,
name: "9router",
label: "9Router",
builtin: false,
baseUrl: "http://localhost:20128/v1",
hasKey: true,
model: "Highest",
models: {
opus: "Highest",
sonnet: "Highest",
haiku: "Highest",
smallFast: "Highest",
subagent: "Highest",
},
};
beforeEach(() => {
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [native] });
vi.mocked(api.aiRouters.create).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.update).mockReset().mockResolvedValue(nineRouter);
vi.mocked(api.aiRouters.remove).mockReset().mockResolvedValue({ removed: "9router" });
vi.mocked(api.aiRouters.test)
.mockReset()
.mockResolvedValue({ ok: true, status: 200, message: "Highest" });
});
describe("AiRouterConfig", () => {
it("lists the built-in native profile with no edit or delete control", async () => {
render(<AiRouterConfig />);
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByLabelText(/aiRouter\.edit Native/)).toBeNull();
expect(screen.queryByLabelText(/aiRouter\.remove Native/)).toBeNull();
});
it("saves a new profile with the typed endpoint, key and model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
model: "Highest",
// Every tier equals the headline model, so none is sent: the server
// fills them, which is what makes one field enough.
models: {},
behavesAs: "",
apiKey: "sk-typed",
})
);
});
it("sends the typed behavesAs, which maps the router's model names in Claude Code (#56)", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "agent");
await user.type(screen.getByLabelText("aiRouter.fieldBehavesAs"), "claude-opus-5-5");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ model: "agent", behavesAs: "claude-opus-5-5" })
)
);
});
it("sends only the tiers that differ from the headline model", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.type(screen.getByLabelText("aiRouter.tier.opus"), "PremiumCombo");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() =>
expect(api.aiRouters.create).toHaveBeenCalledWith(
expect.objectContaining({ models: { opus: "PremiumCombo" } })
)
);
});
it("never puts a stored key in the edit form, and omits it when left blank", async () => {
// The API cannot return a key, so an edit that sends `apiKey: ""` would
// silently erase the one on disk.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText("aiRouter.edit 9Router"));
expect(screen.getByLabelText("aiRouter.fieldApiKey")).toHaveValue("");
await user.clear(screen.getByLabelText("aiRouter.fieldLabel"));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "9Router nhà");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
await waitFor(() => expect(api.aiRouters.update).toHaveBeenCalled());
const call = vi.mocked(api.aiRouters.update).mock.calls[0];
expect(call?.[1]).not.toHaveProperty("apiKey");
});
it("surfaces a server error instead of closing the form", async () => {
vi.mocked(api.aiRouters.create).mockRejectedValue(
new Error("baseUrl must be http:// or https://")
);
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldLabel"), "Bad");
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "file:///etc/passwd");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "x");
await user.click(screen.getByRole("button", { name: "aiRouter.save" }));
// Inside the dialog, not behind its scrim.
const dialog = screen.getByRole("dialog");
expect(await within(dialog).findByText(/must be http/)).toBeInTheDocument();
});
it("closes the form on Escape", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
});
describe("AiRouterConfig — testing an endpoint before saving it", () => {
it("sends the typed endpoint, key and model, and reports success", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldApiKey"), "sk-typed");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
apiKey: "sk-typed",
})
);
expect(await screen.findByTestId("router-test-result")).toHaveTextContent("aiRouter.testOk");
});
it("names the profile being edited so the server can reuse its stored key", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByLabelText(/aiRouter\.edit 9Router/));
await user.click(screen.getByTestId("router-test"));
await waitFor(() =>
expect(api.aiRouters.test).toHaveBeenCalledWith({
baseUrl: "http://localhost:20128/v1",
model: "Highest",
name: "9router",
})
);
});
it("shows the router's own refusal rather than claiming it works", async () => {
vi.mocked(api.aiRouters.test).mockResolvedValue({
ok: false,
status: 401,
message: "invalid api key",
});
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toHaveTextContent(
"aiRouter.testFailed"
);
});
it("drops a stale verdict as soon as a field changes", async () => {
render(<AiRouterConfig />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: /aiRouter\.add/ }));
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "http://localhost:20128/v1");
await user.type(screen.getByLabelText("aiRouter.fieldModel"), "Highest");
await user.click(screen.getByTestId("router-test"));
expect(await screen.findByTestId("router-test-result")).toBeInTheDocument();
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
});
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let pending: PendingConfirm | null = null;
const off = subscribeConfirm((p) => (pending = p));
const user = userEvent.setup();
render(<AiRouterConfig />);
const row = (await screen.findByText("9Router")).closest("tr")!;
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
expect(screen.queryByRole("menu")).toBeNull();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
await waitFor(() => expect(pending?.title).toBe("aiRouter.removeConfirm:9Router"));
settleConfirm(false);
await waitFor(() => expect(pending).toBeNull());
expect(api.aiRouters.remove).not.toHaveBeenCalled();
await user.pointer({ keys: "[MouseRight]", target: row });
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
off();
});
});
@@ -50,7 +50,7 @@ const GEMINI: AiTool = {
prompt: { mode: "flag" },
resume: false,
bypass: true,
routerCapable: false,
proxyCapable: false,
install: { kind: "npm", package: "@google/gemini-cli" },
update: { kind: "npm" },
modelFlag: true,
@@ -30,7 +30,7 @@ vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-co
vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiRouterConfig", () => ({ AiRouterConfig: () => null }));
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
@@ -87,7 +87,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
aiRouters: { list: r({ items: [] }) },
aiProxies: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
};
@@ -184,11 +184,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
}),
},
},
// The lane card's router picker. Only the built-in profile, which is the
// The lane card's proxy picker. Only the built-in profile, which is the
// shape that renders no picker — nothing here asserts on it.
aiRouters: {
aiProxies: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/ai-routers");
recordCall("GET", "/api/ai-proxies");
return { items: [] };
}),
},
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.5.4 (rebuild client to match)
UI build v2.5.5 (rebuild client to match)
</p>
</div>
<div
@@ -258,7 +258,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
return {
...actual,
api: {
aiRouters: { list: r({ items: [] }) },
aiProxies: { list: r({ items: [] }) },
stats: { get: r(stats), facets: r({ cwds: [] }) },
sessions: {
list: r({ sessions: [], total: 0, limit: 50, offset: 0 }),