feat(FN-1484): fix agent picker stale cache on project switch

- Clear agent cache when projectId changes in QuickEntryBox and InlineCreateCard
- Add regression tests for agent picker cache invalidation behavior
- Add changeset for @gsxdsm/fusion patch release
This commit is contained in:
gsxdsm
2026-04-09 21:39:09 -07:00
parent b7005001d2
commit 5883dffc74
5 changed files with 215 additions and 0 deletions

View File

@@ -119,6 +119,12 @@ export function InlineCreateCard({
}
}, [description, projectId]);
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
useEffect(() => {
setAgents([]);
setSelectedAgentId(null);
}, [projectId]);
const loadModels = useCallback(async () => {
if (availableModels) {
setLoadedModels(availableModels);

View File

@@ -230,6 +230,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [description, projectId]);
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
useEffect(() => {
setAgents([]);
setSelectedAgentId(null);
}, [projectId]);
// Clean up legacy disclosure persistence key from previous versions
useEffect(() => {
if (typeof window !== "undefined") {

View File

@@ -1082,6 +1082,106 @@ describe("InlineCreateCard button visibility when collapsed", () => {
expect(payload.assignedAgentId).toBeUndefined();
});
});
it("fetches fresh agents when projectId changes to prevent stale cache leakage", async () => {
const project1Agents = [
{
id: "agent-001",
name: "Project One Agent",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any;
const project2Agents = [
{
id: "agent-002",
name: "Project Two Agent",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any;
// First render with project 1
vi.mocked(fetchAgents).mockResolvedValueOnce(project1Agents);
const { rerender } = renderCard([], { projectId: "proj-1" });
expandCard();
// Open agent picker - should show project 1 agent
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => {
expect(screen.getByText("Project One Agent")).toBeInTheDocument();
});
// Close picker and switch to project 2
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
// Rerender with different projectId - agents should be cleared and re-fetched
vi.mocked(fetchAgents).mockResolvedValueOnce(project2Agents);
rerender(<InlineCreateCard
tasks={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
addToast={vi.fn()}
availableModels={MOCK_MODELS}
projectId="proj-2"
/>);
// Open picker again for project 2
expandCard();
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => {
// Should show project 2's agent, not the stale project 1 agent
expect(screen.getByText("Project Two Agent")).toBeInTheDocument();
});
// Verify project 1's agent is NOT shown
expect(screen.queryByText("Project One Agent")).not.toBeInTheDocument();
});
it("clears selected agent when projectId changes", async () => {
const agents = [
{
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any;
vi.mocked(fetchAgents).mockResolvedValue(agents);
const { rerender } = renderCard([], { projectId: "proj-1" });
expandCard();
// Select an agent
fireEvent.click(screen.getByTestId("inline-create-agent-button"));
await waitFor(() => expect(screen.getByText("Test Agent")).toBeInTheDocument());
fireEvent.click(screen.getByText("Test Agent"));
// Switch to different project
rerender(<InlineCreateCard
tasks={[]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
addToast={vi.fn()}
availableModels={MOCK_MODELS}
projectId="proj-2"
/>);
// Selected agent should be cleared, picker should show "Agent" without name
const agentButton = screen.getByTestId("inline-create-agent-button");
expect(agentButton.textContent).toBe(" Agent");
});
});
describe("description fullscreen expansion", () => {

View File

@@ -2335,6 +2335,104 @@ describe("QuickEntryBox", () => {
expect(payload.assignedAgentId).toBeUndefined();
});
});
it("fetches fresh agents when projectId changes to prevent stale cache leakage", async () => {
const project1Agents = [
{
id: "agent-001",
name: "Project One Agent",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any;
const project2Agents = [
{
id: "agent-002",
name: "Project Two Agent",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any;
// First render with project 1
vi.mocked(fetchAgents).mockResolvedValueOnce(project1Agents);
const { rerender } = renderQuickEntryBox({ projectId: "proj-1" });
expandQuickEntry();
// Open agent picker - should show project 1 agent
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => {
expect(screen.getByText("Project One Agent")).toBeInTheDocument();
});
// Close picker and switch to project 2
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
// Rerender with different projectId - agents should be cleared and re-fetched
vi.mocked(fetchAgents).mockResolvedValueOnce(project2Agents);
rerender(<QuickEntryBox
onCreate={vi.fn()}
addToast={vi.fn()}
tasks={[]}
availableModels={MOCK_MODELS}
projectId="proj-2"
/>);
// Open picker again for project 2
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => {
// Should show project 2's agent, not the stale project 1 agent
expect(screen.getByText("Project Two Agent")).toBeInTheDocument();
});
// Verify project 1's agent is NOT shown
expect(screen.queryByText("Project One Agent")).not.toBeInTheDocument();
});
it("clears selected agent when projectId changes", async () => {
const agents = [
{
id: "agent-001",
name: "Test Agent",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any;
vi.mocked(fetchAgents).mockResolvedValue(agents);
const { rerender } = renderQuickEntryBox({ projectId: "proj-1" });
expandQuickEntry();
// Select an agent
fireEvent.click(screen.getByTestId("quick-entry-agent-button"));
await waitFor(() => expect(screen.getByText("Test Agent")).toBeInTheDocument());
fireEvent.click(screen.getByText("Test Agent"));
// Switch to different project
rerender(<QuickEntryBox
onCreate={vi.fn()}
addToast={vi.fn()}
tasks={[]}
availableModels={MOCK_MODELS}
projectId="proj-2"
/>);
// Selected agent should be cleared, picker should show "Agent" without name
const agentButton = screen.getByTestId("quick-entry-agent-button");
expect(agentButton.textContent).toBe(" Agent");
});
});
describe("description fullscreen expansion", () => {