diff --git a/.changeset/fn-100-chat-new-cancel.md b/.changeset/fn-100-chat-new-cancel.md new file mode 100644 index 0000000000..ca0cd4ee0c --- /dev/null +++ b/.changeset/fn-100-chat-new-cancel.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent false interrupted-response save warnings when starting a new idle chat. +category: fix +dev: Idle ChatManager cancellation now returns a successful no-op while active durability failures remain recoverable. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4052c61453..6652e4401b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -756,7 +756,7 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea - Chat recognizes native structure references in both assistant and user messages using the explicit `fusion:///` form. Supported kinds are `mission`, `milestone`, `roadmap-item`, `research-finding`, `eval-result`, and `goal`. Use a bare token such as `fusion://mission/M-001` in either message type, or an assistant Markdown link such as `[Mission](fusion://mission/M-001)`. `roadmap-item` previews the roadmap feature title and description when available; a missing feature or unavailable roadmap data layer renders the shared unavailable card. - Recognized references render an inline preview card before you leave the conversation. Select **Open** on an available card to navigate to its owning dashboard view; missing, archived, or otherwise unavailable structures show a safe unavailable placeholder instead. Plain-text mode deliberately leaves reference text raw. -- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model +- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model. On an idle Direct Chat this is a successful no-op cancellation barrier with no interrupted-save warning; an active response still waits for its durable interrupted-response recovery before the new thread is selected. - On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome. - **Settings → Project Models → Chat** controls New Chat defaults per project. Choose a default target kind (**Model** with provider/model and optional Thinking Level, or **Agent** with a durable agent id) and a mode: **Prompt for model each time** opens the New Chat dialog with that default preselected, while **Always use configured default** creates the session immediately from the resolved default. If the configured target is incomplete or missing, New Chat falls back to the dialog instead of creating an unroutable session. - In the New Chat dialog's **Model** mode, the model picker includes a **Thinking Level** selector. Choosing **Default** leaves the session unset so Fusion uses the project/global reasoning-effort default; choosing a concrete level stores it on that chat session and applies to model-loop replies. The Default label shows the current resolved default (for example **Default (medium)**) and falls back to **Default (off)** when no default is configured. diff --git a/packages/dashboard/app/api/chat/chat.ts b/packages/dashboard/app/api/chat/chat.ts index dfbb47d34f..b144c0f630 100644 --- a/packages/dashboard/app/api/chat/chat.ts +++ b/packages/dashboard/app/api/chat/chat.ts @@ -417,9 +417,10 @@ export function clearChatRoomMessages( /** * Cancel an in-flight chat generation and await its durable interrupted-message result. - * FNXC:ChatCancellation 2026-08-18-21:55: + * FNXC:ChatCancellation 2026-08-21-01:36: * Stop callers need the persisted assistant prefix before they reconcile the thread or - * release a queued follow-up; the server response is the cancellation barrier. + * release a queued follow-up; `/new` and `/clear` also use this barrier while idle, where a + * successful no-op confirms there was no interrupted response to persist. */ export function cancelChatResponse( sessionId: string, diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index a4d1909319..c8541069a8 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1944,15 +1944,22 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout } clearComposerState(); clearPendingMessage(); - stopStreaming(); - void createSession({ - agentId: activeSession.agentId, - modelProvider: activeSession.modelProvider ?? undefined, - modelId: activeSession.modelId ?? undefined, - thinkingLevel: activeSession.thinkingLevel ?? undefined, - }).catch(() => { - addToast(t("chat.failedToClearConversation", "Failed to clear conversation"), "error"); - }); + /* + FNXC:ChatCancellation 2026-08-21-01:36: + `/new` and `/clear` cross the cancellation barrier even when local isStreaming is false, + because only the project-scoped manager can fence active work. Its idle success result means + no interrupted response exists to save, so session replacement must not show a recovery error. + */ + void stopStreaming() + .then(() => createSession({ + agentId: activeSession.agentId, + modelProvider: activeSession.modelProvider ?? undefined, + modelId: activeSession.modelId ?? undefined, + thinkingLevel: activeSession.thinkingLevel ?? undefined, + })) + .catch(() => { + addToast(t("chat.failedToClearConversation", "Failed to clear conversation"), "error"); + }); return; } diff --git a/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx index 1090b91888..54e6412ba5 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.streaming-thread.test.tsx @@ -71,6 +71,7 @@ import * as useChatRoomsModule from "../../hooks/useChatRooms"; const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession); +const mockCreateChatSession = vi.mocked(apiModule.createChatSession); const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse); const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse); @@ -160,6 +161,7 @@ describe("FN-6599 ChatView streaming prior thread", () => { }); afterEach(() => { + mockFetchChatSession.mockReset(); vi.clearAllMocks(); }); @@ -423,4 +425,61 @@ describe("FN-6599 ChatView streaming prior thread", () => { expectPriorThreadVisible(); expect(screen.getByText(/working/)).toBeInTheDocument(); }); + + it.each([ + ["wide", 1280], + ["compact", 768], + ["phone", 390], + ])("FN-100 starts a fresh Direct thread from idle exact /new and /clear without a recovery toast on %s", async (_label, width) => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + window.dispatchEvent(new Event("resize")); + const idleSession = makeSession({ id: "session-idle", agentId: "agent-001", isGenerating: false }); + const freshSessions = [ + makeSession({ id: "session-fresh-1", agentId: "agent-001", title: "Fresh one", isGenerating: false }), + makeSession({ id: "session-fresh-2", agentId: "agent-001", title: "Fresh two", isGenerating: false }), + ]; + const addToast = vi.fn(); + const idleCancellation = createDeferredPromise<{ success: boolean; interrupted: boolean }>(); + mockCancelChatResponse + .mockImplementationOnce(() => idleCancellation.promise) + .mockResolvedValue({ success: true, interrupted: false }); + mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? idleSession.id : undefined); + mockFetchChatSessions.mockResolvedValue({ sessions: [idleSession] }); + mockFetchChatSession + .mockResolvedValueOnce({ session: idleSession }) + .mockResolvedValueOnce({ session: freshSessions[0] }) + .mockResolvedValueOnce({ session: freshSessions[1] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockCreateChatSession + .mockResolvedValueOnce({ session: freshSessions[0] }) + .mockResolvedValueOnce({ session: freshSessions[1] }); + + render(); + fireEvent.click(await screen.findByTestId(`chat-session-${idleSession.id}`)); + const input = await screen.findByTestId("chat-input"); + fireEvent.change(input, { target: { value: "/new" } }); + fireEvent.click(screen.getByTestId("chat-send-btn")); + await waitFor(() => expect(mockCancelChatResponse).toHaveBeenCalledWith(idleSession.id, "proj-123")); + expect(mockCreateChatSession).not.toHaveBeenCalled(); + idleCancellation.resolve({ success: true, interrupted: false }); + await waitFor(() => expect(mockCreateChatSession).toHaveBeenCalledWith( + expect.objectContaining({ agentId: idleSession.agentId }), + "proj-123", + )); + + await screen.findByTestId(`chat-session-${freshSessions[0].id}`); + const freshInput = screen.getByTestId("chat-input"); + fireEvent.change(freshInput, { target: { value: "/clear" } }); + fireEvent.click(screen.getByTestId("chat-send-btn")); + await waitFor(() => expect(mockCreateChatSession).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mockCancelChatResponse).toHaveBeenCalledWith(idleSession.id, "proj-123")); + + expect(mockStreamChatResponse).not.toHaveBeenCalled(); + expect(addToast).not.toHaveBeenCalledWith( + "Failed to save the interrupted response; it remains visible for recovery.", + "error", + ); + expect(mockFetchChatMessages).toHaveBeenCalledWith(freshSessions[1].id, { limit: 50, order: "desc" }, "proj-123"); + }); + }); diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 94864b2c60..89df09e641 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -2527,6 +2527,43 @@ describe("useChat", () => { expect(result.current.messages.some((message) => message.failureInfo)).toBe(false); }); + it("keeps the interrupted prefix and reports a real cancellation durability failure", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001" }); + const addToast = vi.fn(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + const cancellation = createDeferredPromise<{ success: boolean; interrupted: boolean }>(); + mockCancelChatResponse.mockReturnValue(cancellation.promise); + + let streamHandlers: StreamAppendHandlers | undefined; + mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { + streamHandlers = handlers as StreamAppendHandlers; + return { close: vi.fn(), isConnected: () => true }; + }); + + const { result } = renderHook(() => useChat("proj-123", addToast)); + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + act(() => result.current.selectSession("session-001")); + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + act(() => result.current.sendMessage("Hello")); + await waitFor(() => expect(result.current.isStreaming).toBe(true)); + act(() => streamHandlers?.onText("Durable recovery prefix")); + await waitFor(() => expect(result.current.streamingText).toBe("Durable recovery prefix")); + + act(() => { void result.current.stopStreaming(); }); + await waitFor(() => expect(result.current.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: "assistant", content: "Durable recovery prefix" }), + ]))); + cancellation.resolve({ success: false, interrupted: false }); + await waitFor(() => expect(addToast).toHaveBeenCalledWith( + "Failed to save the interrupted response; it remains visible for recovery.", + "error", + )); + expect(result.current.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: "assistant", content: "Durable recovery prefix" }), + ])); + }); + it("retains the optimistic interrupted prefix when history has no matching durable row", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 5ad49991cf..d528972a19 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -178,7 +178,7 @@ export interface UseChatReturn { * fences and rewinds before acceptance; the hook changes its local range only on acceptance. */ editMessageAndResend: (messageId: string, newContent: string) => Promise; - stopStreaming: () => void; + stopStreaming: () => Promise; clearPendingMessage: (index?: number) => void; updatePendingMessage?: (index: number, content: string) => void; movePendingMessage?: (index: number, direction: -1 | 1) => void; @@ -1484,9 +1484,10 @@ export function useChat( released until that barrier succeeds, so a closed transport or failed reconciliation cannot lose a pending turn or let a stale callback dispatch it into a different session incarnation. */ - const cancelAndReconcile = useCallback((onReconciled: () => void) => { + const cancelAndReconcile = useCallback((onReconciled: () => void): Promise | undefined => { const session = activeSessionRef.current; - if (!session || cancellationInProgressRef.current) return false; + if (!session) return undefined; + if (cancellationInProgressRef.current) return cancellationInProgressRef.current; pendingQueueActionRef.current = true; setPendingQueueAction(true); @@ -1587,12 +1588,11 @@ export function useChat( } }); cancellationInProgressRef.current = cancellation; - return true; + return cancellation; }, [addToast, projectId]); - const stopStreaming = useCallback(() => { - if (!activeSessionRef.current || cancellationInProgressRef.current) return; - cancelAndReconcile(flushPendingMessage); + const stopStreaming = useCallback((): Promise => { + return cancelAndReconcile(flushPendingMessage) ?? Promise.resolve(); }, [cancelAndReconcile, flushPendingMessage]); /** diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 75a15ccba1..eeb73fc714 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -3365,10 +3365,17 @@ describe("ChatManager.sendMessage", () => { } }); - it("cancelGeneration returns false when no active generation exists", async () => { + it("treats an idle cancellation as a successful no-op without durable side effects", async () => { const chatManager = createChatManager(); + const events: Array<{ type: string; data: unknown }> = []; + const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => events.push(event)); - await expect(chatManager.cancelGeneration("chat-001")).resolves.toEqual({ success: false, interrupted: false }); + await expect(chatManager.cancelGeneration("chat-001")).resolves.toEqual({ success: true, interrupted: false }); + + expect(mockChatStore.addMessage).not.toHaveBeenCalled(); + expect(mockChatStore.setInFlightGeneration).not.toHaveBeenCalled(); + expect(events).toEqual([]); + unsubscribe(); }); it("cancelGeneration returns true and aborts an active generation", async () => { diff --git a/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts b/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts index 6030b840c8..e096da8e19 100644 --- a/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/routes-chat-cancellation.test.ts @@ -100,14 +100,15 @@ describe("POST /api/chat/sessions/:id/cancel", () => { expect(currentManager.cancelGeneration).toHaveBeenCalledWith("chat-1"); }); - it("returns no invented message when the scoped session is idle", async () => { + it("passes through the scoped manager's successful idle no-op without inventing a message", async () => { currentManager = { - cancelGeneration: vi.fn().mockResolvedValue({ success: false, interrupted: false }), + cancelGeneration: vi.fn().mockResolvedValue({ success: true, interrupted: false }), }; const response = await request(makeApp(currentManager), "POST", "/api/chat/sessions/chat-idle/cancel?projectId=project-a"); expect(response.status).toBe(200); - expect(response.body).toEqual({ success: false, interrupted: false }); + expect(response.body).toEqual({ success: true, interrupted: false }); expect(response.body.message).toBeUndefined(); + expect(currentManager.cancelGeneration).toHaveBeenCalledWith("chat-idle"); }); }); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index c488634b83..2aa34d9f8c 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -3359,10 +3359,16 @@ export class ChatManager { } } + /** + * FNXC:ChatCancellation 2026-08-21-01:36: + * `/new` and `/clear` always cross this server-authoritative barrier because local streaming + * state can be stale. An idle session has no interrupted state to persist, so absence is a + * successful no-op; only an active generation that cannot become durable reports failure. + */ async cancelGeneration(sessionId: string): Promise { const entry = this.activeGenerations.get(sessionId); if (!entry) { - return { success: false, interrupted: false }; + return { success: true, interrupted: false }; } if (!entry.cancellationRequested) { diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index b52dedabc8..48087e49b1 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -1190,9 +1190,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): try { const chatManager = await resolveScopedChatManager(req); const sessionId = String(req.params.id); - // FNXC:ChatCancellation 2026-08-18-21:52: - // Await cancellation so clients only reconcile or dequeue follow-up sends after - // the interrupted assistant prefix and checkpoint cleanup are durable. + // FNXC:ChatCancellation 2026-08-21-01:36: + // Await the server-authoritative barrier: active generations finish durable prefix/checkpoint + // ordering, while idle /new and /clear receive a successful no-op without recovery work. const result = await chatManager.cancelGeneration(sessionId); res.json(result); } catch (err: unknown) {