FN-7337: make planner chats lazy and archive-scoped

Task planner chats now stay out of global chat history until a user interacts and are retained until task archive.

- Load task planner chat tabs with lookup-only resume before any user send.
- Show task-planner sessions in global Chat only after messages exist, including SSE refresh handling.
- Keep interacted planner chats for done tasks while deleting task-scoped planner sessions on archive.
- Cover chat store deletion, planner tab behavior, route filtering, and chat-list refresh with regression tests.

Files changed:
 .changeset/fn-7337-planner-chat-retention.md       |   7 ++
 docs/dashboard-guide.md                            |   4 +-
 packages/core/src/__tests__/chat-store.test.ts     |  44 +++++++++
 packages/core/src/chat-store.ts                    |  22 +++++
 packages/dashboard/app/api/legacy.ts               |  35 ++++++-
 .../app/components/TaskPlannerChatTab.tsx          |  13 ++-
 .../__tests__/TaskPlannerChatTab.test.tsx          |  91 +++++++++++++-----
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |  41 ++++++++
 packages/dashboard/app/hooks/useChat.ts            |  15 ++-
 .../dashboard/src/__tests__/chat-routes.test.ts    | 105 +++++++++++++++++++++
 .../dashboard/src/routes/register-chat-routes.ts   |  17 +++-
 packages/dashboard/src/server.ts                   |  10 +-
 packages/engine/src/runtimes/in-process-runtime.ts |   8 ++
 13 files changed, 378 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-7337

Fusion-Task-Lineage: 7ef9ed8f-af2e-4fa9-a2b6-0e8c8a805d5e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 21:50:32 -07:00
parent 5ee19647f8
commit 7d2bd9794e
13 changed files with 378 additions and 34 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make task planner chats appear only after user interaction and expire on archive.
category: fix
dev: Planner-chat tabs now load history without pre-creating sessions; archive cleanup deletes task-planner sessions.

View File

