FN-7628: allow editing sent chat messages and rewinding agent responses

Adds the ability to edit a previously sent message in an agent chat, which rewinds the session/task room and regenerates the response from the edited message.

- Add chat-store support for locating/replacing a message and truncating subsequent history for a rewind
- Add a chat-manager rewind-session flow and a new register-chat-routes endpoint to rewind a room to an edited message
- Add legacy API route wiring and useChat hook support for issuing an edit request
- Add ChatView/StandardChatSurface/TaskPlannerChatTab UI affordances (edit control, styling) to trigger message edits
- Add a changeset documenting the new chat message-edit capability
- Add unit/integration tests covering chat-store rewind logic, chat-manager rewind-session behavior, chat routes, useChat, and ChatView edit UI

Files changed:
 .changeset/fn-7628-chat-message-edit.md            |   7 +
 docs/dashboard-guide.md                            |   4 +
 packages/core/src/__tests__/chat-store.test.ts     | 171 ++++++++++++++
 packages/core/src/chat-store.ts                    |  95 ++++++++
 packages/dashboard/app/api/legacy.ts               |  22 ++
 packages/dashboard/app/components/ChatView.css     |  78 ++++++
 packages/dashboard/app/components/ChatView.tsx     |  14 ++
 .../app/components/StandardChatSurface.tsx         |  87 ++++++-
 .../app/components/TaskPlannerChatTab.tsx          |   8 +
 .../__tests__/ChatView.autosize.test.tsx           |   1 +
 .../__tests__/ChatView.default-model-icon.test.tsx |   1 +
 .../components/__tests__/ChatView.draft.test.tsx   |   1 +
 .../__tests__/ChatView.hash-mention.test.tsx       |   1 +
 .../__tests__/ChatView.message-edit.test.tsx       | 262 +++++++++++++++++++++
 .../__tests__/ChatView.mobile-render.test.tsx      |   1 +
 .../components/__tests__/ChatView.rooms.test.tsx   |   1 +
 .../__tests__/ChatView.scroll-to-top.test.tsx      |   1 +
 .../components/__tests__/ChatView.test-harness.tsx |   1 +
 .../dashboard/app/hooks/__tests__/useChat.test.ts  |  98 ++++++++
 packages/dashboard/app/hooks/useChat.ts            |  60 +++++
 .../__tests__/chat-manager-rewind-session.test.ts  | 185 +++++++++++++++
 .../dashboard/src/__tests__/chat-manager.test.ts   |  12 +
 .../dashboard/src/__tests__/chat-routes.test.ts    | 124 ++++++++++
 packages/dashboard/src/chat.ts                     | 147 +++++++++++-
 .../dashboard/src/routes/register-chat-routes.ts   |  52 ++++
 25 files changed, 1429 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7628

Fusion-Task-Lineage: 36d98989-1b75-428c-baf1-b2c7e8e78013

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 09:09:06 -07:00
parent 5b243f1223
commit 42009cfdb9
25 changed files with 1429 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Edit a chat message and resume the conversation from that point.
category: feature
dev: Adds ChatStore.deleteMessagesFrom + PATCH /api/chat/sessions/:id/messages/:messageId; rewinds the pi SessionManager (createBranchedSession) so the model forgets discarded turns. Direct model-loop chats only.

View File

@@ -505,6 +505,10 @@ Chat view provides project-scoped conversations with agents.
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model.
- 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.
- 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.
![Chat view](./screenshots/chat-view.png)

View File

@@ -905,6 +905,177 @@ describe("ChatStore", () => {
);
});
});
describe("deleteMessagesFrom", () => {
it("deletes a middle message and everything after it, retaining the earlier tail", () => {
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "one" });
const m2 = store.addMessage(session.id, { role: "assistant", content: "two" });
const m3 = store.addMessage(session.id, { role: "user", content: "three" });
const m4 = store.addMessage(session.id, { role: "assistant", content: "four" });
const result = store.deleteMessagesFrom(session.id, m3.id);
expect(result.deletedIds.sort()).toEqual([m3.id, m4.id].sort());
expect(result.retained.map((m) => m.id)).toEqual([m1.id, m2.id]);
const remaining = store.getMessages(session.id);
expect(remaining.map((m) => m.id)).toEqual([m1.id, m2.id]);
});
it("deletes only itself when the target is the last message", () => {
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "one" });
const m2 = store.addMessage(session.id, { role: "assistant", content: "two" });
const result = store.deleteMessagesFrom(session.id, m2.id);
expect(result.deletedIds).toEqual([m2.id]);
expect(result.retained.map((m) => m.id)).toEqual([m1.id]);
expect(store.getMessages(session.id).map((m) => m.id)).toEqual([m1.id]);
});
it("deletes everything when the target is the first message", () => {
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "one" });
const m2 = store.addMessage(session.id, { role: "assistant", content: "two" });
const result = store.deleteMessagesFrom(session.id, m1.id);
expect(result.deletedIds.sort()).toEqual([m1.id, m2.id].sort());
expect(result.retained).toEqual([]);
expect(store.getMessages(session.id)).toEqual([]);
});
it("is a no-op (no events) for a non-existent message id", () => {
const session = createTestSession(store);
store.addMessage(session.id, { role: "user", content: "one" });
const deletedListener = vi.fn();
const updatedListener = vi.fn();
store.on("chat:message:deleted", deletedListener);
store.on("chat:session:updated", updatedListener);
const result = store.deleteMessagesFrom(session.id, "msg-nonexistent");
expect(result.deletedIds).toEqual([]);
expect(deletedListener).not.toHaveBeenCalled();
expect(updatedListener).not.toHaveBeenCalled();
});
it("is a no-op (no events) when the message belongs to a different session", () => {
const session1 = createTestSession(store);
const session2 = createTestSession(store);
const otherMsg = store.addMessage(session2.id, { role: "user", content: "elsewhere" });
store.addMessage(session1.id, { role: "user", content: "here" });
const deletedListener = vi.fn();
store.on("chat:message:deleted", deletedListener);
const result = store.deleteMessagesFrom(session1.id, otherMsg.id);
expect(result.deletedIds).toEqual([]);
expect(deletedListener).not.toHaveBeenCalled();
expect(store.getMessages(session2.id)).toHaveLength(1);
});
it("tie-breaks deterministically when messages share an identical createdAt", () => {
startFakeClock();
const session = createTestSession(store);
// All four messages inserted at the exact same timestamp (fake clock frozen).
const m1 = store.addMessage(session.id, { role: "user", content: "one" });
const m2 = store.addMessage(session.id, { role: "assistant", content: "two" });
const m3 = store.addMessage(session.id, { role: "user", content: "three" });
const m4 = store.addMessage(session.id, { role: "assistant", content: "four" });
expect(new Set([m1.createdAt, m2.createdAt, m3.createdAt, m4.createdAt]).size).toBe(1);
const result = store.deleteMessagesFrom(session.id, m3.id);
// Insertion-order (rowid) tiebreak must still put m3/m4 after m1/m2, deleting exactly the tail.
expect(result.retained.map((m) => m.id)).toEqual([m1.id, m2.id]);
expect(result.deletedIds.sort()).toEqual([m3.id, m4.id].sort());
});
it("emits chat:message:deleted per removed id and exactly one chat:session:updated", () => {
const session = createTestSession(store);
const m1 = store.addMessage(session.id, { role: "user", content: "one" });
const m2 = store.addMessage(session.id, { role: "assistant", content: "two" });
const m3 = store.addMessage(session.id, { role: "user", content: "three" });
const deletedListener = vi.fn();
const updatedListener = vi.fn();
store.on("chat:message:deleted", deletedListener);
store.on("chat:session:updated", updatedListener);
store.deleteMessagesFrom(session.id, m2.id);
expect(deletedListener).toHaveBeenCalledTimes(2);
expect(deletedListener.mock.calls.map((c) => c[0]).sort()).toEqual([m2.id, m3.id].sort());
expect(updatedListener).toHaveBeenCalledTimes(1);
void m1;
});
it("bumps the parent session's updatedAt", () => {
startFakeClock();
const session = createTestSession(store);
store.addMessage(session.id, { role: "user", content: "one" });
const beforeUpdatedAt = store.getSession(session.id)!.updatedAt;
advanceClock(10);
const m2 = store.addMessage(session.id, { role: "assistant", content: "two" });
advanceClock(10);
store.deleteMessagesFrom(session.id, m2.id);
const afterUpdatedAt = store.getSession(session.id)!.updatedAt;
expect(new Date(afterUpdatedAt).getTime()).toBeGreaterThan(new Date(beforeUpdatedAt).getTime());
});
});
describe("updateMessageMetadata", () => {
it("merges new metadata onto existing metadata by default", () => {
const session = createTestSession(store);
const message = store.addMessage(session.id, {
role: "user",
content: "hi",
metadata: { mentions: ["agent-1"] },
});
const updated = store.updateMessageMetadata(message.id, { piParentLeafId: "leaf-1" });
expect(updated.metadata).toEqual({ mentions: ["agent-1"], piParentLeafId: "leaf-1" });
});
it("replaces metadata wholesale when merge=false", () => {
const session = createTestSession(store);
const message = store.addMessage(session.id, {
role: "user",
content: "hi",
metadata: { mentions: ["agent-1"] },
});
const updated = store.updateMessageMetadata(message.id, { piParentLeafId: "leaf-1" }, { merge: false });
expect(updated.metadata).toEqual({ piParentLeafId: "leaf-1" });
});
it("emits chat:message:updated", () => {
const session = createTestSession(store);
const message = store.addMessage(session.id, { role: "user", content: "hi" });
const listener = vi.fn();
store.on("chat:message:updated", listener);
store.updateMessageMetadata(message.id, { piParentLeafId: null });
expect(listener).toHaveBeenCalledTimes(1);
});
it("throws for a non-existent message id", () => {
expect(() => store.updateMessageMetadata("msg-nonexistent", { a: 1 })).toThrow("not found");
});
});
});
// ── Room CRUD Tests ───────────────────────────────────────────

