Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions frontend/__tests__/hooks/query/use-conversation-hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationHooks } from "#/hooks/query/use-conversation-hooks";
import { AgentState } from "#/types/agent-state";

vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "conversation-1" }),
}));

vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: () => ({ curAgentState: AgentState.RUNNING }),
}));

describe("useConversationHooks", () => {
let queryClient: QueryClient;

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.restoreAllMocks();
});

it("fetches hooks without a settings gate", async () => {
const hooks = [{ event_type: "stop", matchers: [] }];
const getHooks = vi
.spyOn(V1ConversationService, "getHooks")
.mockResolvedValue({ hooks });

const { result } = renderHook(() => useConversationHooks(), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(getHooks).toHaveBeenCalledWith("conversation-1");
expect(result.current.data).toEqual(hooks);
});
});
57 changes: 57 additions & 0 deletions frontend/__tests__/hooks/query/use-start-tasks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import type {
V1AppConversationStartTask,
V1AppConversationStartTaskStatus,
} from "#/api/conversation-service/v1-conversation-service.types";
import { useStartTasks } from "#/hooks/query/use-start-tasks";

const createTask = (
id: string,
status: V1AppConversationStartTaskStatus,
): V1AppConversationStartTask => ({
id,
created_by_user_id: null,
status,
detail: null,
app_conversation_id: null,
sandbox_id: null,
agent_server_url: null,
request: {} as V1AppConversationStartTask["request"],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
});

describe("useStartTasks", () => {
let queryClient: QueryClient;

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.restoreAllMocks();
});

it("fetches and filters start tasks without a settings gate", async () => {
const searchStartTasks = vi
.spyOn(V1ConversationService, "searchStartTasks")
.mockResolvedValue([
createTask("working", "WORKING"),
createTask("ready", "READY"),
createTask("error", "ERROR"),
]);

const { result } = renderHook(() => useStartTasks(25), { wrapper });

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(searchStartTasks).toHaveBeenCalledWith(25);
expect(result.current.data?.map(({ id }) => id)).toEqual(["working"]);
});
});
12 changes: 12 additions & 0 deletions frontend/__tests__/routes/app-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ describe("Content", () => {
});
});

it("should not render the legacy max-budget control", async () => {
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
v1_enabled: false,
});

renderAppSettingsScreen();

await screen.findByTestId("language-input");
expect(screen.queryByTestId("max-budget-per-task-input")).toBeNull();
});

it("should render analytics toggle as enabled when server returns null (opt-in by default)", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
Expand Down
49 changes: 1 addition & 48 deletions frontend/__tests__/utils/settings-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseMaxBudgetPerTask, extractSettings } from "#/utils/settings-utils";

describe("parseMaxBudgetPerTask", () => {
it("should return null for empty string", () => {
expect(parseMaxBudgetPerTask("")).toBeNull();
});

it("should return null for whitespace-only string", () => {
expect(parseMaxBudgetPerTask(" ")).toBeNull();
});

it("should return null for non-numeric string", () => {
expect(parseMaxBudgetPerTask("abc")).toBeNull();
});

it("should return null for values less than 1", () => {
expect(parseMaxBudgetPerTask("0")).toBeNull();
expect(parseMaxBudgetPerTask("0.5")).toBeNull();
expect(parseMaxBudgetPerTask("-1")).toBeNull();
expect(parseMaxBudgetPerTask("-10.5")).toBeNull();
});

it("should return the parsed value for valid numbers >= 1", () => {
expect(parseMaxBudgetPerTask("1")).toBe(1);
expect(parseMaxBudgetPerTask("1.0")).toBe(1);
expect(parseMaxBudgetPerTask("1.5")).toBe(1.5);
expect(parseMaxBudgetPerTask("10")).toBe(10);
expect(parseMaxBudgetPerTask("100.99")).toBe(100.99);
});

it("should handle string numbers with leading/trailing whitespace", () => {
expect(parseMaxBudgetPerTask(" 1 ")).toBe(1);
expect(parseMaxBudgetPerTask(" 10.5 ")).toBe(10.5);
});

it("should return null for edge cases", () => {
expect(parseMaxBudgetPerTask("0.999")).toBeNull();
expect(parseMaxBudgetPerTask("NaN")).toBeNull();
expect(parseMaxBudgetPerTask("Infinity")).toBeNull();
expect(parseMaxBudgetPerTask("-Infinity")).toBeNull();
});

it("should handle scientific notation", () => {
expect(parseMaxBudgetPerTask("1e0")).toBe(1);
expect(parseMaxBudgetPerTask("1.5e1")).toBe(15);
expect(parseMaxBudgetPerTask("5e-1")).toBeNull(); // 0.5, which is < 1
});
});
import { extractSettings } from "#/utils/settings-utils";

