FN-7366: restore planner chat in-flight state
Planner chat now resumes active generations when task detail is reopened.\n\n- Rehydrate in-flight planner chat snapshots and reattach to the session stream after tab/modal remounts.\n- Preserve accepted optimistic user turns across provider failures while rolling back pre-acceptance failures.\n- Add regression coverage for remount reattachment, attached completion/error refresh, and accepted error reconciliation.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .changeset/fn-7366-planner-chat-resume.md | 7 +\n .../app/components/TaskPlannerChatTab.tsx | 334 +++++++++++++--------\n .../__tests__/TaskPlannerChatTab.test.tsx | 189 +++++++++++-\n 3 files changed, 382 insertions(+), 148 deletions(-) Fusion-Task-Id: FN-7366 Fusion-Task-Lineage: b13b9c5e-9295-461f-b44b-f2b5a9008b4f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7366-planner-chat-resume.md
Normal file
7
.changeset/fn-7366-planner-chat-resume.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Preserve task-detail planner Chat working state after leaving and returning.
|
||||
category: fix
|
||||
dev: Rehydrates task-planner chat generation snapshots and reattaches streams across tab switches and modal remounts.
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
|
||||
import type { ChatInFlightGenerationState, ChatMessage, ResolvedModelSelection, Task, TaskDetail } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
|
||||
import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatStreamErrorMeta } from "../api";
|
||||
import { attachChatStream, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
|
||||
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
@@ -147,7 +147,7 @@ function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
function extractPlannerSteeringResult(toolCall: ToolCallInfo): PlannerSteeringResult | null {
|
||||
if (toolCall.toolName !== TASK_PLANNER_STEERING_TOOL_NAME || toolCall.isError) return null;
|
||||
if (toolCall.toolName !== TASK_PLANNER_STEERING_TOOL_NAME || toolCall.isError || toolCall.status === "running") return null;
|
||||
const resultRecord = readRecord(toolCall.result);
|
||||
const detailsRecord = readRecord(resultRecord?.details) ?? resultRecord;
|
||||
const commentRecord = readRecord(detailsRecord?.steeringComment);
|
||||
@@ -178,6 +178,20 @@ function extractPlannerSteeringTextFromResult(result: unknown): string | null {
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function normalizeChatFailureSummary(error: string | ChatFailureInfo, fallback: string): string {
|
||||
return typeof error === "string" ? error || fallback : error.summary || fallback;
|
||||
}
|
||||
|
||||
function cloneToolCalls(toolCalls: readonly ToolCallInfo[] | readonly ChatInFlightGenerationState["toolCalls"][number][] | undefined): ToolCallInfo[] {
|
||||
return (toolCalls ?? []).map((toolCall) => ({
|
||||
toolName: toolCall.toolName,
|
||||
...(toolCall.args ? { args: { ...toolCall.args } } : {}),
|
||||
isError: toolCall.isError,
|
||||
...(toolCall.result !== undefined ? { result: toolCall.result } : {}),
|
||||
status: toolCall.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function extractToolCalls(message: Pick<ChatMessage, "metadata">): ToolCallInfo[] {
|
||||
const rawToolCalls = message.metadata?.toolCalls;
|
||||
if (!Array.isArray(rawToolCalls)) return [];
|
||||
@@ -277,6 +291,13 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadRequestRef = useRef(0);
|
||||
const streamRequestRef = useRef(0);
|
||||
const addToastRef = useRef(addToast);
|
||||
const onTaskUpdatedRef = useRef(onTaskUpdated);
|
||||
|
||||
useEffect(() => {
|
||||
addToastRef.current = addToast;
|
||||
onTaskUpdatedRef.current = onTaskUpdated;
|
||||
}, [addToast, onTaskUpdated]);
|
||||
|
||||
const planningModelProvider = isUsableModel(planningModel) ? planningModel.provider : undefined;
|
||||
const planningModelId = isUsableModel(planningModel) ? planningModel.modelId : undefined;
|
||||
@@ -289,6 +310,153 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
}, [planningModelId, planningModelProvider]);
|
||||
const plannerChatScopeKey = `${task.id}\u0000${projectId ?? ""}\u0000${planningModelProvider ?? ""}\u0000${planningModelId ?? ""}`;
|
||||
|
||||
const applyStreamingSnapshot = useCallback((resolvedSessionId: string, text: string, thinking: string, toolCalls: ToolCallInfo[]) => {
|
||||
setStreamingThinking(thinking);
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, text, toolCalls, thinking)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshMessagesForSession = useCallback(async (resolvedSessionId: string, isCurrentRequest: () => boolean, options?: { mergeOptimistic?: boolean }) => {
|
||||
try {
|
||||
const { messages: refreshed } = await fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId);
|
||||
if (!isCurrentRequest()) return;
|
||||
if (options?.mergeOptimistic) {
|
||||
setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed));
|
||||
} else {
|
||||
setMessages(sortMessages(refreshed));
|
||||
}
|
||||
setHistoryLoaded(true);
|
||||
} catch (refreshError) {
|
||||
if (!isCurrentRequest()) return;
|
||||
const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat");
|
||||
setError(message);
|
||||
addToastRef.current(message, "error");
|
||||
}
|
||||
}, [projectId, t]);
|
||||
|
||||
const refreshTaskAfterSteering = useCallback(async () => {
|
||||
try {
|
||||
const refreshedTask = await fetchTaskDetail(task.id, projectId);
|
||||
onTaskUpdatedRef.current?.(refreshedTask);
|
||||
addToastRef.current(t("taskDetail.plannerChat.steeringAddedToast", "Added as steering comment"), "success");
|
||||
} catch (refreshError) {
|
||||
const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.refreshTaskFailed", "Steering was added, but task details could not refresh");
|
||||
setError(message);
|
||||
addToastRef.current(message, "error");
|
||||
}
|
||||
}, [projectId, task.id, t]);
|
||||
|
||||
const startPlannerStream = useCallback((options: {
|
||||
resolvedSessionId: string;
|
||||
content?: string;
|
||||
inFlightGeneration?: ChatInFlightGenerationState | null;
|
||||
requestId: number;
|
||||
attach: boolean;
|
||||
}) => {
|
||||
const { resolvedSessionId, content = "", inFlightGeneration, requestId, attach } = options;
|
||||
const isCurrentStreamRequest = () => streamRequestRef.current === requestId;
|
||||
let accumulated = inFlightGeneration?.streamingText ?? "";
|
||||
let accumulatedThinking = inFlightGeneration?.streamingThinking ?? "";
|
||||
const streamingToolCalls = cloneToolCalls(inFlightGeneration?.toolCalls);
|
||||
|
||||
streamRef.current?.close();
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
|
||||
composerStateRef.current = "sending";
|
||||
setComposerState("sending");
|
||||
setError(null);
|
||||
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
|
||||
|
||||
const handlers = {
|
||||
onText: (delta: string) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
accumulated += delta;
|
||||
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
|
||||
},
|
||||
onThinking: (delta: string) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
accumulatedThinking += delta;
|
||||
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
|
||||
},
|
||||
onToolStart: ({ toolName, args }: { toolName: string; args?: Record<string, unknown> }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
|
||||
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
|
||||
},
|
||||
onToolEnd: ({ toolName, isError, result }: { toolName: string; isError: boolean; result?: unknown }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const running = [...streamingToolCalls].reverse().find((toolCall) => toolCall.toolName === toolName && toolCall.status === "running");
|
||||
if (running) {
|
||||
running.status = "completed";
|
||||
running.isError = isError;
|
||||
running.result = result;
|
||||
} else {
|
||||
streamingToolCalls.push({ toolName, isError, result, status: "completed" });
|
||||
}
|
||||
const steeringText = toolName === TASK_PLANNER_STEERING_TOOL_NAME && !isError
|
||||
? extractPlannerSteeringTextFromResult(result)
|
||||
: null;
|
||||
if (steeringText) {
|
||||
void refreshTaskAfterSteering();
|
||||
}
|
||||
applyStreamingSnapshot(resolvedSessionId, accumulated, accumulatedThinking, streamingToolCalls);
|
||||
},
|
||||
onDone: (data: { messageId: string; message?: ChatMessage }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
setStreamingThinking("");
|
||||
streamRef.current = null;
|
||||
if (data.message) {
|
||||
setMessages((current) => {
|
||||
const withoutTemporary = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return sortMessages([...withoutTemporary, data.message!]);
|
||||
});
|
||||
} else {
|
||||
void refreshMessagesForSession(resolvedSessionId, isCurrentStreamRequest, { mergeOptimistic: Boolean(content) });
|
||||
}
|
||||
},
|
||||
onError: (streamError: string | ChatFailureInfo, meta?: ChatStreamErrorMeta) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const message = normalizeChatFailureSummary(streamError, t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
||||
setError(message);
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
setStreamingThinking("");
|
||||
streamRef.current = null;
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((candidate) => candidate.id !== "streaming-assistant");
|
||||
if (meta?.requestAccepted === false && content) {
|
||||
return withoutStreaming.filter((candidate) => !(candidate.role === "user" && candidate.id.startsWith("optimistic-") && candidate.content.trim() === content.trim()));
|
||||
}
|
||||
return withoutStreaming;
|
||||
});
|
||||
if (meta?.requestAccepted === false) return;
|
||||
void refreshMessagesForSession(resolvedSessionId, isCurrentStreamRequest, { mergeOptimistic: Boolean(content) });
|
||||
},
|
||||
};
|
||||
|
||||
streamRef.current = attach
|
||||
? attachChatStream(
|
||||
resolvedSessionId,
|
||||
handlers,
|
||||
projectId,
|
||||
typeof inFlightGeneration?.replayFromEventId === "number"
|
||||
? { lastEventId: inFlightGeneration.replayFromEventId }
|
||||
: undefined,
|
||||
)
|
||||
: streamChatResponse(
|
||||
resolvedSessionId,
|
||||
content,
|
||||
handlers,
|
||||
undefined,
|
||||
projectId,
|
||||
{ taskId: task.id },
|
||||
);
|
||||
}, [applyStreamingSnapshot, projectId, refreshMessagesForSession, refreshTaskAfterSteering, task.id, t]);
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
const requestId = loadRequestRef.current + 1;
|
||||
loadRequestRef.current = requestId;
|
||||
@@ -296,19 +464,33 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
setHistoryLoaded(false);
|
||||
setError(null);
|
||||
try {
|
||||
const { session } = await fetchTaskPlannerChatSession(task.id, modelPayload, projectId);
|
||||
const { session: lookupSession } = await fetchTaskPlannerChatSession(task.id, modelPayload, projectId);
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
if (!session) {
|
||||
if (!lookupSession) {
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setHistoryLoaded(true);
|
||||
return;
|
||||
}
|
||||
setSessionId(session.id);
|
||||
const { messages: loadedMessages } = await fetchChatMessages(session.id, { order: "asc" }, projectId);
|
||||
setSessionId(lookupSession.id);
|
||||
const [{ messages: loadedMessages }, refreshedSessionResult] = await Promise.all([
|
||||
fetchChatMessages(lookupSession.id, { order: "asc" }, projectId),
|
||||
fetchChatSession(lookupSession.id, projectId).catch(() => ({ session: lookupSession })),
|
||||
]);
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
const resolvedSession = refreshedSessionResult.session;
|
||||
setMessages(sortMessages(loadedMessages));
|
||||
setHistoryLoaded(true);
|
||||
if (resolvedSession.isGenerating || resolvedSession.inFlightGeneration) {
|
||||
const streamRequestId = streamRequestRef.current + 1;
|
||||
streamRequestRef.current = streamRequestId;
|
||||
startPlannerStream({
|
||||
resolvedSessionId: lookupSession.id,
|
||||
inFlightGeneration: resolvedSession.inFlightGeneration,
|
||||
requestId: streamRequestId,
|
||||
attach: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat");
|
||||
@@ -319,7 +501,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [modelPayload, projectId, task.id, t]);
|
||||
}, [modelPayload, projectId, startPlannerStream, task.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequestRef.current += 1;
|
||||
@@ -362,18 +544,6 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [messages, composerState]);
|
||||
|
||||
const refreshTaskAfterSteering = useCallback(async () => {
|
||||
try {
|
||||
const refreshedTask = await fetchTaskDetail(task.id, projectId);
|
||||
onTaskUpdated?.(refreshedTask);
|
||||
addToast(t("taskDetail.plannerChat.steeringAddedToast", "Added as steering comment"), "success");
|
||||
} catch (refreshError) {
|
||||
const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.refreshTaskFailed", "Steering was added, but task details could not refresh");
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [addToast, onTaskUpdated, projectId, task.id, t]);
|
||||
|
||||
const sendMessageContent = useCallback(async (messageContent: string) => {
|
||||
const content = messageContent.trim();
|
||||
if (!content || composerStateRef.current === "sending") return;
|
||||
@@ -395,118 +565,13 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
const resolvedSessionId = session.id;
|
||||
setSessionId(resolvedSessionId);
|
||||
setMessages((current) => [...current, makeOptimisticUserMessage(resolvedSessionId, content)]);
|
||||
let accumulated = "";
|
||||
let accumulatedThinking = "";
|
||||
const streamingToolCalls: ToolCallInfo[] = [];
|
||||
|
||||
streamRef.current?.close();
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
streamRef.current = streamChatResponse(
|
||||
startPlannerStream({
|
||||
resolvedSessionId,
|
||||
content,
|
||||
{
|
||||
onText: (delta) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
accumulated += delta;
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
||||
});
|
||||
},
|
||||
onThinking: (delta) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
accumulatedThinking += delta;
|
||||
setStreamingThinking(accumulatedThinking);
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
||||
});
|
||||
},
|
||||
onToolStart: ({ toolName, args }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
streamingToolCalls.push({ toolName, args, isError: false, status: "running" });
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
||||
});
|
||||
},
|
||||
onToolEnd: ({ toolName, isError, result }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const running = [...streamingToolCalls].reverse().find((toolCall) => toolCall.toolName === toolName && toolCall.status === "running");
|
||||
if (running) {
|
||||
running.status = "completed";
|
||||
running.isError = isError;
|
||||
running.result = result;
|
||||
} else {
|
||||
streamingToolCalls.push({ toolName, isError, result, status: "completed" });
|
||||
}
|
||||
const steeringText = toolName === TASK_PLANNER_STEERING_TOOL_NAME && !isError
|
||||
? extractPlannerSteeringTextFromResult(result)
|
||||
: null;
|
||||
if (steeringText) {
|
||||
void refreshTaskAfterSteering();
|
||||
}
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return [...withoutStreaming, makeStreamingAssistantMessage(resolvedSessionId, accumulated, streamingToolCalls, accumulatedThinking)];
|
||||
});
|
||||
},
|
||||
onDone: (data) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
setStreamingThinking("");
|
||||
streamRef.current = null;
|
||||
if (data.message) {
|
||||
setMessages((current) => {
|
||||
const withoutTemporary = current.filter((message) => message.id !== "streaming-assistant");
|
||||
return sortMessages([...withoutTemporary, data.message!]);
|
||||
});
|
||||
} else {
|
||||
void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId)
|
||||
.then(({ messages: refreshed }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed));
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const message = getErrorMessage(refreshError) || t("taskDetail.plannerChat.loadFailed", "Failed to load planner chat");
|
||||
setError(message);
|
||||
addToast(message, "error");
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (streamError, meta?: ChatStreamErrorMeta) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const message = typeof streamError === "string" ? streamError : streamError.summary;
|
||||
setError(message || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond"));
|
||||
composerStateRef.current = "idle";
|
||||
setComposerState("idle");
|
||||
setStreamingThinking("");
|
||||
streamRef.current = null;
|
||||
setMessages((current) => {
|
||||
const withoutStreaming = current.filter((candidate) => candidate.id !== "streaming-assistant");
|
||||
if (meta?.requestAccepted === false) {
|
||||
return withoutStreaming.filter((candidate) => !(candidate.role === "user" && candidate.id.startsWith("optimistic-") && candidate.content.trim() === content));
|
||||
}
|
||||
return withoutStreaming;
|
||||
});
|
||||
if (meta?.requestAccepted !== false) {
|
||||
void fetchChatMessages(resolvedSessionId, { order: "asc" }, projectId)
|
||||
.then(({ messages: refreshed }) => {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
setMessages((current) => mergePlannerTranscriptWithOptimistic(current, refreshed));
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the accepted optimistic user turn visible; a later refresh/SSE will reconcile the persisted id.
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
projectId,
|
||||
{ taskId: task.id },
|
||||
);
|
||||
requestId: streamRequestId,
|
||||
attach: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isCurrentStreamRequest()) return;
|
||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.sendFailed", "Planner chat failed to respond");
|
||||
@@ -516,7 +581,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
setComposerState("idle");
|
||||
setStreamingThinking("");
|
||||
}
|
||||
}, [addToast, modelPayload, projectId, refreshTaskAfterSteering, sessionId, task.id, t]);
|
||||
}, [addToast, modelPayload, projectId, sessionId, startPlannerStream, task.id, t]);
|
||||
|
||||
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
|
||||
|
||||
@@ -611,6 +676,12 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
||||
Session loads are scoped to the current task/project/model and stale responses are ignored so a delayed previous task load cannot attach starter-prompt sends to the wrong planner-chat session.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-07-01-14:47:
|
||||
Task-detail Planner Chat must survive Activity tab switches and modal remounts by rehydrating the persisted session's in-flight generation snapshot, then reattaching to `/chat/sessions/:id/stream`. Lookup-only tab activation stays non-mutating; explicit sends remain the only path that creates a planner-chat session.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-07-01-00:00:
|
||||
Provider failures after planner-chat stream acceptance must keep the user's visible turn because the server may have persisted it and included it in model context. Reconcile accepted optimistic rows with refreshed history, but roll back only explicit pre-acceptance failures.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-18:20:
|
||||
Opening or switching to the task-detail Chat tab performs lookup-only history loading. Planner-chat rows are lazily created only by explicit user messages (composer sends, starter prompts, or planner-question answers), so unvisited conversations do not clutter global Chat history.
|
||||
|
||||
@@ -623,9 +694,6 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
||||
FNXC:TaskDetailPlannerChat 2026-07-01-09:34:
|
||||
Planner Chat delegates transcript bubbles, thinking details, tool-call framing, and mobile send/stop gestures to StandardChatSurface. TaskPlannerChatTab keeps lookup-only session loading, task-context sends, starter prompts, and steering confirmations local so reuse does not collapse the lazy ChatView chunk or merge planner chat with Activity.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-07-01-00:00:
|
||||
Provider failures after planner-chat stream acceptance must keep the user's visible turn because the server may have persisted it and included it in model context. Reconcile accepted optimistic rows with refreshed history, but roll back only explicit pre-acceptance failures.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation.
|
||||
*/
|
||||
|
||||
@@ -4,14 +4,16 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||
|
||||
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockTranslations, mockT } = vi.hoisted(() => {
|
||||
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockTranslations, mockT } = vi.hoisted(() => {
|
||||
const translations = new Map<string, string>();
|
||||
return {
|
||||
mockEnsureTaskPlannerChatSession: vi.fn(),
|
||||
mockFetchTaskPlannerChatSession: vi.fn(),
|
||||
mockFetchChatSession: vi.fn(),
|
||||
mockFetchChatMessages: vi.fn(),
|
||||
mockFetchTaskDetail: vi.fn(),
|
||||
mockStreamChatResponse: vi.fn(),
|
||||
mockAttachChatStream: vi.fn(),
|
||||
mockTranslations: translations,
|
||||
mockT: (key: string, fallback: string) => translations.get(key) ?? fallback,
|
||||
};
|
||||
@@ -29,9 +31,11 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
...actual,
|
||||
ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession,
|
||||
fetchTaskPlannerChatSession: mockFetchTaskPlannerChatSession,
|
||||
fetchChatSession: mockFetchChatSession,
|
||||
fetchChatMessages: mockFetchChatMessages,
|
||||
fetchTaskDetail: mockFetchTaskDetail,
|
||||
streamChatResponse: mockStreamChatResponse,
|
||||
attachChatStream: mockAttachChatStream,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,6 +50,24 @@ function makeTask(id: string) {
|
||||
return { id, description: "Test task", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-06-30T00:00:00.000Z", updatedAt: "2026-06-30T00:00:00.000Z", planningModelProvider: "anthropic", planningModelId: "claude-plan" } as any;
|
||||
}
|
||||
|
||||
function makePlannerSession(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "chat-planner",
|
||||
agentId: "task-planner:FN-7310",
|
||||
title: "FN-7310 planner chat",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-plan",
|
||||
createdAt: "2026-06-30T00:00:00.000Z",
|
||||
updatedAt: "2026-06-30T00:00:00.000Z",
|
||||
cliSessionFile: null,
|
||||
cliExecutorAdapterId: null,
|
||||
inFlightGeneration: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -92,25 +114,14 @@ describe("TaskPlannerChatTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTranslations.clear();
|
||||
const plannerSession = {
|
||||
id: "chat-planner",
|
||||
agentId: "task-planner:FN-7310",
|
||||
title: "FN-7310 planner chat",
|
||||
status: "active",
|
||||
projectId: null,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-plan",
|
||||
createdAt: "2026-06-30T00:00:00.000Z",
|
||||
updatedAt: "2026-06-30T00:00:00.000Z",
|
||||
cliSessionFile: null,
|
||||
cliExecutorAdapterId: null,
|
||||
inFlightGeneration: null,
|
||||
};
|
||||
const plannerSession = makePlannerSession();
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
|
||||
mockFetchChatSession.mockResolvedValue({ session: plannerSession });
|
||||
mockEnsureTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310"));
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
});
|
||||
|
||||
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
|
||||
@@ -357,6 +368,127 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(screen.getByTestId("task-planner-chat-empty")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reattaches an in-flight planner generation after modal remount and keeps stop visible", async () => {
|
||||
const firstClose = vi.fn();
|
||||
const inFlightGeneration = {
|
||||
status: "generating",
|
||||
streamingText: "Partial planner answer",
|
||||
streamingThinking: "Reviewing task context",
|
||||
toolCalls: [{ toolName: "fn_task_planner_add_steering", args: { text: "Add this steering" }, isError: false, status: "running" }],
|
||||
replayFromEventId: 7,
|
||||
updatedAt: "2026-07-01T14:00:00.000Z",
|
||||
};
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [{ id: "user-1", sessionId: "chat-planner", role: "user", content: "Help plan this", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T13:59:00.000Z" }],
|
||||
});
|
||||
mockAttachChatStream.mockReturnValueOnce({ close: firstClose, isConnected: () => true }).mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||
|
||||
const { unmount } = renderPlannerChat();
|
||||
|
||||
expect(await screen.findByText("Partial planner answer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reviewing task context")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-planner-chat-steering-pending")).toHaveTextContent("Adding steering comment…");
|
||||
expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument();
|
||||
expect(mockAttachChatStream).toHaveBeenCalledWith("chat-planner", expect.any(Object), undefined, { lastEventId: 7 });
|
||||
|
||||
unmount();
|
||||
expect(firstClose).toHaveBeenCalled();
|
||||
|
||||
renderPlannerChat();
|
||||
expect(await screen.findByText("Partial planner answer")).toBeInTheDocument();
|
||||
expect(mockAttachChatStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes to completed planner history after returning to a remounted modal", async () => {
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: false, inFlightGeneration: null }) });
|
||||
mockFetchChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: false, inFlightGeneration: null }) });
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [
|
||||
{ id: "user-1", sessionId: "chat-planner", role: "user", content: "Summarize blockers", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T13:59:00.000Z" },
|
||||
{ id: "assistant-1", sessionId: "chat-planner", role: "assistant", content: "The planner finished while you were away.", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T14:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = renderPlannerChat();
|
||||
expect(await screen.findByText("The planner finished while you were away.")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Stop generation" })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderPlannerChat();
|
||||
expect(await screen.findByText("The planner finished while you were away.")).toBeInTheDocument();
|
||||
expect(mockAttachChatStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes attached planner completion without leaving a stale streaming bubble", async () => {
|
||||
let attachedHandlers: any;
|
||||
const inFlightGeneration = {
|
||||
status: "generating",
|
||||
streamingText: "Partial draft",
|
||||
streamingThinking: "Synthesizing",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 11,
|
||||
updatedAt: "2026-07-01T14:00:00.000Z",
|
||||
};
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ id: "assistant-complete", sessionId: "chat-planner", role: "assistant", content: "Final planner response", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T14:01:00.000Z" }],
|
||||
});
|
||||
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
||||
attachedHandlers = handlers;
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
renderPlannerChat();
|
||||
expect(await screen.findByText("Partial draft")).toBeInTheDocument();
|
||||
act(() => {
|
||||
attachedHandlers.onDone({ messageId: "assistant-complete" });
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Final planner response")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Partial draft")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Stop generation" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("surfaces a failed reattached planner stream as recoverable error and refreshed history", async () => {
|
||||
let attachedHandlers: any;
|
||||
const inFlightGeneration = {
|
||||
status: "generating",
|
||||
streamingText: "",
|
||||
streamingThinking: "Still thinking",
|
||||
toolCalls: [],
|
||||
replayFromEventId: 9,
|
||||
updatedAt: "2026-07-01T14:00:00.000Z",
|
||||
};
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatMessages
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ id: "assistant-failed", sessionId: "chat-planner", role: "assistant", content: "Planner failed while you were away.", thinkingOutput: null, metadata: { failureInfo: { summary: "Planner failed while you were away." } }, createdAt: "2026-07-01T14:01:00.000Z" }],
|
||||
});
|
||||
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
|
||||
attachedHandlers = handlers;
|
||||
return { close: vi.fn(), isConnected: () => true };
|
||||
});
|
||||
|
||||
renderPlannerChat();
|
||||
expect(await screen.findByText("Still thinking")).toBeInTheDocument();
|
||||
act(() => {
|
||||
attachedHandlers.onError({ summary: "Planner failed while you were away." });
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Planner failed while you were away.");
|
||||
expect(await screen.findAllByText("Planner failed while you were away.")).toHaveLength(2);
|
||||
expect(screen.queryByRole("button", { name: "Stop generation" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not show starter prompts while planner-chat history is loading", async () => {
|
||||
mockFetchChatMessages.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
@@ -742,6 +874,33 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||
});
|
||||
|
||||
it("renders reattached planner questions and pending/completed/error steering tool states", async () => {
|
||||
const inFlightGeneration = {
|
||||
status: "generating",
|
||||
streamingText: "I need clarification and may add steering.",
|
||||
streamingThinking: "Checking planner tools",
|
||||
toolCalls: [
|
||||
{ toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false, status: "completed" },
|
||||
{ toolName: "fn_task_planner_add_steering", args: { text: "Persist this later" }, isError: false, status: "running" },
|
||||
{ toolName: "fn_task_planner_add_steering", args: { text: "Persisted steering" }, isError: false, result: { details: { text: "Persisted steering" } }, status: "completed" },
|
||||
{ toolName: "fn_task_planner_add_steering", args: { text: "Bad steering" }, isError: true, result: { error: "Invalid steering" }, status: "completed" },
|
||||
],
|
||||
replayFromEventId: 12,
|
||||
updatedAt: "2026-07-01T14:00:00.000Z",
|
||||
};
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatSession.mockResolvedValue({ session: makePlannerSession({ isGenerating: true, inFlightGeneration }) });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
renderPlannerChat();
|
||||
|
||||
expect(await screen.findByTestId("chat-question-response")).toHaveTextContent("Pick a path");
|
||||
expect(screen.getByTestId("task-planner-chat-steering-pending")).toHaveTextContent("Adding steering comment…");
|
||||
expect(screen.getByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Persisted steering");
|
||||
expect(screen.getByTestId("task-planner-chat-steering-error")).toHaveTextContent("Steering comment was not added");
|
||||
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders clarification questions without refreshing task steering", async () => {
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
messages: [{
|
||||
|
||||
Reference in New Issue
Block a user