Skip to content

Commit 10460d1

Browse files
prosdevclaude
andcommitted
fix: address code review findings from PR #14
- Add StatePanel.test.tsx with 12 tests covering render, delete/undo flow, confirm-delete for in-use fields, add field, node navigation - Add removeStateFields readonly guard and addStateFields dedup tests - Guard against NaN in LLMNodeConfig temperature/max_tokens handlers - Clear undo timer on StatePanel unmount - Remove api_key local var from models.py, use logger.error over logger.exception to avoid leaking keys in stack frames Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ee28bd commit 10460d1

5 files changed

Lines changed: 212 additions & 10 deletions

File tree

packages/canvas/src/components/panels/StatePanel.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useCanvasContext } from "@contexts/CanvasContext";
22
import type { NodeSchema, StateField } from "@shared/schema";
33
import { useGraphStore } from "@store/graphSlice";
44
import { Sheet } from "@ui/Sheet";
5-
import { useCallback, useMemo, useRef, useState } from "react";
5+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
66
import { extractRootKey } from "../canvas/runInputUtils";
77
import { AddFieldForm } from "./AddFieldForm";
88
import { StateFieldRow } from "./StateFieldRow";
@@ -67,6 +67,13 @@ export function StatePanel() {
6767
[stateFields],
6868
);
6969