View File

@@ -772,6 +772,101 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
return true;
}
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Truncate a chat session from (and including) a target message onward. Editing an earlier
* user turn must "forget" that turn and every turn after it — both from the persisted
* transcript here AND from the model's resumable pi session context (rewound separately by
* ChatManager.rewindSessionForEdit) — so future responses are not biased by discarded turns.
*
* Ordering is resolved by (createdAt ASC, rowid ASC) rather than createdAt alone, since
* multiple messages can share an identical createdAt timestamp (same-millisecond inserts);
* rowid is SQLite's implicit monotonic insertion-order tiebreaker, guaranteeing the edited
* message and every later message (in true insertion order) are always included, with no
* sibling straggler surviving the truncation.
*
* @param sessionId - Parent session ID
* @param fromMessageId - Id of the earliest message to delete (inclusive)
* @returns deletedIds (in ASC order) and retained messages (pre-edit history, ASC order)
*/
deleteMessagesFrom(sessionId: string, fromMessageId: string): { deletedIds: string[]; retained: ChatMessage[] } {
const target = this.db.prepare(
"SELECT id, sessionId, rowid as rowid_ FROM chat_messages WHERE id = ?",
).get(fromMessageId) as { id: string; sessionId: string; rowid_: number } | undefined;
if (!target || target.sessionId !== sessionId) {
return { deletedIds: [], retained: this.getMessages(sessionId) };
}
// Ordered id list for the session (createdAt ASC, rowid ASC tiebreak) so we can
// deterministically split retained-vs-deleted around the target message.
const orderedRows = this.db.prepare(
"SELECT id, rowid as rowid_ FROM chat_messages WHERE sessionId = ? ORDER BY createdAt ASC, rowid_ ASC",
).all(sessionId) as { id: string; rowid_: number }[];
const targetIndex = orderedRows.findIndex((row) => row.id === fromMessageId);
if (targetIndex === -1) {
return { deletedIds: [], retained: this.getMessages(sessionId) };
}
const retainedIds = orderedRows.slice(0, targetIndex).map((row) => row.id);
const deletedIds = orderedRows.slice(targetIndex).map((row) => row.id);
const retained = retainedIds
.map((id) => this.getMessage(id))
.filter((message): message is ChatMessage => Boolean(message));
if (deletedIds.length === 0) {
return { deletedIds: [], retained };
}
const now = new Date().toISOString();
const placeholders = deletedIds.map(() => "?").join(", ");
this.db.prepare(`DELETE FROM chat_messages WHERE id IN (${placeholders})`).run(...deletedIds);
this.db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId);
this.db.bumpLastModified();
for (const id of deletedIds) {
this.emit("chat:message:deleted", id);
}
const updatedSession = this.getSession(sessionId);
if (updatedSession) {
this.emit("chat:session:updated", updatedSession);
}
return { deletedIds, retained };
}
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Merge (default) or replace a persisted message's metadata. Used by the model-loop generation
* path to record the pi SessionManager parent-leaf id (`metadata.piParentLeafId`) onto the
* just-created user message, without disturbing other metadata (e.g. `mentions`). This linkage
* is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf().
*/
updateMessageMetadata(messageId: string, metadata: Record<string, unknown> | null, options?: { merge?: boolean }): ChatMessage {
const existing = this.getMessage(messageId);
if (!existing) {
throw new Error(`Message ${messageId} not found`);
}
const merge = options?.merge !== false;
const nextMetadata = metadata === null
? (merge ? existing.metadata : null)
: (merge ? { ...(existing.metadata ?? {}), ...metadata } : metadata);
this.db.prepare("UPDATE chat_messages SET metadata = ? WHERE id = ?").run(toJsonNullable(nextMetadata), messageId);
const updated = this.getMessage(messageId);
if (!updated) {
throw new Error(`Failed to update message ${messageId}`);
}
this.db.bumpLastModified();
this.emit("chat:message:updated", updated);
return updated;
}
createRoom(input: ChatRoomCreateInput & { memberAgentIds?: string[] }): ChatRoom {
const normalizedName = this.normalizeRoomName(input.name);
if (!normalizedName) throw new Error("Room name cannot be empty");

View File

@@ -10188,6 +10188,28 @@ export function deleteChatMessage(
);
}
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Edit an earlier user message in a direct (model-loop) chat session. Truncates the persisted
* transcript from (and including) the target message onward AND rewinds the pi session context
* server-side, so the returned `retained` list is the surviving pre-edit history. Does NOT
* trigger regeneration — the caller resends the edited content via the existing streaming send.
*/
export function editChatMessage(
sessionId: string,
messageId: string,
content: string,
projectId?: string,
): Promise<{ retained: ChatMessage[] }> {
return api<{ retained: ChatMessage[] }>(
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, projectId),
{
method: "PATCH",
body: JSON.stringify({ content }),
},
);
}
export function fetchChatRooms(
options: { status?: string; agentId?: string } = {},
projectId?: string,

View File

@@ -1326,6 +1326,84 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
color: var(--color-error);
}
/*
FNXC:ChatMessageEdit 2026-07-07-09:00:
User messages in direct (model-loop) chat can carry an inline edit affordance mirroring the
assistant copy/scroll-to-top action row pattern above, plus an inline textarea editor. Editing
an earlier message resumes the conversation from that point, forgetting everything after it, so
keep the affordance visually consistent with existing chat-message-actions styling rather than a
one-off treatment. Rendered only for user messages on direct/model-loop sessions (never rooms,
CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEditAction`.
*/
.chat-message-actions--user {
justify-content: flex-start;
}
.chat-message-edit-action {
display: flex;
align-items: center;
justify-content: center;
width: calc(var(--space-lg) * 2);
height: calc(var(--space-lg) * 2);
min-width: calc(var(--space-lg) * 2);
min-height: calc(var(--space-lg) * 2);
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent-text) 15%, transparent);
color: var(--accent-text);
cursor: pointer;
opacity: 0.85;
transition: opacity var(--transition-fast), background var(--transition-fast);
}
.chat-message-edit-action:hover {
opacity: 1;
background: color-mix(in srgb, var(--accent-text) 25%, transparent);
}
.chat-message-edit-action:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
opacity: 1;
}
.chat-message-edit-editor {
display: flex;
flex-direction: column;
gap: var(--space-xs);
width: 100%;
}
.chat-message-edit-textarea {
width: 100%;
resize: vertical;
min-height: calc(var(--space-lg) * 4);
font: inherit;
color: var(--text);
background: var(--surface);
}
.chat-message-edit-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-xs);
}
@media (max-width: 768px) {
.chat-message-edit-action {
width: calc(var(--space-lg) * 2.25);
height: calc(var(--space-lg) * 2.25);
min-width: calc(var(--space-lg) * 2.25);
min-height: calc(var(--space-lg) * 2.25);
}
.chat-message-edit-textarea {
min-height: calc(var(--space-lg) * 5);
}
}
.chat-message-content--markdown > :first-child {
margin-top: 0;
}

