Files
fusion/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx
gsxdsm b2977d1a7a FN-7982: clear stale planner chat streaming on fresh reply
Fix task chat showing a previous agent message while a new reply is generating by clearing streaming carriers on fresh generations.

- Clear streamingThinking and the streaming-assistant row when starting a generation without an in-flight snapshot
- Preserve text/thinking/tool restore only when attaching to a live in-flight generation
- Add regression coverage for consecutive replies that must not reuse prior stream content
- Add patch changeset for the planner chat stale-message fix

Files changed:
 .changeset/fn-7982-planner-chat-stale-message.md   |  7 ++++
 .../app/components/TaskPlannerChatTab.tsx          | 19 ++++++++--
 .../__tests__/TaskPlannerChatTab.test.tsx          | 44 ++++++++++++++++++++++
 3 files changed, 67 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7982

Fusion-Task-Lineage: 71daaa80-6d66-4940-a656-a2c42a6b03bb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-15 12:22:33 -07:00

1774 lines
84 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
const taskPlannerChatCss = readFileSync(resolve(__dirname, "../TaskPlannerChatTab.css"), "utf8");
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockAddSteeringComment, 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(),
mockEditChatMessage: vi.fn(),
mockAddSteeringComment: vi.fn(),
mockTranslations: translations,
mockT: (key: string, fallback: string) => translations.get(key) ?? fallback,
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: mockT,
}),
}));
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
ensureTaskPlannerChatSession: mockEnsureTaskPlannerChatSession,
fetchTaskPlannerChatSession: mockFetchTaskPlannerChatSession,
fetchChatSession: mockFetchChatSession,
fetchChatMessages: mockFetchChatMessages,
fetchTaskDetail: mockFetchTaskDetail,
streamChatResponse: mockStreamChatResponse,
attachChatStream: mockAttachChatStream,
editChatMessage: mockEditChatMessage,
addSteeringComment: mockAddSteeringComment,
};
});
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
Maximize2: (props: any) => React.createElement("svg", { "data-testid": "maximize2-icon", ...props }),
Minimize2: (props: any) => React.createElement("svg", { "data-testid": "minimize2-icon", ...props }),
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
};
});
function makeTask(id: string, overrides: Record<string, unknown> = {}) {
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", ...overrides } 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;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function firstTapSendFromFocusedPlannerTextarea(input: HTMLElement, sendButton: HTMLElement) {
input.focus();
expect(input).toHaveFocus();
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
fireEvent.blur(input);
fireEvent.click(sendButton);
}
function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPlannerChatTab>> = {}) {
return render(
<TaskPlannerChatTab
task={makeTask("FN-7310")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
{...overrides}
/>,
);
}
function plannerQuestionMessage(id: string, args: Record<string, unknown>, createdAt = "2026-06-30T00:02:00.000Z") {
return {
id,
sessionId: "chat-planner",
role: "assistant",
content: "Planner needs clarification.",
thinkingOutput: null,
metadata: { toolCalls: [{ toolName: "fn_ask_question", args, isError: false }] },
createdAt,
};
}
describe("TaskPlannerChatTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTranslations.clear();
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 });
mockEditChatMessage.mockResolvedValue({ retained: [] });
mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310"));
});
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(document.querySelector(".task-planner-chat-header")).toBeNull();
expect(screen.queryByText("Planner Chat")).toBeNull();
expect(emptyState).toHaveTextContent("Ask planning questions about this task's current status, recent activity, blockers, next steps, or definition.");
expect(emptyState).toHaveTextContent("Starter prompts send as normal chat messages.");
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-7310",
{ modelProvider: "anthropic", modelId: "claude-plan" },
undefined,
);
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(mockFetchChatMessages).toHaveBeenCalledWith("chat-planner", { order: "asc" }, undefined);
const modelBadge = screen.getByTestId("task-planner-chat-model");
expect(modelBadge).toHaveAccessibleName("anthropic/claude-plan");
expect(modelBadge).toHaveAttribute("title", "anthropic/claude-plan");
expect(modelBadge).toHaveTextContent("");
expect(emptyState).toContainElement(modelBadge);
expect(modelBadge).toHaveClass("task-planner-chat-empty-model");
expect(modelBadge.querySelector(".provider-icon[data-provider='anthropic']")).toBeTruthy();
expect(screen.getByRole("button", { name: /Summarize recent activity/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Explain status and blockers/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Identify the next best action/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Review the plan or definition/ })).toBeInTheDocument();
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("lets a done task with no planner history create a task-scoped session on first composer send", async () => {
const user = userEvent.setup();
const addToast = vi.fn();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat({ task: makeTask("FN-DONE", { column: "done" }), addToast });
await screen.findByTestId("task-planner-chat-empty");
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
await user.type(screen.getByLabelText("Message planner chat"), "What changed in this completed task?");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-DONE",
{ modelProvider: "anthropic", modelId: "claude-plan" },
undefined,
);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"What changed in this completed task?",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-DONE" },
);
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("planner chat can only be started while a task is live"), "error");
});
it("lets a done task with no planner history create a task-scoped session from a starter prompt", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat({ task: makeTask("FN-DONE", { column: "done" }) });
await screen.findByTestId("task-planner-chat-empty");
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-DONE",
{ modelProvider: "anthropic", modelId: "claude-plan" },
undefined,
);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Summarize the recent activity for this task and call out anything important I should know.",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-DONE" },
);
});
it("renders accessible expand controls without moving the composer out of the panel", async () => {
const onExpandedChange = vi.fn();
renderPlannerChat({ expanded: true, onExpandedChange });
expect(await screen.findByTestId("task-planner-chat-empty")).toBeInTheDocument();
const toggle = screen.getByTestId("task-planner-chat-expand-toggle");
const modelBadge = screen.getByTestId("task-planner-chat-model");
expect(toggle).toHaveAccessibleName("Collapse planner chat");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(toggle).toHaveClass("task-planner-chat-expand-toggle--overlay");
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(toggle);
expect(screen.getByTestId("task-planner-chat-empty")).toContainElement(modelBadge);
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByLabelText("Message planner chat"));
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByRole("button", { name: "Send" }));
await userEvent.click(toggle);
expect(onExpandedChange).toHaveBeenCalledWith(false);
});
it("omits model override when the effective planning model is undefined", async () => {
renderPlannerChat({ planningModel: {} });
await screen.findByTestId("task-planner-chat-empty");
expect(mockFetchTaskPlannerChatSession).toHaveBeenCalledWith("FN-7310", {}, undefined);
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(screen.queryByTestId("task-planner-chat-model")).not.toBeInTheDocument();
});
it("does not reload when parent rerenders with an equivalent planning model", async () => {
const { rerender } = renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
rerender(
<TaskPlannerChatTab
task={makeTask("FN-7310")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>,
);
await Promise.resolve();
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>();
mockFetchTaskPlannerChatSession.mockImplementation((taskId: string) => {
if (taskId === "FN-7310") return firstLoad.promise;
return Promise.resolve({
session: {
id: "chat-new-task",
agentId: `task-planner:${taskId}`,
title: `${taskId} 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,
},
});
});
mockFetchChatMessages.mockImplementation((sessionId: string) => Promise.resolve({
messages: sessionId === "chat-new-task"
? []
: [{ id: "old-message", sessionId, role: "assistant", content: "Stale old task answer", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" }],
}));
const { rerender } = renderPlannerChat();
rerender(
<TaskPlannerChatTab
task={makeTask("FN-7312")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>,
);
await screen.findByTestId("task-planner-chat-empty");
firstLoad.resolve({
session: {
id: "chat-old-task",
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,
},
});
await Promise.resolve();
await Promise.resolve();
expect(mockFetchChatMessages).not.toHaveBeenCalledWith("chat-old-task", { order: "asc" }, undefined);
expect(screen.queryByText("Stale old task answer")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-new-task",
"Summarize the recent activity for this task and call out anything important I should know.",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7312" },
);
});
it("ignores stale stream callbacks after the task scope changes", async () => {
const oldStreamClose = vi.fn();
let oldHandlers: any;
mockEnsureTaskPlannerChatSession.mockImplementation((taskId: string) => Promise.resolve({
session: {
id: taskId === "FN-7310" ? "chat-old-task" : "chat-new-task",
agentId: `task-planner:${taskId}`,
title: `${taskId} 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,
},
}));
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
oldHandlers = handlers;
return { close: oldStreamClose, isConnected: () => true };
});
const { rerender } = renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await userEvent.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
expect(screen.getByText("Summarize the recent activity for this task and call out anything important I should know.")).toBeInTheDocument();
rerender(
<TaskPlannerChatTab
task={makeTask("FN-7312")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>,
);
await screen.findByTestId("task-planner-chat-empty");
act(() => {
oldHandlers.onText("Stale old task answer");
oldHandlers.onDone({
messageId: "assistant-old-task",
message: { id: "assistant-old-task", sessionId: "chat-old-task", role: "assistant", content: "Committed stale answer", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:04:00.000Z" },
});
oldHandlers.onError("Stale stream error");
});
expect(oldStreamClose).toHaveBeenCalled();
expect(screen.queryByText("Stale old task answer")).not.toBeInTheDocument();
expect(screen.queryByText("Committed stale answer")).not.toBeInTheDocument();
expect(screen.queryByText("Stale stream error")).not.toBeInTheDocument();
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));
renderPlannerChat();
expect(await screen.findByRole("status")).toHaveTextContent("Loading planner chat…");
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Summarize recent activity/ })).not.toBeInTheDocument();
});
it("does not render duplicate starter prompt buttons when labels collide", async () => {
mockTranslations.set("taskDetail.plannerChat.starters.statusBlockers.label", "Summarize recent activity");
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
expect(screen.getAllByRole("button", { name: /Summarize recent activity/ })).toHaveLength(1);
expect(screen.getAllByTestId(/task-planner-chat-starter-/)).toHaveLength(3);
});
it("keeps load errors recoverable without showing premature starter prompts", async () => {
mockFetchChatMessages.mockRejectedValue(new Error("History unavailable"));
renderPlannerChat();
expect(await screen.findByRole("alert")).toHaveTextContent("History unavailable");
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
expect(screen.getByLabelText("Message planner chat")).toBeEnabled();
});
it("renders persisted planner-chat messages", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "m2", sessionId: "chat-planner", role: "assistant", content: "Planner answer", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" },
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Question", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
],
});
renderPlannerChat();
expect(await screen.findByText("Question")).toBeInTheDocument();
expect(screen.getByText("Planner answer")).toBeInTheDocument();
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Summarize recent activity/ })).not.toBeInTheDocument();
});
it("sends messages through the chat stream and appends success responses", async () => {
const user = userEvent.setup();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onText("Hello");
handlers.onDone({
messageId: "assistant-1",
message: { id: "assistant-1", sessionId: "chat-planner", role: "assistant", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:03:00.000Z" },
});
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Help plan this");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Help plan this",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
expect(screen.getByText("Help plan this")).toBeInTheDocument();
await waitFor(() => expect(screen.getByText("Hello")).toBeInTheDocument());
});
it("clears a completed turn's streaming carriers before a delayed consecutive reply", async () => {
const user = userEvent.setup();
const streamHandlers: any[] = [];
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
streamHandlers.push(handlers);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
const input = screen.getByLabelText("Message planner chat");
await user.type(input, "First reply");
await user.click(screen.getByRole("button", { name: "Send" }));
act(() => {
streamHandlers[0].onText("Previous transient reply");
streamHandlers[0].onThinking("Previous transient thinking");
streamHandlers[0].onDone({
messageId: "assistant-first",
message: {
id: "assistant-first",
sessionId: "chat-planner",
role: "assistant",
content: "Previous completed reply",
thinkingOutput: "Previous completed thinking",
metadata: null,
createdAt: "2026-06-30T00:03:00.000Z",
},
});
});
expect(await screen.findByText("Previous completed reply")).toBeInTheDocument();
await user.type(input, "Second delayed reply");
await user.click(screen.getByRole("button", { name: "Send" }));
// The prior persisted turn remains in the transcript, but the new live bubble must be empty
// until this generation supplies its own first event.
const streamingBubble = screen.getByTestId("chat-message-__streaming__");
expect(streamingBubble).toHaveTextContent("Working…");
expect(streamingBubble).not.toHaveTextContent("Previous transient reply");
expect(streamingBubble).not.toHaveTextContent("Previous transient thinking");
expect(streamingBubble).not.toHaveTextContent("Previous completed reply");
expect(streamingBubble).not.toHaveTextContent("Previous completed thinking");
});
it("sends planner Chat exactly once on the first mobile tap while the textarea is focused", async () => {
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat({ projectId: "project-1" });
await screen.findByTestId("task-planner-chat-empty");
const input = screen.getByLabelText("Message planner chat");
fireEvent.change(input, { target: { value: "First mobile tap planner message" } });
firstTapSendFromFocusedPlannerTextarea(input, screen.getByRole("button", { name: "Send" }));
await waitFor(() => {
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-7310",
{ modelProvider: "anthropic", modelId: "claude-plan" },
"project-1",
);
});
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"First mobile tap planner message",
expect.any(Object),
undefined,
"project-1",
{ taskId: "FN-7310" },
);
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
});
it("keeps planner mobile first-tap guards for blank drafts and in-flight sends", async () => {
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
const input = screen.getByLabelText("Message planner chat");
const sendButton = screen.getByRole("button", { name: "Send" });
expect(sendButton).toBeDisabled();
fireEvent.change(input, { target: { value: " \n " } });
expect(sendButton).toBeDisabled();
firstTapSendFromFocusedPlannerTextarea(input, sendButton);
expect(mockStreamChatResponse).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "Do not duplicate planner tap" } });
expect(sendButton).not.toBeDisabled();
firstTapSendFromFocusedPlannerTextarea(input, sendButton);
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
fireEvent.click(sendButton);
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledTimes(1));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Do not duplicate planner tap",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
});
it("keeps send and thinking stop icons visible in planner chat", async () => {
const user = userEvent.setup();
let streamHandlers: any;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Think with an icon");
const sendButton = screen.getByTestId("chat-send-btn");
expect(sendButton).toHaveAccessibleName("Send");
expect(sendButton.querySelector("svg")).toBeTruthy();
// FN-7685: the idle send button is icon-only — no visible "Send" text span,
// even though the accessible name (aria-label) above still resolves to "Send".
expect(sendButton.querySelector("span")).toBeNull();
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
act(() => {
streamHandlers.onThinking?.("checking the plan");
});
const stopButton = await screen.findByTestId("chat-stop-btn");
expect(stopButton).toHaveAccessibleName("Stop generation");
const stopIcon = stopButton.querySelector(".chat-input-stop-icon");
expect(stopIcon).toBeTruthy();
expect(stopIcon).toHaveAttribute("aria-hidden", "true");
// FN-7655: the streaming stop button is icon-only — no visible "Stop generation" text span,
// even though the accessible name (aria-label) above still resolves to "Stop generation".
expect(stopButton.querySelector("span:not(.chat-input-stop-icon)")).toBeNull();
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText("checking the plan")).toBeInTheDocument();
const mobileTextHideRule = taskPlannerChatCss.match(/@media \(max-width: 768px\)[\s\S]*?\.task-planner-chat-send[^{}]*\{[^}]*clip:[^}]*\}/)?.[0] ?? "";
expect(mobileTextHideRule).toContain("clip:");
expect(mobileTextHideRule).toMatch(/span:not\(\.chat-input-stop-icon\)/);
expect(mobileTextHideRule).not.toMatch(/\.task-planner-chat-send\s+span\s*\{/);
expect(stopIcon).toHaveClass("chat-input-stop-icon");
});
it("keeps the planner stop button width in parity with the send button on desktop and mobile (FN-7634)", () => {
// Desktop: both `.chat-input-send` and `.chat-input-stop` (from ChatView.css) read
// `width: var(--chat-input-control-size)`, which is only declared inside `.chat-input-row`.
// The Planner composer must declare that same custom property itself so the value never
// resolves to `auto`, and must give the shared `.task-planner-chat-send` class (present on
// both the send and stop button variants) a `min-inline-size` floor bound to it — otherwise
// the streaming Stop button can render narrower than the idle Send button.
expect(taskPlannerChatCss).toMatch(/\.task-planner-chat-composer\s*\{[\s\S]*?--chat-input-control-size:\s*calc\(var\(--space-lg\)\s*\*\s*2\.5\);/);
// The base (non-mobile) `.task-planner-chat-send` rule is the first one in source order,
// before the `@media (max-width: 768px)` block redeclares it for mobile.
const mediaQueryStart = taskPlannerChatCss.indexOf("@media (max-width: 768px)");
const desktopCss = mediaQueryStart >= 0 ? taskPlannerChatCss.slice(0, mediaQueryStart) : taskPlannerChatCss;
const desktopSendRule = desktopCss.match(/\.task-planner-chat-send\s*\{[^}]*\}/)?.[0] ?? "";
// The desktop rule must bind the same control-size custom property as its floor so the
// Send button (idle) and Stop button (streaming) — both of which carry this class — never
// differ in width footprint.
expect(desktopSendRule).toMatch(/min-inline-size:\s*var\(--chat-input-control-size\)/);
// Mobile keeps its pre-existing explicit square sizing, which already applies identically
// to both the send and stop variants via the shared `.task-planner-chat-send` class.
const mobileSendRule = taskPlannerChatCss
.slice(mediaQueryStart)
.match(/\.task-planner-chat-send\s*\{[^}]*\}/)?.[0] ?? "";
expect(mobileSendRule).toMatch(/inline-size:\s*calc\(var\(--space-2xl\)\s*\+\s*var\(--space-lg\)\)/);
expect(mobileSendRule).toMatch(/min-inline-size:\s*calc\(var\(--space-2xl\)\s*\+\s*var\(--space-lg\)\)/);
// Guard the FN-7594-era contract that the stop icon span stays visible on mobile even
// though other text-label spans are visually hidden.
const mobileTextHideRule = taskPlannerChatCss.match(/@media \(max-width: 768px\)[\s\S]*?\.task-planner-chat-send[^{}]*\{[^}]*clip:[^}]*\}/)?.[0] ?? "";
expect(mobileTextHideRule).toMatch(/span:not\(\.chat-input-stop-icon\)/);
});
it("renders live and stored thinking output through the standard chat surface", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{
id: "assistant-thinking",
sessionId: "chat-planner",
role: "assistant",
content: "Stored answer",
thinkingOutput: "stored plan notes",
metadata: null,
createdAt: "2026-06-30T00:02:00.000Z",
}],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onThinking?.("live plan");
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
expect(await screen.findByText("Stored answer")).toBeInTheDocument();
expect(screen.getByText("stored plan notes")).toBeInTheDocument();
await user.type(screen.getByLabelText("Message planner chat"), "Think about this");
fireEvent.pointerDown(screen.getByTestId("chat-send-btn"), { pointerType: "touch" });
expect(await screen.findByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText("live plan")).toBeInTheDocument();
});
it("dedupes mobile first-tap sends through the standard composer action", async () => {
const user = userEvent.setup();
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Mobile first tap");
const sendButton = screen.getByTestId("chat-send-btn");
fireEvent.pointerDown(sendButton, { pointerType: "touch" });
fireEvent.click(sendButton);
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Mobile first tap",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
});
it("shows a recoverable error when the post-stream refresh fails", async () => {
const user = userEvent.setup();
const addToast = vi.fn();
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockRejectedValueOnce(new Error("Refresh unavailable"));
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onDone({ messageId: "assistant-refresh" });
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat({ addToast });
await screen.findByTestId("task-planner-chat-empty");
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
expect(await screen.findByRole("alert")).toHaveTextContent("Refresh unavailable");
expect(addToast).toHaveBeenCalledWith("Refresh unavailable", "error");
expect(screen.getByLabelText("Message planner chat")).toBeEnabled();
});
it("sends manual status/progress questions with the current task identity", async () => {
const user = userEvent.setup();
renderPlannerChat({ task: makeTask("FN-STATUS") });
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "What is the current status and progress?");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"What is the current status and progress?",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-STATUS" },
);
});
it("answers recent-activity starter prompts without creating steering feedback", async () => {
const user = userEvent.setup();
const onTaskUpdated = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onDone({
messageId: "assistant-summary",
message: {
id: "assistant-summary",
sessionId: "chat-planner",
role: "assistant",
content: "Recent activity: executor started work and posted an update.",
thinkingOutput: null,
metadata: null,
createdAt: "2026-06-30T00:03:00.000Z",
},
});
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat({
task: {
...makeTask("FN-ACTIVITY"),
column: "in-progress",
dependencies: ["FN-BLOCKER"],
prompt: "# Plan\nKeep Activity and Chat separate.",
log: [{ timestamp: "2026-06-30T00:01:00.000Z", action: "Started work" }],
} as any,
onTaskUpdated,
});
await screen.findByTestId("task-planner-chat-empty");
await user.click(screen.getByRole("button", { name: /Summarize recent activity/ }));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Summarize the recent activity for this task and call out anything important I should know.",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-ACTIVITY" },
);
expect(await screen.findByText("Recent activity: executor started work and posted an update.")).toBeInTheDocument();
expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument();
expect(onTaskUpdated).not.toHaveBeenCalled();
});
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" },
});
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Explain the current task state with whatever context exists.");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(mockEnsureTaskPlannerChatSession).toHaveBeenCalledWith(
"FN-MISSING-CONTEXT",
{ modelProvider: "openai", modelId: "gpt-planner" },
undefined,
);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Explain the current task state with whatever context exists.",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-MISSING-CONTEXT" },
);
});
it("sends starter prompts through the planner chat stream", async () => {
const user = userEvent.setup();
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.click(screen.getByRole("button", { name: /Identify the next best action/ }));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"What is the next best action for this task, and why?",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
});
it("renders mixed persisted planner question tool calls with the shared answer UI outside collapsed details", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({
messages: [{
...plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] }),
metadata: {
toolCalls: [
{ toolName: "bash", args: { command: "echo hi" }, isError: false, result: "hi", status: "completed" },
{ toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false, status: "completed" },
],
},
}],
});
renderPlannerChat();
const transcript = await screen.findByTestId("task-planner-chat-transcript");
const question = await screen.findByTestId("chat-question-response");
expect(transcript).toContainElement(question);
expect(question).toHaveTextContent("Pick a path");
expect(question.closest("details.chat-tool-calls-group")).toBeNull();
expect(screen.getByTestId("chat-tool-calls")).toHaveTextContent("bash");
expect(screen.getByTestId("chat-tool-calls")).not.toHaveTextContent("fn_ask_question");
await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
await user.click(screen.getByTestId("chat-question-response-submit"));
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"> Q: Pick a path\nConservative",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
});
it("renders steering-tool confirmation and refreshes task detail after persistence", async () => {
const user = userEvent.setup();
const updatedTask = { ...makeTask("FN-7310"), steeringComments: [{ id: "steer-1", text: "Keep Activity and Chat separate", author: "user" }] } as any;
const onTaskUpdated = vi.fn();
mockFetchTaskDetail.mockResolvedValue(updatedTask);
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onToolStart({ toolName: "fn_task_planner_add_steering", args: { text: "Keep Activity and Chat separate" } });
handlers.onToolEnd({
toolName: "fn_task_planner_add_steering",
isError: false,
result: { details: { taskId: "FN-7310", text: "Keep Activity and Chat separate", steeringComment: { id: "steer-1", text: "Keep Activity and Chat separate", author: "user" } } },
});
handlers.onDone({
messageId: "assistant-steering",
message: {
id: "assistant-steering",
sessionId: "chat-planner",
role: "assistant",
content: "I added that as steering.",
thinkingOutput: null,
metadata: {
toolCalls: [{
toolName: "fn_task_planner_add_steering",
args: { text: "Keep Activity and Chat separate" },
isError: false,
result: { details: { taskId: "FN-7310", text: "Keep Activity and Chat separate", steeringComment: { id: "steer-1", text: "Keep Activity and Chat separate", author: "user" } } },
}],
},
createdAt: "2026-06-30T00:03:00.000Z",
},
});
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat({ projectId: "project-1", onTaskUpdated });
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Tell the executor to keep Activity and Chat separate");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Added as steering comment");
expect(screen.getByTestId("task-planner-chat-steering-confirmation")).toHaveTextContent("Keep Activity and Chat separate");
await waitFor(() => expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-7310", "project-1"));
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
});
it("renders reattached planner questions and pending/completed/error steering and refinement tool states", async () => {
const inFlightGeneration = {
status: "generating",
streamingText: "I need clarification and may add steering or refinement.",
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" },
{ toolName: "fn_task_planner_create_refinement", args: { feedback: "Create follow-up" }, isError: false, status: "running" },
{ toolName: "fn_task_planner_create_refinement", args: { feedback: "Add export support" }, isError: false, result: { details: { sourceTaskId: "FN-7310", refinementTaskId: "FN-REFINE", description: "Add export support" } }, status: "completed" },
{ toolName: "fn_task_planner_create_refinement", args: { feedback: "" }, isError: true, result: { details: { sourceTaskId: "FN-7310", error: "feedback required" } }, 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.getByTestId("task-planner-chat-refinement-pending")).toHaveTextContent("Creating refinement task…");
expect(screen.getByTestId("task-planner-chat-refinement-confirmation")).toHaveTextContent("Created refinement task FN-REFINE");
expect(screen.getByTestId("task-planner-chat-refinement-confirmation")).toHaveTextContent("Add export support");
expect(screen.getByTestId("task-planner-chat-refinement-error")).toHaveTextContent("Refinement task was not created");
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
});
it("renders clarification questions without refreshing task steering", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [{
id: "assistant-question",
sessionId: "chat-planner",
role: "assistant",
content: "Do you want this recorded as steering?",
thinkingOutput: null,
metadata: { toolCalls: [{ toolName: "fn_ask_question", args: { question: "Record this as steering?", options: ["Yes", "No"] }, isError: false }] },
createdAt: "2026-06-30T00:02:00.000Z",
}],
});
renderPlannerChat({ projectId: "project-1", onTaskUpdated: vi.fn() });
expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument();
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
});
it("streams mixed planner question tool calls as an actionable card outside collapsed details", async () => {
const user = userEvent.setup();
const onTaskUpdated = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onToolStart({ toolName: "bash", args: { command: "echo preparing" } });
handlers.onToolEnd({ toolName: "bash", isError: false, result: "preparing" });
handlers.onToolStart({ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] } });
handlers.onDone({
messageId: "assistant-risky-question",
message: {
id: "assistant-risky-question",
sessionId: "chat-planner",
role: "assistant",
content: "I need clarification before adding steering.",
thinkingOutput: null,
metadata: {
toolCalls: [
{ toolName: "bash", args: { command: "echo preparing" }, isError: false, result: "preparing", status: "completed" },
{ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] }, isError: false, status: "completed" },
],
},
createdAt: "2026-06-30T00:03:00.000Z",
},
});
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat({ projectId: "project-1", onTaskUpdated });
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Delete the risky parts and rewrite the security flow broadly");
await user.click(screen.getByRole("button", { name: "Send" }));
const question = await screen.findByTestId("chat-question-response");
expect(question).toHaveTextContent("Which files and safety constraints should this destructive change use?");
expect(question.closest("details.chat-tool-calls-group")).toBeNull();
expect(screen.getAllByTestId("chat-question-response")).toHaveLength(1);
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
expect(screen.getByTestId("chat-tool-calls")).toHaveTextContent("bash");
expect(screen.getByTestId("chat-tool-calls")).not.toHaveTextContent("fn_ask_question");
await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
await user.click(screen.getByTestId("chat-question-response-submit"));
expect(mockStreamChatResponse).toHaveBeenLastCalledWith(
"chat-planner",
"> Q: Which files and safety constraints should this destructive change use?\nClarify scope",
expect.any(Object),
undefined,
"project-1",
{ taskId: "FN-7310" },
);
expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument();
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
expect(onTaskUpdated).not.toHaveBeenCalled();
});
it("shows failed steering tool results without optimistic steering confirmation or duplicate refresh", async () => {
const user = userEvent.setup();
const onTaskUpdated = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onToolEnd({
toolName: "fn_task_planner_add_steering",
isError: true,
result: { error: "Invalid steering text" },
});
handlers.onDone({
messageId: "assistant-steering-error",
message: {
id: "assistant-steering-error",
sessionId: "chat-planner",
role: "assistant",
content: "I could not add that as steering.",
thinkingOutput: null,
metadata: { toolCalls: [{ toolName: "fn_task_planner_add_steering", args: { text: " " }, isError: true, result: { error: "Invalid steering text" } }] },
createdAt: "2026-06-30T00:03:00.000Z",
},
});
}, 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat({ projectId: "project-1", onTaskUpdated });
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Add empty steering");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("I could not add that as steering.")).toBeInTheDocument();
expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument();
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
expect(onTaskUpdated).not.toHaveBeenCalled();
});
it("renders text, single-select, multi-select, confirm, and missing-option planner questions", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({
messages: [plannerQuestionMessage("assistant-question", {
questions: [
{ id: "text", question: "Describe the risk", type: "text" },
{ id: "single", question: "Pick one", type: "single_select", options: [{ id: "safe", label: "Safe" }] },
{ id: "multi", question: "Pick many", type: "multi_select", options: [{ id: "a", label: "A" }, { id: "b", label: "B" }] },
{ id: "confirm", question: "Proceed?", type: "confirm" },
{ id: "missing", question: "Missing choices", type: "single_select" },
],
})],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
expect(screen.getByTestId("chat-question-response-submit")).toBeDisabled();
await user.type(screen.getByTestId("chat-question-response-text-text"), "Low risk");
await user.click(screen.getByTestId("chat-question-response-option-single-safe"));
await user.click(screen.getByTestId("chat-question-response-option-multi-a"));
await user.click(screen.getByTestId("chat-question-response-option-confirm-no"));
await user.type(screen.getByTestId("chat-question-response-text-missing"), "Use the default");
await user.click(screen.getByTestId("chat-question-response-submit"));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"> Q: Describe the risk\nLow risk\n\n> Q: Pick one\nSafe\n\n> Q: Pick many\nA\n\n> Q: Proceed?\nNo\n\n> Q: Missing choices\nUse the default",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
});
it("renders answered planner questions read-only with the submitted answer", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] }),
{ id: "user-answer", sessionId: "chat-planner", role: "user", content: "> Q: Pick a path\nAggressive", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:03:00.000Z" },
],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Aggressive");
expect(screen.getByText("Answered")).toBeInTheDocument();
expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-question-response-option-q-0-opt-1")).not.toBeInTheDocument();
});
it("hides older duplicate pending planner questions after a refetch", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
plannerQuestionMessage("assistant-question-old", { question: "Pick a path", options: ["Conservative", "Aggressive"] }, "2026-06-30T00:02:00.000Z"),
plannerQuestionMessage("assistant-question-new", { question: "Pick a path", options: ["Conservative", "Aggressive"] }, "2026-06-30T00:03:00.000Z"),
],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
expect(screen.getAllByTestId("chat-question-response")).toHaveLength(1);
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
});
it("keeps accepted silent planner streams waiting and reconciles late history", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{ id: "planner-user-slow", sessionId: "chat-planner", role: "user", content: "slow planner prompt", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:00.000Z" },
{ id: "planner-assistant-late", sessionId: "chat-planner", role: "assistant", content: "late planner answer", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:01.000Z" },
],
});
let doneHandler: any;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
doneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "slow planner prompt");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("slow planner prompt")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("Timed out waiting for first response event")).not.toBeInTheDocument();
expect(screen.queryByText("Planner chat failed to respond")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")).toBeInTheDocument();
act(() => doneHandler?.({ messageId: "planner-assistant-late" }));
expect(await screen.findByText("late planner answer")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")).not.toBeInTheDocument();
});
it("reattaches accepted silent planner streams without showing timeout errors", async () => {
const inFlightSession = makePlannerSession({
isGenerating: true,
inFlightGeneration: {
status: "generating",
streamingText: "",
streamingThinking: "",
toolCalls: [],
replayFromEventId: 9,
updatedAt: "2026-07-01T00:00:00.000Z",
},
});
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: inFlightSession });
mockFetchChatSession.mockResolvedValueOnce({ session: inFlightSession });
mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({
messages: [{ id: "planner-attached-late", sessionId: "chat-planner", role: "assistant", content: "attached late answer", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:01.000Z" }],
});
let attachedDoneHandler: any;
mockAttachChatStream.mockImplementation((_sessionId, handlers) => {
attachedDoneHandler = handlers.onDone;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await waitFor(() => expect(mockAttachChatStream).toHaveBeenCalledWith("chat-planner", expect.any(Object), undefined, { lastEventId: 9 }));
expect(screen.queryByText("Timed out waiting for first response event")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(document.querySelector(".chat-message--streaming")).toBeInTheDocument();
act(() => attachedDoneHandler?.({ messageId: "planner-attached-late" }));
expect(await screen.findByText("attached late answer")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps first planner message visible after accepted provider error and reconciles persisted history", async () => {
const user = userEvent.setup();
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{ id: "planner-user-1", sessionId: "chat-planner", role: "user", content: "hello after 429", thinkingOutput: null, metadata: null, createdAt: "2026-07-01T00:00:00.000Z" }],
});
let errorHandler: any;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "hello after 429");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("hello after 429")).toBeInTheDocument();
act(() => errorHandler?.({ summary: "Planner provider rate limit" }, { requestAccepted: true, receivedStreamEvent: true }));
expect(await screen.findByRole("alert")).toHaveTextContent("Planner provider rate limit");
await waitFor(() => expect(screen.getAllByText("hello after 429")).toHaveLength(1));
expect(mockFetchChatMessages).toHaveBeenCalledWith("chat-planner", { order: "asc" }, undefined);
});
it("rolls back planner optimistic message for pre-acceptance failures", async () => {
const user = userEvent.setup();
let errorHandler: any;
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
errorHandler = handlers.onError;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "blocked before persist");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("blocked before persist")).toBeInTheDocument();
act(() => errorHandler?.("Request failed: 429", { requestAccepted: false, receivedStreamEvent: false }));
await waitFor(() => expect(screen.queryByText("blocked before persist")).not.toBeInTheDocument());
expect(screen.getByRole("alert")).toHaveTextContent("Request failed: 429");
});
it("ignores stale planner provider errors after the task scope changes", async () => {
const user = userEvent.setup();
let oldHandlers: any;
mockStreamChatResponse.mockImplementationOnce((_sessionId, _content, handlers) => {
oldHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
const { rerender } = renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "old task message");
await user.click(screen.getByRole("button", { name: "Send" }));
rerender(
<TaskPlannerChatTab
task={makeTask("FN-7312")}
active
planningModel={{ provider: "anthropic", modelId: "claude-plan" }}
addToast={vi.fn()}
/>,
);
await screen.findByTestId("task-planner-chat-empty");
act(() => oldHandlers.onError("Stale provider error", { requestAccepted: true, receivedStreamEvent: true }));
expect(screen.queryByText("Stale provider error")).not.toBeInTheDocument();
});
it("shows API errors and re-enables the composer", async () => {
const user = userEvent.setup();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => handlers.onError("Planner unavailable"), 0);
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "Question");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Planner unavailable");
await waitFor(() => expect(screen.getByLabelText("Message planner chat")).toBeEnabled());
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
/*
* FNXC:TaskDetailPlannerChat 2026-07-07-10:15:
* Covers the FN-7639 edit-and-resend affordance across the enumerated surfaces: renders only
* for persisted user rows, absent on assistant/optimistic/streaming rows and while sending,
* truncates-then-resends in order, reloads truthful history and toasts on PATCH failure without
* resending, preserves planner-question dedup across an edited answer, and refreshes task detail
* with a discard notice (but no reversal) when the discarded range held a steering/refinement
* confirmation.
*/
describe("message edit affordance", () => {
it("renders the edit affordance only for persisted user messages, hiding it for assistant, optimistic, and streaming rows", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
{ id: "m2", sessionId: "chat-planner", role: "assistant", content: "Hi there", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" },
],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-message-edit-m1")).toBeInTheDocument();
expect(screen.queryByTestId("chat-message-edit-m2")).toBeNull();
const assistantMessage = screen.getByTestId("chat-message-m2");
expect(assistantMessage.querySelector("[aria-label='Edit message']")).toBeNull();
});
it("hides the edit affordance for an optimistic row and the streaming placeholder while sending", async () => {
const deferredStream = createDeferred<void>();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
void deferredStream.promise.then(() => {
handlers.onText("partial");
});
return { close: vi.fn(), isConnected: () => true };
});
const user = userEvent.setup();
renderPlannerChat();
await screen.findByTestId("task-planner-chat-empty");
await user.type(screen.getByLabelText("Message planner chat"), "In flight");
await user.click(screen.getByRole("button", { name: "Send" }));
const optimisticMessage = await screen.findByText("In flight");
const optimisticRow = optimisticMessage.closest("[data-testid^='chat-message-']") as HTMLElement;
expect(optimisticRow?.querySelector("[aria-label='Edit message']")).toBeNull();
expect(screen.queryByLabelText("Edit message")).toBeNull();
deferredStream.resolve();
});
it("truncates locally, calls editChatMessage, then resends through the normal streaming send in order", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
{ id: "m2", sessionId: "chat-planner", role: "assistant", content: "Hi there", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" },
],
});
const deferredEdit = createDeferred<{ retained: unknown[] }>();
mockEditChatMessage.mockReturnValue(deferredEdit.promise);
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const user = userEvent.setup();
renderPlannerChat();
await screen.findByText("Hello");
await user.click(screen.getByTestId("chat-message-edit-m1"));
const editor = screen.getByTestId("chat-message-edit-editor-m1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Hello, edited" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(mockEditChatMessage).toHaveBeenCalledWith("chat-planner", "m1", "Hello, edited", undefined));
// Optimistic truncation happens before the PATCH resolves: the edited row and its tail drop immediately.
await waitFor(() => expect(screen.queryByText("Hello")).not.toBeInTheDocument());
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
expect(mockStreamChatResponse).not.toHaveBeenCalled();
deferredEdit.resolve({ retained: [] });
await waitFor(() => expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"Hello, edited",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
));
const editCallOrder = mockEditChatMessage.mock.invocationCallOrder[0];
const sendCallOrder = mockStreamChatResponse.mock.invocationCallOrder[mockStreamChatResponse.mock.calls.length - 1];
expect(editCallOrder).toBeLessThan(sendCallOrder);
});
it("reloads truthful history and toasts on PATCH failure without resending", async () => {
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
],
});
mockFetchChatMessages.mockResolvedValueOnce({
messages: [
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
],
});
mockEditChatMessage.mockRejectedValueOnce(new Error("edit failed"));
const addToast = vi.fn();
renderPlannerChat({ addToast });
await screen.findByText("Hello");
fireEvent.click(screen.getByTestId("chat-message-edit-m1"));
const editor = screen.getByTestId("chat-message-edit-editor-m1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Hello, edited" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("edit failed", "error"));
await waitFor(() => expect(mockFetchChatMessages).toHaveBeenCalledTimes(2));
expect(mockStreamChatResponse).not.toHaveBeenCalled();
expect(await screen.findByText("Hello")).toBeInTheDocument();
});
it("hides the edit affordance on an already-persisted message while a new generation is streaming", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Hello", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
],
});
const deferredStream = createDeferred<void>();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
void deferredStream.promise.then(() => handlers.onText("partial"));
return { close: vi.fn(), isConnected: () => true };
});
const user = userEvent.setup();
renderPlannerChat();
await screen.findByTestId("chat-message-edit-m1");
await user.type(screen.getByLabelText("Message planner chat"), "another message");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => expect(screen.queryByTestId("chat-message-edit-m1")).toBeNull());
expect(mockEditChatMessage).not.toHaveBeenCalled();
deferredStream.resolve();
});
it("editing a planner-question answer does not corrupt question dedup", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] }, "2026-06-30T00:01:00.000Z"),
{ id: "answer-1", sessionId: "chat-planner", role: "user", content: "> Q: Pick a path\nConservative", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:02:00.000Z" },
],
});
mockEditChatMessage.mockResolvedValue({ retained: [] });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
renderPlannerChat();
await screen.findByTestId("chat-question-response");
expect(screen.getByTestId("chat-question-response")).toHaveClass("chat-question-response--answered");
fireEvent.click(screen.getByTestId("chat-message-edit-answer-1"));
const editor = screen.getByTestId("chat-message-edit-editor-answer-1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "> Q: Pick a path\nAggressive" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(mockEditChatMessage).toHaveBeenCalledWith("chat-planner", "answer-1", "> Q: Pick a path\nAggressive", undefined));
// The prior answer is discarded and the edited content is resent as the new answer: the
// question card stays a single card (no duplicate/corrupted dedup state) and reflects the
// resent answer as the current answered state.
await waitFor(() => expect(screen.getAllByTestId("chat-question-response")).toHaveLength(1));
expect(screen.getByTestId("chat-question-response")).toHaveClass("chat-question-response--answered");
expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Aggressive");
});
it("refreshes task detail and surfaces a discard notice (without reverting) when the discarded range held a steering confirmation", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
{ id: "m1", sessionId: "chat-planner", role: "user", content: "Please steer this", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" },
{
id: "m2",
sessionId: "chat-planner",
role: "assistant",
content: "Added that as steering.",
thinkingOutput: null,
metadata: {
toolCalls: [{
toolName: "fn_task_planner_add_steering",
args: { text: "Please steer this" },
isError: false,
result: { details: { taskId: "FN-7310", text: "Please steer this", steeringComment: { id: "steer-1", text: "Please steer this" } } },
status: "completed",
}],
},
createdAt: "2026-06-30T00:02:00.000Z",
},
],
});
mockEditChatMessage.mockResolvedValue({ retained: [] });
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
const onTaskUpdated = vi.fn();
const addToast = vi.fn();
renderPlannerChat({ onTaskUpdated, addToast });
await screen.findByTestId("task-planner-chat-steering-confirmation");
fireEvent.click(screen.getByTestId("chat-message-edit-m1"));
const editor = screen.getByTestId("chat-message-edit-editor-m1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Please steer this differently" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(mockFetchTaskDetail).toHaveBeenCalledWith("FN-7310", undefined));
await waitFor(() => expect(onTaskUpdated).toHaveBeenCalled());
await waitFor(() => expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("were not undone"),
"info",
));
// No reversal call is made for the already-applied steering comment.
expect(mockFetchTaskDetail).not.toHaveBeenCalledWith("FN-7310", expect.anything(), expect.anything());
});
});
describe("slash-command /steer", () => {
it("shows /steer in the '/' menu, disabled with a hint, when the task's agent is not running", async () => {
renderPlannerChat({ task: makeTask("FN-7310", { column: "todo" }) });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "/" } });
const option = await screen.findByRole("option", { name: /steer/i });
expect(option).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText(/no running agent/i)).toBeInTheDocument();
});
it("enables /steer in the menu when the task's agent is running (column === in-progress)", async () => {
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "/" } });
const option = await screen.findByRole("option", { name: /steer/i });
expect(option).toHaveAttribute("aria-disabled", "false");
});
it("submitting '/steer do X' on a running task calls addSteeringComment and does not start a planner-chat send", async () => {
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }), projectId: "proj-1" });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "/steer do X" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(mockAddSteeringComment).toHaveBeenCalledWith("FN-7310", "do X", "proj-1"));
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
await waitFor(() => expect(textarea).toHaveValue(""));
});
it("submitting a normal message still starts a planner-chat send when the task's agent is running", async () => {
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "What is the status?" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await screen.findByText("What is the status?");
expect(mockAddSteeringComment).not.toHaveBeenCalled();
});
it("submitting '/steer ...' with no running agent shows a hint and does not dispatch or send a message", async () => {
const addToast = vi.fn();
renderPlannerChat({ task: makeTask("FN-7310", { column: "todo" }), addToast });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "/steer do X" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("No running agent"), "warning"));
expect(mockAddSteeringComment).not.toHaveBeenCalled();
expect(mockEnsureTaskPlannerChatSession).not.toHaveBeenCalled();
expect(textarea).toHaveValue("/steer do X");
});
it("does not dispatch when the trigger appears mid-message", async () => {
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "please /steer this" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await screen.findByText("please /steer this");
expect(mockAddSteeringComment).not.toHaveBeenCalled();
});
// FUX-015 composer-wipe race: the draft is cleared on submit (before awaiting
// command.run), not in the success path, so text typed while the command is
// in flight is not wiped when run() resolves.
it("preserves text typed while the command is in flight (no late wipe on success)", async () => {
let resolveRun: () => void = () => {};
const runPromise = new Promise<void>((resolve) => { resolveRun = resolve; });
mockAddSteeringComment.mockReturnValueOnce(runPromise as unknown as ReturnType<typeof mockAddSteeringComment>);
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }), projectId: "proj-1" });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "/steer do X" } });
fireEvent.keyDown(textarea, { key: "Enter" });
// Draft cleared immediately on submit, before the command resolves.
await waitFor(() => expect(textarea).toHaveValue(""));
// User begins a new message while the command is still in flight.
fireEvent.change(textarea, { target: { value: "next message" } });
resolveRun();
await waitFor(() => expect(mockAddSteeringComment).toHaveBeenCalledWith("FN-7310", "do X", "proj-1"));
// The in-flight text must not be wiped by the success path.
expect(textarea).toHaveValue("next message");
});
// The planner command menu renders only commands (not skills), so its
// accessible copy must say "command", not the reused skill-menu copy.
it("labels the command menu with command-specific copy, not skill copy", async () => {
renderPlannerChat({ task: makeTask("FN-7310", { column: "in-progress" }) });
const textarea = await screen.findByLabelText("Message planner chat");
fireEvent.change(textarea, { target: { value: "/" } });
const menu = await screen.findByRole("listbox", { name: /command suggestions/i });
expect(menu).toBeInTheDocument();
expect(screen.queryByRole("listbox", { name: /skill suggestions/i })).not.toBeInTheDocument();
});
});
});