70+
// Clear undo timer on unmount to avoid setting state on unmounted component
71+
useEffect(() => {
72+
return () => {
73+
if (undoTimerRef.current) clearTimeout(undoTimerRef.current);
74+
};
75+
}, []);
76+
7077
const handleDelete = useCallback(
7178
(field: StateField) => {
7279
removeStateFields([field.key]);
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { NodeSchema, StateField } from "@shared/schema";
2+
import { act, fireEvent, render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { StatePanel } from "../StatePanel";
5+
6+
// Mock CanvasContext
7+
const mockSetSelectedNodeId = vi.fn();
8+
const mockSetStatePanelOpen = vi.fn();
9+
vi.mock("@contexts/CanvasContext", () => ({
10+
useCanvasContext: () => ({
11+
statePanelOpen: true,
12+
setStatePanelOpen: mockSetStatePanelOpen,
13+
setSelectedNodeId: mockSetSelectedNodeId,
14+
}),
15+
}));
16+
17+
// Mock graphSlice
18+
const mockAddStateFields = vi.fn();
19+
const mockRemoveStateFields = vi.fn();
20+
let mockState: StateField[] = [];
21+
let mockNodes: NodeSchema[] = [];
22+
23+
vi.mock("@store/graphSlice", () => ({
24+
useGraphStore: (selector: (s: Record<string, unknown>) => unknown) =>
25+
selector({
26+
graph: { state: mockState },
27+
nodes: mockNodes,
28+
addStateFields: mockAddStateFields,
29+
removeStateFields: mockRemoveStateFields,
30+
}),
31+
DEFAULT_FIELD_KEYS: new Set(["messages", "user_input", "llm_response"]),
32+
}));
33+
34+
const DEFAULT_FIELDS: StateField[] = [
35+
{ key: "messages", type: "list", reducer: "append", readonly: true },
36+
{ key: "user_input", type: "string", reducer: "replace" },
37+
{ key: "llm_response", type: "string", reducer: "replace" },
38+
];
39+
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
mockState = [...DEFAULT_FIELDS];
43+
mockNodes = [];
44+
});
45+
46+
describe("StatePanel", () => {
47+
it("renders all state fields", () => {
48+
render(<StatePanel />);
49+
expect(screen.getByText("messages")).toBeInTheDocument();
50+
expect(screen.getByText("user_input")).toBeInTheDocument();
51+
expect(screen.getByText("llm_response")).toBeInTheDocument();
52+
});
53+
54+
it("shows empty hint when no fields exist", () => {
55+
mockState = [];
56+
render(<StatePanel />);
57+
expect(screen.getByText(/no state fields yet/i)).toBeInTheDocument();
58+
});
59+
60+
it("shows readonly badge on readonly fields", () => {
61+
render(<StatePanel />);
62+
expect(screen.getByText("RO")).toBeInTheDocument();
63+
});
64+
65+
it("hides delete button for readonly fields", () => {
66+
render(<StatePanel />);
67+
expect(screen.queryByLabelText("Delete messages")).not.toBeInTheDocument();
68+
});
69+
70+
it("shows delete button for non-readonly fields", () => {
71+
render(<StatePanel />);
72+
expect(screen.getByLabelText("Delete user_input")).toBeInTheDocument();
73+
});
74+
75+
it("calls removeStateFields when delete is clicked", () => {
76+
render(<StatePanel />);
77+
fireEvent.click(screen.getByLabelText("Delete user_input"));
78+
expect(mockRemoveStateFields).toHaveBeenCalledWith(["user_input"]);
79+
});
80+
81+
it("shows undo toast after delete", () => {
82+
render(<StatePanel />);
83+
fireEvent.click(screen.getByLabelText("Delete user_input"));
84+
expect(screen.getByText(/removed field/i)).toBeInTheDocument();
85+
expect(screen.getByText("Undo")).toBeInTheDocument();
86+
});
87+
88+
it("undo toast disappears after 5 seconds", () => {
89+
vi.useFakeTimers();
90+
render(<StatePanel />);
91+
fireEvent.click(screen.getByLabelText("Delete user_input"));
92+
expect(screen.getByText("Undo")).toBeInTheDocument();
93+
act(() => {
94+
vi.advanceTimersByTime(5000);
95+
});
96+
expect(screen.queryByText("Undo")).not.toBeInTheDocument();
97+
vi.useRealTimers();
98+
});
99+
100+
it("undo restores the deleted field", () => {
101+
render(<StatePanel />);
102+
fireEvent.click(screen.getByLabelText("Delete user_input"));
103+
fireEvent.click(screen.getByText("Undo"));
104+
expect(mockAddStateFields).toHaveBeenCalledWith([
105+
{ key: "user_input", type: "string", reducer: "replace" },
106+
]);
107+
expect(screen.queryByText("Undo")).not.toBeInTheDocument();
108+
});
109+
110+
it("shows confirm dialog when deleting a field with usage", () => {
111+
mockNodes = [
112+
{
113+
id: "llm-1",
114+
type: "llm",
115+
label: "Chat",
116+
position: { x: 0, y: 0 },
117+
config: {
118+
provider: "openai",
119+
model: "gpt-4o",
120+
system_prompt: "",
121+
temperature: 0.7,
122+
max_tokens: 1024,
123+
input_map: { prompt: "user_input" },
124+
output_key: "llm_response",
125+
},
126+
} as NodeSchema,
127+
];
128+
render(<StatePanel />);
129+
fireEvent.click(screen.getByLabelText("Delete user_input"));
130+
// First click shows confirmation, not delete
131+
expect(mockRemoveStateFields).not.toHaveBeenCalled();
132+
expect(screen.getByText(/referenced by nodes/i)).toBeInTheDocument();
133+
// Click "Remove anyway" to actually delete
134+
fireEvent.click(screen.getByText("Remove anyway"));
135+
expect(mockRemoveStateFields).toHaveBeenCalledWith(["user_input"]);
136+
});
137+
138+
it("adds a new field via AddFieldForm", async () => {
139+
render(<StatePanel />);
140+
const input = screen.getByPlaceholderText("field_name");
141+
await userEvent.type(input, "custom_data");
142+
fireEvent.click(screen.getByText("Add field"));
143+
expect(mockAddStateFields).toHaveBeenCalledWith([
144+
{ key: "custom_data", type: "string", reducer: "replace" },
145+
]);
146+
});
147+
148+
it("clicking a writer node name calls setSelectedNodeId", () => {
149+
mockNodes = [
150+
{
151+
id: "llm-1",
152+
type: "llm",
153+
label: "ChatBot",
154+
position: { x: 0, y: 0 },
155+
config: {
156+
provider: "openai",
157+
model: "gpt-4o",
158+
system_prompt: "",
159+
temperature: 0.7,
160+
max_tokens: 1024,
161+
input_map: {},
162+
output_key: "llm_response",
163+
},
164+
} as NodeSchema,
165+
];
166+
render(<StatePanel />);
167+
// llm_response is written by "ChatBot" node — click the usage link
168+
const writerLinks = screen.getAllByText(/ChatBot/);
169+
fireEvent.click(writerLinks[0] as HTMLElement);
170+
expect(mockSetSelectedNodeId).toHaveBeenCalledWith("llm-1");
171+
});
172+
});

packages/canvas/src/components/panels/config/LLMNodeConfig.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,16 @@ function LLMNodeConfigComponent({ node, onChange }: LLMNodeConfigProps) {
174174

175175
const handleTemperatureChange = useCallback(
176176
(e: ChangeEvent<HTMLInputElement>) => {
177-
onChange({ config: { temperature: Number.parseFloat(e.target.value) } });
177+
const parsed = Number.parseFloat(e.target.value);
178+
if (!Number.isNaN(parsed)) onChange({ config: { temperature: parsed } });
178179
},
179180
[onChange],
180181
);
181182

182183
const handleMaxTokensChange = useCallback(
183184
(e: ChangeEvent<HTMLInputElement>) => {
184-
onChange({ config: { max_tokens: Number.parseInt(e.target.value, 10) } });
185+
const parsed = Number.parseInt(e.target.value, 10);
186+
if (!Number.isNaN(parsed)) onChange({ config: { max_tokens: parsed } });
185187
},
186188
[onChange],
187189
);

packages/canvas/src/store/__tests__/graphSlice.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,27 @@ describe("graphSlice", () => {
155155
expect(graph?.name).toBe("New Name");
156156
expect(dirty).toBe(true);
157157
});
158+
159+
it("removeStateFields ignores readonly fields", () => {
160+
useGraphStore.getState().newGraph("G");
161+
const before = useGraphStore.getState().graph?.state ?? [];
162+
// messages is readonly, user_input is not
163+
useGraphStore.getState().removeStateFields(["messages", "user_input"]);
164+
const after = useGraphStore.getState().graph?.state ?? [];
165+
expect(after.find((f) => f.key === "messages")).toBeDefined();
166+
expect(after.find((f) => f.key === "user_input")).toBeUndefined();
167+
expect(after).toHaveLength(before.length - 1);
168+
});
169+
170+
it("addStateFields deduplicates against existing keys", () => {
171+
useGraphStore.getState().newGraph("G");
172+
const before = useGraphStore.getState().graph?.state ?? [];
173+
useGraphStore
174+
.getState()
175+
.addStateFields([
176+
{ key: "messages", type: "string", reducer: "replace" },
177+
]);
178+
const after = useGraphStore.getState().graph?.state ?? [];
179+
expect(after).toHaveLength(before.length);
180+
});
158181
});

