feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)

Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
2026-09-29 16:09:19 +07:00
parent 7ec5a7cd85
commit 969b06253c
95 changed files with 9278 additions and 1696 deletions
@@ -0,0 +1,98 @@
/**
* @file PricingSettings.test.tsx
* @description The shared Model Pricing table: each tool's tab shows its own
* vendor's rows, **Show all models** lists the rest, a row added outside the
* filter turns that switch on instead of vanishing, and Reset Defaults is
* offered only where asked for (the Claude tab).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { PricingSettings } from "../PricingSettings";
import { api } from "../../../lib/api";
import type { ModelPricing } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
settings: { resetPricing: vi.fn() },
},
};
});
const rule = (model_pattern: string, display_name: string) =>
({
model_pattern,
display_name,
input_per_mtok: 1,
output_per_mtok: 2,
cache_read_per_mtok: 0.1,
cache_write_per_mtok: 0,
cache_write_1h_per_mtok: 0,
fast_input_per_mtok: 0,
fast_output_per_mtok: 0,
updated_at: "2026-09-29 10:00:00",
}) as ModelPricing;
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
beforeEach(() => {
i18n.changeLanguage("en");
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
vi.mocked(api.pricing.upsert)
.mockReset()
.mockResolvedValue({} as never);
});
describe("PricingSettings", () => {
it("shows the vendor's rows, and the rest behind Show all models", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
});
it("says where OpenAI prices come from when there are none", async () => {
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
render(<PricingSettings vendor="openai" />);
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
});
it("turns Show all on when a row outside the filter is added", async () => {
const user = userEvent.setup();
render(<PricingSettings vendor="openai" />);
await screen.findByText("gpt-5.6-sol%");
await user.click(screen.getByRole("button", { name: /Add Model/i }));
const [pattern, name] = screen.getAllByRole("textbox");
await user.type(pattern!, "deepseek-v4%");
await user.type(name!, "DeepSeek V4");
vi.mocked(api.pricing.list).mockResolvedValue({
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
});
await user.click(screen.getByTitle(/save/i));
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
});
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
const user = userEvent.setup();
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
render(<PricingSettings vendor="claude" allowReset />);
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
expect(api.settings.resetPricing).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /confirm/i }));
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
});
});