FN-7639: wire message editing into Planner Chat

Adds the ability to edit and resend a prior user message in task-detail Planner Chat, discarding subsequent turns without reverting already-applied steering/refinement side effects.

- Wire FN-7628's editChatMessage + rewindSessionForEdit into TaskPlannerChatTab for synthetic task-planner:<id> sessions
- Add edit affordance/UI flow and message resend handling in TaskPlannerChatTab
- Ensure already-applied steering comments and refinement tasks are not reverted when a turn is discarded
- Expand TaskPlannerChatTab test coverage for edit/resend flows
- Update dashboard guide docs to describe the new Planner Chat edit behavior
- Add changeset for @runfusion/fusion (minor)

Files changed:
 .changeset/fn-7639-planner-chat-edit.md            |   7 +
 docs/dashboard-guide.md                            |   4 +-
 .../app/components/TaskPlannerChatTab.tsx          |  90 +++++++-
 .../__tests__/TaskPlannerChatTab.test.tsx          | 242 ++++++++++++++++++++-
 4 files changed, 328 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-7639

Fusion-Task-Lineage: ed568a81-12fa-42a8-9ecf-29e6c9a2a884

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 09:34:20 -07:00
parent 26f22861fa
commit 1b7bb1fe18
4 changed files with 328 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Edit and resend a message in task-detail Planner Chat.
category: feature
dev: Wires FN-7628's edit affordance (editChatMessage + rewindSessionForEdit) into TaskPlannerChatTab for synthetic task-planner:<id> sessions; already-applied steering comments and refinement tasks are not reverted when a turn is discarded.

View File

@@ -507,8 +507,10 @@ Chat view provides project-scoped conversations with agents.
- Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked.
<!-- FNXC:ChatMessageEdit 2026-07-07-09:00: Document the message-edit affordance and its resume-from-edit ("forget everything after") semantics, including the model-loop-only scope. -->
- Your own messages in a **direct (model-loop) chat** can be edited: hover/tap a user message and use the **Edit message** (pencil) action to swap it for an inline textarea, then **Save** (or Cmd/Ctrl+Enter) or **Cancel** (or Escape). Saving an edit **resumes the conversation from that point** — the edited turn and every turn after it are discarded from both the visible transcript and the model's memory, so the agent responds fresh from the edited content with no bias from what was removed. This is the only way to correct or steer an earlier turn without leaving a stale, misleading message in the thread.
- Message editing applies to direct/model-loop chat sessions only. It is **not** available in **Chat Rooms** (multi-agent, different persistence) or in **CLI-agent-backed sessions** (the transcript is owned by a live terminal, not a rewindable model session). The edit action is also disabled while a response is actively streaming, to avoid racing a live generation.
- Message editing applies to direct/model-loop chat sessions, **including task-detail Planner Chat** (the synthetic `task-planner:<id>` session, a model-loop session under the hood). It is **not** available in **Chat Rooms** (multi-agent, different persistence) or in **CLI-agent-backed sessions** (the transcript is owned by a live terminal, not a rewindable model session). The edit action is also disabled while a response is actively streaming, to avoid racing a live generation, and never renders on optimistic/in-flight rows that have no persisted message id yet.
- Editing is truncate-and-resend, not append: the edited message and everything after it are removed first, then the edited text is sent as a new turn through the normal streaming path — so the resulting transcript looks the same as if you had deleted the old messages and typed the correction from scratch, but in one action.
<!-- FNXC:TaskDetailPlannerChat 2026-07-07-10:15: Document Planner Chat edit-and-resend and the steering/refinement side-effect decision on discard. -->
- In task-detail **Planner Chat**, editing an earlier message resumes the conversation from that point exactly as in direct chat. If the discarded turns already triggered task-scoped side effects — a steering comment added via the planner steering tool, or a refinement task created via the planner refinement tool — those **are not reverted**: the steering comment stays on the task and the refinement task stays open, because undoing either is destructive and out of scope for a chat edit. After a successful edit-and-resend, task detail refreshes automatically (so Activity/steering reflects reality), and if the discarded range held one of those confirmations you get an informational toast noting that the earlier change was not undone.
![Chat view](./screenshots/chat-view.png)

View File