packages/execution/app/models.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@
2929

3030
async def list_openai_models() -> list[str]:
3131
"""List available OpenAI chat models."""
32-
api_key = os.getenv("OPENAI_API_KEY")
33-
if not api_key:
32+
if not os.getenv("OPENAI_API_KEY"):
3433
return []
3534
try:
3635
from openai import AsyncOpenAI
@@ -43,19 +42,18 @@ async def list_openai_models() -> list[str]:
4342
if not m.id.startswith(_OPENAI_NON_CHAT_PREFIXES) and "realtime" not in m.id
4443
)
4544
except Exception:
46-
logger.exception("Failed to list OpenAI models")
45+
logger.error("Failed to list OpenAI models", exc_info=True)
4746
return []
4847

4948

5049
async def list_gemini_models() -> list[str]:
5150
"""List available Gemini models that support generateContent."""
52-
api_key = os.getenv("GEMINI_API_KEY")
53-
if not api_key:
51+
if not os.getenv("GEMINI_API_KEY"):
5452
return []
5553
try:
5654
import google.generativeai as genai
5755

58-
genai.configure(api_key=api_key)
56+
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
5957
# genai.list_models() is synchronous — wrap to avoid blocking event loop
6058
raw_models = await asyncio.to_thread(genai.list_models)
6159
models = []
@@ -66,7 +64,7 @@ async def list_gemini_models() -> list[str]:
6664
models.append(name)
6765
return sorted(models)
6866
except Exception:
69-
logger.exception("Failed to list Gemini models")
67+
logger.error("Failed to list Gemini models", exc_info=True)
7068
return []
7169

7270

0 commit comments

Comments
 (0)