feat(FN-1396): add Budget Settings section to AgentDetailView ConfigTab
- Add Budget Settings section to agent ConfigTab in AgentDetailView - Section includes Token Budget, Usage Threshold, Budget Period, and Reset Day controls - Budget settings are stored in agent.runtimeConfig.budgetConfig and persisted via PATCH /api/agents/:id - Add comprehensive tests for Budget Settings UI interactions - Document Budget Settings in AGENTS.md
This commit is contained in:
@@ -1887,6 +1887,28 @@ function ConfigTab({
|
||||
return initial;
|
||||
});
|
||||
|
||||
// Budget config state initialised from agent.runtimeConfig.budgetConfig
|
||||
const [budgetValues, setBudgetValues] = useState<Record<string, string>>(() => {
|
||||
const bc = (agent.runtimeConfig ?? {}).budgetConfig as Record<string, unknown> | undefined;
|
||||
const initial: Record<string, string> = {};
|
||||
if (bc !== undefined && bc !== null) {
|
||||
if (bc.tokenBudget !== undefined && bc.tokenBudget !== null) {
|
||||
initial.tokenBudget = String(bc.tokenBudget);
|
||||
}
|
||||
if (bc.usageThreshold !== undefined && bc.usageThreshold !== null) {
|
||||
// Convert fraction (0-1) to percentage (0-100) for display
|
||||
initial.usageThreshold = String(Number(bc.usageThreshold) * 100);
|
||||
}
|
||||
if (bc.budgetPeriod !== undefined && bc.budgetPeriod !== null) {
|
||||
initial.budgetPeriod = String(bc.budgetPeriod);
|
||||
}
|
||||
if (bc.resetDay !== undefined && bc.resetDay !== null) {
|
||||
initial.resetDay = String(bc.resetDay);
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
@@ -1913,6 +1935,22 @@ function ConfigTab({
|
||||
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
if (current !== persisted) return true;
|
||||
}
|
||||
// Check budget config values
|
||||
const persistedBc = rc.budgetConfig as Record<string, unknown> | undefined;
|
||||
for (const key of ["tokenBudget", "budgetPeriod", "resetDay"] as const) {
|
||||
const current = budgetValues[key]?.trim() ?? "";
|
||||
const persisted = persistedBc?.[key] !== undefined && persistedBc?.[key] !== null
|
||||
? String(persistedBc[key])
|
||||
: "";
|
||||
if (current !== persisted) return true;
|
||||
}
|
||||
// usageThreshold: compare percentage (UI) against fraction * 100 (persisted)
|
||||
const currentThreshold = budgetValues.usageThreshold?.trim() ?? "";
|
||||
const persistedThreshold = persistedBc?.usageThreshold !== undefined && persistedBc?.usageThreshold !== null
|
||||
? String(Number(persistedBc.usageThreshold) * 100)
|
||||
: "";
|
||||
if (currentThreshold !== persistedThreshold) return true;
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
@@ -1949,6 +1987,18 @@ function ConfigTab({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBudgetFieldChange = (key: string, value: string) => {
|
||||
setBudgetValues((prev) => ({ ...prev, [key]: value }));
|
||||
setJustSaved(false);
|
||||
if (errors[key]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate advanced settings
|
||||
const validationErrors = validateAdvancedSettings(formValues);
|
||||
@@ -1974,6 +2024,49 @@ function ConfigTab({
|
||||
validationErrors.messageResponseMode = "\"Message Response Mode\" must be either immediate or on-heartbeat";
|
||||
}
|
||||
|
||||
// Validate budget settings
|
||||
const tokenBudgetRaw = budgetValues.tokenBudget?.trim();
|
||||
if (tokenBudgetRaw) {
|
||||
const num = Number(tokenBudgetRaw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors.tokenBudget = "\"Token Budget\" must be a valid number";
|
||||
} else if (num <= 0) {
|
||||
validationErrors.tokenBudget = "\"Token Budget\" must be greater than 0";
|
||||
}
|
||||
}
|
||||
|
||||
const usageThresholdRaw = budgetValues.usageThreshold?.trim();
|
||||
if (usageThresholdRaw) {
|
||||
const num = Number(usageThresholdRaw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors.usageThreshold = "\"Usage Threshold\" must be a valid number";
|
||||
} else if (num < 1 || num > 100) {
|
||||
validationErrors.usageThreshold = "\"Usage Threshold\" must be between 1 and 100";
|
||||
}
|
||||
}
|
||||
|
||||
const budgetPeriodRaw = budgetValues.budgetPeriod?.trim();
|
||||
if (budgetPeriodRaw && !["daily", "weekly", "monthly", "lifetime"].includes(budgetPeriodRaw)) {
|
||||
validationErrors.budgetPeriod = "\"Budget Period\" must be one of: daily, weekly, monthly, lifetime";
|
||||
}
|
||||
|
||||
const resetDayRaw = budgetValues.resetDay?.trim();
|
||||
const periodForResetDay = budgetPeriodRaw || "lifetime";
|
||||
if (resetDayRaw) {
|
||||
const num = Number(resetDayRaw);
|
||||
if (Number.isNaN(num) || !Number.isFinite(num)) {
|
||||
validationErrors.resetDay = "\"Reset Day\" must be a valid number";
|
||||
} else if (periodForResetDay === "weekly") {
|
||||
if (num < 0 || num > 6 || !Number.isInteger(num)) {
|
||||
validationErrors.resetDay = "\"Reset Day\" must be between 0 (Sunday) and 6 (Saturday) for weekly period";
|
||||
}
|
||||
} else if (periodForResetDay === "monthly") {
|
||||
if (num < 1 || num > 31 || !Number.isInteger(num)) {
|
||||
validationErrors.resetDay = "\"Reset Day\" must be between 1 and 31 for monthly period";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
addToast("Please fix validation errors before saving", "error");
|
||||
@@ -2012,6 +2105,34 @@ function ConfigTab({
|
||||
newRuntimeConfig.messageResponseMode = messageResponseMode;
|
||||
}
|
||||
|
||||
// Build budgetConfig payload — only include non-empty values
|
||||
const newBudgetConfig: Record<string, unknown> = {};
|
||||
const tokenBudget = budgetValues.tokenBudget?.trim();
|
||||
const usageThreshold = budgetValues.usageThreshold?.trim();
|
||||
const budgetPeriod = budgetValues.budgetPeriod?.trim();
|
||||
const resetDay = budgetValues.resetDay?.trim();
|
||||
|
||||
if (tokenBudget) {
|
||||
newBudgetConfig.tokenBudget = Number(tokenBudget);
|
||||
}
|
||||
if (usageThreshold) {
|
||||
// Convert percentage (UI) to fraction (storage)
|
||||
newBudgetConfig.usageThreshold = Number(usageThreshold) / 100;
|
||||
}
|
||||
if (budgetPeriod) {
|
||||
newBudgetConfig.budgetPeriod = budgetPeriod;
|
||||
}
|
||||
if (resetDay) {
|
||||
newBudgetConfig.resetDay = Number(resetDay);
|
||||
}
|
||||
|
||||
// Only persist budgetConfig if it has any values
|
||||
if (Object.keys(newBudgetConfig).length > 0) {
|
||||
newRuntimeConfig.budgetConfig = newBudgetConfig;
|
||||
} else {
|
||||
delete newRuntimeConfig.budgetConfig;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateAgent(agent.id, { metadata: newMetadata, runtimeConfig: newRuntimeConfig }, projectId);
|
||||
@@ -2166,6 +2287,95 @@ function ConfigTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<h3>Budget Settings</h3>
|
||||
<p className="config-description">
|
||||
Configure token budget limits for this agent. Leave all fields empty to disable budget tracking.
|
||||
</p>
|
||||
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<label htmlFor="budget-tokenBudget">Token Budget</label>
|
||||
<input
|
||||
id="budget-tokenBudget"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.tokenBudget && "input--error")}
|
||||
placeholder="No limit"
|
||||
value={budgetValues.tokenBudget ?? ""}
|
||||
onChange={(e) => handleBudgetFieldChange("tokenBudget", e.target.value)}
|
||||
/>
|
||||
{errors.tokenBudget ? (
|
||||
<span className="config-error">{errors.tokenBudget}</span>
|
||||
) : (
|
||||
<span className="config-hint">Total token cap (input + output) for this agent. Leave empty for no limit.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="budget-usageThreshold">Usage Threshold (%)</label>
|
||||
<input
|
||||
id="budget-usageThreshold"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.usageThreshold && "input--error")}
|
||||
placeholder="80"
|
||||
value={budgetValues.usageThreshold ?? ""}
|
||||
onChange={(e) => handleBudgetFieldChange("usageThreshold", e.target.value)}
|
||||
/>
|
||||
{errors.usageThreshold ? (
|
||||
<span className="config-error">{errors.usageThreshold}</span>
|
||||
) : (
|
||||
<span className="config-hint">Warning threshold as a percentage. Agent warns when usage reaches this level. Default: 80%.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="budget-budgetPeriod">Budget Period</label>
|
||||
<select
|
||||
id="budget-budgetPeriod"
|
||||
className={cn("select", !!errors.budgetPeriod && "input--error")}
|
||||
value={budgetValues.budgetPeriod ?? ""}
|
||||
onChange={(e) => handleBudgetFieldChange("budgetPeriod", e.target.value)}
|
||||
>
|
||||
<option value="">No reset (lifetime)</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
{errors.budgetPeriod ? (
|
||||
<span className="config-error">{errors.budgetPeriod}</span>
|
||||
) : (
|
||||
<span className="config-hint">How often the budget counter resets. Leave empty for lifetime budget.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="budget-resetDay">Reset Day</label>
|
||||
<input
|
||||
id="budget-resetDay"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.resetDay && "input--error")}
|
||||
placeholder="Auto"
|
||||
value={budgetValues.resetDay ?? ""}
|
||||
onChange={(e) => handleBudgetFieldChange("resetDay", e.target.value)}
|
||||
/>
|
||||
{errors.resetDay ? (
|
||||
<span className="config-error">{errors.resetDay}</span>
|
||||
) : (
|
||||
<span className="config-hint">
|
||||
{budgetValues.budgetPeriod === "weekly"
|
||||
? "Day of week (0=Sunday to 6=Saturday) for reset."
|
||||
: budgetValues.budgetPeriod === "monthly"
|
||||
? "Day of month (1-31) for reset."
|
||||
: "Day for reset (weekly: 0-6, monthly: 1-31). Leave empty for automatic."}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<h3>Advanced Settings</h3>
|
||||
<p className="config-description">
|
||||
|
||||
@@ -1204,6 +1204,408 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Budget Settings ──────────────────────────────────────────────────────
|
||||
|
||||
describe("Budget Settings", () => {
|
||||
const navigateToSettings = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByText("Settings"));
|
||||
};
|
||||
|
||||
it("renders Budget Settings section with all fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Token Budget")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Usage Threshold (%)")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Budget Period")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Reset Day")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-fills budget fields from existing runtimeConfig.budgetConfig", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
budgetConfig: {
|
||||
tokenBudget: 1000000,
|
||||
usageThreshold: 0.8, // fraction stored, should display as 80%
|
||||
budgetPeriod: "monthly",
|
||||
resetDay: 15,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const tokenBudgetInput = screen.getByLabelText("Token Budget") as HTMLInputElement;
|
||||
expect(tokenBudgetInput.value).toBe("1000000");
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Usage Threshold (%)") as HTMLInputElement;
|
||||
expect(thresholdInput.value).toBe("80"); // Converted from 0.8 to 80
|
||||
|
||||
const periodSelect = screen.getByLabelText("Budget Period") as HTMLSelectElement;
|
||||
expect(periodSelect.value).toBe("monthly");
|
||||
|
||||
const resetDayInput = screen.getByLabelText("Reset Day") as HTMLInputElement;
|
||||
expect(resetDayInput.value).toBe("15");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty fields when budgetConfig is not set", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
await waitFor(() => {
|
||||
const tokenBudgetInput = screen.getByLabelText("Token Budget") as HTMLInputElement;
|
||||
expect(tokenBudgetInput.value).toBe("");
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Usage Threshold (%)") as HTMLInputElement;
|
||||
expect(thresholdInput.value).toBe("");
|
||||
|
||||
const periodSelect = screen.getByLabelText("Budget Period") as HTMLSelectElement;
|
||||
expect(periodSelect.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => {
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
|
||||
await user.clear(tokenBudgetInput);
|
||||
await user.type(tokenBudgetInput, "500000");
|
||||
|
||||
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
|
||||
await user.clear(thresholdInput);
|
||||
await user.type(thresholdInput, "75");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({
|
||||
runtimeConfig: expect.objectContaining({
|
||||
budgetConfig: {
|
||||
tokenBudget: 500000,
|
||||
usageThreshold: 0.75, // Converted from 75% to 0.75 fraction
|
||||
},
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("converts usage threshold percentage to fraction when saving", async () => {
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
|
||||
await user.clear(thresholdInput);
|
||||
await user.type(thresholdInput, "90");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = mockUpdateAgent.mock.calls[0];
|
||||
const payload = (call as any)[1];
|
||||
expect(payload.runtimeConfig.budgetConfig.usageThreshold).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
it("removes budgetConfig when all budget fields are cleared", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
budgetConfig: {
|
||||
tokenBudget: 1000000,
|
||||
usageThreshold: 0.8,
|
||||
},
|
||||
heartbeatIntervalMs: 30000,
|
||||
},
|
||||
}));
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Clear all budget fields
|
||||
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
|
||||
await user.clear(tokenBudgetInput);
|
||||
|
||||
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
|
||||
await user.clear(thresholdInput);
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgent).toHaveBeenCalledWith(
|
||||
"agent-001",
|
||||
expect.objectContaining({
|
||||
runtimeConfig: expect.not.objectContaining({ budgetConfig: expect.anything() }),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unrelated runtimeConfig keys when saving budget config", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
heartbeatIntervalMs: 30000,
|
||||
heartbeatTimeoutMs: 60000,
|
||||
},
|
||||
}));
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
|
||||
await user.clear(tokenBudgetInput);
|
||||
await user.type(tokenBudgetInput, "200000");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = mockUpdateAgent.mock.calls[0];
|
||||
const payload = (call as any)[1];
|
||||
expect(payload.runtimeConfig.heartbeatIntervalMs).toBe(30000);
|
||||
expect(payload.runtimeConfig.heartbeatTimeoutMs).toBe(60000);
|
||||
expect(payload.runtimeConfig.budgetConfig.tokenBudget).toBe(200000);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for non-numeric token budget", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
|
||||
await user.clear(tokenBudgetInput);
|
||||
await user.type(tokenBudgetInput, "abc");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Token Budget.*must be a valid number/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for token budget <= 0", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
|
||||
await user.clear(tokenBudgetInput);
|
||||
await user.type(tokenBudgetInput, "0");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Token Budget.*must be greater than 0/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for usage threshold outside 1-100 range", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const thresholdInput = await screen.findByLabelText("Usage Threshold (%)");
|
||||
await user.clear(thresholdInput);
|
||||
await user.type(thresholdInput, "150");
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Usage Threshold.*must be between 1 and 100/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid reset day with weekly period", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
budgetConfig: {
|
||||
budgetPeriod: "weekly",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Change period to weekly
|
||||
const periodSelect = await screen.findByLabelText("Budget Period");
|
||||
await user.selectOptions(periodSelect, "weekly");
|
||||
|
||||
const resetDayInput = await screen.findByLabelText("Reset Day");
|
||||
await user.clear(resetDayInput);
|
||||
await user.type(resetDayInput, "7"); // Invalid: 7 is not in 0-6 range
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Reset Day.*must be between 0.*6.*for weekly/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows validation error for invalid reset day with monthly period", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({
|
||||
runtimeConfig: {
|
||||
budgetConfig: {
|
||||
budgetPeriod: "monthly",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
// Change period to monthly
|
||||
const periodSelect = await screen.findByLabelText("Budget Period");
|
||||
await user.selectOptions(periodSelect, "monthly");
|
||||
|
||||
const resetDayInput = await screen.findByLabelText("Reset Day");
|
||||
await user.clear(resetDayInput);
|
||||
await user.type(resetDayInput, "32"); // Invalid: 32 is not in 1-31 range
|
||||
|
||||
await user.click(screen.getByText("Save Settings"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Reset Day.*must be between 1 and 31.*for monthly/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Save Settings button when budget field is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await navigateToSettings(user);
|
||||
|
||||
const tokenBudgetInput = await screen.findByLabelText("Token Budget");
|
||||
await user.clear(tokenBudgetInput);
|
||||
await user.type(tokenBudgetInput, "100000");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Save Settings")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Runs Tab — Click to show logs ──────────────────────────────────
|
||||
|
||||
describe("Runs Tab — click to show logs", () => {
|
||||
|
||||
Reference in New Issue
Block a user