View File

@@ -486,6 +486,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
renameSession,
deleteSession,
sendMessage,
editMessageAndResend,
stopStreaming,
pendingMessages,
clearPendingMessage,
@@ -2311,6 +2312,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
// Terminal attach id: the native session linkage when known, else the chat id.
const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || "";
/*
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Editing is supported only for direct (model-loop) chat sessions: never CLI-agent-backed
* sessions (a live PTY owns the transcript, not a rewindable pi session), and never while a
* generation is streaming (an edit cannot race a live send). Rooms don't route through this
* pane at all, so no additional gate is needed here for that surface.
*/
const canEditChatMessages = !cliChatActive && !isStreaming;
// The session message pane and composer, captured once so both the normal
// provider path and the CLI-backed path (CliChatSurface thunks) render the
// exact same JSX — no parallel message/composer UI.
@@ -2341,6 +2351,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
onQuestionSubmit={handleQuestionSubmit}
canEdit={canEditChatMessages}
onEditMessage={editMessageAndResend}
/>
))}
<StandardStreamingMessage
@@ -2383,6 +2395,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
onQuestionSubmit={handleQuestionSubmit}
canEdit={canEditChatMessages}
onEditMessage={editMessageAndResend}
/>
))}
</>

View File

@@ -1,9 +1,9 @@
import type { Agent } from "@fusion/core";
import React, { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { ArrowUpToLine, Bot, File, Send, TriangleAlert } from "lucide-react";
import { ArrowUpToLine, Bot, File, Pencil, Send, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ChatMessageInfo, FailureInfo, ToolCallInfo } from "../hooks/chatTypes";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
@@ -33,6 +33,21 @@ export interface StandardChatMessageItemProps {
submittedQuestionAnswer?: string;
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined;
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* When set together with `canEdit`, a user message renders an edit affordance that swaps its
* content for an inline textarea. Saving calls this with the edited text; the caller is
* responsible for truncating server + local history from this message onward and resending
* (see useChat.editMessageAndResend) so the model forgets everything after the edited turn.
* Only rendered for `role === "user"` messages — never for assistant/system messages.
*/
onEditMessage?: (messageId: string, newContent: string) => void | Promise<void>;
/**
* Gate for whether editing is currently supported/allowed for this message's surface (direct
* model-loop chat, not Rooms or CLI-agent sessions) and state (not while streaming). When
* false or `onEditMessage` is absent, no affordance renders at all — never a disabled/dead one.
*/
canEdit?: boolean;
}
export interface StandardStreamingMessageProps {
@@ -325,9 +340,47 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
submittedQuestionAnswer,
onQuestionSubmit,
toolCallRenderer,
onEditMessage,
canEdit = false,
}: StandardChatMessageItemProps) {
const { t } = useTranslation("app");
const isAssistantMessage = message.role === "assistant";
const isUserMessage = message.role === "user";
/*
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Edit affordance is scoped strictly to user messages on surfaces that opt in via both
* `canEdit` and `onEditMessage`; absent either, `showEditAction` is false and nothing renders
* (no dead button, no empty shell) — e.g. assistant/system messages, Rooms, CLI-agent chat, or
* while a generation is streaming.
*/
const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage);
const [isEditing, setIsEditing] = useState(false);
const [editedText, setEditedText] = useState(message.content);
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
const startEditing = useCallback(() => {
setEditedText(message.content);
setIsEditing(true);
}, [message.content]);
const cancelEditing = useCallback(() => {
setIsEditing(false);
setEditedText(message.content);
}, [message.content]);
const saveEdit = useCallback(() => {
const trimmed = editedText.trim();
if (!trimmed || trimmed === message.content) return;
setIsEditing(false);
void onEditMessage?.(message.id, trimmed);
}, [editedText, message.content, message.id, onEditMessage]);
useEffect(() => {
if (isEditing) {
editTextareaRef.current?.focus();
editTextareaRef.current?.select();
}
}, [isEditing]);
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
const renderedUserContent = useMemo<ReactNode>(() => {
@@ -377,10 +430,36 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
return renderStandardAssistantContent(message.content, forcePlain);
}, [failureInfo, forcePlain, isAssistantMessage, message.content, t]);
return (
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}${isEditing ? " chat-message--editing" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
{showAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}
{isAssistantMessage ? assistantBody : <div className="chat-message-content">{renderedUserContent}</div>}
{isEditing ? (
<div className="chat-message-edit-editor" data-testid={`chat-message-edit-editor-${message.id}`}>
<textarea
ref={editTextareaRef}
className="input chat-message-edit-textarea"
value={editedText}
onChange={(event) => setEditedText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelEditing();
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
saveEdit();
}
}}
rows={3}
/>
<div className="chat-message-edit-actions">
<button type="button" className="btn btn-sm" onClick={cancelEditing}>{t("chat.editMessageCancel", "Cancel")}</button>
<button type="button" className="btn btn-sm btn-primary" disabled={!editedText.trim() || editedText.trim() === message.content} onClick={saveEdit}>{t("chat.editMessageSave", "Save")}</button>
</div>
</div>
) : (
isAssistantMessage ? assistantBody : <div className="chat-message-content">{renderedUserContent}</div>
)}
{isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && <div className="chat-message-actions">{copyAction}{onScrollToTop && <button type="button" className="btn-icon chat-message-scroll-to-top-action" aria-label={t("chat.scrollMessageToTop", "Scroll message to top")} data-testid={`chat-message-scroll-to-top-${message.id}`} onClick={() => onScrollToTop(message.id)}><ArrowUpToLine size={14} /></button>}</div>}
{showEditAction && !isEditing && <div className="chat-message-actions chat-message-actions--user"><button type="button" className="btn-icon chat-message-edit-action" aria-label={t("chat.editMessage", "Edit message")} data-testid={`chat-message-edit-${message.id}`} onClick={startEditing}><Pencil size={14} /></button></div>}
{renderStandardToolCalls(message.toolCalls, t, { isAwaitingAnswer: isAwaitingQuestionAnswer, submittedAnswer: submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer })}
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
{renderedAttachments}