describe("extractSettings", () => {
it("should preserve model name case when extracting settings", () => {
Expand Down
10 changes: 1 addition & 9 deletions frontend/src/hooks/query/use-conversation-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,23 @@ import V1ConversationService from "#/api/conversation-service/v1-conversation-se
import { useConversationId } from "../use-conversation-id";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useSettings } from "./use-settings";

export const useConversationHooks = () => {
const { conversationId } = useConversationId();
const { curAgentState } = useAgentState();
const { data: settings } = useSettings();

return useQuery({
queryKey: ["conversation", conversationId, "hooks", settings?.v1_enabled],
queryKey: ["conversation", conversationId, "hooks"],
queryFn: async () => {
if (!conversationId) {
throw new Error("No conversation ID provided");
}

// Hooks are only available for V1 conversations
if (!settings?.v1_enabled) {
return [];
}

const data = await V1ConversationService.getHooks(conversationId);
return data.hooks;
},
enabled:
!!conversationId &&
!!settings?.v1_enabled &&
curAgentState !== AgentState.LOADING &&
curAgentState !== AgentState.INIT,
staleTime: 1000 * 60 * 5, // 5 minutes
Expand Down
10 changes: 2 additions & 8 deletions frontend/src/hooks/query/use-start-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useSettings } from "#/hooks/query/use-settings";

/**
* Hook to fetch in-progress V1 conversation start tasks
Expand All @@ -13,17 +12,12 @@ import { useSettings } from "#/hooks/query/use-settings";
* @param limit Maximum number of tasks to return (max 100)
* @returns Query result with array of in-progress start tasks
*/
export const useStartTasks = (limit = 10) => {
const { data: settings } = useSettings();
const isV1Enabled = settings?.v1_enabled;

return useQuery({
export const useStartTasks = (limit = 10) =>
useQuery({
queryKey: ["start-tasks", "search", limit],
queryFn: () => V1ConversationService.searchStartTasks(limit),
enabled: isV1Enabled,
select: (tasks) =>
tasks.filter(
(task) => task.status !== "READY" && task.status !== "ERROR",
),
});
};
32 changes: 0 additions & 32 deletions frontend/src/routes/app-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
import { useConfig } from "#/hooks/query/use-config";
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
import {
SandboxGroupingStrategy,
SandboxGroupingStrategyOptions,
Expand Down Expand Up @@ -69,8 +68,6 @@ function AppSettingsScreen() {
const [selectedSandboxSpecId, setSelectedSandboxSpecId] = React.useState<
string | null | undefined
>(undefined);
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
React.useState(false);
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
React.useState(false);
const [gitUserEmailHasChanged, setGitUserEmailHasChanged] =
Expand Down Expand Up @@ -108,11 +105,6 @@ function AppSettingsScreen() {
? selectedSandboxSpecId
: (settings?.default_sandbox_spec_id ?? null);

const maxBudgetPerTaskValue = formData
.get("max-budget-per-task-input")
?.toString();
const maxBudgetPerTask = parseMaxBudgetPerTask(maxBudgetPerTaskValue || "");

const gitUserName =
formData.get("git-user-name-input")?.toString() ||
DEFAULT_SETTINGS.git_user_name;
Expand All @@ -129,7 +121,6 @@ function AppSettingsScreen() {
enable_solvability_analysis: enableSolvabilityAnalysis,
sandbox_grouping_strategy: sandboxGroupingStrategy,
default_sandbox_spec_id: defaultSandboxSpecId,
max_budget_per_task: maxBudgetPerTask,
git_user_name: gitUserName,
git_user_email: gitUserEmail,
git_full_clone: gitFullClone,
Expand All @@ -154,7 +145,6 @@ function AppSettingsScreen() {
setSelectedSandboxGroupingStrategy(null);
setSandboxSpecIdHasChanged(false);
setSelectedSandboxSpecId(undefined);
setMaxBudgetPerTaskHasChanged(false);
setGitUserNameHasChanged(false);
setGitUserEmailHasChanged(false);
setGitFullCloneHasChanged(false);
Expand Down Expand Up @@ -217,12 +207,6 @@ function AppSettingsScreen() {
setSandboxSpecIdHasChanged(newSpecId !== currentSpecId);
};

const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
const newValue = parseMaxBudgetPerTask(value);
const currentValue = settings?.max_budget_per_task;
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
};

const checkIfGitUserNameHasChanged = (value: string) => {
const currentValue = settings?.git_user_name;
setGitUserNameHasChanged(value !== currentValue);
Expand All @@ -246,7 +230,6 @@ function AppSettingsScreen() {
!solvabilityAnalysisSwitchHasChanged &&
!sandboxGroupingStrategyHasChanged &&
!sandboxSpecIdHasChanged &&
!maxBudgetPerTaskHasChanged &&
!gitUserNameHasChanged &&
!gitUserEmailHasChanged &&
!gitFullCloneHasChanged;
Expand Down Expand Up @@ -352,21 +335,6 @@ function AppSettingsScreen() {
wrapperClassName="w-full max-w-[680px]"
/>

{!settings?.v1_enabled && (
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.max_budget_per_task?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
/>
)}

<div className="border-t border-t-tertiary pt-6 mt-2">
<h3 className="text-lg font-medium mb-2">
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
Expand Down
17 changes: 0 additions & 17 deletions frontend/src/utils/settings-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,6 @@ const extractBasicFormData = (formData: FormData) => {
};
};

/**
* Parses and validates a max budget per task value.
* Ensures the value is at least 1 dollar.
* @param value - The string value to parse
* @returns The parsed number if valid (>= 1), null otherwise
*/
export const parseMaxBudgetPerTask = (value: string): number | null => {
if (!value) {
return null;
}

const parsedValue = parseFloat(value);
return parsedValue && parsedValue >= 1 && Number.isFinite(parsedValue)
? parsedValue
: null;
};

export const extractSettings = (
formData: FormData,
): Partial<Settings> & Record<string, unknown> => {
Expand Down
Loading