FN-100: prevent false interrupted-response save errors on new chats

Make starting or clearing an idle chat a successful cancellation no-op while preserving durable cancellation barriers for active responses.

- Return success for idle generation cancellation instead of showing a false save warning.
- Await cancellation and reconciliation from the chat hook before queue/session transitions.
- Update chat API, route handling, documentation, regression tests, and the published fix changeset.

Files changed:
 .changeset/fn-100-chat-new-cancel.md               |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/api/chat/chat.ts            |  5 +-
 packages/dashboard/app/components/ChatView.tsx     | 25 +++++----
 .../__tests__/ChatView.streaming-thread.test.tsx   | 59 ++++++++++++++++++++++
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 37 ++++++++++++++
 packages/dashboard/app/hooks/useChat.ts            | 14 ++---
 .../dashboard/src/__tests__/chat-manager.test.ts   | 11 +++-
 .../src/__tests__/routes-chat-cancellation.test.ts |  7 +--
 packages/dashboard/src/chat.ts                     |  8 ++-
 .../dashboard/src/routes/register-chat-routes.ts   |  6 +--
 11 files changed, 153 insertions(+), 28 deletions(-)

Fusion-Task-Id: FN-100

Fusion-Task-Lineage: caa5fe20-4abd-4884-ba1d-f9473c12b52f

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 02:10:54 +00:00
parent b5e366da62
commit 3066123516
11 changed files with 153 additions and 28 deletions

View File

@@ -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.

View File

@@ -756,7 +756,7 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea
<!-- FNXC:NativeStructureEmbed 2026-07-19-20:00: Roadmap-item references now resolve through the roadmap plugin's PostgreSQL-safe read adapter and open the restored hosted Roadmaps destination. -->
- Chat recognizes native structure references in both assistant and user messages using the explicit `fusion://<kind>/<id>` 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.

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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(<ChatView projectId="proj-123" addToast={addToast} />);
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");
});
});

View File

@@ -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] });

View File

@@ -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<void>;
stopStreaming: () => void;
stopStreaming: () => Promise<void>;
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<void> | 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<void> => {
return cancelAndReconcile(flushPendingMessage) ?? Promise.resolve();
}, [cancelAndReconcile, flushPendingMessage]);
/**

View File

@@ -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 () => {

View File

@@ -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");
});
});

View File

@@ -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<ChatCancellationResult> {
const entry = this.activeGenerations.get(sessionId);
if (!entry) {
return { success: false, interrupted: false };
return { success: true, interrupted: false };
}
if (!entry.cancellationRequested) {

View File

@@ -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) {