feat(FN-1647): merge fusion/fn-1647
This commit is contained in:
@@ -1317,6 +1317,37 @@ export function SettingsModal({
|
||||
/>
|
||||
<small>Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="specStalenessEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="specStalenessEnabled"
|
||||
type="checkbox"
|
||||
checked={form.specStalenessEnabled || false}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, specStalenessEnabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Enable specification staleness enforcement
|
||||
</label>
|
||||
<small>When enabled, tasks with stale specifications (PROMPT.md older than the threshold) are automatically sent back to triage for re-specification</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="specStalenessMaxAgeMs">Stale Spec Threshold (hours)</label>
|
||||
<input
|
||||
id="specStalenessMaxAgeMs"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.specStalenessMaxAgeMs !== undefined ? Math.round(form.specStalenessMaxAgeMs / 3600000) : ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number(val);
|
||||
setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined }));
|
||||
}}
|
||||
disabled={!form.specStalenessEnabled}
|
||||
/>
|
||||
<small>Maximum age in hours before a specification is considered stale. Default: 6 (21600000 ms). Stored as milliseconds internally.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="maxStuckKills">Max Stuck Retries</label>
|
||||
<input
|
||||
|
||||
@@ -26,6 +26,8 @@ const defaultSettings: Settings = {
|
||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||||
taskStuckTimeoutMs: undefined,
|
||||
maxStuckKills: 6,
|
||||
specStalenessEnabled: false,
|
||||
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: 2,
|
||||
showQuickChatFAB: false,
|
||||
@@ -70,7 +72,7 @@ vi.mock("../PluginManager", () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchPlugins } from "../../api";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification } from "../../api";
|
||||
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
@@ -2316,6 +2318,162 @@ describe("SettingsModal", () => {
|
||||
expect(payload.maxStuckKills).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- Specification Staleness field tests ---
|
||||
|
||||
it("shows Specification Staleness fields in Scheduling section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement");
|
||||
expect(checkbox).toBeTruthy();
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)");
|
||||
expect(thresholdInput).toBeTruthy();
|
||||
expect(thresholdInput.getAttribute("type")).toBe("number");
|
||||
expect(thresholdInput.getAttribute("min")).toBe("0");
|
||||
});
|
||||
|
||||
it("Specification Staleness threshold input is disabled when toggle is off", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
expect(thresholdInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it("Specification Staleness threshold input is enabled when toggle is on", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
expect(thresholdInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("Specification Staleness threshold displays rounded hours from milliseconds", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
specStalenessEnabled: true,
|
||||
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
expect(thresholdInput.value).toBe("6");
|
||||
});
|
||||
|
||||
it("Specification Staleness threshold converts hours to milliseconds on save", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
fireEvent.change(thresholdInput, { target: { value: "12" } });
|
||||
expect(thresholdInput.value).toBe("12");
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.specStalenessEnabled).toBe(true);
|
||||
expect(payload.specStalenessMaxAgeMs).toBe(12 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("Specification Staleness threshold of 0 hours persists 0 milliseconds", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
fireEvent.change(thresholdInput, { target: { value: "0" } });
|
||||
expect(thresholdInput.value).toBe("0");
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.specStalenessEnabled).toBe(true);
|
||||
expect(payload.specStalenessMaxAgeMs).toBe(0);
|
||||
});
|
||||
|
||||
it("Specification Staleness enabled with empty threshold submits without invalid numeric payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
fireEvent.change(thresholdInput, { target: { value: "" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.specStalenessEnabled).toBe(true);
|
||||
expect(payload.specStalenessMaxAgeMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Disabling Specification Staleness retains configured max age", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
specStalenessEnabled: true,
|
||||
specStalenessMaxAgeMs: 8 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
const checkbox = screen.getByLabelText("Enable specification staleness enforcement") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const thresholdInput = screen.getByLabelText("Stale Spec Threshold (hours)") as HTMLInputElement;
|
||||
expect(thresholdInput.value).toBe("8");
|
||||
expect(thresholdInput).not.toBeDisabled();
|
||||
|
||||
// Disable the toggle
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(thresholdInput).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.specStalenessEnabled).toBe(false);
|
||||
expect(payload.specStalenessMaxAgeMs).toBe(8 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("Specification Staleness helper text is visible", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
expect(screen.getByText(/Maximum age in hours before a specification is considered stale/)).toBeTruthy();
|
||||
expect(screen.getByText(/When enabled, tasks with stale specifications/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("scope banners render for global and project sections with theme-aware icons", async () => {
|
||||
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
Reference in New Issue
Block a user