View File

@@ -825,6 +825,14 @@ 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.
*/
return (
<StandardChatMessageItem
key={message.id}

View File

@@ -75,6 +75,7 @@ const defaultChatState: UseChatReturn = {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -82,6 +82,7 @@ function setupMockChat(session: ChatSessionInfo): void {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -83,6 +83,7 @@ const defaultChatState: UseChatReturn = {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -68,6 +68,7 @@ const defaultChatState: UseChatReturn = {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -0,0 +1,262 @@
/*
FNXC:ChatMessageEdit 2026-07-07-09:00:
Covers the FN-7628 chat message edit affordance across the surfaces enumerated in
PROMPT.md: renders only for user messages in direct/model-loop chat, is absent for
assistant messages, CLI-agent-backed sessions, and Rooms, and is disabled while
streaming. Also covers the inline editor save/cancel interaction and the
editMessageAndResend wiring.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
Element.prototype.scrollIntoView = vi.fn();
vi.mock("../SessionTerminal", () => ({
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-terminal" data-session-id={sessionId}>
terminal
</div>
),
}));
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
};
});
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
Pencil: (props: any) => <svg data-testid="icon-pencil" {...props} />,
};
});
async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
let result: ReturnType<typeof rtlRender> | undefined;
await act(async () => {
result = rtlRender(ui);
});
return result!;
}
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
function makeSession(overrides: Partial<ChatSessionInfo> = {}): ChatSessionInfo {
return {
id: "session-001",
agentId: "agent-001",
status: "active",
title: "Test Chat",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
...overrides,
};
}
const roomA = {
id: "room-a",
name: "Room A",
slug: "room-a",
description: null,
projectId: "proj-123",
createdBy: "agent-1",
status: "active" as const,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
function baseRoomsState(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult {
return {
rooms: [roomA],
roomsLoading: false,
roomsError: null,
activeRoom: null,
activeRoomMembers: [],
messages: [],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
clearRoom: vi.fn(),
refreshRooms: vi.fn(),
...overrides,
};
}
function baseChatState(overrides: Partial<UseChatReturn> = {}): UseChatReturn {
const session = overrides.activeSession ?? makeSession();
return {
sessions: [session],
activeSession: session,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
renameSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [session],
refreshSessions: vi.fn(),
agentsMap: new Map(),
...overrides,
};
}
describe("ChatView message edit affordance", () => {
beforeEach(() => {
localStorage.clear();
mockUseChatRooms.mockReturnValue(baseRoomsState());
});
it("renders the edit affordance only on user messages in a direct chat session", async () => {
mockUseChat.mockReturnValue(baseChatState({
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "assistant-1", sessionId: "session-001", role: "assistant", content: "hi there", createdAt: "2026-04-08T00:00:01.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
expect(screen.getByTestId("chat-message-edit-user-1")).toHaveAttribute("aria-label", "Edit message");
expect(screen.queryByTestId("chat-message-edit-assistant-1")).toBeNull();
});
it("is absent for CLI-agent-backed sessions", async () => {
mockUseChat.mockReturnValue(baseChatState({
activeSession: makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" }),
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-message-edit-user-1")).toBeNull();
});
it("is absent for Rooms messages", async () => {
mockUseChat.mockReturnValue(baseChatState({ messages: [] }));
mockUseChatRooms.mockReturnValue(baseRoomsState({
activeRoom: roomA,
messages: [
{ id: "room-user-1", roomId: roomA.id, role: "user", content: "hey room", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
fireEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.queryByTestId("chat-message-edit-room-user-1")).toBeNull();
});
it("is absent while a generation is streaming", async () => {
mockUseChat.mockReturnValue(baseChatState({
isStreaming: true,
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-message-edit-user-1")).toBeNull();
});
it("swaps to an inline editor and saves via editMessageAndResend", async () => {
const editMessageAndResend = vi.fn();
mockUseChat.mockReturnValue(baseChatState({
editMessageAndResend,
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-message-edit-user-1"));
const editor = screen.getByTestId("chat-message-edit-editor-user-1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea.value).toBe("hello");
fireEvent.change(textarea, { target: { value: "hello, edited" } });
fireEvent.click(screen.getByText("Save"));
expect(editMessageAndResend).toHaveBeenCalledWith("user-1", "hello, edited");
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
});
it("cancel restores the original content without calling editMessageAndResend", async () => {
const editMessageAndResend = vi.fn();
mockUseChat.mockReturnValue(baseChatState({
editMessageAndResend,
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-message-edit-user-1"));
const editor = screen.getByTestId("chat-message-edit-editor-user-1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "changed but cancelled" } });
fireEvent.click(screen.getByText("Cancel"));
expect(editMessageAndResend).not.toHaveBeenCalled();
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
expect(screen.getByTestId("chat-message-user-1")).toHaveTextContent("hello");
});
it("does not leave an empty edit-action shell for assistant messages", async () => {
mockUseChat.mockReturnValue(baseChatState({
messages: [
{ id: "assistant-1", sessionId: "session-001", role: "assistant", content: "hi there", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
const assistantMessage = screen.getByTestId("chat-message-assistant-1");
expect(assistantMessage.querySelector("[aria-label='Edit message']")).toBeNull();
});
});

View File

@@ -66,6 +66,7 @@ const defaultChatState: UseChatReturn = {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -75,6 +75,7 @@ const defaultChatState: UseChatReturn = {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -71,6 +71,7 @@ const defaultChatState: UseChatReturn = {
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -66,6 +66,7 @@ export const defaultChatState: UseChatReturn = {
renameSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),

View File

@@ -19,6 +19,7 @@ vi.mock("../../api", () => ({
fetchChatMessages: vi.fn(),
updateChatSession: vi.fn(),
deleteChatSession: vi.fn(),
editChatMessage: vi.fn(),
streamChatResponse: vi.fn(),
attachChatStream: vi.fn(),
cancelChatResponse: vi.fn(),
@@ -54,6 +55,7 @@ const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
const mockDeleteChatSession = vi.mocked(apiModule.deleteChatSession);
const mockEditChatMessage = vi.mocked(apiModule.editChatMessage);
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
@@ -404,6 +406,102 @@ describe("useChat", () => {
expect(sendResult).toBeUndefined();
});
describe("editMessageAndResend", () => {
const mockAddToast = vi.fn();
// fetchChatMessages returns newest-first (order=desc); useChat reverses it to display
// oldest-first. `messages` here is given in display (oldest-first) order for readability,
// so we reverse it before handing it to the mock to match the real API contract.
async function setupWithMessages(messages: ChatMessage[]) {
const session = makeSession({ id: "session-001", agentId: "agent-001" });
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatMessages.mockResolvedValueOnce({ messages: messages.slice().reverse() });
const { result } = renderHook(() => useChat("proj-123", mockAddToast));
await waitFor(() => {
expect(result.current.sessions).toHaveLength(1);
});
act(() => {
result.current.selectSession("session-001");
});
await waitFor(() => {
expect(result.current.messages).toHaveLength(messages.length);
});
return result;
}
it("optimistically truncates from the edited message and resends via sendMessage", async () => {
const m1 = makeMessage({ id: "msg-1", sessionId: "session-001", role: "user", content: "one" });
const m2 = makeMessage({ id: "msg-2", sessionId: "session-001", role: "assistant", content: "two" });
const m3 = makeMessage({ id: "msg-3", sessionId: "session-001", role: "user", content: "three" });
const result = await setupWithMessages([m1, m2, m3]);
mockEditChatMessage.mockResolvedValueOnce({ retained: [m1, m2] });
const closeFn = vi.fn();
mockStreamChatResponse.mockImplementation(() => ({ close: closeFn, isConnected: () => true }));
await act(async () => {
await result.current.editMessageAndResend("msg-3", "three (edited)");
});
expect(mockEditChatMessage).toHaveBeenCalledWith("session-001", "msg-3", "three (edited)", "proj-123");
await waitFor(() => {
// Optimistic truncation drops msg-3, then sendMessage appends a fresh optimistic user bubble
// with the edited content — so retained [m1, m2] plus the new turn is 3 messages.
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0]?.id).toBe("msg-1");
expect(result.current.messages[1]?.id).toBe("msg-2");
expect(result.current.messages[2]?.role).toBe("user");
expect(result.current.messages[2]?.content).toBe("three (edited)");
});
expect(mockStreamChatResponse).toHaveBeenCalledWith("session-001", "three (edited)", expect.anything(), undefined, "proj-123");
});
it("is a no-op while streaming", async () => {
const m1 = makeMessage({ id: "msg-1", sessionId: "session-001", role: "user", content: "one" });
const result = await setupWithMessages([m1]);
mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => true }));
act(() => {
result.current.sendMessage("in flight");
});
await waitFor(() => {
expect(result.current.isStreaming).toBe(true);
});
mockEditChatMessage.mockClear();
await act(async () => {
await result.current.editMessageAndResend("msg-1", "edited");
});
expect(mockEditChatMessage).not.toHaveBeenCalled();
});
it("reloads messages and does not resend on PATCH failure", async () => {
const m1 = makeMessage({ id: "msg-1", sessionId: "session-001", role: "user", content: "one" });
const m2 = makeMessage({ id: "msg-2", sessionId: "session-001", role: "assistant", content: "two" });
const result = await setupWithMessages([m1, m2]);
mockEditChatMessage.mockRejectedValueOnce(new Error("boom"));
// fetchChatMessages returns newest-first; the reload path reverses it back to [m1, m2].
mockFetchChatMessages.mockResolvedValueOnce({ messages: [m2, m1] });
mockStreamChatResponse.mockClear();
await act(async () => {
await result.current.editMessageAndResend("msg-1", "edited");
});
await waitFor(() => {
expect(result.current.messages.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
});
expect(mockStreamChatResponse).not.toHaveBeenCalled();
});
});
it("populates agentsMap on mount", async () => {
const { result } = renderHook(() => useChat("proj-123"));

View File

@@ -6,6 +6,7 @@ import {
fetchChatMessages,
updateChatSession,
deleteChatSession,
editChatMessage,
attachChatStream,
streamChatResponse,
cancelChatResponse,
@@ -93,6 +94,14 @@ export interface UseChatReturn {
// Message operations
/** Send a message, optionally with file attachments to upload with the prompt. */
sendMessage: (content: string, attachments?: File[]) => void;
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Edit an earlier user message: truncates local + persisted history from that message onward
* (server also rewinds the pi session context so the model forgets discarded turns), then
* resends the edited content through the normal `sendMessage` streaming path. No-ops while a
* generation is streaming or when there is no active session.
*/
editMessageAndResend: (messageId: string, newContent: string) => Promise<void>;
stopStreaming: () => void;
clearPendingMessage: (index?: number) => void;
loadMoreMessages: () => Promise<void>;
@@ -1222,6 +1231,56 @@ export function useChat(
sendMessageRef.current = sendMessage;
/*
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Editing an earlier message must resume the conversation from that point, forgetting
* everything after it, so future responses are not biased by discarded turns. The optimistic
* local truncation happens first (immediate UI feedback), then the server truncates its
* persisted rows AND rewinds the pi session context (ChatManager.rewindSessionForEdit) before
* we resend the edited content through the normal streaming sendMessage path. Blocked while
* streaming so an edit cannot race a live generation.
*/
const editMessageAndResend = useCallback(
async (messageId: string, newContent: string) => {
if (isStreamingRef.current || !activeSession) {
return;
}
const trimmed = newContent.trim();
if (!trimmed) {
return;
}
const sessionId = activeSession.id;
const previousMessages = messagesRef.current;
const targetIndex = previousMessages.findIndex((m) => m.id === messageId);
if (targetIndex === -1) {
return;
}
// Optimistic truncation: drop the edited message and everything after it immediately.
setMessages(previousMessages.slice(0, targetIndex));
try {
await editChatMessage(sessionId, messageId, trimmed, projectId);
} catch (error) {
console.error("[useChat] Failed to edit message:", error);
addToast?.("Failed to edit message", "error");
// Restore truthful state from the server rather than trusting the optimistic truncation.
await loadMessages(sessionId);
return;
}
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
if (cacheKey) {
clearCache(cacheKey);
}
sendMessage(trimmed);
},
[activeSession, projectId, addToast, loadMessages, getChatMessagesCacheKey, sendMessage],
);
// Filter sessions based on search query
const filteredSessions = searchQuery
? sessions.filter(
@@ -1486,6 +1545,7 @@ export function useChat(
renameSession,
deleteSession,
sendMessage,
editMessageAndResend,
stopStreaming,
clearPendingMessage,
loadMoreMessages,

View File

@@ -0,0 +1,185 @@
/*
FNXC:ChatMessageEdit 2026-07-07-09:00:
"Forget everything after" seam test for FN-7628. Deleting `chat_messages` rows alone does NOT
make the model forget a discarded turn — the pi SessionManager file is a separate append-only
transcript that the model-loop path resumes from (see chat.ts `resolveCliSessionManager`). This
test proves the rewind at the seam that actually matters: after
`ChatManager.rewindSessionForEdit`, re-opening the SAME on-disk pi session file and calling
`buildSessionContext()` no longer includes the discarded turn's content, while the retained
turn's content survives. It deliberately does NOT mock `@earendil-works/pi-coding-agent` — a
real, temp-directory-backed `SessionManager` is used so the assertion exercises the actual
`branch()`/`resetLeaf()` behavior described in `session-manager.d.ts`, not a stub.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rm } from "node:fs/promises";
import { ChatManager } from "../chat.js";
import { ChatStore, Database } from "@fusion/core";
import { SessionManager } from "@earendil-works/pi-coding-agent";
function makeAssistantMessage(text: string) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: "chat",
provider: "anthropic",
model: "claude-sonnet-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
}
function extractText(context: ReturnType<SessionManager["buildSessionContext"]>): string[] {
return context.messages.flatMap((message: any) => {
if (typeof message.content === "string") return [message.content];
if (Array.isArray(message.content)) {
return message.content
.filter((part: any) => part?.type === "text")
.map((part: any) => part.text as string);
}
return [];
});
}
describe("ChatManager.rewindSessionForEdit — pi session context seam (real SessionManager)", () => {
let tmpDir: string;
let db: Database;
let chatStore: ChatStore;
let chatManager: ChatManager;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), "fn-chat-rewind-test-"));
const fusionDir = join(tmpDir, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
chatStore = new ChatStore(fusionDir, db);
chatManager = new ChatManager(chatStore, tmpDir);
});
afterAll(async () => {
try {
db.close();
} catch {
// already closed
}
await rm(tmpDir, { recursive: true, force: true });
});
it("primary path (recorded parent-leaf id): branch() forgets the edited turn and everything after it", async () => {
const session = chatStore.createSession({ agentId: "agent-001" });
// Seed a real, file-backed pi session with two user/assistant turn pairs, mirroring what
// ChatManager.sendMessage would have produced across two prior chat turns.
const seedManager = SessionManager.create(tmpDir);
const sessionFile = seedManager.getSessionFile();
expect(sessionFile).toBeTruthy();
chatStore.setCliSessionFile(session.id, sessionFile!);
seedManager.appendMessage({ role: "user", content: "first turn", timestamp: Date.now() });
seedManager.appendMessage(makeAssistantMessage("first reply"));
const leafAfterTurn1 = seedManager.getLeafId();
seedManager.appendMessage({ role: "user", content: "second turn", timestamp: Date.now() });
seedManager.appendMessage(makeAssistantMessage("second reply"));
// Persist the corresponding chat_messages rows, recording the second user turn's pi
// parent-leaf id the way ChatManager.sendMessage does before calling prompt().
const m1 = chatStore.addMessage(session.id, { role: "user", content: "first turn" });
chatStore.addMessage(session.id, { role: "assistant", content: "first reply" });
const m3 = chatStore.addMessage(session.id, { role: "user", content: "second turn" });
chatStore.updateMessageMetadata(m3.id, { piParentLeafId: leafAfterTurn1 });
chatStore.addMessage(session.id, { role: "assistant", content: "second reply" });
// Sanity: before the edit, the full pi context includes both turns.
const beforeTexts = extractText(seedManager.buildSessionContext());
expect(beforeTexts).toContain("first turn");
expect(beforeTexts).toContain("second turn");
expect(beforeTexts).toContain("second reply");
const { retained } = await chatManager.rewindSessionForEdit(session.id, m3.id);
expect(retained.map((m) => m.id)).toEqual([m1.id, expect.any(String)]);
expect(retained.map((m) => m.content)).toEqual(["first turn", "first reply"]);
// The DB truncation alone is not the seam that matters — assert the persisted rows too,
// but the load-bearing assertion is the pi session context below.
expect(chatStore.getMessages(session.id).map((m) => m.content)).toEqual(["first turn", "first reply"]);
// The rewind materializes a NEW session file (createBranchedSession) and repoints the
// chat row at it — branch()/resetLeaf() alone only mutate an in-memory leaf pointer and do
// not survive a fresh SessionManager.open() on the next turn, so re-fetch the file the next
// real send would actually resume from and prove the discarded turn is unreachable there.
const rewoundSession = chatStore.getSession(session.id)!;
expect(rewoundSession.cliSessionFile).not.toBe(sessionFile);
const reopened = SessionManager.open(rewoundSession.cliSessionFile!);
const afterTexts = extractText(reopened.buildSessionContext());
expect(afterTexts).toContain("first turn");
expect(afterTexts).toContain("first reply");
expect(afterTexts).not.toContain("second turn");
expect(afterTexts).not.toContain("second reply");
// The OLD file is never mutated (append-only tree semantics) — the discarded turn is still
// physically present there, which is why repointing cliSessionFile (not just moving an
// in-memory leaf) is the part of this fix that actually matters.
const oldFileStillHasDiscardedTurn = extractText(SessionManager.open(sessionFile!).buildSessionContext());
expect(oldFileStillHasDiscardedTurn).toContain("second turn");
});
it("primary path, first-turn edit (no recorded parent leaf): resetLeaf() forgets everything", async () => {
const session = chatStore.createSession({ agentId: "agent-001" });
const seedManager = SessionManager.create(tmpDir);
const sessionFile = seedManager.getSessionFile();
chatStore.setCliSessionFile(session.id, sessionFile!);
// First turn: parent leaf is null (nothing before it).
seedManager.appendMessage({ role: "user", content: "only turn", timestamp: Date.now() });
seedManager.appendMessage(makeAssistantMessage("only reply"));
const m1 = chatStore.addMessage(session.id, { role: "user", content: "only turn" });
chatStore.updateMessageMetadata(m1.id, { piParentLeafId: null });
chatStore.addMessage(session.id, { role: "assistant", content: "only reply" });
const { retained } = await chatManager.rewindSessionForEdit(session.id, m1.id);
expect(retained).toEqual([]);
const rewoundSession = chatStore.getSession(session.id)!;
expect(rewoundSession.cliSessionFile).not.toBe(sessionFile);
const reopened = SessionManager.open(rewoundSession.cliSessionFile!);
const afterTexts = extractText(reopened.buildSessionContext());
expect(afterTexts).not.toContain("only turn");
expect(afterTexts).not.toContain("only reply");
});
it("rejects editing a non-user message", async () => {
const session = chatStore.createSession({ agentId: "agent-001" });
const seedManager = SessionManager.create(tmpDir);
chatStore.setCliSessionFile(session.id, seedManager.getSessionFile()!);
const assistantMsg = chatStore.addMessage(session.id, { role: "assistant", content: "hi" });
await expect(chatManager.rewindSessionForEdit(session.id, assistantMsg.id)).rejects.toThrow(/user message/);
});
it("rejects an edit while a generation is in flight for the session", async () => {
const session = chatStore.createSession({ agentId: "agent-001" });
const seedManager = SessionManager.create(tmpDir);
chatStore.setCliSessionFile(session.id, seedManager.getSessionFile()!);
const userMsg = chatStore.addMessage(session.id, { role: "user", content: "hi" });
chatManager.beginGeneration(session.id);
try {
await expect(chatManager.rewindSessionForEdit(session.id, userMsg.id)).rejects.toThrow(/generation is currently in progress/);
} finally {
chatManager.cancelGeneration(session.id);
}
});
});

View File

@@ -51,9 +51,18 @@ vi.mock("../sse.js", () => ({
// SessionManager is constructed per-chat for CLI session continuity. We don't
// want tests touching the real ~/.pi sessions directory, so stub the static
// methods. The test `cliSessionFile-threading` asserts call shapes.
// FNXC:ChatMessageEdit 2026-07-07-09:00: fakeManager also stubs the pi SessionManager rewind
// surface (getLeafId/branch/resetLeaf/appendMessage) that ChatManager.sendMessage and
// rewindSessionForEdit now call, so the shared fake stays in sync with the real API shape used
// for the edit-and-resend flow.
const { mockSessionManagerCreate, mockSessionManagerOpen } = vi.hoisted(() => {
const fakeManager = {
getSessionFile: () => "/tmp/test/.pi-fake/session-abc.jsonl",
getLeafId: () => "leaf-fake",
branch: () => {},
resetLeaf: () => {},
appendMessage: () => "entry-fake",
createBranchedSession: () => "/tmp/test/.pi-fake/session-branched.jsonl",
};
return {
mockSessionManagerCreate: vi.fn(() => fakeManager),
@@ -74,12 +83,15 @@ const mockChatStore = {
getSession: vi.fn(),
createSession: vi.fn(),
addMessage: vi.fn(),
getMessage: vi.fn(),
getMessages: vi.fn(),
updateSession: vi.fn(),
setCliSessionFile: vi.fn(),
setInFlightGeneration: vi.fn(),
getRoomMessages: vi.fn(),
recordTokenUsage: vi.fn(),
deleteMessagesFrom: vi.fn(),
updateMessageMetadata: vi.fn(),
};
const mockAgentStore = {

View File

@@ -58,6 +58,7 @@ const {
mockBeginGeneration,
mockIsGenerating,
mockGetActiveGenerationId,
mockRewindSessionForEdit,
} = vi.hoisted(() => {
// Store subscribers per session for broadcast simulation
const subscribers = new Map<string, Set<{ callback: (event: any, eventId?: number) => void; generationId?: number }>>();
@@ -124,6 +125,7 @@ const {
mockBeginGeneration: vi.fn(() => ({ generationId: 1, abortController: new AbortController() })),
mockIsGenerating: vi.fn(() => false),
mockGetActiveGenerationId: vi.fn(() => undefined),
mockRewindSessionForEdit: vi.fn(),
mockChatStreamManager: chatStreamManager,
};
});
@@ -186,6 +188,7 @@ vi.mock("../chat.js", () => {
beginGeneration = mockBeginGeneration;
isGenerating = mockIsGenerating;
getActiveGenerationId = mockGetActiveGenerationId;
rewindSessionForEdit = mockRewindSessionForEdit;
},
chatStreamManager: mockChatStreamManager,
TASK_PLANNER_CHAT_AGENT_ID_PREFIX: "task-planner:",
@@ -318,6 +321,7 @@ function createMockChatManager() {
beginGeneration: mockBeginGeneration,
isGenerating: mockIsGenerating,
getActiveGenerationId: mockGetActiveGenerationId,
rewindSessionForEdit: mockRewindSessionForEdit,
};
}
@@ -390,6 +394,7 @@ describe("Chat API Routes", () => {
mockCancelGeneration.mockReset();
mockIsGenerating.mockReset();
mockGetActiveGenerationId.mockReset();
mockRewindSessionForEdit.mockReset();
mockAgentStoreInit.mockResolvedValue(undefined);
mockAgentStoreGetAgent.mockReset();
mockGetOrCreateProjectStore.mockReset();
@@ -402,6 +407,7 @@ describe("Chat API Routes", () => {
mockCancelGeneration.mockReturnValue(false);
mockIsGenerating.mockReturnValue(false);
mockGetActiveGenerationId.mockReturnValue(undefined);
mockRewindSessionForEdit.mockResolvedValue({ retained: [] });
// Default agent mock - agent with model config
mockAgentStoreGetAgent.mockResolvedValue({
@@ -1426,6 +1432,124 @@ describe("Chat API Routes", () => {
});
});
describe("PATCH /api/chat/sessions/:id/messages/:messageId", () => {
it("truncates from the target message and returns retained history", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessage.mockReturnValue(sampleMessage);
mockIsGenerating.mockReturnValue(false);
const retained = [{ ...sampleMessage, id: "msg-earlier", content: "earlier turn" }];
mockRewindSessionForEdit.mockResolvedValue({ retained });
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
JSON.stringify({ content: "edited content" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).retained).toEqual(retained);
expect(mockRewindSessionForEdit).toHaveBeenCalledWith("chat-abc123", "msg-xyz789");
});
it("allows editing the first message in a thread", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessage.mockReturnValue(sampleMessage);
mockIsGenerating.mockReturnValue(false);
mockRewindSessionForEdit.mockResolvedValue({ retained: [] });
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
JSON.stringify({ content: "edited first message" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(200);
expect((response.body as any).retained).toEqual([]);
});
it("returns 400 when the target message is not a user message", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessage.mockReturnValue({ ...sampleMessage, role: "assistant" as const });
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
JSON.stringify({ content: "edited content" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(mockRewindSessionForEdit).not.toHaveBeenCalled();
});
it("returns 400 for empty content", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessage.mockReturnValue(sampleMessage);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
JSON.stringify({ content: " " }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(mockRewindSessionForEdit).not.toHaveBeenCalled();
});
it("returns 404 when session not found", async () => {
mockGetSession.mockReturnValue(undefined);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/nonexistent/messages/msg-xyz789",
JSON.stringify({ content: "edited content" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
});
it("returns 404 when message not found", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessage.mockReturnValue(undefined);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123/messages/nonexistent",
JSON.stringify({ content: "edited content" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(404);
});
it("rejects the edit while a generation is in flight for the session", async () => {
mockGetSession.mockReturnValue(sampleSession);
mockGetMessage.mockReturnValue(sampleMessage);
mockIsGenerating.mockReturnValue(true);
const response = await request(
app,
"PATCH",
"/api/chat/sessions/chat-abc123/messages/msg-xyz789",
JSON.stringify({ content: "edited content" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(400);
expect(mockRewindSessionForEdit).not.toHaveBeenCalled();
});
});
// ── SSE Streaming Tests ────────────────────────────────────────────────────
describe("POST /api/chat/sessions/:id/messages (SSE)", () => {

View File

@@ -18,6 +18,7 @@ import type {
ChatMention,
ChatAttachment,
ChatInFlightGenerationState,
ChatMessage,
ChatStore,
ChatRoomMessage,
ChatSession,
@@ -1984,13 +1985,15 @@ export class ChatManager {
const mentions = hasMentionCandidates ? await this.parseMentions(content, mentionAgents) : [];
// Persist user message
let persistedUserMessageId: string | undefined;
try {
this.chatStore.addMessage(sessionId, {
const persistedUserMessage = this.chatStore.addMessage(sessionId, {
role: "user",
content,
metadata: mentions.length > 0 ? { mentions } : undefined,
attachments,
});
persistedUserMessageId = persistedUserMessage.id;
} catch (err) {
this.flushInFlightGenerationPersist(sessionId, null);
chatStreamManager.broadcast(sessionId, {
@@ -2150,6 +2153,27 @@ export class ChatManager {
// first user message we create a fresh, file-backed session and persist
// its path; subsequent messages reopen the same file.
const sessionManager = this.resolveCliSessionManager(session);
/*
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Capture the pi SessionManager leaf BEFORE prompt() appends the user turn (and the
* assistant reply) as children of it. Persisting this parent-leaf id onto the just-saved
* user message is what lets a later edit rewind losslessly via SessionManager.branch()/
* resetLeaf() (null when this is the first turn) instead of falling back to a lossy
* text-only session rebuild. Best-effort: a persistence failure must not block sending.
*/
const parentLeafId = sessionManager.getLeafId();
if (persistedUserMessageId) {
try {
this.chatStore.updateMessageMetadata(persistedUserMessageId, { piParentLeafId: parentLeafId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
diagnostics.warn(
`Failed to record pi parent-leaf id for chat ${sessionId} message ${persistedUserMessageId}: ${message}`,
);
}
}
const chatModelSettings = await this.getChatModelSettings();
const usesConfiguredDefaultModel =
requestedModelProvider === chatModelSettings.defaultProvider
@@ -2556,6 +2580,127 @@ export class ChatManager {
getGeneratingSessionIds(): string[] {
return [...this.activeGenerations.keys()];
}
/**
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Rewind a direct (model-loop) chat session so an edit to an earlier user message resumes
* the conversation from that point with everything after it — the edited turn included —
* forgotten. This is a two-part invariant: (1) the persisted `chat_messages` rows are
* truncated via `ChatStore.deleteMessagesFrom`, and (2) the pi SessionManager leaf is rewound
* so `buildSessionContext()` no longer includes the discarded turns, otherwise the model would
* still "remember" content that the UI claims was forgotten. Regeneration is NOT triggered
* here — callers resend the edited content through the existing streaming `sendMessage` path.
*/
async rewindSessionForEdit(sessionId: string, fromMessageId: string): Promise<{ retained: ChatMessage[] }> {
const session = this.chatStore.getSession(sessionId);
if (!session) {
throw new Error(`Chat session ${sessionId} not found`);
}
const target = this.chatStore.getMessage(fromMessageId);
if (!target || target.sessionId !== sessionId) {
throw new Error(`Message ${fromMessageId} not found in session ${sessionId}`);
}
if (target.role !== "user") {
throw new Error(`Message ${fromMessageId} is not a user message and cannot be edited`);
}
// Guard against racing a live stream: rewinding mid-generation would pull the pi session
// leaf out from under the in-flight prompt() call.
if (this.activeGenerations.has(sessionId)) {
throw new Error(`Cannot edit message ${fromMessageId}: a generation is currently in progress for session ${sessionId}`);
}
const parentLeafId = (target.metadata as { piParentLeafId?: string | null } | null)?.piParentLeafId;
const hasRecordedParentLeaf = target.metadata != null && Object.prototype.hasOwnProperty.call(target.metadata, "piParentLeafId");
const { retained } = this.chatStore.deleteMessagesFrom(sessionId, fromMessageId);
if (hasRecordedParentLeaf) {
/*
* Primary path. `SessionManager.branch()`/`resetLeaf()` only mutate the calling
* instance's IN-MEMORY leaf pointer — nothing is written to disk, and a fresh
* `SessionManager.open()` on the next turn recomputes the leaf from the file itself, which
* would silently undo the rewind. Persisting the truncation therefore requires materializing
* a NEW session file: `createBranchedSession(leafId)` writes a file containing only the
* root→leafId path, which we then adopt as the chat's `cliSessionFile`. The abandoned turns
* remain physically present in the OLD file (never mutated) but are no longer reachable from
* the new file, so `buildSessionContext()` on the next open cannot include them.
*/
try {
const sessionManager = this.resolveCliSessionManager(session);
if (parentLeafId) {
const branchedFile = sessionManager.createBranchedSession(parentLeafId);
if (!branchedFile) {
throw new Error("createBranchedSession returned no file (non-persisting session)");
}
this.chatStore.setCliSessionFile(sessionId, branchedFile);
} else {
// First-turn edit: nothing precedes the edited message, so there is no path to
// branch from. A brand-new empty session is the correct "forget everything" state.
const fresh = SessionManager.create(this.rootDir);
this.chatStore.setCliSessionFile(sessionId, fresh.getSessionFile() ?? null);
}
return { retained };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
diagnostics.warn(
`Failed to branch pi session for chat ${sessionId} at leaf ${String(parentLeafId)} (${message}); rebuilding session from retained history`,
);
}
}
// Fallback path: legacy sessions with no recorded parent-leaf id (created before this
// feature shipped), or a failed branch/resetLeaf above. Best-effort: rebuild a fresh
// session containing only the retained (pre-edit) turns as text-only messages — tool-call
// and thinking fidelity is a documented limitation of this path. On ANY failure, fall back
// further to a clean, empty session rather than risk leaving the model able to recall a
// turn the UI says was discarded.
try {
const rebuilt = SessionManager.create(this.rootDir);
for (const message of retained) {
if (message.role === "user") {
rebuilt.appendMessage({
role: "user",
content: message.content,
timestamp: Date.parse(message.createdAt) || Date.now(),
});
} else if (message.role === "assistant") {
rebuilt.appendMessage({
role: "assistant",
content: [{ type: "text", text: message.content }],
api: "chat",
provider: session.modelProvider ?? "unknown",
model: session.modelId ?? "unknown",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.parse(message.createdAt) || Date.now(),
});
}
}
const rebuiltFile = rebuilt.getSessionFile();
this.chatStore.setCliSessionFile(sessionId, rebuiltFile ?? null);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
diagnostics.warn(
`Failed to rebuild pi session for chat ${sessionId} from retained history (${message}); clearing CLI session file so no discarded turn can be recalled`,
);
try {
this.chatStore.setCliSessionFile(sessionId, null);
} catch {
// best-effort; nothing further we can do here
}
}
return { retained };
}
}
// ── Test Helpers ────────────────────────────────────────────────────────────

View File

@@ -895,6 +895,57 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
}
});
/**
* PATCH /api/chat/sessions/:id/messages/:messageId
*
* FNXC:ChatMessageEdit 2026-07-07-09:00:
* Edit a user's earlier message: truncates the persisted transcript from (and including)
* the target message onward AND rewinds the pi session context so the model forgets the
* discarded turns (see ChatManager.rewindSessionForEdit). Does NOT stream a regeneration —
* the client resends the edited content through the existing streaming POST send after this
* call returns the retained (pre-edit) history.
*/
router.patch("/chat/sessions/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try {
const projectId = req.query.projectId as string | undefined;
const { chatStore } = await resolveScopedChatStore(projectId);
const chatManager = await resolveScopedChatManager(projectId);
const sessionId = String(req.params.id);
const messageId = String(req.params.messageId);
const content = (req.body as { content?: unknown } | undefined)?.content;
if (typeof content !== "string" || content.trim().length === 0) {
throw badRequest("content must be a non-empty string");
}
const session = chatStore.getSession(sessionId);
if (!session) {
throw notFound(`Chat session ${sessionId} not found`);
}
const message = chatStore.getMessage(messageId);
if (!message || message.sessionId !== sessionId) {
throw notFound(`Message ${messageId} not found`);
}
if (message.role !== "user") {
throw badRequest("Only user messages can be edited");
}
if (chatManager.isGenerating(sessionId)) {
throw badRequest("Cannot edit a message while a generation is in progress");
}
const { retained } = await chatManager.rewindSessionForEdit(sessionId, messageId);
res.json({ retained });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to edit chat message");
}
});
if (process.env.FUSION_DEBUG_CHAT_ROUTES === "1") {
const chatRoutes = [
"GET /chat/sessions",
@@ -910,6 +961,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
"POST /chat/sessions/:id/messages",
"POST /chat/sessions/:id/cancel",
"DELETE /chat/sessions/:id/messages/:messageId",
"PATCH /chat/sessions/:id/messages/:messageId",
];
chatLogger.info("routes registered", { chatRoutes });
}