@@ -5,7 +5,7 @@ import { Loader2, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ToastType } from "../hooks/useToast";
import type { ChatMessageInfo, ToolCallInfo } from "../hooks/chatTypes";
import { attachChatStream, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
import { attachChatStream, editChatMessage, ensureTaskPlannerChatSession, fetchChatMessages, fetchChatSession, fetchTaskDetail, fetchTaskPlannerChatSession, streamChatResponse, type ChatFailureInfo, type ChatStreamErrorMeta } from "../api";
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
import { ProviderIcon } from "./ProviderIcon";
@@ -604,6 +604,75 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
}
}, [addToast, modelPayload, projectId, sessionId, startPlannerStream, task.id, t]);
const refreshTaskAfterEdit = useCallback(async (hadDiscardedSideEffect: boolean) => {
try {
const refreshedTask = await fetchTaskDetail(task.id, projectId);
onTaskUpdatedRef.current?.(refreshedTask);
} catch {
// Best-effort: the edit itself already succeeded and resent; a task-detail refresh
// failure here is non-fatal and must not be surfaced as an edit failure.
}
if (hadDiscardedSideEffect) {
addToastRef.current(
t(
"taskDetail.plannerChat.editDiscardedSideEffectsToast",
"Earlier steering comments or refinement tasks from the discarded messages were not undone",
),
"info",
);
}
}, [projectId, t, task.id]);
/*
* FNXC:TaskDetailPlannerChat 2026-07-07-10:15:
* Editing an earlier Planner Chat message resumes the conversation from that point and forgets
* everything after it — both the persisted rows (via editChatMessage's server-side truncation)
* and the pi session context (via ChatManager.rewindSessionForEdit, reused unmodified from
* FN-7628). Product decision for already-applied task-scoped side effects: discarded turns may
* have already run fn_task_planner_add_steering (persisted a steering comment) or
* fn_task_planner_create_refinement (created a real task). Reverting those is destructive and
* out of scope here, so this task deliberately does NOT attempt to undo them — the steering
* comment stays on the task and the refinement task stays open. Instead, after a successful
* edit-and-resend we refresh task detail (so Activity/steering reflects reality) and, only when
* the discarded range contained a steering/refinement tool result, surface an informational
* toast so the user is not misled into thinking those changes were reverted.
*/
const editMessageAndResend = useCallback(async (messageId: string, newContent: string) => {
if (composerStateRef.current === "sending" || !sessionId) return;
if (messageId.startsWith("optimistic-") || messageId === "streaming-assistant") return;
const trimmed = newContent.trim();
if (!trimmed) return;
const resolvedSessionId = sessionId;
const targetIndex = messages.findIndex((candidate) => candidate.id === messageId);
if (targetIndex === -1) return;
const discardedRange = messages.slice(targetIndex);
const hadDiscardedSideEffect = discardedRange.some((candidate) =>
extractToolCalls(candidate).some((toolCall) =>
extractPlannerSteeringResult(toolCall) !== null || extractPlannerRefinementResult(toolCall) !== null,
),
);
// Optimistic truncation: drop the edited message and everything after it immediately,
// matching the server's index-based truncation semantics (not just a timestamp filter).
setMessages((current) => current.slice(0, targetIndex));
try {
await editChatMessage(resolvedSessionId, messageId, trimmed, projectId);
} catch (err) {
const message = getErrorMessage(err) || t("taskDetail.plannerChat.editFailed", "Failed to edit planner chat message");
setError(message);
addToastRef.current(message, "error");
// Restore truthful state from the server rather than trusting the optimistic truncation.
void refreshMessagesForSession(resolvedSessionId, () => true);
return;
}
await sendMessageContent(trimmed);
await refreshTaskAfterEdit(hadDiscardedSideEffect);
}, [messages, projectId, refreshMessagesForSession, refreshTaskAfterEdit, sendMessageContent, sessionId, t]);
const sendMessage = useCallback(() => sendMessageContent(draft), [draft, sendMessageContent]);
const stopPlannerStreaming = useCallback(() => {
@@ -826,12 +895,12 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
);
}
/*
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Planner Chat (task-planner:<id> synthetic session) is model-loop and could support
* edit, but wiring an equivalent rewind-and-resend action here is deferred to a
* follow-up task. Deliberately pass no `onEditMessage`/`canEdit` so
* StandardChatMessageItem renders no edit affordance at all here — never a dead/no-op
* button.
* FNXC:ChatMessageEdit 2026-07-07-10:15:
* Planner Chat (task-planner:<id> synthetic session) is model-loop and reuses FN-7628's
* rewind-and-resend path via the local editMessageAndResend orchestration above. The
* affordance is only offered on persisted user rows (never optimistic-<ts>/
* streaming-assistant placeholders, never assistant/system rows, and never while a
* generation is in flight) so StandardChatMessageItem never renders a dead/no-op button.
*/
return (
<StandardChatMessageItem
@@ -847,6 +916,13 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
isAwaitingQuestionAnswer={message.role === "assistant"}
onQuestionSubmit={(answerText) => void sendMessageContent(answerText)}
toolCallRenderer={(toolCall, index) => renderPlannerToolCall(message, toolCall, index)}
onEditMessage={editMessageAndResend}
canEdit={
message.role === "user"
&& !message.id.startsWith("optimistic-")
&& message.id !== "streaming-assistant"
&& composerState !== "sending"
}
/>
);
})}

View File

@@ -8,7 +8,7 @@ import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
const taskPlannerChatCss = readFileSync(resolve(__dirname, "../TaskPlannerChatTab.css"), "utf8");
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockTranslations, mockT } = vi.hoisted(() => {
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockTranslations, mockT } = vi.hoisted(() => {
const translations = new Map<string, string>();
return {
mockEnsureTaskPlannerChatSession: vi.fn(),
@@ -18,6 +18,7 @@ const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockF
mockFetchTaskDetail: vi.fn(),
mockStreamChatResponse: vi.fn(),
mockAttachChatStream: vi.fn(),
mockEditChatMessage: vi.fn(),
mockTranslations: translations,
mockT: (key: string, fallback: string) => translations.get(key) ?? fallback,
};
@@ -40,15 +41,20 @@ vi.mock("../../api", async (importOriginal) => {
fetchTaskDetail: mockFetchTaskDetail,
streamChatResponse: mockStreamChatResponse,
attachChatStream: mockAttachChatStream,
editChatMessage: mockEditChatMessage,
};
});
vi.mock("lucide-react", () => ({
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 }),
}));
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;
@@ -126,6 +132,7 @@ describe("TaskPlannerChatTab", () => {
mockFetchTaskDetail.mockResolvedValue(makeTask("FN-7310"));
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
mockEditChatMessage.mockResolvedValue({ retained: [] });
});
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
@@ -1382,4 +1389,225 @@ describe("TaskPlannerChatTab", () => {
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());
});
});
});