@@ -1040,7 +1040,7 @@ For related global/project configuration behavior, see [Settings reference](./se
Inspect task definition, logs, review feedback, comments, artifacts, workflow outcomes, model overrides, and task routing from a single modal.
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It starts a task-scoped planner-model conversation using the task's effective planning model, persists messages in a resumable chat session, and sends bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions without polluting visible chat history. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the task's effective planning model, but opening the tab is lookup-only: Fusion creates the task-scoped planner chat only after you send a composer message, starter prompt, or planner-question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
- The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
- The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`.
- Task-detail Activity steering comments are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass.
@@ -1122,7 +1122,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
<!-- FNXC:TaskDetailPlannerChat 2026-06-30-23:58: Task Detail is Chat-first for active tasks: Chat renders before Activity and defaults open for non-done tasks, while Activity keeps the legacy `chat` id for explicit links and operational Live/Feed/Raw Logs history. -->
The **Activity** tab is the second task-detail tab, after **Chat**, and presents a segmented control for **Live**, **Feed**, and **Raw Logs**. Live contains the live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the **Steering comment** composer sends guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Live message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show a steering composer. On a `done` task, the composer switches to **Refinement request** copy; sending starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Live segment keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
The preceding top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. When no planner-chat history has loaded yet, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review; selecting one sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control.
The preceding top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. Opening the tab with no existing history does not create a database chat row; when no planner-chat history is found, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review. Selecting a starter creates/resumes the planner chat and sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control.
The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed:

View File

@@ -214,6 +214,50 @@ describe("ChatStore", () => {
});
});
describe("deleteSessionsForAgentId", () => {
it("deletes all matching agent sessions and cascades messages without touching other chats", () => {
const plannerOne = createTestSession(store, { agentId: "task-planner:FN-7337", projectId: "proj-1" });
const plannerTwo = createTestSession(store, { agentId: "task-planner:FN-7337", projectId: "proj-1" });
const otherTaskPlanner = createTestSession(store, { agentId: "task-planner:FN-7338", projectId: "proj-1" });
const normal = createTestSession(store, { agentId: "agent-001", projectId: "proj-1" });
const deletedEvents: string[] = [];
store.on("chat:session:deleted", (sessionId) => deletedEvents.push(sessionId));
const message = store.addMessage(plannerOne.id, { role: "user", content: "Keep until archive" });
store.addMessage(otherTaskPlanner.id, { role: "user", content: "Other task" });
store.addMessage(normal.id, { role: "user", content: "Normal chat" });
const deletedCount = store.deleteSessionsForAgentId("task-planner:FN-7337", { projectId: "proj-1" });
expect(deletedCount).toBe(2);
expect(store.getSession(plannerOne.id)).toBeUndefined();
expect(store.getSession(plannerTwo.id)).toBeUndefined();
expect(store.getMessage(message.id)).toBeUndefined();
expect(store.getSession(otherTaskPlanner.id)).toBeDefined();
expect(store.getSession(normal.id)).toBeDefined();
expect(new Set(deletedEvents)).toEqual(new Set([plannerOne.id, plannerTwo.id]));
});
it("is idempotent when no matching sessions exist", () => {
createTestSession(store, { agentId: "agent-001" });
expect(store.deleteSessionsForAgentId("task-planner:FN-missing")).toBe(0);
expect(store.listSessions()).toHaveLength(1);
});
});
describe("hasMessages", () => {
it("reports whether a session has any persisted messages", () => {
const session = createTestSession(store, { agentId: "task-planner:FN-7337" });
expect(store.hasMessages(session.id)).toBe(false);
store.addMessage(session.id, { role: "user", content: "Start planner chat" });
expect(store.hasMessages(session.id)).toBe(true);
expect(store.hasMessages("chat-missing")).toBe(false);
});
});
describe("findLatestActiveSessionForTarget", () => {
it("returns newest exact model match for model-specific targets", () => {
startFakeClock();

View File

@@ -522,6 +522,23 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
return true;
}
deleteSessionsForAgentId(agentId: string, options?: { projectId?: string | null }): number {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) return 0;
const projectId = options?.projectId ?? undefined;
const sessions = this.listSessions({
agentId: normalizedAgentId,
...(projectId ? { projectId } : {}),
});
let deletedCount = 0;
for (const session of sessions) {
if (this.deleteSession(session.id)) {
deletedCount += 1;
}
}
return deletedCount;
}
// ── Message CRUD Operations ───────────────────────────────────────
/**
@@ -679,6 +696,11 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
return result;
}
hasMessages(sessionId: string): boolean {
const row = this.db.prepare("SELECT 1 FROM chat_messages WHERE sessionId = ? LIMIT 1").get(sessionId) as { 1: number } | undefined;
return Boolean(row);
}
/**
* Delete a message by ID.
*

View File

@@ -9905,11 +9905,7 @@ export function fetchChatSession(id: string, projectId?: string): Promise<ChatSe
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId));
}
export function ensureTaskPlannerChatSession(
taskId: string,
input: TaskPlannerChatSessionInput = {},
projectId?: string,
): Promise<ChatSessionResponse> {
function normalizeTaskPlannerChatInput(taskId: string, input: TaskPlannerChatSessionInput = {}) {
const normalizedTaskId = taskId.trim();
if (!normalizedTaskId) {
throw new Error("taskId is required");
@@ -9919,10 +9915,39 @@ export function ensureTaskPlannerChatSession(
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
throw new Error("Both modelProvider and modelId must be provided together, or neither should be provided");
}
return { normalizedTaskId, normalizedProvider, normalizedModelId };
}
export function fetchTaskPlannerChatSession(
taskId: string,
input: TaskPlannerChatSessionInput = {},
projectId?: string,
): Promise<{ session: EnrichedChatSession | null }> {
const { normalizedTaskId, normalizedProvider, normalizedModelId } = normalizeTaskPlannerChatInput(taskId, input);
/*
FNXC:TaskDetailPlannerChat 2026-06-30-18:20:
Task-detail planner chats are task-local but no longer pre-created by opening the Chat tab. Use lookup-only resume here so global Chat history only receives planner sessions after an explicit user message creates one.
*/
return fetchResumeChatSession({
agentId: `task-planner:${normalizedTaskId}`,
...(normalizedProvider && normalizedModelId ? { modelProvider: normalizedProvider, modelId: normalizedModelId } : {}),
}, projectId);
}
export function ensureTaskPlannerChatSession(
taskId: string,
input: TaskPlannerChatSessionInput = {},
projectId?: string,
): Promise<ChatSessionResponse> {
const { normalizedTaskId, normalizedProvider, normalizedModelId } = normalizeTaskPlannerChatInput(taskId, input);
/*
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
Task planner chat uses a task-scoped session seam instead of the generic agent-chat creator so it can bind the conversation to the task and planning model without requiring a real executor/reviewer agent or turning the message into steering.
FNXC:TaskDetailPlannerChat 2026-06-30-18:20:
This mutating helper is reserved for explicit user sends (composer, starter prompts, and planner-question answers). Tab activation must call fetchTaskPlannerChatSession instead so empty task-detail visits do not create chat history.
*/
return api<ChatSessionResponse>(
withProjectId(`/chat/task-planner/${encodeURIComponent(normalizedTaskId)}/session`, projectId),

View File

@@ -7,7 +7,7 @@ import { Loader2, Maximize2, Minimize2, Send } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ToastType } from "../hooks/useToast";
import type { ToolCallInfo } from "../hooks/chatTypes";
import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, streamChatResponse } from "../api";
import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse } from "../api";
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { markdownComponents } from "./AgentLogViewer";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
@@ -260,8 +260,14 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
setHistoryLoaded(false);
setError(null);
try {
const { session } = await ensureTaskPlannerChatSession(task.id, modelPayload, projectId);
const { session } = await fetchTaskPlannerChatSession(task.id, modelPayload, projectId);
if (loadRequestRef.current !== requestId) return;
if (!session) {
setSessionId(null);
setMessages([]);
setHistoryLoaded(true);
return;
}
setSessionId(session.id);
const { messages: loadedMessages } = await fetchChatMessages(session.id, { order: "asc" }, projectId);
if (loadRequestRef.current !== requestId) return;
@@ -478,6 +484,9 @@ 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-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.
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
Stream callbacks are guarded by a per-send token because closing an EventSource/stream is not enough to prevent queued text, tool, done, error, or fallback refresh callbacks from mutating the newly selected task's Chat tab.

View File

@@ -4,10 +4,11 @@ import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
const { mockEnsureTaskPlannerChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockTranslations, mockT } = vi.hoisted(() => {
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockTranslations, mockT } = vi.hoisted(() => {
const translations = new Map<string, string>();
return {
mockEnsureTaskPlannerChatSession: vi.fn(),
mockFetchTaskPlannerChatSession: vi.fn(),
mockFetchChatMessages: vi.fn(),
mockFetchTaskDetail: vi.fn(),
mockStreamChatResponse: vi.fn(),
@@ -27,6 +28,7 @@ vi.mock("../../api", async (importOriginal) => {
return {
...actual,
ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession,
fetchTaskPlannerChatSession: mockFetchTaskPlannerChatSession,
fetchChatMessages: mockFetchChatMessages,
fetchTaskDetail: mockFetchTaskDetail,
streamChatResponse: mockStreamChatResponse,
@@ -82,38 +84,39 @@ describe("TaskPlannerChatTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTranslations.clear();
mockEnsureTaskPlannerChatSession.mockResolvedValue({
session: {
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 = {
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,
};
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
mockEnsureTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
mockFetchChatMessages.mockResolvedValue({ messages: [] });
mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310"));
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
});
it("loads a task-scoped planner session and renders the starter-prompt empty state", async () => {
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
renderPlannerChat();
const emptyState = await screen.findByTestId("task-planner-chat-empty");
expect(emptyState).toHaveTextContent("Start a task-aware chat");
expect(emptyState).toHaveTextContent("Starter prompts send as normal chat messages.");
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-7310",
{ modelProvider: "anthropic", modelId: "claude-plan" },
undefined,
);
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(mockFetchChatMessages).toHaveBeenCalledWith("chat-planner", { order: "asc" }, undefined);
expect(screen.getByTestId("task-planner-chat-model")).toHaveTextContent("anthropic/claude-plan");
expect(screen.getByRole("button", { name: /Summarize recent activity/ })).toBeInTheDocument();
@@ -123,6 +126,47 @@ describe("TaskPlannerChatTab", () => {
expect(screen.getAllByTestId(/task-planner-chat-starter-/)).toHaveLength(4);
});
it("does not create a planner session when no existing history is found on tab activation", async () => {
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat();
const emptyState = await screen.findByTestId("task-planner-chat-empty");
expect(emptyState).toHaveTextContent("Start a task-aware chat");
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-7310",
{ modelProvider: "anthropic", modelId: "claude-plan" },
undefined,
);
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(mockFetchChatMessages).not.toHaveBeenCalled();
});
it("creates a planner session only when a starter prompt is clicked without existing history", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Identify the next best action/ }));
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-7310",
{ modelProvider: "anthropic", modelId: "claude-plan" },
undefined,
);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"What is the next best action for this task, and why?",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
});
it("renders accessible expand controls without moving the composer out of the panel", async () => {
const onExpandedChange = vi.fn();
renderPlannerChat({ expanded: true, onExpandedChange });
@@ -145,7 +189,8 @@ describe("TaskPlannerChatTab", () => {
renderPlannerChat({ planningModel: {} });
await screen.findByTestId("task-planner-chat-empty");
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith("FN-7310", {}, undefined);
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith("FN-7310", {}, undefined);
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(screen.queryByTestId("task-planner-chat-model")).not.toBeInTheDocument();
});
@@ -163,14 +208,15 @@ describe("TaskPlannerChatTab", () => {
);
await Promise.resolve();
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledTimes(1);
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledTimes(1);
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(mockFetchChatMessages).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("task-planner-chat-empty")).toBeInTheDocument();
});
it("ignores stale planner-chat load responses after the task scope changes", async () => {
const firstLoad = createDeferred<any>();
mockEnsureTaskPlannerChatSession.mockImplementation((taskId: string) => {
mockFetchTaskPlannerChatSession.mockImplementation((taskId: string) => {
if (taskId === "FN-7310") return firstLoad.promise;
return Promise.resolve({
session: {
@@ -457,6 +503,7 @@ describe("TaskPlannerChatTab", () => {
it("keeps missing task context sendable while preserving explicit planning model overrides", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat({
task: { ...makeTask("FN-MISSING-CONTEXT"), dependencies: [], prompt: undefined, log: undefined } as any,
planningModel: { provider: "openai", modelId: "gpt-planner" },

View File

@@ -3084,6 +3084,47 @@ describe("useChat", () => {
});
});
it("ignores empty task-planner session create events until a message exists", async () => {
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [] });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(0);
});
act(() => {
subscribeHandler["chat:session:created"]?.({
data: JSON.stringify(makeSession({ id: "chat-empty-planner", agentId: "task-planner:FN-7337" })),
} as MessageEvent);
});
expect(result.current.sessions).toHaveLength(0);
});
it("refreshes server-filtered sessions when a message arrives for an unseen planner session", async () => {
mockFetchChatSessions
.mockResolvedValueOnce({ sessions: [] })
.mockResolvedValueOnce({ sessions: [{ ...makeSession({ id: "chat-planner", agentId: "task-planner:FN-7337" }), lastMessagePreview: "Hello", lastMessageAt: "2026-04-08T00:01:00.000Z" } as any] });
const { result } = renderHook(() => useChat("proj-123"));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(0);
});
act(() => {
subscribeHandler["chat:message:added"]?.({
data: JSON.stringify(makeMessage({ id: "msg-planner", sessionId: "chat-planner", role: "user", content: "Hello" })),
} as MessageEvent);
});
await waitFor(() => {
expect(result.current.sessions.map((session) => session.id)).toEqual(["chat-planner"]);
});
expect(mockFetchChatSessions).toHaveBeenCalledTimes(2);
});
it("avoids duplicate sessions on chat:session:created", async () => {
mockFetchChatSessions.mockResolvedValueOnce({
sessions: [makeSession({ id: "session-001", agentId: "agent-001" })],

View File

@@ -18,6 +18,11 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation";
import type { Agent, ChatInFlightGenerationState, ChatMessage } from "@fusion/core";
const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
function isEmptyTaskPlannerSession(session: ChatSessionInfo): boolean {
return session.agentId.startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX) && !session.lastMessageAt && !session.lastMessagePreview;
}
export interface ChatSessionInfo {
id: string;
@@ -1290,6 +1295,11 @@ export function useChat(
const handleChatSessionCreated = (e: MessageEvent) => {
if (isStale()) return;
const session: ChatSessionInfo = JSON.parse(e.data);
/*
FNXC:TaskDetailPlannerChat 2026-06-30-18:35:
Global Chat may list task-planner sessions after user interaction, but SSE creation can arrive before the first message preview. Ignore empty planner-session creates and let the message event refresh the server-filtered list after the user message exists.
*/
if (isEmptyTaskPlannerSession(session)) return;
// Avoid duplicates
setSessions((prev) => {
if (prev.some((s) => s.id === session.id)) return prev;
@@ -1333,6 +1343,9 @@ export function useChat(
if (isStale()) return;
const rawMessage = JSON.parse(e.data) as ChatMessage;
const message = mapChatMessageToInfo(rawMessage);
if (!sessionsRef.current.some((session) => session.id === message.sessionId)) {
void refreshSessions();
}
// Skip if this message was already added via streaming completion
// (SSE event may arrive before streaming state clears)
@@ -1407,7 +1420,7 @@ export function useChat(
});
return unsubscribe;
}, [attachIfGenerating, getChatMessagesCacheKey, projectId, flushPendingMessage]);
}, [attachIfGenerating, getChatMessagesCacheKey, projectId, flushPendingMessage, refreshSessions]);
// Cleanup on unmount
useEffect(() => {

View File

@@ -145,6 +145,7 @@ const mockGetMessage = vi.fn();
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
const mockFindLatestActiveSessionForTarget = vi.fn();
const mockDeleteMessage = vi.fn();
const mockDeleteSessionsForAgentId = vi.fn();
// Mock AgentStore
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
@@ -167,6 +168,7 @@ vi.mock("@fusion/core", async (importOriginal) => createCoreMock(
getLastMessageForSessions = mockGetLastMessageForSessions;
findLatestActiveSessionForTarget = mockFindLatestActiveSessionForTarget;
deleteMessage = mockDeleteMessage;
deleteSessionsForAgentId = mockDeleteSessionsForAgentId;
},
AgentStore: class MockAgentStore {
init = mockAgentStoreInit;
@@ -297,6 +299,7 @@ const mockChatStoreInstance = {
getLastMessageForSessions: mockGetLastMessageForSessions,
findLatestActiveSessionForTarget: mockFindLatestActiveSessionForTarget,
deleteMessage: mockDeleteMessage,
deleteSessionsForAgentId: mockDeleteSessionsForAgentId,
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
@@ -540,6 +543,63 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(404);
expect((response.body as any).error).toContain("Task FN-MISSING not found");
});
it("resumes an existing planner chat for a done task without creating a new one", async () => {
vi.spyOn(store, "getTask").mockResolvedValueOnce({
id: "FN-DONE",
title: "Done planner task",
description: "Task description",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-06-30T00:00:00.000Z",
updatedAt: "2026-06-30T00:00:00.000Z",
} as any);
const existing = { ...sampleSession, id: "chat-done-planner", agentId: "task-planner:FN-DONE" };
mockFindLatestActiveSessionForTarget.mockReturnValue(existing);
const response = await request(
app,
"POST",
"/api/chat/task-planner/FN-DONE/session",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).session).toEqual(existing);
expect(mockCreateSession).not.toHaveBeenCalled();
});
it("rejects starting a new planner chat for done or archived tasks", async () => {
vi.spyOn(store, "getTask").mockResolvedValueOnce({
id: "FN-DONE-EMPTY",
title: "Done planner task",
description: "Task description",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-06-30T00:00:00.000Z",
updatedAt: "2026-06-30T00:00:00.000Z",
} as any);
mockFindLatestActiveSessionForTarget.mockReturnValue(null);
const doneResponse = await request(
app,
"POST",
"/api/chat/task-planner/FN-DONE-EMPTY/session",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(doneResponse.status).toBe(400);
expect((doneResponse.body as any).error).toContain("planner chat can only be started while a task is live");
expect(mockCreateSession).not.toHaveBeenCalled();
});
});
describe("GET /api/chat/sessions", () => {
@@ -667,6 +727,51 @@ describe("Chat API Routes", () => {
expect(enrichedSession.lastMessageAt).toBeUndefined();
});
it("hides empty planner-chat sessions but keeps normal direct sessions in the global list", async () => {
const normalSession = { ...sampleSession, id: "chat-normal", agentId: "agent-001" };
const emptyPlanner = { ...sampleSession, id: "chat-empty-planner", agentId: "task-planner:FN-7337" };
mockListSessions.mockReturnValue([normalSession, emptyPlanner]);
mockGetLastMessageForSessions.mockReturnValue(new Map());
const response = await request(app, "GET", "/api/chat/sessions");
expect(response.status).toBe(200);
expect((response.body as any).sessions.map((session: any) => session.id)).toEqual(["chat-normal"]);
});
it("shows planner-chat sessions in the global list after a user message exists", async () => {
const normalSession = { ...sampleSession, id: "chat-normal", agentId: "agent-001" };
const populatedPlanner = { ...sampleSession, id: "chat-planner", agentId: "task-planner:FN-7337" };
const plannerMessage = {
id: "msg-planner",
sessionId: "chat-planner",
role: "user",
content: "What should happen next?",
thinkingOutput: null,
metadata: null,
createdAt: "2026-06-30T18:35:00.000Z",
};
mockListSessions.mockReturnValue([normalSession, populatedPlanner]);
mockGetLastMessageForSessions.mockReturnValue(new Map([["chat-planner", plannerMessage]]));
const response = await request(app, "GET", "/api/chat/sessions");
expect(response.status).toBe(200);
expect((response.body as any).sessions.map((session: any) => session.id)).toEqual(["chat-normal", "chat-planner"]);
expect((response.body as any).sessions[1].lastMessagePreview).toBe("What should happen next?");
});
it("preserves explicit planner resume lookup even when the session has no messages", async () => {
const emptyPlanner = { ...sampleSession, id: "chat-empty-planner", agentId: "task-planner:FN-7337" };
mockFindLatestActiveSessionForTarget.mockReturnValue(emptyPlanner);
mockGetLastMessageForSessions.mockReturnValue(new Map());
const response = await request(app, "GET", "/api/chat/sessions?lookup=resume&agentId=task-planner%3AFN-7337");
expect(response.status).toBe(200);
expect((response.body as any).sessions.map((session: any) => session.id)).toEqual(["chat-empty-planner"]);
});
it("uses engine chatStore when engineManager is configured for requested projectId", async () => {
const { createServer } = await import("../server.js");

View File

@@ -137,6 +137,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
/*
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
Task planner Chat uses a synthetic task-scoped chat target (`task-planner:<taskId>`) so the dashboard can persist/resume a conversation without binding it to an executor/reviewer agent or the Activity steering-comment pipeline. The route validates the task in the scoped project store and stores the effective planning model override on the session.
FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45:
Planner chats that already have user interaction remain available when a task reaches done, but new planner chats are not started for done or archived tasks. Archived-task cleanup removes existing task-planner sessions through ChatStore deletion so archived tasks stop retaining task-local planner context.
*/
router.post("/chat/task-planner/:taskId/session", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
@@ -168,6 +171,10 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
return;
}
if (task.column === "done" || task.column === "archived") {
throw badRequest(`Task ${task.id} is ${task.column}; planner chat can only be started while a task is live`);
}
const session = chatStore.createSession({
agentId,
title: `${task.id} planner chat`,
@@ -216,7 +223,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw badRequest("agentId is required when lookup=resume");
}
const sessions = isResumeLookup
let sessions = isResumeLookup
? (() => {
const matched = chatStore.findLatestActiveSessionForTarget({
agentId: agentId!.trim(),
@@ -242,6 +249,14 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
const sessionIds = sessions.map((s) => s.id);
const lastMessages = chatStore.getLastMessageForSessions(sessionIds);
if (!isResumeLookup) {
/*
FNXC:TaskDetailPlannerChat 2026-06-30-18:35:
Planner-chat sessions may appear in global Chat only after a user has sent at least one message. Lazy creation prevents most empty rows; this server-side guard keeps stale/legacy task-planner rows with no messages out of every global Chat surface while preserving normal direct and room sessions.
*/
sessions = sessions.filter((session) => !session.agentId.startsWith(TASK_PLANNER_CHAT_AGENT_ID_PREFIX) || lastMessages.has(session.id));
}
// Batch-gather generating session IDs to avoid N+1 calls
const resolvedChatManager = projectId
? await resolveScopedChatManager(projectId).catch(() => options?.chatManager)

View File

@@ -62,7 +62,7 @@ import {
setAiSessionStore as setMilestoneSliceAiSessionStore,
rehydrateFromStore as rehydrateMilestoneSliceSessions,
} from "./milestone-slice-interview.js";
import { ChatManager } from "./chat.js";
import { ChatManager, TASK_PLANNER_CHAT_AGENT_ID_PREFIX } from "./chat.js";
import { CliChatSessionRunner } from "./cli-chat.js";
import { stopAllDevServers } from "./dev-server-routes.js";
import type { SkillsAdapter } from "./skills-adapter.js";
@@ -1002,6 +1002,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Create ChatStore for chat session management (available for SSE event forwarding)
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
store.on("task:moved", (data: { task: Task; from: string; to: string }) => {
if (data.to !== "archived") return;
/*
FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45:
Task-detail planner chats are retained after done when a user interacted, but task archival is the retention cutoff. Delete exact task-planner sessions on archive so normal chats and other tasks' planner chats remain intact while chat:session:deleted events clear dashboard caches.
*/
chatStore.deleteSessionsForAgentId(`${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${data.task.id}`);
});
options?.engine?.attachChatStore?.(chatStore);
if (typeof options?.engineManager?.getAllEngines === "function") {
for (const engine of options.engineManager.getAllEngines().values()) {

View File

@@ -58,6 +58,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
const yieldEventLoop = (): Promise<void> => new Promise((resolve) => setImmediateCb(resolve));
export const CLI_AGENT_AWAITING_INPUT_EVENT = "cli-agent-awaiting-input" as const;
const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
export interface CliAgentAwaitingInputNotificationInfo {
sessionId: string;
@@ -1594,6 +1595,13 @@ export class InProcessRuntime
// Forward task:moved events
this.taskStore.on("task:moved", (data: { task: Task; from: string; to: string }) => {
this.recordActivity();
if (data.to === "archived") {
/*
FNXC:TaskDetailPlannerChatRetention 2026-06-30-18:45:
In-process task archival is the retention cutoff for task-local planner chats. Keep interacted planner chats when tasks reach done, but delete exact task-planner sessions on archive through ChatStore so normal conversations and other tasks remain untouched.
*/
this.chatStore?.deleteSessionsForAgentId(`${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${data.task.id}`, { projectId: this.config.projectId });
}
this.emit("task:moved", data);
});