FN-016: preserve interrupted chat responses

Persist partial assistant output when chat streaming is stopped so users can resume from an honest interrupted transcript.

- Snapshot streamed text, thinking, and tool calls before cancellation.
- Persist and reconcile interrupted responses for main chat and planner chat surfaces.
- Add cancellation routes, manager handling, changeset metadata, and desktop/mobile regression coverage.

Files changed:
 .changeset/fn-016-chat-cancel-partial.md           |   7 +
 packages/dashboard/app/api/chat/chat.ts            |  13 +-
 .../app/components/TaskPlannerChatTab.tsx          |  91 ++++++++-
 .../__tests__/ChatView.streaming-thread.test.tsx   |  54 ++++++
 .../__tests__/TaskPlannerChatTab.test.tsx          |  53 +++++-
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |  84 ++++++++
 packages/dashboard/app/hooks/useChat.ts            | 131 +++++++++++--
 .../dashboard/src/__tests__/chat-manager.test.ts   |  80 ++++++--
 packages/dashboard/src/chat.ts                     | 208 ++++++++++++++-----
 .../dashboard/src/routes/register-chat-routes.ts   |   7 +-
 10 files changed, 641 insertions(+), 87 deletions(-)

Fusion-Task-Id: FN-016

Fusion-Task-Lineage: 3e14b9b7-967b-4f80-b941-2216bfa9c69e

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-18 22:28:33 +00:00
parent 6a28811d1a
commit 0899d49e5c
10 changed files with 641 additions and 87 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Preserve partially generated chat replies when an operator stops generation.
category: fix
dev: Direct Chat and task Planner Chat persist interrupted assistant prefixes before cancellation completes.

View File

