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

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Fix agent picker showing stale agents when switching between projects in QuickEntryBox and InlineCreateCard. The picker now clears cached agents when projectId changes, ensuring fresh agent data is always fetched for the current project context.

View File

@@ -119,6 +119,12 @@ export function InlineCreateCard({
} }
}, [description, projectId]); }, [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 () => { const loadModels = useCallback(async () => {
if (availableModels) { if (availableModels) {
setLoadedModels(availableModels); setLoadedModels(availableModels);

View File

@@ -230,6 +230,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} }
}, [description, projectId]); }, [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 // Clean up legacy disclosure persistence key from previous versions
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {

View File

@@ -1082,6 +1082,106 @@ describe("InlineCreateCard button visibility when collapsed", () => {
expect(payload.assignedAgentId).toBeUndefined(); 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", () => { describe("description fullscreen expansion", () => {

View File

@@ -2335,6 +2335,104 @@ describe("QuickEntryBox", () => {
expect(payload.assignedAgentId).toBeUndefined(); 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", () => { describe("description fullscreen expansion", () => {