feat(FN-3078): merge fusion/fn-3078
This merge lands four features: a `/clear` command for Chat and Quick Chat with session banner fixes (FN-3062), a todo view redesign with restructured rows and improved styling (FN-3063), deterministic peer exchange shutdown with dashboard improvements (FN-3040), and a summary Q&A disclosure feature Fusion-Task-Id: FN-3078
This commit is contained in:
5
.changeset/raise-planning-rate-limit.md
Normal file
5
.changeset/raise-planning-rate-limit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Raise the per-IP planning session rate limit from 5/hour to 1000/hour. The previous cap was tripping for normal interactive usage during a single session.
|
||||
@@ -1599,7 +1599,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
|
||||
clearThinking();
|
||||
|
||||
if (err instanceof RateLimitError) {
|
||||
console.error("\n Rate limit exceeded. Maximum 5 planning sessions per hour.\n");
|
||||
console.error("\n Rate limit exceeded. Maximum 1000 planning sessions per hour.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2317,7 +2317,7 @@ describe("Planning Mode API", () => {
|
||||
|
||||
it("throws on rate limit error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 5 planning sessions per hour." }, 429)
|
||||
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 1000 planning sessions per hour." }, 429)
|
||||
);
|
||||
|
||||
await expect(startPlanning("Build something")).rejects.toThrow("Rate limit exceeded");
|
||||
|
||||
@@ -776,6 +776,14 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.planning-summary-qa-disclosure {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.planning-summary-qa-disclosure .onboarding-disclosure-content {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.planning-summary-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { OnboardingDisclosure } from "./OnboardingDisclosure";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
@@ -1731,10 +1732,10 @@ function SummaryView({
|
||||
<div className="planning-summary">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
{historyEntries.length > 0 && (
|
||||
<>
|
||||
<OnboardingDisclosure summary="Show user Q&A" className="planning-summary-qa-disclosure">
|
||||
<ConversationHistory entries={historyEntries} />
|
||||
<div className="conversation-separator" />
|
||||
</>
|
||||
</OnboardingDisclosure>
|
||||
)}
|
||||
|
||||
<div className="planning-summary-header">
|
||||
|
||||
@@ -963,6 +963,134 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
|
||||
describe("Conversation history", () => {
|
||||
it("hides completed-session Q&A by default behind a summary disclosure", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Summary with hidden history",
|
||||
description: "Recovered summary description",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Deliverable A"],
|
||||
};
|
||||
|
||||
const restoredHistory = [
|
||||
{
|
||||
question: {
|
||||
id: "q1",
|
||||
type: "single_select",
|
||||
question: "What scope do you need?",
|
||||
options: [
|
||||
{ id: "small", label: "Small" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
response: { q1: "medium" },
|
||||
thinkingOutput: "Reasoning for scope question",
|
||||
},
|
||||
];
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-with-history",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: resumedSummary.title,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
|
||||
conversationHistory: JSON.stringify(restoredHistory),
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-with-history"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Show user Q&A" })).toBeDefined();
|
||||
expect(screen.queryByTestId("conversation-history")).toBeNull();
|
||||
expect(screen.queryByText("What scope do you need?")).toBeNull();
|
||||
expect(screen.queryByText("Medium")).toBeNull();
|
||||
});
|
||||
|
||||
it("reveals completed-session Q&A when summary disclosure is expanded", async () => {
|
||||
const resumedSummary: PlanningSummary = {
|
||||
title: "Summary with expandable history",
|
||||
description: "Recovered summary description",
|
||||
suggestedSize: "M",
|
||||
suggestedDependencies: [],
|
||||
keyDeliverables: ["Deliverable A"],
|
||||
};
|
||||
|
||||
const restoredHistory = [
|
||||
{
|
||||
question: {
|
||||
id: "q1",
|
||||
type: "single_select",
|
||||
question: "What scope do you need?",
|
||||
options: [
|
||||
{ id: "small", label: "Small" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
response: { q1: "medium" },
|
||||
thinkingOutput: "Reasoning for scope question",
|
||||
},
|
||||
];
|
||||
|
||||
mockFetchAiSession.mockResolvedValueOnce({
|
||||
id: "session-complete-with-history-2",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: resumedSummary.title,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
|
||||
conversationHistory: JSON.stringify(restoredHistory),
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(resumedSummary),
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
resumeSessionId="session-complete-with-history-2"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Planning Complete!")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("conversation-history")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByText("What scope do you need?")).toBeDefined();
|
||||
expect(within(screen.getByTestId("conversation-history")).getByText("Medium")).toBeDefined();
|
||||
});
|
||||
|
||||
it("restores all persisted Q&A pairs when resuming a session", async () => {
|
||||
mockConnectPlanningStream.mockImplementationOnce(() => ({
|
||||
close: vi.fn(),
|
||||
|
||||
@@ -311,12 +311,12 @@ describe("planning module", () => {
|
||||
|
||||
it("enforces rate limiting", async () => {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions (5 per hour)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// Create max sessions (1000 per hour)
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
|
||||
}
|
||||
|
||||
// 6th session should fail
|
||||
// 1001st session should fail
|
||||
await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(RateLimitError);
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("planning module", () => {
|
||||
try {
|
||||
const mockIp = getUniqueIp();
|
||||
// Create max sessions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
|
||||
}
|
||||
|
||||
|
||||
@@ -9987,9 +9987,9 @@ describe("Planning Mode Routes", () => {
|
||||
expect(res.body.sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
it("enforces rate limiting (5 sessions per hour per IP)", async () => {
|
||||
// Create 5 sessions (should succeed)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
it("enforces rate limiting (1000 sessions per hour per IP)", async () => {
|
||||
// Create 1000 sessions (should succeed)
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
@@ -10000,12 +10000,12 @@ describe("Planning Mode Routes", () => {
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
|
||||
// 6th session should be rate limited
|
||||
// 1001st session should be rate limited
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Plan 6" }),
|
||||
JSON.stringify({ initialPlan: "Plan 1001" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Max planning sessions per IP per hour */
|
||||
const MAX_SESSIONS_PER_IP_PER_HOUR = 5;
|
||||
const MAX_SESSIONS_PER_IP_PER_HOUR = 1000;
|
||||
|
||||
/** Rate limiting window in milliseconds (1 hour) */
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
Reference in New Issue
Block a user