@@ -433,12 +433,17 @@ export function clearChatRoomMessages(
* Do not add streamChatRoomResponse until FN-3810 introduces AI invocation/streaming. * Do not add streamChatRoomResponse until FN-3810 introduces AI invocation/streaming.
*/ */
/** Cancel an in-flight chat generation. */ /**
* Cancel an in-flight chat generation and await its durable interrupted-message result.
* FNXC:ChatCancellation 2026-08-18-21:55:
* 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.
*/
export function cancelChatResponse( export function cancelChatResponse(
sessionId: string, sessionId: string,
projectId?: string, projectId?: string,
): Promise<{ success: boolean }> { ): Promise<{ success: boolean; interrupted: boolean; message?: ChatMessage }> {
return api<{ success: boolean }>( return api<{ success: boolean; interrupted: boolean; message?: ChatMessage }>(
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/cancel`, projectId), withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/cancel`, projectId),
{ {
method: "POST", method: "POST",
@@ -547,7 +552,7 @@ export interface ChatStreamHandlers {
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void; onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void; onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void; onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void;
onDone?: (data: { messageId: string; message?: ChatMessage }) => void; onDone?: (data: { messageId: string; message?: ChatMessage; interrupted?: boolean }) => void;
onError?: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => void; onError?: (data: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => void;
onConnectionStateChange?: (state: StreamConnectionState) => void; onConnectionStateChange?: (state: StreamConnectionState) => void;
} }

View File

@@ -8,7 +8,7 @@ import type { ToastType } from "../hooks/useToast";
import { useComposerDictation } from "../hooks/useComposerDictation"; import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton"; import { MicButton } from "./MicButton";
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes"; import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
import { attachChatStream, editChatMessage, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api"; import { attachChatStream, cancelChatResponse, editChatMessage, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall"; import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { ChatQuestionResponse } from "./ChatQuestionResponse"; import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
@@ -328,6 +328,14 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
const [historyLoaded, setHistoryLoaded] = useState(false); const [historyLoaded, setHistoryLoaded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const streamRef = useRef<{ close: () => void } | null>(null); const streamRef = useRef<{ close: () => void } | null>(null);
const streamSnapshotRef = useRef<{
requestId: number;
sessionId: string;
text: string;
thinking: string;
toolCalls: ToolCallInfo[];
} | null>(null);
const cancellationInProgressRef = useRef<Promise<void> | null>(null);
const transcriptRef = useRef<HTMLDivElement | null>(null); const transcriptRef = useRef<HTMLDivElement | null>(null);
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
const isTranscriptAtBottomRef = useRef(true); const isTranscriptAtBottomRef = useRef(true);
@@ -428,6 +436,16 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
let accumulated = inFlightSnapshot?.streamingText ?? ""; let accumulated = inFlightSnapshot?.streamingText ?? "";
let accumulatedThinking = inFlightSnapshot?.streamingThinking ?? ""; let accumulatedThinking = inFlightSnapshot?.streamingThinking ?? "";
const streamingToolCalls = cloneToolCalls(inFlightSnapshot?.toolCalls); const streamingToolCalls = cloneToolCalls(inFlightSnapshot?.toolCalls);
const updateStreamSnapshot = (): void => {
streamSnapshotRef.current = {
requestId,
sessionId: resolvedSessionId,
text: accumulated,
thinking: accumulatedThinking,
toolCalls: cloneToolCalls(streamingToolCalls),
};
};
updateStreamSnapshot();
/* /*
* FNXC:TaskDetailPlannerChat 2026-07-15-00:00: * FNXC:TaskDetailPlannerChat 2026-07-15-00:00:
@@ -453,16 +471,19 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
onText: (delta: string) => { onText: (delta: string) => {
if (!isCurrentStreamRequest()) return; if (!isCurrentStreamRequest()) return;
accumulated += delta; accumulated += delta;
updateStreamSnapshot();
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls); applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
}, },
onThinking: (delta: string) => { onThinking: (delta: string) => {
if (!isCurrentStreamRequest()) return; if (!isCurrentStreamRequest()) return;
accumulatedThinking += delta; accumulatedThinking += delta;
updateStreamSnapshot();
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls); applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
}, },
onToolStart: ({ toolName, args }: { toolName: string; args?: Record<string, unknown> }) => { onToolStart: ({ toolName, args }: { toolName: string; args?: Record<string, unknown> }) => {
if (!isCurrentStreamRequest()) return; if (!isCurrentStreamRequest()) return;
streamingToolCalls.push({ toolName, args, isError: false, status: "running" }); streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
updateStreamSnapshot();
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls); applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
}, },
onToolEnd: ({ toolName, isError, result }: { toolName: string; isError: boolean; result?: unknown }) => { onToolEnd: ({ toolName, isError, result }: { toolName: string; isError: boolean; result?: unknown }) => {
@@ -481,6 +502,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
if (steeringText) { if (steeringText) {
void refreshTaskAfterSteering(); void refreshTaskAfterSteering();
} }
updateStreamSnapshot();
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls); applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
}, },
onDone: (data: { messageId: string; message?: ChatMessage }) => { onDone: (data: { messageId: string; message?: ChatMessage }) => {
@@ -488,6 +510,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
composerStateRef.current = "idle"; composerStateRef.current = "idle";
setComposerState("idle"); setComposerState("idle");
setStreamingThinking(""); setStreamingThinking("");
streamSnapshotRef.current = null;
streamRef.current = null; streamRef.current = null;
if (data.message) { if (data.message) {
setMessages((current) => { setMessages((current) => {
@@ -505,6 +528,7 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
composerStateRef.current = "idle"; composerStateRef.current = "idle";
setComposerState("idle"); setComposerState("idle");
setStreamingThinking(""); setStreamingThinking("");
streamSnapshotRef.current = null;
streamRef.current = null; streamRef.current = null;
setMessages((current) => { setMessages((current) => {
const withoutStreaming = current.filter((candidate) => candidate.id !== "streaming-assistant"); const withoutStreaming = current.filter((candidate) => candidate.id !== "streaming-assistant");
@@ -846,14 +870,75 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
}, []); }, []);
const stopPlannerStreaming = useCallback(() => { const stopPlannerStreaming = useCallback(() => {
if (cancellationInProgressRef.current) return;
const snapshot = streamSnapshotRef.current;
if (!snapshot) return;
streamRequestRef.current += 1; streamRequestRef.current += 1;
streamRef.current?.close(); streamRef.current?.close();
streamRef.current = null; streamRef.current = null;
composerStateRef.current = "idle"; composerStateRef.current = "idle";
setComposerState("idle"); setComposerState("idle");
setStreamingThinking(""); setStreamingThinking("");
const interruptedLocalId = `interrupted-${snapshot.requestId}`;
const hasInterruptedOutput = Boolean(snapshot.text || snapshot.thinking || snapshot.toolCalls.length > 0);
if (hasInterruptedOutput) {
// FNXC:ChatCancellation 2026-08-18-21:55:
// Planner Stop keeps its displayed prefix as a normal transcript bubble until the
// scoped cancellation response confirms the durable interrupted assistant message.
setMessages((current) => [
...current.filter((message) => message.id !== "streaming-assistant" && message.id !== interruptedLocalId),
{
id: interruptedLocalId,
sessionId: snapshot.sessionId,
role: "assistant",
content: snapshot.text,
thinkingOutput: snapshot.thinking || null,
metadata: snapshot.toolCalls.length > 0 ? { toolCalls: snapshot.toolCalls, interrupted: true } : { interrupted: true },
createdAt: new Date().toISOString(),
},
]);
} else {
setMessages((current) => current.filter((message) => message.id !== "streaming-assistant")); setMessages((current) => current.filter((message) => message.id !== "streaming-assistant"));
}, []); }
const cancellation = Promise.resolve(cancelChatResponse(snapshot.sessionId, projectId))
.then(async (result) => {
const cancellationResult = result ?? { success: true, interrupted: false };
if (!cancellationResult.success) {
throw new Error("Planner chat cancellation did not complete");
}
let refreshed: ChatMessage[] | null = null;
try {
refreshed = (await fetchChatMessages(snapshot.sessionId, { order: "asc" }, projectId)).messages;
} catch {
// Keep the local interrupted bubble if the history read is temporarily unavailable.
}
const persisted = cancellationResult.message ? [cancellationResult.message] : [];
if (refreshed || persisted.length > 0) {
const reconciled = [
...(refreshed ?? []),
...persisted.filter((message) => !(refreshed ?? []).some((candidate) => candidate.id === message.id)),
];
setMessages((current) => mergePlannerTranscriptWithOptimistic(
current.filter((message) => message.id !== interruptedLocalId),
reconciled,
));
}
})
.catch((cancelError) => {
addToastRef.current(getErrorMessage(cancelError) || t("taskDetail.plannerChat.cancelFailed", "Failed to save the interrupted planner response"), "error");
})
.finally(() => {
streamSnapshotRef.current = null;
if (cancellationInProgressRef.current === cancellation) {
cancellationInProgressRef.current = null;
}
});
cancellationInProgressRef.current = cancellation;
}, [projectId, t]);
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showCommandMenu && event.key === "ArrowDown") { if (showCommandMenu && event.key === "ArrowDown") {

View File

@@ -72,6 +72,8 @@ import * as useChatRoomsModule from "../../hooks/useChatRooms";
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession); const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream); const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
const mockGetScopedItem = vi.mocked(projectStorageModule.getScopedItem); const mockGetScopedItem = vi.mocked(projectStorageModule.getScopedItem);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse); const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
@@ -152,6 +154,8 @@ describe("FN-6599 ChatView streaming prior thread", () => {
mockGetScopedItem.mockReturnValue(undefined); mockGetScopedItem.mockReturnValue(undefined);
mockSubscribeSse.mockReturnValue(() => {}); mockSubscribeSse.mockReturnValue(() => {});
mockFetchChatSession.mockResolvedValue({ session: makeSession({ id: "session-001", agentId: "agent-001" }) }); mockFetchChatSession.mockResolvedValue({ session: makeSession({ id: "session-001", agentId: "agent-001" }) });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockCancelChatResponse.mockResolvedValue({ success: true, interrupted: false });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
}); });
@@ -251,6 +255,56 @@ describe("FN-6599 ChatView streaming prior thread", () => {
expect(mockAttachChatStream).toHaveBeenCalledTimes(2); expect(mockAttachChatStream).toHaveBeenCalledTimes(2);
}); });
it.each([
["desktop", 1280],
["mobile", 390],
])("FN-016 keeps a direct partial reply after rendered Stop on %s", async (_label, width) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.dispatchEvent(new Event("resize"));
const session = makeSession({ id: "session-stop", agentId: "agent-001" });
const interrupted = makeMessage({
id: "assistant-interrupted",
sessionId: session.id,
role: "assistant",
content: "Distinct direct stopped prefix",
metadata: { interrupted: true },
createdAt: "2026-08-18T21:55:00.000Z",
});
mockGetScopedItem.mockImplementation((key) => key === "kb-chat-active-session" ? session.id : undefined);
mockFetchChatSessions.mockResolvedValue({ sessions: [session] });
mockFetchChatSession.mockResolvedValue({ session });
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValue({ messages: [
makeMessage({ id: "user-stop", sessionId: session.id, role: "user", content: "Keep this" }),
interrupted,
] });
mockCancelChatResponse.mockResolvedValue({ success: true, interrupted: true, message: interrupted });
let streamHandlers: any;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
const rendered = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const input = await screen.findByTestId("chat-input");
fireEvent.change(input, { target: { value: "Keep this" } });
fireEvent.click(await screen.findByTestId("chat-send-btn"));
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
act(() => streamHandlers?.onText?.("Distinct direct stopped prefix"));
await waitFor(() => expect(screen.getByText("Distinct direct stopped prefix")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("chat-stop-btn"));
await waitFor(() => expect(mockCancelChatResponse).toHaveBeenCalledWith(session.id, "proj-123"));
await waitFor(() => expect(screen.getAllByText("Distinct direct stopped prefix")).toHaveLength(1));
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
expect(screen.queryByTestId("chat-stop-btn")).not.toBeInTheDocument();
rendered.unmount();
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(await screen.findByText("Distinct direct stopped prefix")).toBeInTheDocument();
});
it.each([ it.each([
["desktop", 1280], ["desktop", 1280],
["mobile", 390], ["mobile", 390],

View File

@@ -12,7 +12,7 @@ const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => { const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockCancelChatResponse, mockEditChatMessage, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
const translations = new Map<string, string>(); const translations = new Map<string, string>();
return { return {
mockEnsureTaskPlannerChatSession: vi.fn(), mockEnsureTaskPlannerChatSession: vi.fn(),
@@ -22,6 +22,7 @@ const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockF
mockFetchTaskDetail: vi.fn(), mockFetchTaskDetail: vi.fn(),
mockStreamChatResponse: vi.fn(), mockStreamChatResponse: vi.fn(),
mockAttachChatStream: vi.fn(), mockAttachChatStream: vi.fn(),
mockCancelChatResponse: vi.fn(),
mockEditChatMessage: vi.fn(), mockEditChatMessage: vi.fn(),
mockAddSteeringComment: vi.fn(), mockAddSteeringComment: vi.fn(),
mockTranslations: translations, mockTranslations: translations,
@@ -46,6 +47,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchTaskDetail: mockFetchTaskDetail, fetchTaskDetail: mockFetchTaskDetail,
streamChatResponse: mockStreamChatResponse, streamChatResponse: mockStreamChatResponse,
attachChatStream: mockAttachChatStream, attachChatStream: mockAttachChatStream,
cancelChatResponse: mockCancelChatResponse,
editChatMessage: mockEditChatMessage, editChatMessage: mockEditChatMessage,
addSteeringComment: mockAddSteeringComment, addSteeringComment: mockAddSteeringComment,
}; };
@@ -180,6 +182,7 @@ describe("TaskPlannerChatTab", () => {
mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310")); mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310"));
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockCancelChatResponse.mockResolvedValue({ success: true, interrupted: false });
mockEditChatMessage.mockResolvedValue({ retained: [] }); mockEditChatMessage.mockResolvedValue({ retained: [] });
mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310")); mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310"));
}); });
@@ -220,6 +223,54 @@ describe("TaskPlannerChatTab", () => {
expect(screen.getAllByTestId(/task-planner-chat-starter-/)).toHaveLength(4); expect(screen.getAllByTestId(/task-planner-chat-starter-/)).toHaveLength(4);
}); });
it.each([
["desktop", "mouse"],
["mobile", "touch"],
])("FN-016 keeps a planner partial reply after Stop on %s", async (_label, pointerType) => {
let streamHandlers: any;
const interrupted = {
id: "planner-interrupted",
sessionId: "chat-planner",
role: "assistant" as const,
content: "Distinct planner stopped prefix",
thinkingOutput: null,
metadata: { interrupted: true },
createdAt: "2026-08-18T21:55:00.000Z",
};
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
const plannerRender = renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
mockFetchChatMessages.mockResolvedValue({ messages: [interrupted] });
mockCancelChatResponse.mockResolvedValue({ success: true, interrupted: true, message: interrupted });
await userEvent.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
act(() => streamHandlers?.onText("Distinct planner stopped prefix"));
await screen.findByText("Distinct planner stopped prefix");
const stopButton = screen.getByTestId("chat-stop-btn");
if (pointerType === "touch") {
fireEvent.pointerDown(stopButton, { pointerType: "touch" });
} else {
fireEvent.click(stopButton);
}
await waitFor(() => expect(mockCancelChatResponse).toHaveBeenCalledWith("chat-planner", undefined));
await waitFor(() => expect(screen.getAllByText("Distinct planner stopped prefix")).toHaveLength(1));
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
expect(screen.queryByTestId("chat-stop-btn")).not.toBeInTheDocument();
act(() => streamHandlers?.onText(" stale late callback"));
expect(screen.queryByText("stale late callback")).not.toBeInTheDocument();
plannerRender.unmount();
renderPlannerChat();
expect(await screen.findByText("Distinct planner stopped prefix")).toBeInTheDocument();
});
it("does not create a planner session when no existing history is found on tab activation", async () => { it("does not create a planner session when no existing history is found on tab activation", async () => {
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null }); mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });

View File

@@ -2469,6 +2469,51 @@ describe("useChat", () => {
}); });
}); });
it("keeps and reconciles a visible interrupted prefix after Stop", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
const persistedAssistant = makeMessage({
id: "assistant-interrupted",
sessionId: "session-001",
role: "assistant",
content: "Distinct direct prefix",
metadata: { interrupted: true },
createdAt: "2026-08-18T21:55:00.000Z",
});
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValue({ messages: [
makeMessage({ id: "user-1", sessionId: "session-001", role: "user", content: "Hello" }),
persistedAssistant,
] });
mockCancelChatResponse.mockResolvedValue({ success: true, interrupted: true, message: persistedAssistant });
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"));
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("Distinct direct prefix"));
await waitFor(() => expect(result.current.streamingText).toBe("Distinct direct prefix"));
act(() => result.current.stopStreaming());
await waitFor(() => expect(mockCancelChatResponse).toHaveBeenCalledWith("session-001", "proj-123"));
await waitFor(() => {
const assistants = result.current.messages.filter((message) => message.role === "assistant" && message.content === "Distinct direct prefix");
expect(assistants).toHaveLength(1);
expect(result.current.isStreaming).toBe(false);
});
expect(result.current.messages.some((message) => message.failureInfo)).toBe(false);
});
it("stopStreaming with no pendingMessages cancels stream without sending anything", async () => { it("stopStreaming with no pendingMessages cancels stream without sending anything", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" }); const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
@@ -2945,6 +2990,45 @@ describe("useChat", () => {
}); });
}); });
it("waits for durable stop reconciliation before starting a queued follow-up", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
const cancellation = createDeferredPromise<{ success: boolean; interrupted: boolean }>();
const reconciliation = createDeferredPromise<{ messages: ChatMessage[] }>();
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockImplementation(async (_sessionId, options) => (
options?.order === "asc" ? reconciliation.promise : { messages: [] }
));
mockCancelChatResponse.mockReturnValue(cancellation.promise);
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const { result } = renderHook(() => useChat("proj-123"));
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("First"));
await waitFor(() => expect(result.current.isStreaming).toBe(true));
act(() => {
result.current.sendMessage("Queued follow-up");
result.current.stopStreaming();
});
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
await act(async () => {
cancellation.resolve({ success: true, interrupted: false });
await Promise.resolve();
});
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
await act(async () => {
reconciliation.resolve({ messages: [] });
await Promise.resolve();
});
await waitFor(() => {
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Queued follow-up");
});
});
it("stopStreaming sends queued pendingMessages after cancelling the stream", async () => { it("stopStreaming sends queued pendingMessages after cancelling the stream", async () => {
const session = makeSession({ id: "session-001", agentId: "agent-001" }); const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import type { SetStateAction } from "react";
import { import {
fetchChatSessions, fetchChatSessions,
fetchChatSession, fetchChatSession,
@@ -485,6 +486,10 @@ export function useChat(
const streamRef = useRef<{ close: () => void } | null>(null); const streamRef = useRef<{ close: () => void } | null>(null);
const lastAttachedGenerationRef = useRef<{ sessionId: string; replayFromEventId: number | null } | null>(null); const lastAttachedGenerationRef = useRef<{ sessionId: string; replayFromEventId: number | null } | null>(null);
const cancelledByUserRef = useRef(false); const cancelledByUserRef = useRef(false);
const cancellationInProgressRef = useRef<Promise<void> | null>(null);
const streamingTextRef = useRef("");
const streamingThinkingRef = useRef("");
const streamingToolCallsRef = useRef<ToolCallInfo[]>([]);
const pendingMessagesRef = useRef<string[]>([]); const pendingMessagesRef = useRef<string[]>([]);
const attachIfGeneratingRef = useRef<( const attachIfGeneratingRef = useRef<(
sessionId: string, sessionId: string,
@@ -509,6 +514,31 @@ export function useChat(
activeSessionRef.current = activeSession; activeSessionRef.current = activeSession;
messagesRef.current = messages; messagesRef.current = messages;
isStreamingRef.current = isStreaming; isStreamingRef.current = isStreaming;
streamingTextRef.current = streamingText;
streamingThinkingRef.current = streamingThinking;
streamingToolCallsRef.current = streamingToolCalls;
const updateStreamingText = useCallback((next: SetStateAction<string>) => {
setStreamingText((previous) => {
const resolved = typeof next === "function" ? next(previous) : next;
streamingTextRef.current = resolved;
return resolved;
});
}, []);
const updateStreamingThinking = useCallback((next: SetStateAction<string>) => {
setStreamingThinking((previous) => {
const resolved = typeof next === "function" ? next(previous) : next;
streamingThinkingRef.current = resolved;
return resolved;
});
}, []);
const updateStreamingToolCalls = useCallback((next: SetStateAction<ToolCallInfo[]>) => {
setStreamingToolCalls((previous) => {
const resolved = typeof next === "function" ? next(previous) : next;
streamingToolCallsRef.current = resolved;
return resolved;
});
}, []);
useEffect(() => { useEffect(() => {
pendingMessagesRef.current = pendingMessages; pendingMessagesRef.current = pendingMessages;
@@ -731,6 +761,9 @@ export function useChat(
cancelStreamingFlushesRef.current = null; cancelStreamingFlushesRef.current = null;
pendingMessagesRef.current = []; pendingMessagesRef.current = [];
setPendingMessages([]); setPendingMessages([]);
streamingTextRef.current = "";
streamingThinkingRef.current = "";
streamingToolCallsRef.current = [];
setStreamingText(""); setStreamingText("");
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
@@ -868,9 +901,9 @@ export function useChat(
initialText: inFlightGeneration?.streamingText, initialText: inFlightGeneration?.streamingText,
initialThinking: inFlightGeneration?.streamingThinking, initialThinking: inFlightGeneration?.streamingThinking,
initialToolCalls: inFlightGeneration?.toolCalls, initialToolCalls: inFlightGeneration?.toolCalls,
setStreamingText, setStreamingText: updateStreamingText,
setStreamingThinking, setStreamingThinking: updateStreamingThinking,
setStreamingToolCalls, setStreamingToolCalls: updateStreamingToolCalls,
cancelStreamingFlushesRef, cancelStreamingFlushesRef,
addToast: options?.silent ? undefined : addToast, addToast: options?.silent ? undefined : addToast,
onFallbackSession: (data, fallbackSessionId) => { onFallbackSession: (data, fallbackSessionId) => {
@@ -925,7 +958,7 @@ export function useChat(
}); });
streamRef.current = stream; streamRef.current = stream;
return true; return true;
}, [addToast, flushPendingMessage, flushPendingMessageAfterAttachedError, hydrateMessagesFromCache, loadMessages, projectId]); }, [addToast, flushPendingMessage, flushPendingMessageAfterAttachedError, hydrateMessagesFromCache, loadMessages, projectId, updateStreamingText, updateStreamingThinking, updateStreamingToolCalls]);
attachIfGeneratingRef.current = attachIfGenerating; attachIfGeneratingRef.current = attachIfGenerating;
// Select a session // Select a session
@@ -1389,7 +1422,8 @@ export function useChat(
}, [activeSession, hasMoreMessages, loadMessages]); }, [activeSession, hasMoreMessages, loadMessages]);
const stopStreaming = useCallback(() => { const stopStreaming = useCallback(() => {
if (!activeSession) return; const session = activeSessionRef.current;
if (!session || cancellationInProgressRef.current) return;
cancelledByUserRef.current = true; cancelledByUserRef.current = true;
cancelStreamingFlushesRef.current?.(); cancelStreamingFlushesRef.current?.();
@@ -1398,17 +1432,86 @@ export function useChat(
streamRef.current = null; streamRef.current = null;
lastAttachedGenerationRef.current = null; lastAttachedGenerationRef.current = null;
void cancelChatResponse(activeSession.id, projectId).catch(() => { const sessionSelectionVersion = activeSessionSelectionRef.current;
// Best-effort cancellation; ignore backend errors. const stoppedText = streamingTextRef.current;
}); const stoppedThinking = streamingThinkingRef.current;
const stoppedToolCalls = streamingToolCallsRef.current;
const interruptedLocalId = `interrupted-${Date.now()}`;
const hasInterruptedOutput = Boolean(stoppedText || stoppedThinking || stoppedToolCalls.length > 0);
if (hasInterruptedOutput) {
// FNXC:ChatCancellation 2026-08-18-21:55:
// Keep the displayed prefix in the transcript while the awaited cancel request
// reconciles its durable PostgreSQL message; a failed request must not erase it.
setMessages((previous) => appendChatMessageChronologically(previous, {
id: interruptedLocalId,
sessionId: session.id,
role: "assistant",
content: stoppedText,
thinkingOutput: stoppedThinking || null,
toolCalls: stoppedToolCalls.length > 0 ? stoppedToolCalls : undefined,
createdAt: new Date().toISOString(),
}));
}
setIsStreaming(false); setIsStreaming(false);
isStreamingRef.current = false; isStreamingRef.current = false;
streamingTextRef.current = "";
streamingThinkingRef.current = "";
streamingToolCallsRef.current = [];
setStreamingText(""); setStreamingText("");
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
const cancellation = cancelChatResponse(session.id, projectId)
.then(async (result) => {
const cancellationResult = result ?? { success: true, interrupted: false };
if (!cancellationResult.success) {
throw new Error("Chat cancellation did not complete");
}
let refreshedMessages: ChatMessageInfo[] | null = null;
try {
const data = await fetchChatMessages(session.id, { limit: 50, order: "asc" }, projectId);
refreshedMessages = data.messages.map(mapChatMessageToInfo);
} catch {
// The local interrupted row remains recoverable when reconciliation cannot read history.
}
if (activeSessionRef.current?.id !== session.id || activeSessionSelectionRef.current !== sessionSelectionVersion) {
return;
}
const persistedInterruptedMessage = cancellationResult.message
? mapChatMessageToInfo(cancellationResult.message)
: undefined;
if (refreshedMessages || persistedInterruptedMessage) {
const reconciled = [
...(refreshedMessages ?? []),
...(persistedInterruptedMessage && !(refreshedMessages ?? []).some((message) => message.id === persistedInterruptedMessage.id)
? [persistedInterruptedMessage]
: []),
];
setMessages((current) => {
let next = current.filter((message) => message.id !== interruptedLocalId && message.id !== "streaming-assistant");
for (const persisted of reconciled) {
next = reconcileOptimisticSentMessage(next, persisted);
}
return sortChatMessagesChronologically(next);
});
}
flushPendingMessage(); flushPendingMessage();
}, [activeSession, projectId, flushPendingMessage]); })
.catch(() => {
if (activeSessionRef.current?.id === session.id && activeSessionSelectionRef.current === sessionSelectionVersion) {
addToast?.("Failed to save the interrupted response; it remains visible for recovery.", "error");
}
})
.finally(() => {
if (cancellationInProgressRef.current === cancellation) {
cancellationInProgressRef.current = null;
}
});
cancellationInProgressRef.current = cancellation;
}, [activeSession, addToast, flushPendingMessage, projectId]);
/** /**
* Send a user message to the active chat session. * Send a user message to the active chat session.
@@ -1514,9 +1617,9 @@ export function useChat(
const { handlers } = createChatStreamHandlers({ const { handlers } = createChatStreamHandlers({
sessionId: activeSession.id, sessionId: activeSession.id,
tempUserMessageId: tempId, tempUserMessageId: tempId,
setStreamingText, setStreamingText: updateStreamingText,
setStreamingThinking, setStreamingThinking: updateStreamingThinking,
setStreamingToolCalls, setStreamingToolCalls: updateStreamingToolCalls,
cancelStreamingFlushesRef, cancelStreamingFlushesRef,
addToast, addToast,
onFallbackSession: (data, sessionId) => { onFallbackSession: (data, sessionId) => {
@@ -1657,7 +1760,7 @@ export function useChat(
onAccepted: () => callbacks?.onAccepted?.(), onAccepted: () => callbacks?.onAccepted?.(),
}, attachments, projectId); }, attachments, projectId);
}, },
[activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently, flushPendingMessage], [activeSession, projectId, refreshSessions, addToast, attachIfGenerating, reconnectSessionSilently, flushPendingMessage, updateStreamingText, updateStreamingThinking, updateStreamingToolCalls],
); );
sendMessageRef.current = sendMessage; sendMessageRef.current = sendMessage;

View File

@@ -3349,13 +3349,13 @@ describe("ChatManager.sendMessage", () => {
} }
}); });
it("cancelGeneration returns false when no active generation exists", () => { it("cancelGeneration returns false when no active generation exists", async () => {
const chatManager = createChatManager(); const chatManager = createChatManager();
expect(chatManager.cancelGeneration("chat-001")).toBe(false); await expect(chatManager.cancelGeneration("chat-001")).resolves.toEqual({ success: false, interrupted: false });
}); });
it("cancelGeneration returns true and aborts an active generation", () => { it("cancelGeneration returns true and aborts an active generation", async () => {
const chatManager = createChatManager(); const chatManager = createChatManager();
const abortController = new AbortController(); const abortController = new AbortController();
const dispose = vi.fn(); const dispose = vi.fn();
@@ -3363,20 +3363,15 @@ describe("ChatManager.sendMessage", () => {
(chatManager as any).activeGenerations.set("chat-001", { (chatManager as any).activeGenerations.set("chat-001", {
abortController, abortController,
agentResult: { session: { dispose } }, agentResult: { session: { dispose } },
generationId: 1,
cancellationRequested: false,
}); });
const events: Array<{ type: string; data: unknown }> = []; const result = await chatManager.cancelGeneration("chat-001");
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
const result = chatManager.cancelGeneration("chat-001"); expect(result).toEqual({ success: true, interrupted: false });
unsubscribe();
expect(result).toBe(true);
expect(abortController.signal.aborted).toBe(true); expect(abortController.signal.aborted).toBe(true);
expect(dispose).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1);
expect(events).toContainEqual({ type: "error", data: "Generation cancelled" });
}); });
it("cancelled generation does not persist assistant message", async () => { it("cancelled generation does not persist assistant message", async () => {
@@ -3405,13 +3400,68 @@ describe("ChatManager.sendMessage", () => {
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(chatManager.cancelGeneration("chat-001")).toBe(true); await expect(chatManager.cancelGeneration("chat-001")).resolves.toEqual({ success: true, interrupted: false });
await sendPromise; await sendPromise;
const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant"); const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant");
expect(assistantCalls).toHaveLength(0); expect(assistantCalls).toHaveLength(0);
}); });
it("persists one interrupted assistant message before clearing the checkpoint", async () => {
let rejectPrompt: ((reason?: unknown) => void) | undefined;
const addMessageCalls: Array<{ role: string; content: string }> = [];
mockChatStore.addMessage.mockImplementation(async (_sessionId: string, input: { role: string; content: string; thinkingOutput?: string; metadata?: Record<string, unknown> }) => {
addMessageCalls.push({ role: input.role, content: input.content });
return {
id: input.role === "user" ? "user-1" : "assistant-interrupted-1",
sessionId: "chat-001",
role: input.role,
content: input.content,
thinkingOutput: input.thinkingOutput ?? null,
metadata: input.metadata ?? null,
createdAt: "2026-08-18T21:55:00.000Z",
};
});
__setCreateFnAgent(async (options: any) => ({
session: {
prompt: vi.fn().mockImplementation(() => {
options.onThinking("thinking prefix");
options.onText("Distinct interrupted prefix");
options.onToolStart("bash", { command: "echo partial" });
return new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
});
}),
dispose: vi.fn().mockImplementation(() => rejectPrompt?.(new Error("Disposed"))),
state: { messages: [] },
},
}));
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => events.push(event));
const chatManager = createChatManager();
const sendPromise = chatManager.sendMessage("chat-001", "Hello");
await new Promise((resolve) => setTimeout(resolve, 0));
const cancellation = await chatManager.cancelGeneration("chat-001");
await sendPromise;
unsubscribe();
expect(cancellation).toEqual(expect.objectContaining({ success: true, interrupted: true }));
expect(addMessageCalls.filter((call) => call.role === "assistant")).toEqual([
{ role: "assistant", content: "Distinct interrupted prefix" },
]);
expect(mockChatStore.setInFlightGeneration.mock.invocationCallOrder.at(-1)).toBeGreaterThan(
mockChatStore.addMessage.mock.invocationCallOrder.at(-1)!,
);
expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null);
expect(events.filter((event) => event.type === "done")).toHaveLength(1);
expect(events.find((event) => event.type === "done")?.data).toEqual(expect.objectContaining({
interrupted: true,
messageId: "assistant-interrupted-1",
}));
});
it("cancelled generation broadcasts error event with cancellation message", async () => { it("cancelled generation broadcasts error event with cancellation message", async () => {
let rejectPrompt: ((reason?: unknown) => void) | undefined; let rejectPrompt: ((reason?: unknown) => void) | undefined;
@@ -3440,11 +3490,11 @@ describe("ChatManager.sendMessage", () => {
const sendPromise = chatManager.sendMessage("chat-001", "Hello"); const sendPromise = chatManager.sendMessage("chat-001", "Hello");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
chatManager.cancelGeneration("chat-001"); await chatManager.cancelGeneration("chat-001");
await sendPromise; await sendPromise;
unsubscribe(); unsubscribe();
expect(events.some((event) => event.type === "error" && event.data === "Generation cancelled")).toBe(true); expect(events.some((event) => event.type === "done" && (event.data as { interrupted?: boolean }).interrupted === true)).toBe(true);
}); });
it("cleans active generation state even when dispose fails", async () => { it("cleans active generation state even when dispose fails", async () => {

View File

@@ -1085,6 +1085,7 @@ export type ChatStreamEvent =
createdAt: string; createdAt: string;
}; };
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
interrupted?: boolean;
}; };
} }
| { type: "error"; data: string | ChatFailureInfo }; | { type: "error"; data: string | ChatFailureInfo };
@@ -1414,15 +1415,28 @@ export class RoomReplyGenerationError extends Error {
} }
} }
interface ChatCancellationResult {
success: boolean;
interrupted: boolean;
message?: ChatMessage;
}
interface ActiveChatGeneration {
abortController: AbortController;
agentResult?: AgentResult;
generationId: number;
cancellationRequested: boolean;
cancellationResult?: ChatCancellationResult;
settled: Promise<void>;
resolveSettled: () => void;
}
export class ChatManager { export class ChatManager {
private agentStoreReady?: Promise<void>; private agentStoreReady?: Promise<void>;
private generationCounter = 0; private generationCounter = 0;
private inFlightPersistTimers = new Map<string, ReturnType<typeof setTimeout>>(); private inFlightPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
private activeGenerations = new Map<string, { private inFlightPersistChains = new Map<string, Promise<void>>();
abortController: AbortController; private activeGenerations = new Map<string, ActiveChatGeneration>();
agentResult?: AgentResult;
generationId: number;
}>();
constructor( constructor(
private chatStore: ChatStore, private chatStore: ChatStore,
@@ -1511,17 +1525,40 @@ export class ChatManager {
its rejection observed so one failed jsonb write cannot become a process-wide its rejection observed so one failed jsonb write cannot become a process-wide
unhandled rejection or interrupt the streaming turn. unhandled rejection or interrupt the streaming turn.
*/ */
private persistInFlightGeneration(sessionId: string, snapshot: ChatInFlightGenerationState | null): void { private persistInFlightGeneration(
sessionId: string,
snapshot: ChatInFlightGenerationState | null,
generationId?: number,
): Promise<void> {
const previous = this.inFlightPersistChains.get(sessionId) ?? Promise.resolve();
const write = previous.then(async () => {
// FNXC:ChatCancellation 2026-08-18-21:52:
// Generation-scoped checkpoint writes are serialized so delayed work from an
// interrupted turn cannot clear or overwrite a newer turn's recovery slot.
if (generationId !== undefined && this.activeGenerations.get(sessionId)?.generationId !== generationId) {
return;
}
try { try {
void this.chatStore.setInFlightGeneration(sessionId, snapshot).catch(() => { await this.chatStore.setInFlightGeneration(sessionId, snapshot);
diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`);
});
} catch { } catch {
diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`); diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`);
} }
});
const tracked = write.finally(() => {
if (this.inFlightPersistChains.get(sessionId) === tracked) {
this.inFlightPersistChains.delete(sessionId);
}
});
this.inFlightPersistChains.set(sessionId, tracked);
void tracked.catch(() => undefined);
return tracked;
} }
private queueInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void { private queueInFlightGenerationPersist(
sessionId: string,
snapshot: ChatInFlightGenerationState | null,
generationId: number,
): void {
const existingTimer = this.inFlightPersistTimers.get(sessionId); const existingTimer = this.inFlightPersistTimers.get(sessionId);
if (existingTimer) { if (existingTimer) {
clearTimeout(existingTimer); clearTimeout(existingTimer);
@@ -1529,18 +1566,22 @@ export class ChatManager {
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.inFlightPersistTimers.delete(sessionId); this.inFlightPersistTimers.delete(sessionId);
this.persistInFlightGeneration(sessionId, snapshot); void this.persistInFlightGeneration(sessionId, snapshot, generationId);
}, IN_FLIGHT_PERSIST_DEBOUNCE_MS); }, IN_FLIGHT_PERSIST_DEBOUNCE_MS);
this.inFlightPersistTimers.set(sessionId, timer); this.inFlightPersistTimers.set(sessionId, timer);
} }
private flushInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void { private flushInFlightGenerationPersist(
sessionId: string,
snapshot: ChatInFlightGenerationState | null,
generationId?: number,
): Promise<void> {
const existingTimer = this.inFlightPersistTimers.get(sessionId); const existingTimer = this.inFlightPersistTimers.get(sessionId);
if (existingTimer) { if (existingTimer) {
clearTimeout(existingTimer); clearTimeout(existingTimer);
this.inFlightPersistTimers.delete(sessionId); this.inFlightPersistTimers.delete(sessionId);
} }
this.persistInFlightGeneration(sessionId, snapshot); return this.persistInFlightGeneration(sessionId, snapshot, generationId);
} }
private async getChatModelSettings(): Promise<{ private async getChatModelSettings(): Promise<{
@@ -1671,7 +1712,17 @@ export class ChatManager {
this.generationCounter += 1; this.generationCounter += 1;
const generationId = this.generationCounter; const generationId = this.generationCounter;
const abortController = new AbortController(); const abortController = new AbortController();
this.activeGenerations.set(sessionId, { abortController, generationId }); let resolveSettled!: () => void;
const settled = new Promise<void>((resolve) => {
resolveSettled = resolve;
});
this.activeGenerations.set(sessionId, {
abortController,
generationId,
cancellationRequested: false,
settled,
resolveSettled,
});
return { generationId, abortController }; return { generationId, abortController };
} }
@@ -2306,6 +2357,7 @@ export class ChatManager {
abortController = allocated.abortController; abortController = allocated.abortController;
} }
const broadcastOptions = { generationId }; const broadcastOptions = { generationId };
const generationState = this.activeGenerations.get(sessionId);
const session = await this.chatStore.getSession(sessionId); const session = await this.chatStore.getSession(sessionId);
@@ -2348,6 +2400,7 @@ export class ChatManager {
if (current?.generationId === generationId) { if (current?.generationId === generationId) {
this.activeGenerations.delete(sessionId); this.activeGenerations.delete(sessionId);
} }
generationState?.resolveSettled?.();
} }
return; return;
} }
@@ -2397,7 +2450,7 @@ export class ChatManager {
], ],
replayFromEventId: lastStreamEventId, replayFromEventId: lastStreamEventId,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); }, generationId);
}; };
try { try {
@@ -2417,7 +2470,7 @@ export class ChatManager {
toolCalls: [], toolCalls: [],
replayFromEventId: 0, replayFromEventId: 0,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); }, generationId);
const parsedSkillCommands = parseSkillCommands(content); const parsedSkillCommands = parseSkillCommands(content);
@@ -2453,7 +2506,7 @@ export class ChatManager {
void Promise.resolve(emitted).catch(() => undefined); void Promise.resolve(emitted).catch(() => undefined);
} catch { /* telemetry must not enter the message-save failure path */ } } catch { /* telemetry must not enter the message-save failure path */ }
} catch (err) { } catch (err) {
this.flushInFlightGenerationPersist(sessionId, null); await this.flushInFlightGenerationPersist(sessionId, null, generationId);
chatStreamManager.broadcast(sessionId, { chatStreamManager.broadcast(sessionId, {
type: "error", type: "error",
data: `Failed to save message: ${err instanceof Error ? err.message : "Unknown error"}`, data: `Failed to save message: ${err instanceof Error ? err.message : "Unknown error"}`,
@@ -2881,12 +2934,16 @@ export class ChatManager {
...(this.taskStore ? { mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId: agent?.id })).servers } : {}), ...(this.taskStore ? { mcpServers: (await resolveMcpServersForStore(this.taskStore, { agentId: agent?.id })).servers } : {}),
...sessionOptions, ...sessionOptions,
}); });
this.activeGenerations.set(sessionId, { abortController, agentResult, generationId }); const generationEntry = this.activeGenerations.get(sessionId);
if (!generationEntry || generationEntry.generationId !== generationId) {
if (abortController.signal.aborted) {
agentResult.session.dispose?.(); agentResult.session.dispose?.();
return; return;
} }
generationEntry.agentResult = agentResult;
if (abortController.signal.aborted) {
throw new Error("Generation cancelled");
}
// Send user message and get response // Send user message and get response
await enginePromptWithFallback( await enginePromptWithFallback(
@@ -2896,7 +2953,7 @@ export class ChatManager {
); );
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
return; throw new Error("Generation cancelled");
} }
interface AgentMessage { interface AgentMessage {
@@ -2917,7 +2974,7 @@ export class ChatManager {
effectiveModelId, effectiveModelId,
); );
await persistFailureMessage(this.chatStore, sessionId, failureInfo); await persistFailureMessage(this.chatStore, sessionId, failureInfo);
this.flushInFlightGenerationPersist(sessionId, null); await this.flushInFlightGenerationPersist(sessionId, null, generationId);
chatStreamManager.broadcast(sessionId, { chatStreamManager.broadcast(sessionId, {
type: "error", type: "error",
data: failureInfo, data: failureInfo,
@@ -2985,7 +3042,7 @@ export class ChatManager {
}); });
} }
this.flushInFlightGenerationPersist(sessionId, null); await this.flushInFlightGenerationPersist(sessionId, null, generationId);
// Broadcast done event with persisted assistant snapshot so clients can // Broadcast done event with persisted assistant snapshot so clients can
// render completion even when incremental text deltas were absent. // render completion even when incremental text deltas were absent.
@@ -3007,12 +3064,65 @@ export class ChatManager {
}, },
}, broadcastOptions); }, broadcastOptions);
} catch (err) { } catch (err) {
if (abortController.signal.aborted) { const generationEntry = this.activeGenerations.get(sessionId);
this.flushInFlightGenerationPersist(sessionId, null); const isExplicitCancellation = abortController.signal.aborted
&& generationEntry?.generationId === generationId
&& generationEntry.cancellationRequested;
if (isExplicitCancellation) {
let interruptedMessage: ChatMessage | undefined;
// FNXC:ChatCancellation 2026-08-18-21:52:
// Stop is a durable conversation transition: save the visible prefix before
// clearing its checkpoint so the next model turn and reload see the same context.
if (accumulatedText || accumulatedThinking || toolCallsAccum.length > 0) {
try {
interruptedMessage = await this.chatStore.addMessage(sessionId, {
role: "assistant",
content: accumulatedText,
thinkingOutput: accumulatedThinking || undefined,
metadata: {
interrupted: true,
...(fallbackInfo ? { fallback: fallbackInfo } : {}),
...(toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : {}),
},
});
} catch (persistErr) {
diagnostics.error(`Failed to persist interrupted response for session ${sessionId}:`, persistErr);
}
}
await this.flushInFlightGenerationPersist(sessionId, null, generationId);
const current = this.activeGenerations.get(sessionId);
if (current?.generationId === generationId) {
current.cancellationResult = {
success: true,
interrupted: Boolean(interruptedMessage),
...(interruptedMessage ? { message: interruptedMessage } : {}),
};
chatStreamManager.broadcast(sessionId, { chatStreamManager.broadcast(sessionId, {
type: "error", type: "done",
data: "Generation cancelled", data: {
messageId: interruptedMessage?.id ?? "",
...(interruptedMessage ? {
message: {
id: interruptedMessage.id,
sessionId: interruptedMessage.sessionId,
role: "assistant" as const,
content: interruptedMessage.content,
thinkingOutput: interruptedMessage.thinkingOutput,
metadata: interruptedMessage.metadata,
attachments: interruptedMessage.attachments,
createdAt: interruptedMessage.createdAt,
},
} : {}),
interrupted: true,
},
}, broadcastOptions); }, broadcastOptions);
}
return;
}
if (abortController.signal.aborted) {
await this.flushInFlightGenerationPersist(sessionId, null, generationId);
return; return;
} }
@@ -3045,7 +3155,7 @@ export class ChatManager {
diagnostics.error(`Failed to persist failure message for session ${sessionId}:`, persistErr); diagnostics.error(`Failed to persist failure message for session ${sessionId}:`, persistErr);
} }
this.flushInFlightGenerationPersist(sessionId, null); await this.flushInFlightGenerationPersist(sessionId, null, generationId);
chatStreamManager.broadcast(sessionId, { chatStreamManager.broadcast(sessionId, {
type: "error", type: "error",
@@ -3060,6 +3170,7 @@ export class ChatManager {
if (stillOwnsSlot) { if (stillOwnsSlot) {
this.activeGenerations.delete(sessionId); this.activeGenerations.delete(sessionId);
} }
generationState?.resolveSettled?.();
// Dispose the agent session — but ONLY when we still own the slot. // Dispose the agent session — but ONLY when we still own the slot.
// //
@@ -3084,12 +3195,14 @@ export class ChatManager {
} }
} }
cancelGeneration(sessionId: string): boolean { async cancelGeneration(sessionId: string): Promise<ChatCancellationResult> {
const entry = this.activeGenerations.get(sessionId); const entry = this.activeGenerations.get(sessionId);
if (!entry) { if (!entry) {
return false; return { success: false, interrupted: false };
} }
if (!entry.cancellationRequested) {
entry.cancellationRequested = true;
entry.abortController.abort(); entry.abortController.abort();
if (entry.agentResult) { if (entry.agentResult) {
@@ -3099,15 +3212,14 @@ export class ChatManager {
diagnostics.error(`Error disposing agent session during cancellation:`, err); diagnostics.error(`Error disposing agent session during cancellation:`, err);
} }
} }
}
this.flushInFlightGenerationPersist(sessionId, null); // The send loop owns persistence and its terminal SSE event. Waiting here
// makes the HTTP cancel response a durable reconciliation barrier for clients.
chatStreamManager.broadcast(sessionId, { if (entry.settled) {
type: "error", await entry.settled;
data: "Generation cancelled", }
}, { generationId: entry.generationId }); return entry.cancellationResult ?? { success: true, interrupted: false };
return true;
} }
/** /**

View File

@@ -1124,8 +1124,11 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
try { try {
const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined); const chatManager = await resolveScopedChatManager(req.query.projectId as string | undefined);
const sessionId = String(req.params.id); const sessionId = String(req.params.id);
const success = chatManager.cancelGeneration(sessionId); // FNXC:ChatCancellation 2026-08-18-21:52:
res.json({ success }); // Await cancellation so clients only reconcile or dequeue follow-up sends after
// the interrupted assistant prefix and checkpoint cleanup are durable.
const result = await chatManager.cancelGeneration(sessionId);
res.json(result);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;