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();
|
clearThinking();
|
||||||
|
|
||||||
if (err instanceof RateLimitError) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2317,7 +2317,7 @@ describe("Planning Mode API", () => {
|
|||||||
|
|
||||||
it("throws on rate limit error", async () => {
|
it("throws on rate limit error", async () => {
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(
|
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");
|
await expect(startPlanning("Build something")).rejects.toThrow("Rate limit exceeded");
|
||||||
|
|||||||
@@ -776,6 +776,14 @@
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planning-summary-qa-disclosure {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-summary-qa-disclosure .onboarding-disclosure-content {
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.planning-summary-form {
|
.planning-summary-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 { 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 { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { ConversationHistory } from "./ConversationHistory";
|
import { ConversationHistory } from "./ConversationHistory";
|
||||||
|
import { OnboardingDisclosure } from "./OnboardingDisclosure";
|
||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../hooks/useSessionLock";
|
||||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||||
@@ -1731,10 +1732,10 @@ function SummaryView({
|
|||||||
<div className="planning-summary">
|
<div className="planning-summary">
|
||||||
<div className="planning-view-scroll planning-summary-scroll">
|
<div className="planning-view-scroll planning-summary-scroll">
|
||||||
{historyEntries.length > 0 && (
|
{historyEntries.length > 0 && (
|
||||||
<>
|
<OnboardingDisclosure summary="Show user Q&A" className="planning-summary-qa-disclosure">
|
||||||
<ConversationHistory entries={historyEntries} />
|
<ConversationHistory entries={historyEntries} />
|
||||||
<div className="conversation-separator" />
|
<div className="conversation-separator" />
|
||||||
</>
|
</OnboardingDisclosure>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="planning-summary-header">
|
<div className="planning-summary-header">
|
||||||
|
|||||||
@@ -963,6 +963,134 @@ describe("PlanningModeModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Conversation history", () => {
|
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 () => {
|
it("restores all persisted Q&A pairs when resuming a session", async () => {
|
||||||
mockConnectPlanningStream.mockImplementationOnce(() => ({
|
mockConnectPlanningStream.mockImplementationOnce(() => ({
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
|
|||||||
@@ -311,12 +311,12 @@ describe("planning module", () => {
|
|||||||
|
|
||||||
it("enforces rate limiting", async () => {
|
it("enforces rate limiting", async () => {
|
||||||
const mockIp = getUniqueIp();
|
const mockIp = getUniqueIp();
|
||||||
// Create max sessions (5 per hour)
|
// Create max sessions (1000 per hour)
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
|
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);
|
await expect(createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR)).rejects.toThrow(RateLimitError);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ describe("planning module", () => {
|
|||||||
try {
|
try {
|
||||||
const mockIp = getUniqueIp();
|
const mockIp = getUniqueIp();
|
||||||
// Create max sessions
|
// 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);
|
await createSession(mockIp, `${initialPlan} ${i}`, undefined, TEST_ROOT_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9987,9 +9987,9 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(res.body.sessionId).toBeDefined();
|
expect(res.body.sessionId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enforces rate limiting (5 sessions per hour per IP)", async () => {
|
it("enforces rate limiting (1000 sessions per hour per IP)", async () => {
|
||||||
// Create 5 sessions (should succeed)
|
// Create 1000 sessions (should succeed)
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
"POST",
|
"POST",
|
||||||
@@ -10000,12 +10000,12 @@ describe("Planning Mode Routes", () => {
|
|||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6th session should be rate limited
|
// 1001st session should be rate limited
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
"POST",
|
"POST",
|
||||||
"/api/planning/start",
|
"/api/planning/start",
|
||||||
JSON.stringify({ initialPlan: "Plan 6" }),
|
JSON.stringify({ initialPlan: "Plan 1001" }),
|
||||||
{ "Content-Type": "application/json" }
|
{ "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;
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/** Max planning sessions per IP per hour */
|
/** 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) */
|
/** Rate limiting window in milliseconds (1 hour) */
|
||||||
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|||||||
Reference in New Issue
Block a user