Skip to content

Commit 93fc8cc

Browse files
committed
Support tri-state nullable boolean
1 parent 541a59f commit 93fc8cc

File tree

2 files changed

+102
-1
lines changed

2 files changed

+102
-1
lines changed

client/src/components/ToolsTab.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,11 @@ const ToolsTab = ({
179179
onCheckedChange={(checked: boolean) =>
180180
setParams({
181181
...params,
182-
[key]: checked ? null : prop.default,
182+
[key]: checked
183+
? null
184+
: prop.type === "boolean"
185+
? false
186+
: prop.default,
183187
})
184188
}
185189
/>

client/src/components/__tests__/ToolsTab.test.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,103 @@ describe("ToolsTab", () => {
177177
});
178178
});
179179

180+
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
181+
const mockCallTool = jest.fn();
182+
const toolWithNullableBoolean: Tool = {
183+
name: "testTool",
184+
description: "Tool with nullable boolean",
185+
inputSchema: {
186+
type: "object" as const,
187+
properties: {
188+
optionalBoolean: {
189+
type: ["boolean", "null"] as const,
190+
default: null,
191+
},
192+
},
193+
},
194+
};
195+
196+
renderToolsTab({
197+
tools: [toolWithNullableBoolean],
198+
selectedTool: toolWithNullableBoolean,
199+
callTool: mockCallTool,
200+
});
201+
202+
const nullCheckbox = screen.getByRole("checkbox", { name: /null/i });
203+
const runButton = screen.getByRole("button", { name: /run tool/i });
204+
205+
// State 1: Initial state should be null (input disabled)
206+
const wrapper = screen.getByRole("toolinputwrapper");
207+
expect(wrapper.classList).toContain("pointer-events-none");
208+
expect(wrapper.classList).toContain("opacity-50");
209+
210+
// Verify tool is called with null initially
211+
await act(async () => {
212+
fireEvent.click(runButton);
213+
});
214+
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
215+
optionalBoolean: null,
216+
});
217+
218+
// State 2: Uncheck null checkbox -> should set value to false and enable input
219+
await act(async () => {
220+
fireEvent.click(nullCheckbox);
221+
});
222+
expect(wrapper.classList).not.toContain("pointer-events-none");
223+
224+
// Clear previous calls to make assertions clearer
225+
mockCallTool.mockClear();
226+
227+
// Verify tool can be called with false
228+
await act(async () => {
229+
fireEvent.click(runButton);
230+
});
231+
expect(mockCallTool).toHaveBeenLastCalledWith(
232+
toolWithNullableBoolean.name,
233+
{
234+
optionalBoolean: false,
235+
},
236+
);
237+
238+
// State 3: Check boolean checkbox -> should set value to true
239+
// Find the boolean checkbox within the input wrapper (to avoid ID conflict with null checkbox)
240+
const booleanCheckbox = within(wrapper).getByRole("checkbox");
241+
242+
mockCallTool.mockClear();
243+
244+
await act(async () => {
245+
fireEvent.click(booleanCheckbox);
246+
});
247+
248+
// Verify tool can be called with true
249+
await act(async () => {
250+
fireEvent.click(runButton);
251+
});
252+
expect(mockCallTool).toHaveBeenLastCalledWith(
253+
toolWithNullableBoolean.name,
254+
{
255+
optionalBoolean: true,
256+
},
257+
);
258+
259+
// State 4: Check null checkbox again -> should set value back to null and disable input
260+
await act(async () => {
261+
fireEvent.click(nullCheckbox);
262+
});
263+
expect(wrapper.classList).toContain("pointer-events-none");
264+
265+
// Verify tool can be called with null again
266+
await act(async () => {
267+
fireEvent.click(runButton);
268+
});
269+
expect(mockCallTool).toHaveBeenLastCalledWith(
270+
toolWithNullableBoolean.name,
271+
{
272+
optionalBoolean: null,
273+
},
274+
);
275+
});
276+
180277
it("should disable button and change text while tool is running", async () => {
181278
// Create a promise that we can resolve later
182279
let resolvePromise: ((value: unknown) => void) | undefined;

0 commit comments

Comments
 (0)