feat: improved agent streaming

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-29 07:20:31 -07:00
parent afb4b09cde
commit c110f5b955
60 changed files with 8068 additions and 303 deletions

View file

@ -14,6 +14,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
import {
agentActionsByChatTurnIdAtom,
markAgentActionRevertedAtom,
resetAgentActionMapAtom,
updateAgentActionReversibleAtom,
upsertAgentActionAtom,
} from "@/atoms/chat/agent-actions.atom";
import {
clearTargetCommentIdAtom,
currentThreadAtom,
@ -36,6 +43,11 @@ import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
EditMessageDialog,
type EditMessageDialogChoice,
} from "@/components/assistant-ui/edit-message-dialog";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Thread } from "@/components/assistant-ui/thread";
import {
@ -55,14 +67,19 @@ import {
setActivePodcastTaskId,
} from "@/lib/chat/podcast-state";
import {
addStepSeparator,
addToolCall,
appendReasoning,
appendText,
buildContentForPersistence,
buildContentForUI,
type ContentPartsState,
endReasoning,
FrameBatchedUpdater,
findToolCallIdByLcId,
readSSEStream,
type ThinkingStepData,
type ToolUIGate,
updateThinkingSteps,
updateToolCall,
} from "@/lib/chat/streaming-state";
@ -161,44 +178,38 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
}
/**
* Tools that should render custom UI in the chat.
* Every tool call renders a card. The legacy
* ``BASE_TOOLS_WITH_UI`` allowlist used to drop unknown tool calls on the
* floor; we now route everything through ``ToolFallback``. Persisted
* payload size stays bounded because the backend's
* ``format_thinking_step`` summarisation and the
* ``result_length``-only default for unknown tools (see
* ``stream_new_chat.py``) keep the JSON from ballooning.
*/
const BASE_TOOLS_WITH_UI = new Set([
"web_search",
"generate_podcast",
"generate_report",
"generate_resume",
"generate_video_presentation",
"display_image",
"generate_image",
"delete_notion_page",
"create_notion_page",
"update_notion_page",
"create_linear_issue",
"update_linear_issue",
"delete_linear_issue",
"create_google_drive_file",
"delete_google_drive_file",
"create_onedrive_file",
"delete_onedrive_file",
"create_dropbox_file",
"delete_dropbox_file",
"create_calendar_event",
"update_calendar_event",
"delete_calendar_event",
"create_gmail_draft",
"update_gmail_draft",
"send_gmail_email",
"trash_gmail_email",
"create_jira_issue",
"update_jira_issue",
"delete_jira_issue",
"create_confluence_page",
"update_confluence_page",
"delete_confluence_page",
"execute",
// "write_todos", // Disabled for now
]);
const TOOLS_WITH_UI_ALL: ToolUIGate = "all";
/**
* When a streamed message is persisted, the backend returns the durable
* ``turn_id`` (``configurable.turn_id`` from the agent run). Merge it
* into the assistant-ui message metadata so the per-turn "Revert turn"
* button can scope to this turn's actions even after a full chat reload.
*/
function mergeChatTurnIdIntoMessage(
msg: ThreadMessageLike,
turnId: string | null | undefined
): ThreadMessageLike {
if (!turnId) return msg;
const existingMeta = (msg.metadata ?? {}) as { custom?: Record<string, unknown> };
const existingCustom = existingMeta.custom ?? {};
if ((existingCustom as { chatTurnId?: string }).chatTurnId === turnId) return msg;
return {
...msg,
metadata: {
...existingMeta,
custom: { ...existingCustom, chatTurnId: turnId },
},
};
}
export default function NewChatPage() {
const params = useParams();
@ -215,7 +226,7 @@ export default function NewChatPage() {
assistantMsgId: string;
interruptData: Record<string, unknown>;
} | null>(null);
const toolsWithUI = useMemo(() => new Set([...BASE_TOOLS_WITH_UI]), []);
const toolsWithUI = TOOLS_WITH_UI_ALL;
// Get disabled tools from the tool toggle UI
const disabledTools = useAtomValue(disabledToolsAtom);
@ -235,6 +246,25 @@ export default function NewChatPage() {
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
const setPendingUserImageUrls = useSetAtom(pendingUserImageDataUrlsAtom);
// Agent action log SSE side-channel.
const upsertAgentAction = useSetAtom(upsertAgentActionAtom);
const updateAgentActionReversible = useSetAtom(updateAgentActionReversibleAtom);
const markAgentActionReverted = useSetAtom(markAgentActionRevertedAtom);
const resetAgentActionMap = useSetAtom(resetAgentActionMapAtom);
// Chat-turn-keyed action map for the edit-from-position pre-flight
// that decides whether to show the confirmation dialog.
const agentActionsByChatTurnId = useAtomValue(agentActionsByChatTurnIdAtom);
// Edit dialog state. Holds the message id being edited and
// the (already extracted) regenerate args so we can resume the edit
// after the user picks "revert all" / "continue" / "cancel".
const [editDialogState, setEditDialogState] = useState<{
fromMessageId: number;
userQuery: string | null;
userMessageContent: ThreadMessageLike["content"];
userImages: NewChatUserImagePayload[];
downstreamReversibleCount: number;
downstreamTotalCount: number;
} | null>(null);
// Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom);
@ -327,6 +357,7 @@ export default function NewChatPage() {
clearPlanOwnerRegistry();
closeReportPanel();
closeEditorPanel();
resetAgentActionMap();
try {
if (urlChatId > 0) {
@ -395,6 +426,7 @@ export default function NewChatPage() {
removeChatTab,
searchSpaceId,
tokenUsageStore,
resetAgentActionMap,
]);
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
@ -655,11 +687,14 @@ export default function NewChatPage() {
const contentPartsState: ContentPartsState = {
contentParts: [],
currentTextPartIndex: -1,
currentReasoningPartIndex: -1,
toolCallIndices: new Map(),
};
const { contentParts, toolCallIndices } = contentPartsState;
let wasInterrupted = false;
let tokenUsageData: Record<string, unknown> | null = null;
// Captured from ``data-turn-info`` at stream start.
let streamedChatTurnId: string | null = null;
// Add placeholder assistant message
setMessages((prev) => [
@ -752,21 +787,52 @@ export default function NewChatPage() {
scheduleFlush();
break;
case "reasoning-delta":
appendReasoning(contentPartsState, parsed.delta);
scheduleFlush();
break;
case "reasoning-end":
endReasoning(contentPartsState);
scheduleFlush();
break;
case "start-step":
addStepSeparator(contentPartsState);
scheduleFlush();
break;
case "finish-step":
break;
case "tool-input-start":
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
addToolCall(
contentPartsState,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
{},
false,
parsed.langchainToolCallId
);
batcher.flush();
break;
case "tool-input-available": {
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {},
langchainToolCallId: parsed.langchainToolCallId,
});
} else {
addToolCall(
contentPartsState,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
parsed.input || {},
false,
parsed.langchainToolCallId
);
}
batcher.flush();
@ -774,7 +840,10 @@ export default function NewChatPage() {
}
case "tool-output-available": {
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
});
markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId);
@ -880,6 +949,50 @@ export default function NewChatPage() {
break;
}
case "data-action-log": {
const al = parsed.data;
const matchedToolCallId = al.lc_tool_call_id
? findToolCallIdByLcId(contentPartsState, al.lc_tool_call_id)
: null;
upsertAgentAction({
action: {
id: al.id,
threadId: currentThreadId,
lcToolCallId: al.lc_tool_call_id,
chatTurnId: al.chat_turn_id,
toolName: al.tool_name,
reversible: al.reversible,
reverseDescriptorPresent: al.reverse_descriptor_present,
error: al.error,
revertedByActionId: null,
isRevertAction: false,
createdAt: al.created_at,
},
toolCallId: matchedToolCallId,
});
break;
}
case "data-action-log-updated": {
updateAgentActionReversible({
id: parsed.data.id,
reversible: parsed.data.reversible,
});
break;
}
case "data-turn-info": {
streamedChatTurnId = parsed.data.chat_turn_id || null;
if (streamedChatTurnId) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
)
);
}
break;
}
case "data-token-usage":
tokenUsageData = parsed.data;
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
@ -900,13 +1013,18 @@ export default function NewChatPage() {
role: "assistant",
content: finalContent,
token_usage: tokenUsageData ?? undefined,
turn_id: streamedChatTurnId,
});
// Update message ID from temporary to database ID so comments work immediately
const newMsgId = `msg-${savedMessage.id}`;
tokenUsageStore.rename(assistantMsgId, newMsgId);
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
prev.map((m) =>
m.id === assistantMsgId
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
: m
)
);
// Update pending interrupt with the new persisted message ID
@ -929,7 +1047,9 @@ export default function NewChatPage() {
const hasContent = contentParts.some(
(part) =>
(part.type === "text" && part.text.length > 0) ||
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
(part.type === "reasoning" && part.text.length > 0) ||
(part.type === "tool-call" &&
(toolsWithUI === "all" || toolsWithUI.has(part.toolName)))
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
@ -937,12 +1057,17 @@ export default function NewChatPage() {
const savedMessage = await appendMessage(currentThreadId, {
role: "assistant",
content: partialContent,
turn_id: streamedChatTurnId,
});
// Update message ID from temporary to database ID
const newMsgId = `msg-${savedMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
prev.map((m) =>
m.id === assistantMsgId
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
: m
)
);
} catch (err) {
console.error("Failed to persist partial assistant message:", err);
@ -1030,10 +1155,13 @@ export default function NewChatPage() {
const contentPartsState: ContentPartsState = {
contentParts: [],
currentTextPartIndex: -1,
currentReasoningPartIndex: -1,
toolCallIndices: new Map(),
};
const { contentParts, toolCallIndices } = contentPartsState;
let tokenUsageData: Record<string, unknown> | null = null;
// Captured from ``data-turn-info`` at stream start.
let streamedChatTurnId: string | null = null;
const existingMsg = messages.find((m) => m.id === assistantMsgId);
if (existingMsg && Array.isArray(existingMsg.content)) {
@ -1136,8 +1264,34 @@ export default function NewChatPage() {
scheduleFlush();
break;
case "reasoning-delta":
appendReasoning(contentPartsState, parsed.delta);
scheduleFlush();
break;
case "reasoning-end":
endReasoning(contentPartsState);
scheduleFlush();
break;
case "start-step":
addStepSeparator(contentPartsState);
scheduleFlush();
break;
case "finish-step":
break;
case "tool-input-start":
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
addToolCall(
contentPartsState,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
{},
false,
parsed.langchainToolCallId
);
batcher.flush();
break;
@ -1145,6 +1299,7 @@ export default function NewChatPage() {
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {},
langchainToolCallId: parsed.langchainToolCallId,
});
} else {
addToolCall(
@ -1152,7 +1307,9 @@ export default function NewChatPage() {
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
parsed.input || {},
false,
parsed.langchainToolCallId
);
}
batcher.flush();
@ -1161,6 +1318,7 @@ export default function NewChatPage() {
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
});
markInterruptsCompleted(contentParts);
batcher.flush();
@ -1222,6 +1380,50 @@ export default function NewChatPage() {
break;
}
case "data-action-log": {
const al = parsed.data;
const matchedToolCallId = al.lc_tool_call_id
? findToolCallIdByLcId(contentPartsState, al.lc_tool_call_id)
: null;
upsertAgentAction({
action: {
id: al.id,
threadId: resumeThreadId,
lcToolCallId: al.lc_tool_call_id,
chatTurnId: al.chat_turn_id,
toolName: al.tool_name,
reversible: al.reversible,
reverseDescriptorPresent: al.reverse_descriptor_present,
error: al.error,
revertedByActionId: null,
isRevertAction: false,
createdAt: al.created_at,
},
toolCallId: matchedToolCallId,
});
break;
}
case "data-action-log-updated": {
updateAgentActionReversible({
id: parsed.data.id,
reversible: parsed.data.reversible,
});
break;
}
case "data-turn-info": {
streamedChatTurnId = parsed.data.chat_turn_id || null;
if (streamedChatTurnId) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
)
);
}
break;
}
case "data-token-usage":
tokenUsageData = parsed.data;
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
@ -1241,11 +1443,16 @@ export default function NewChatPage() {
role: "assistant",
content: finalContent,
token_usage: tokenUsageData ?? undefined,
turn_id: streamedChatTurnId,
});
const newMsgId = `msg-${savedMessage.id}`;
tokenUsageStore.rename(assistantMsgId, newMsgId);
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
prev.map((m) =>
m.id === assistantMsgId
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
: m
)
);
} catch (err) {
console.error("Failed to persist resumed assistant message:", err);
@ -1340,6 +1547,12 @@ export default function NewChatPage() {
editExtras?: {
userMessageContent: ThreadMessageLike["content"];
userImages: NewChatUserImagePayload[];
},
editFromPosition?: {
/** Message id (numeric, parsed from ``msg-<n>``) to rewind to. */
fromMessageId?: number | null;
/** When true, revert reversible downstream actions before stream. */
revertActions?: boolean;
}
) => {
if (!threadId) {
@ -1384,9 +1597,20 @@ export default function NewChatPage() {
userQueryToDisplay = newUserQuery;
}
// Remove the last two messages (user + assistant) from the UI immediately
// The backend will also delete them from the database
// Remove downstream messages from the UI immediately. The
// backend will also delete them from the database.
//
// When an explicit ``fromMessageId`` is passed, slice from
// that message forward; otherwise fall back to the legacy
// "drop the last 2" behaviour.
setMessages((prev) => {
if (editFromPosition?.fromMessageId != null) {
const targetId = `msg-${editFromPosition.fromMessageId}`;
const sliceIndex = prev.findIndex((m) => m.id === targetId);
if (sliceIndex >= 0) {
return prev.slice(0, sliceIndex);
}
}
if (prev.length >= 2) {
return prev.slice(0, -2);
}
@ -1406,11 +1630,16 @@ export default function NewChatPage() {
const contentPartsState: ContentPartsState = {
contentParts: [],
currentTextPartIndex: -1,
currentReasoningPartIndex: -1,
toolCallIndices: new Map(),
};
const { contentParts, toolCallIndices } = contentPartsState;
const batcher = new FrameBatchedUpdater();
let tokenUsageData: Record<string, unknown> | null = null;
// Captured from ``data-turn-info`` at stream start; stamped
// onto persisted messages so future edits can locate the
// right LangGraph checkpoint.
let streamedChatTurnId: string | null = null;
// Add placeholder messages to UI
// Always add back the user message (with new query for edit, or original content for reload)
@ -1449,6 +1678,16 @@ export default function NewChatPage() {
if (isEdit) {
requestBody.user_images = editExtras?.userImages ?? [];
}
// Explicit edit-from-arbitrary-position. Only send
// ``from_message_id`` / ``revert_actions`` when the
// caller asked for them; otherwise the backend keeps the
// legacy "last 2 messages" behaviour for back-compat.
if (editFromPosition?.fromMessageId != null) {
requestBody.from_message_id = editFromPosition.fromMessageId;
if (editFromPosition.revertActions) {
requestBody.revert_actions = true;
}
}
const response = await fetch(getRegenerateUrl(threadId), {
method: "POST",
headers: {
@ -1481,28 +1720,62 @@ export default function NewChatPage() {
scheduleFlush();
break;
case "reasoning-delta":
appendReasoning(contentPartsState, parsed.delta);
scheduleFlush();
break;
case "reasoning-end":
endReasoning(contentPartsState);
scheduleFlush();
break;
case "start-step":
addStepSeparator(contentPartsState);
scheduleFlush();
break;
case "finish-step":
break;
case "tool-input-start":
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
addToolCall(
contentPartsState,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
{},
false,
parsed.langchainToolCallId
);
batcher.flush();
break;
case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {},
langchainToolCallId: parsed.langchainToolCallId,
});
} else {
addToolCall(
contentPartsState,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
parsed.input || {},
false,
parsed.langchainToolCallId
);
}
batcher.flush();
break;
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
});
markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId);
@ -1528,6 +1801,82 @@ export default function NewChatPage() {
break;
}
case "data-action-log": {
const al = parsed.data;
const matchedToolCallId = al.lc_tool_call_id
? findToolCallIdByLcId(contentPartsState, al.lc_tool_call_id)
: null;
upsertAgentAction({
action: {
id: al.id,
threadId,
lcToolCallId: al.lc_tool_call_id,
chatTurnId: al.chat_turn_id,
toolName: al.tool_name,
reversible: al.reversible,
reverseDescriptorPresent: al.reverse_descriptor_present,
error: al.error,
revertedByActionId: null,
isRevertAction: false,
createdAt: al.created_at,
},
toolCallId: matchedToolCallId,
});
break;
}
case "data-action-log-updated": {
updateAgentActionReversible({
id: parsed.data.id,
reversible: parsed.data.reversible,
});
break;
}
case "data-turn-info": {
streamedChatTurnId = parsed.data.chat_turn_id || null;
if (streamedChatTurnId) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? mergeChatTurnIdIntoMessage(m, streamedChatTurnId) : m
)
);
}
break;
}
case "data-revert-results": {
const summary = parsed.data;
// failureCount must include every "not undone" bucket
// (not_reversible, permission_denied, failed) so the
// toast's "X could not be rolled back" math matches
// the response invariant ``total === sum(counters)``.
// ``skipped`` rows are batch revert artefacts (revert
// rows themselves) and are not user-facing failures.
const failureCount =
summary.failed + summary.not_reversible + (summary.permission_denied ?? 0);
if (failureCount > 0) {
toast.warning(
`Pre-revert: ${summary.reverted}/${summary.total} undone, ${failureCount} could not be rolled back.`
);
} else if (summary.reverted > 0) {
toast.success(
summary.reverted === 1
? "Reverted 1 downstream action before regenerating."
: `Reverted ${summary.reverted} downstream actions before regenerating.`
);
}
for (const r of summary.results) {
if (r.status === "reverted" || r.status === "already_reverted") {
markAgentActionReverted({
id: r.action_id,
newActionId: r.new_action_id ?? null,
});
}
}
break;
}
case "data-token-usage":
tokenUsageData = parsed.data;
tokenUsageStore.set(assistantMsgId, parsed.data as TokenUsageData);
@ -1552,12 +1901,17 @@ export default function NewChatPage() {
const savedUserMessage = await appendMessage(threadId, {
role: "user",
content: userContentToPersist,
turn_id: streamedChatTurnId,
});
// Update user message ID to database ID
const newUserMsgId = `msg-${savedUserMessage.id}`;
setMessages((prev) =>
prev.map((m) => (m.id === userMsgId ? { ...m, id: newUserMsgId } : m))
prev.map((m) =>
m.id === userMsgId
? mergeChatTurnIdIntoMessage({ ...m, id: newUserMsgId }, savedUserMessage.turn_id)
: m
)
);
// Persist assistant message
@ -1565,12 +1919,17 @@ export default function NewChatPage() {
role: "assistant",
content: finalContent,
token_usage: tokenUsageData ?? undefined,
turn_id: streamedChatTurnId,
});
const newMsgId = `msg-${savedMessage.id}`;
tokenUsageStore.rename(assistantMsgId, newMsgId);
setMessages((prev) =>
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
prev.map((m) =>
m.id === assistantMsgId
? mergeChatTurnIdIntoMessage({ ...m, id: newMsgId }, savedMessage.turn_id)
: m
)
);
trackChatResponseReceived(searchSpaceId, threadId);
@ -1608,7 +1967,14 @@ export default function NewChatPage() {
[threadId, searchSpaceId, messages, disabledTools, tokenUsageStore, toolsWithUI]
);
// Handle editing a message - truncates history and regenerates with new query
// Handle editing a message - truncates history and regenerates with new query.
//
// When ``message.sourceId`` is set (the assistant-ui way to say
// "this edit replaces an older message"), we pin
// ``from_message_id`` so the backend rewinds to the right LangGraph
// checkpoint instead of relying on the legacy "last 2 messages"
// rewind. We also count downstream reversible actions and prompt the
// user to revert / continue / cancel before regenerating.
const onEdit = useCallback(
async (message: AppendMessage) => {
const { userQuery, userImages } = extractUserTurnForNewChatApi(message, []);
@ -1619,9 +1985,95 @@ export default function NewChatPage() {
}
const userMessageContent = message.content as unknown as ThreadMessageLike["content"];
await handleRegenerate(queryForApi, { userMessageContent, userImages });
// ``sourceId`` per @assistant-ui/core's ``AppendMessage`` is
// "the ID of the message that was edited". Parse the numeric
// suffix so we can map it back to a DB row.
const sourceId = (message as { sourceId?: string }).sourceId;
const fromMessageId =
sourceId && /^msg-\d+$/.test(sourceId)
? Number.parseInt(sourceId.replace(/^msg-/, ""), 10)
: null;
if (fromMessageId == null) {
// No source id (or non-DB id) — fall back to today's
// last-2 behaviour. The user gets the legacy edit flow.
await handleRegenerate(queryForApi, { userMessageContent, userImages });
return;
}
// Pre-flight: count reversible downstream actions so we can
// auto-skip the dialog for harmless edits.
//
// "Downstream" means messages AFTER the edited one. The
// previous slice ``messages.slice(editedIndex)`` included
// the edited message itself in both the total
// count and the reversibility scan (any actions on the
// edited turn would be double-counted). Slice from
// ``editedIndex + 1`` so the dialog text matches reality:
// "N downstream messages will be dropped".
const editedIndex = messages.findIndex((m) => m.id === `msg-${fromMessageId}`);
let downstreamReversibleCount = 0;
let downstreamTotalCount = 0;
if (editedIndex >= 0) {
const downstream = messages.slice(editedIndex + 1);
downstreamTotalCount = downstream.length;
const seenTurns = new Set<string>();
for (const m of downstream) {
const meta = (m.metadata ?? {}) as { custom?: { chatTurnId?: string } };
const tid = meta.custom?.chatTurnId;
if (!tid || seenTurns.has(tid)) continue;
seenTurns.add(tid);
const turnActions = agentActionsByChatTurnId.get(tid) ?? [];
for (const a of turnActions) {
if (a.reversible && a.revertedByActionId === null && !a.isRevertAction && !a.error) {
downstreamReversibleCount += 1;
}
}
}
}
if (downstreamReversibleCount === 0) {
// Nothing to revert — submit silently.
await handleRegenerate(
queryForApi,
{ userMessageContent, userImages },
{ fromMessageId, revertActions: false }
);
return;
}
setEditDialogState({
fromMessageId,
userQuery: queryForApi,
userMessageContent,
userImages,
downstreamReversibleCount,
downstreamTotalCount,
});
},
[handleRegenerate]
[handleRegenerate, messages, agentActionsByChatTurnId]
);
const handleEditDialogChoice = useCallback(
async (choice: EditMessageDialogChoice) => {
const pending = editDialogState;
if (!pending) return;
setEditDialogState(null);
if (choice === "cancel") return;
await handleRegenerate(
pending.userQuery,
{
userMessageContent: pending.userMessageContent,
userImages: pending.userImages,
},
{
fromMessageId: pending.fromMessageId,
revertActions: choice === "revert",
}
);
},
[editDialogState, handleRegenerate]
);
// Handle reloading/refreshing the last AI response
@ -1671,6 +2123,7 @@ export default function NewChatPage() {
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<StepSeparatorDataUI />
<div key={searchSpaceId} className="flex h-full overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread />
@ -1679,6 +2132,15 @@ export default function NewChatPage() {
<MobileEditorPanel />
<MobileHitlEditPanel />
</div>
<EditMessageDialog
open={editDialogState !== null}
onOpenChange={(open) => {
if (!open) setEditDialogState(null);
}}
downstreamReversibleCount={editDialogState?.downstreamReversibleCount ?? 0}
downstreamTotalCount={editDialogState?.downstreamTotalCount ?? 0}
onChoose={handleEditDialogChoice}
/>
</AssistantRuntimeProvider>
</TokenUsageProvider>
);

View file

@ -0,0 +1,194 @@
"use client";
import { atom } from "jotai";
/**
* Minimal per-row projection of ``AgentActionLog`` that the tool card
* needs to decide whether to render a Revert button.
*
* Fields are deliberately a subset of the full ``AgentAction`` so the
* SSE side-channel (``data-action-log`` / ``data-action-log-updated``)
* can populate them without depending on the REST endpoint
* ``GET /threads/.../actions`` (which 503s when
* ``SURFSENSE_ENABLE_ACTION_LOG`` is off).
*/
export interface AgentActionLite {
id: number;
threadId: number | null;
lcToolCallId: string | null;
chatTurnId: string | null;
toolName: string;
reversible: boolean;
reverseDescriptorPresent: boolean;
error: boolean;
revertedByActionId: number | null;
isRevertAction: boolean;
createdAt: string | null;
}
/**
* Map keyed off the LangChain ``tool_call.id`` (mirrors ``ContentPart
* tool-call.langchainToolCallId``).
*/
export const agentActionByLcIdAtom = atom<Map<string, AgentActionLite>>(new Map());
/**
* Parallel map keyed off the synthetic chat-card ``toolCallId``
* (``call_<run-id>``) so ``ToolFallback`` (which only receives the
* synthetic id from assistant-ui) can join its card to the action log.
*
* Both maps are kept in sync by ``upsertAgentActionAtom``.
*/
export const agentActionByToolCallIdAtom = atom<Map<string, AgentActionLite>>(new Map());
/**
* Index keyed by ``chat_turn_id`` so the per-turn revert UI can answer
* "how many reversible actions does this assistant turn contain?" in
* O(1). Each entry's array is ordered by insertion (which
* for a single turn matches ``created_at`` because action-log writes
* happen synchronously).
*/
export const agentActionsByChatTurnIdAtom = atom<Map<string, AgentActionLite[]>>(new Map());
/**
* Action to upsert one ``AgentActionLite`` row.
*
* ``toolCallId`` is the synthetic card id (``call_<run-id>`` from
* ``stream_new_chat.py``). When provided alongside ``lcToolCallId``, the
* action is indexed under BOTH ids so the tool card can perform the
* lookup without going via the streaming state.
*/
export const upsertAgentActionAtom = atom(
null,
(_get, set, payload: { action: AgentActionLite; toolCallId?: string | null }) => {
const { action, toolCallId } = payload;
const upsertInto = (
prev: Map<string, AgentActionLite>,
key: string
): Map<string, AgentActionLite> => {
const next = new Map(prev);
const existing = next.get(key);
next.set(key, {
...action,
// Preserve the local "reverted" bookkeeping if a reversibility
// flip arrives AFTER the user already reverted via the REST
// route. We never want a stale ``reversible=true`` event to
// resurrect a Reverted card.
revertedByActionId: existing?.revertedByActionId ?? action.revertedByActionId,
isRevertAction: existing?.isRevertAction ?? action.isRevertAction,
});
return next;
};
if (action.lcToolCallId) {
set(agentActionByLcIdAtom, (prev) => upsertInto(prev, action.lcToolCallId as string));
}
if (toolCallId) {
set(agentActionByToolCallIdAtom, (prev) => upsertInto(prev, toolCallId));
}
if (action.chatTurnId) {
set(agentActionsByChatTurnIdAtom, (prev) => {
const next = new Map(prev);
const turnId = action.chatTurnId as string;
const existing = next.get(turnId) ?? [];
const priorEntry = existing.find((row) => row.id === action.id);
const merged: AgentActionLite = {
...action,
revertedByActionId: priorEntry?.revertedByActionId ?? action.revertedByActionId,
isRevertAction: priorEntry?.isRevertAction ?? action.isRevertAction,
};
const others = existing.filter((row) => row.id !== action.id);
next.set(turnId, [...others, merged]);
return next;
});
}
}
);
function mutateById(
prev: Map<string, AgentActionLite>,
id: number,
mutator: (entry: AgentActionLite) => AgentActionLite
): Map<string, AgentActionLite> {
let mutated = false;
const next = new Map(prev);
for (const [key, value] of next) {
if (value.id === id) {
next.set(key, mutator(value));
mutated = true;
}
}
return mutated ? next : prev;
}
function mutateByIdInTurnIndex(
prev: Map<string, AgentActionLite[]>,
id: number,
mutator: (entry: AgentActionLite) => AgentActionLite
): Map<string, AgentActionLite[]> {
let mutated = false;
const next = new Map(prev);
for (const [key, list] of next) {
let listMutated = false;
const updated = list.map((row) => {
if (row.id === id) {
listMutated = true;
return mutator(row);
}
return row;
});
if (listMutated) {
next.set(key, updated);
mutated = true;
}
}
return mutated ? next : prev;
}
/**
* Action to flip an existing entry's ``reversible`` flag, keyed by the
* AgentActionLog row id (the SSE ``data-action-log-updated`` payload
* does NOT carry ``lcToolCallId``).
*/
export const updateAgentActionReversibleAtom = atom(
null,
(_get, set, payload: { id: number; reversible: boolean }) => {
const apply = (entry: AgentActionLite): AgentActionLite => ({
...entry,
reversible: payload.reversible,
});
set(agentActionByLcIdAtom, (prev) => mutateById(prev, payload.id, apply));
set(agentActionByToolCallIdAtom, (prev) => mutateById(prev, payload.id, apply));
set(agentActionsByChatTurnIdAtom, (prev) => mutateByIdInTurnIndex(prev, payload.id, apply));
}
);
/** Action to mark an existing entry as reverted (post-revert call). */
export const markAgentActionRevertedAtom = atom(
null,
(_get, set, payload: { id: number; newActionId: number | null }) => {
const apply = (entry: AgentActionLite): AgentActionLite => ({
...entry,
revertedByActionId: payload.newActionId ?? -1,
});
set(agentActionByLcIdAtom, (prev) => mutateById(prev, payload.id, apply));
set(agentActionByToolCallIdAtom, (prev) => mutateById(prev, payload.id, apply));
set(agentActionsByChatTurnIdAtom, (prev) => mutateByIdInTurnIndex(prev, payload.id, apply));
}
);
/** Mark every action in a turn as reverted, given a list of (id, newActionId) pairs. */
export const markAgentActionsRevertedBatchAtom = atom(
null,
(_get, set, payload: { entries: Array<{ id: number; newActionId: number | null }> }) => {
for (const entry of payload.entries) {
set(markAgentActionRevertedAtom, entry);
}
}
);
/** Reset all maps (e.g. when the active thread changes). */
export const resetAgentActionMapAtom = atom(null, (_get, set) => {
set(agentActionByLcIdAtom, new Map());
set(agentActionByToolCallIdAtom, new Map());
set(agentActionsByChatTurnIdAtom, new Map());
});

View file

@ -33,6 +33,8 @@ import {
useAllCitationMetadata,
} from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -491,6 +493,7 @@ const AssistantMessageInner: FC = () => {
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: {
generate_report: GenerateReportToolUI,
@ -699,6 +702,13 @@ const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
const aui = useAui();
const api = useElectronAPI();
// Surface the persisted ``chat_turn_id`` so the per-turn revert
// affordance can scope to just this message's actions. Streamed
// turns get their id once the assistant message is hydrated/finalised.
const chatTurnId = useAuiState(({ message }) => {
const meta = message?.metadata as { custom?: { chatTurnId?: string | null } } | undefined;
return meta?.custom?.chatTurnId ?? null;
});
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
@ -743,6 +753,9 @@ const AssistantActionBar: FC = () => {
</TooltipIconButton>
)}
<MessageInfoDropdown />
<div className="ml-auto">
<RevertTurnButton chatTurnId={chatTurnId} />
</div>
</ActionBarPrimitive.Root>
);
};

View file

@ -0,0 +1,106 @@
"use client";
/**
* Confirmation dialog shown when the user edits a message that has
* reversible downstream actions. Three buttons:
*
* "Revert all & resubmit" POST regenerate with revert_actions=true
* "Continue without revert" POST regenerate with revert_actions=false
* "Cancel" abort the edit entirely
*
* The dialog is auto-skipped when zero reversible downstream actions
* exist (the caller checks first via ``downstreamReversibleCount``).
*/
import { useEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
export type EditMessageDialogChoice = "revert" | "continue" | "cancel";
export interface EditMessageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
downstreamReversibleCount: number;
downstreamTotalCount: number;
onChoose: (choice: EditMessageDialogChoice) => void | Promise<void>;
}
export function EditMessageDialog({
open,
onOpenChange,
downstreamReversibleCount,
downstreamTotalCount,
onChoose,
}: EditMessageDialogProps) {
const [busy, setBusy] = useState<EditMessageDialogChoice | null>(null);
// The parent's ``handleEditDialogChoice`` calls
// ``setEditDialogState(null)`` BEFORE awaiting ``handleRegenerate``.
// That collapses the dialog (Radix unmounts it) while ``onChoose``
// is still awaiting the long-running stream. Without this guard,
// the ``finally { setBusy(null) }`` below ran after unmount and
// produced a "state update on unmounted component" dev warning.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const handle = async (choice: EditMessageDialogChoice) => {
setBusy(choice);
try {
await onChoose(choice);
} finally {
if (mountedRef.current) {
setBusy(null);
}
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Edit this message?</AlertDialogTitle>
<AlertDialogDescription>
This edit drops {downstreamTotalCount} downstream message
{downstreamTotalCount === 1 ? "" : "s"} from the thread. {downstreamReversibleCount}{" "}
action
{downstreamReversibleCount === 1 ? "" : "s"} (e.g. file writes, connector changes) can
be rolled back. Pick how to handle them before regenerating.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-2">
<Button variant="default" disabled={busy !== null} onClick={() => handle("revert")}>
{busy === "revert"
? "Reverting & resubmitting…"
: `Revert ${downstreamReversibleCount} action${
downstreamReversibleCount === 1 ? "" : "s"
} & resubmit`}
</Button>
<Button variant="outline" disabled={busy !== null} onClick={() => handle("continue")}>
{busy === "continue" ? "Resubmitting…" : "Continue without reverting"}
</Button>
</div>
<AlertDialogFooter className="sm:justify-start">
<AlertDialogCancel disabled={busy !== null} onClick={() => handle("cancel")}>
Cancel
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -0,0 +1,81 @@
"use client";
import type { ReasoningMessagePartComponent } from "@assistant-ui/react";
import { ChevronRightIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { cn } from "@/lib/utils";
/**
* Renders the structured `reasoning` part emitted by the backend's
* stream-parity v2 path (A1).
*
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
* - collapsed by default;
* - auto-expanded while the part is still `running`;
* - auto-collapsed once status flips to `complete`.
*
* The component is registered via the `Reasoning` slot on
* `MessagePrimitive.Parts` in `assistant-message.tsx` so it lives at the
* exact ordinal position of the reasoning block in the message content
* array (i.e. above the assistant text that follows it).
*/
export const ReasoningMessagePart: ReasoningMessagePartComponent = ({ text, status }) => {
const isRunning = status?.type === "running";
const [isOpen, setIsOpen] = useState(() => isRunning);
useEffect(() => {
if (isRunning) {
setIsOpen(true);
} else if (status?.type === "complete") {
setIsOpen(false);
}
}, [isRunning, status?.type]);
const headerLabel = useMemo(() => {
if (isRunning) return "Thinking";
if (status?.type === "incomplete") return "Thinking interrupted";
return "Thought";
}, [isRunning, status?.type]);
if (!text || text.length === 0) {
if (!isRunning) return null;
}
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<div className="rounded-lg">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
)}
>
{isRunning ? (
<TextShimmerLoader text={headerLabel} size="sm" />
) : (
<span>{headerLabel}</span>
)}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="mt-2 border-l border-muted-foreground/30 pl-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap wrap-break-word">
{text}
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,232 @@
"use client";
/**
* "Revert turn" button rendered at the bottom of every completed
* assistant turn that has at least one reversible action.
*
* The button reads the action map keyed by ``chat_turn_id`` from the
* SSE side-channel (``data-action-log`` events). It shows a confirmation
* dialog summarising "N reversible / M total" and, on confirm, calls
* ``POST /threads/{id}/revert-turn/{chat_turn_id}``.
*
* The route returns a per-action result list and never collapses the
* batch into a 4xx so we render any failed/not_reversible rows inline
* with their messages.
*/
import { useAtomValue, useSetAtom } from "jotai";
import { selectAtom } from "jotai/utils";
import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
type AgentActionLite,
agentActionsByChatTurnIdAtom,
markAgentActionsRevertedBatchAtom,
} from "@/atoms/chat/agent-actions.atom";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
agentActionsApiService,
type RevertTurnActionResult,
} from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { cn } from "@/lib/utils";
interface RevertTurnButtonProps {
chatTurnId: string | null | undefined;
}
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
// Empty-array sentinel so the per-turn ``selectAtom`` slice returns a
// stable reference when the turn has no recorded actions yet. Without
// this every render allocates a fresh ``[]`` and Jotai's
// equality check would re-render the button on unrelated turn updates.
const EMPTY_ACTIONS: readonly AgentActionLite[] = Object.freeze([]);
export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
const session = useAtomValue(chatSessionStateAtom);
const markRevertedBatch = useSetAtom(markAgentActionsRevertedBatchAtom);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [resultsOpen, setResultsOpen] = useState(false);
const [results, setResults] = useState<RevertTurnActionResult[]>([]);
// Subscribe ONLY to the slice of the global action map that belongs
// to ``chatTurnId``. Previously the button read the whole
// ``agentActionsByChatTurnIdAtom``, which meant every action
// upsert (one per tool call) re-rendered every Revert button on
// the page. With ``selectAtom`` we re-render only when our turn's
// list reference changes — and the upsert/mark atoms produce a
// fresh list reference for the affected turn only.
const sliceAtom = useMemo(
() =>
selectAtom(
agentActionsByChatTurnIdAtom,
(turnIndex) => (chatTurnId ? turnIndex.get(chatTurnId) : undefined) ?? EMPTY_ACTIONS
),
[chatTurnId]
);
const actions = useAtomValue(sliceAtom);
const reversibleCount = useMemo(
() =>
actions.filter(
(a) => a.reversible && a.revertedByActionId === null && !a.isRevertAction && !a.error
).length,
[actions]
);
const totalCount = useMemo(() => actions.filter((a) => !a.isRevertAction).length, [actions]);
if (!chatTurnId) return null;
if (reversibleCount === 0) return null;
const threadId = session?.threadId;
if (!threadId) return null;
const handleRevertTurn = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revertTurn(threadId, chatTurnId);
setResults(response.results);
const revertedEntries = response.results
.filter((r) => r.status === "reverted" || r.status === "already_reverted")
.map((r) => ({ id: r.action_id, newActionId: r.new_action_id ?? null }));
if (revertedEntries.length > 0) {
markRevertedBatch({ entries: revertedEntries });
}
if (response.status === "ok") {
toast.success(
response.reverted === 1 ? "Reverted 1 action." : `Reverted ${response.reverted} actions.`
);
} else {
// Every "not undone" bucket counts as a failure for the
// user-facing summary. ``skipped`` rows are batch
// artefacts (revert rows themselves) and intentionally
// excluded from the failure tally.
const failureCount =
response.failed + response.not_reversible + (response.permission_denied ?? 0);
toast.warning(
`Reverted ${response.reverted} of ${response.total}. ${failureCount} could not be undone.`
);
setResultsOpen(true);
}
} catch (err) {
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert turn.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<RotateCcw className="size-3.5" />
<span>Revert turn</span>
<span className="text-xs tabular-nums opacity-70">
{reversibleCount}/{totalCount}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this turn?</AlertDialogTitle>
<AlertDialogDescription>
This will undo {reversibleCount} of {totalCount} action
{totalCount === 1 ? "" : "s"} from this turn in reverse order. The chat history and
any read-only actions are preserved. Some rows may not be reversible partial success
is normal.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevertTurn();
}}
disabled={isReverting}
>
{isReverting ? "Reverting…" : "Revert turn"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={resultsOpen} onOpenChange={setResultsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert results</AlertDialogTitle>
<AlertDialogDescription>
Some actions could not be reverted. Review per-row outcomes below.
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="max-h-72 overflow-y-auto space-y-2 text-sm">
{results.map((r) => (
<RevertResultRow key={r.action_id} result={r} />
))}
</ul>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setResultsOpen(false)}>Close</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function RevertResultRow({ result }: { result: RevertTurnActionResult }) {
const isOk = result.status === "reverted" || result.status === "already_reverted";
const Icon = isOk ? CheckIcon : XCircleIcon;
return (
<li className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
<Icon
className={cn("size-4 mt-0.5 shrink-0", isOk ? "text-emerald-500" : "text-destructive")}
/>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">
{formatToolName(result.tool_name)}{" "}
<span className="ml-1 text-xs text-muted-foreground">
{result.status.replace(/_/g, " ")}
</span>
</p>
{(result.message || result.error) && (
<p className="text-xs text-muted-foreground mt-0.5">{result.error ?? result.message}</p>
)}
</div>
</li>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { makeAssistantDataUI } from "@assistant-ui/react";
/**
* Renders a thin horizontal divider between model steps within a single
* assistant turn. The data part is pushed by `addStepSeparator` in
* `streaming-state.ts` whenever a `start-step` SSE event arrives after
* the message already has non-step content.
*
* Today the backend emits one `start-step` / `finish-step` pair per turn,
* so most messages won't contain a separator. The renderer is wired up so
* the planned per-model-step refactor (A2 follow-up) can light up without
* touching the persistence path.
*/
function StepSeparatorDataRenderer() {
return (
<div className="mx-auto my-3 w-full max-w-(--thread-max-width) px-2">
<div className="border-t border-border/60" />
</div>
);
}
export const StepSeparatorDataUI = makeAssistantDataUI({
name: "step-separator",
render: StepSeparatorDataRenderer,
});

View file

@ -1,12 +1,33 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, RotateCcw, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import {
agentActionByToolCallIdAtom,
markAgentActionRevertedAtom,
} from "@/atoms/chat/agent-actions.atom";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import {
DoomLoopApprovalToolUI,
isDoomLoopInterrupt,
} from "@/components/tool-ui/doom-loop-approval";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils";
@ -14,7 +35,99 @@ function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Inline Revert button rendered on a tool card when the matching
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
* Reads from the SSE side-channel atom keyed by the synthetic
* ``toolCallId`` so it lights up even when ``GET /threads/.../actions``
* is gated behind ``SURFSENSE_ENABLE_ACTION_LOG=False`` (503).
*/
function ToolCardRevertButton({ toolCallId }: { toolCallId: string }) {
const session = useAtomValue(chatSessionStateAtom);
const actionMap = useAtomValue(agentActionByToolCallIdAtom);
const markReverted = useSetAtom(markAgentActionRevertedAtom);
const action = actionMap.get(toolCallId);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
if (!action) return null;
if (!action.reversible) return null;
if (action.revertedByActionId !== null) return null;
if (action.isRevertAction) return null;
if (action.error) return null;
const threadId = session?.threadId;
if (!threadId) return null;
const handleRevert = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revert(threadId, action.id);
markReverted({ id: action.id, newActionId: response.new_action_id ?? null });
toast.success(response.message || "Action reverted.");
} catch (err) {
// 503 means revert is gated off on this deployment — hide the
// button silently rather than nagging the user. Any other error
// is surfaced as a toast so the operator can investigate.
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert action.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<RotateCcw className="size-3.5" />
Revert
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription>
This will undo <span className="font-medium">{formatToolName(action.toolName)}</span>{" "}
and append a new audit entry. Chat history is preserved only the tool's effects on
your knowledge base or connectors will be reversed where possible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevert();
}}
disabled={isReverting}
>
{isReverting ? "Reverting…" : "Revert"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
toolCallId,
toolName,
argsText,
result,
@ -145,6 +258,9 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
</div>
</>
)}
<div className="flex justify-end">
<ToolCardRevertButton toolCallId={toolCallId} />
</div>
</div>
</>
)}

View file

@ -9,6 +9,7 @@ import {
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import { ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import {
createTokenUsageStore,
@ -17,10 +18,13 @@ import {
} from "@/components/assistant-ui/token-usage-context";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import {
addStepSeparator,
addToolCall,
appendReasoning,
appendText,
buildContentForUI,
type ContentPartsState,
endReasoning,
FrameBatchedUpdater,
readSSEStream,
type ThinkingStepData,
@ -32,7 +36,9 @@ import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
const TOOLS_WITH_UI = new Set(["web_search", "document_qna"]);
// Render all tool calls via ToolFallback; backend keeps persisted
// payloads bounded by summarising / truncating outputs.
const TOOLS_WITH_UI = "all" as const;
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
/** Try to parse a CAPTCHA_REQUIRED or CAPTCHA_INVALID code from a non-ok response. */
@ -125,6 +131,7 @@ export function FreeChatPage() {
const contentPartsState: ContentPartsState = {
contentParts: [],
currentTextPartIndex: -1,
currentReasoningPartIndex: -1,
toolCallIndices: new Map(),
};
const { toolCallIndices } = contentPartsState;
@ -148,28 +155,62 @@ export function FreeChatPage() {
scheduleFlush();
break;
case "reasoning-delta":
appendReasoning(contentPartsState, parsed.delta);
scheduleFlush();
break;
case "reasoning-end":
endReasoning(contentPartsState);
scheduleFlush();
break;
case "start-step":
addStepSeparator(contentPartsState);
scheduleFlush();
break;
case "finish-step":
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
parsed.toolCallId,
parsed.toolName,
{},
false,
parsed.langchainToolCallId
);
batcher.flush();
break;
case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {},
langchainToolCallId: parsed.langchainToolCallId,
});
} else {
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
parsed.input || {},
false,
parsed.langchainToolCallId
);
}
batcher.flush();
break;
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
});
batcher.flush();
break;
@ -369,6 +410,7 @@ export function FreeChatPage() {
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<StepSeparatorDataUI />
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-4">
<FreeModelSelector />

View file

@ -1,6 +1,7 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Navbar } from "@/components/homepage/navbar";
import { ReportPanel } from "@/components/report-panel/report-panel";
@ -41,6 +42,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<Navbar scrolledBgClassName={navbarScrolledBg} />
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<StepSeparatorDataUI />
<div className="flex h-screen pt-16 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />

View file

@ -13,6 +13,7 @@ import Image from "next/image";
import { type FC, type ReactNode, useState } from "react";
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
@ -157,6 +158,7 @@ const PublicAssistantMessage: FC = () => {
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: {
generate_podcast: GeneratePodcastToolUI,

View file

@ -1,27 +1,112 @@
import {
BookOpen,
Brain,
Calendar,
Check,
FileEdit,
FilePlus,
FileText,
FileUser,
FileX,
Film,
FolderPlus,
FolderTree,
FolderX,
Globe,
ImageIcon,
ListTodo,
type LucideIcon,
Mail,
MessagesSquare,
Move,
Plus,
Podcast,
ScanLine,
Search,
Send,
Trash2,
Wrench,
} from "lucide-react";
/**
* Every tool now renders a card via ``ToolFallback``. The icon map is
* keyed on the canonical backend tool name (registered in
* ``surfsense_backend/app/agents/new_chat/tools/registry.py``); unknown
* names fall back to the generic ``Wrench`` icon so the card still
* communicates "this is a tool call".
*/
const TOOL_ICONS: Record<string, LucideIcon> = {
// Generators
generate_podcast: Podcast,
generate_video_presentation: Film,
generate_report: FileText,
generate_resume: FileUser,
generate_image: ImageIcon,
display_image: ImageIcon,
// Web / search
scrape_webpage: ScanLine,
web_search: Globe,
search_surfsense_docs: BookOpen,
// Memory
update_memory: Brain,
// Filesystem (built-in deepagent + middleware)
read_file: FileText,
write_file: FilePlus,
edit_file: FileEdit,
move_file: Move,
rm: FileX,
rmdir: FolderX,
mkdir: FolderPlus,
ls: FolderTree,
write_todos: ListTodo,
// Calendar
search_calendar_events: Search,
create_calendar_event: Calendar,
update_calendar_event: Calendar,
delete_calendar_event: Calendar,
// Gmail
search_gmail: Search,
read_gmail_email: Mail,
create_gmail_draft: Mail,
update_gmail_draft: FileEdit,
send_gmail_email: Send,
trash_gmail_email: Trash2,
// Notion / Confluence pages
create_notion_page: FilePlus,
update_notion_page: FileEdit,
delete_notion_page: FileX,
create_confluence_page: FilePlus,
update_confluence_page: FileEdit,
delete_confluence_page: FileX,
// Linear / Jira issues
create_linear_issue: Plus,
update_linear_issue: FileEdit,
delete_linear_issue: Trash2,
create_jira_issue: Plus,
update_jira_issue: FileEdit,
delete_jira_issue: Trash2,
// Drive-like file connectors
create_google_drive_file: FilePlus,
delete_google_drive_file: FileX,
create_dropbox_file: FilePlus,
delete_dropbox_file: FileX,
create_onedrive_file: FilePlus,
delete_onedrive_file: FileX,
// Chat connectors
list_discord_channels: MessagesSquare,
read_discord_messages: MessagesSquare,
send_discord_message: Send,
list_teams_channels: MessagesSquare,
read_teams_messages: MessagesSquare,
send_teams_message: Send,
// Luma
list_luma_events: Calendar,
read_luma_event: Calendar,
create_luma_event: Calendar,
// Misc
get_connected_accounts: Check,
execute: Wrench,
execute_code: Wrench,
};
export function getToolIcon(name: string): LucideIcon {

View file

@ -15,6 +15,12 @@ const AgentActionReadSchema = z.object({
reverse_of: z.number().nullable(),
reverted_by_action_id: z.number().nullable(),
is_revert_action: z.boolean(),
// Correlation ids added in migration 135. The LangChain
// ``tool_call_id`` joins this row to the chat tool card via the
// ``data-action-log.lc_tool_call_id`` SSE event, and
// ``chat_turn_id`` keys the per-turn revert endpoint.
tool_call_id: z.string().nullable().optional(),
chat_turn_id: z.string().nullable().optional(),
created_at: z.string(),
});
@ -38,6 +44,48 @@ const RevertResponseSchema = z.object({
export type RevertResponse = z.infer<typeof RevertResponseSchema>;
// Per-turn batch revert. The route never returns whole-batch 4xx;
// partial success is the common case and surfaced as
// ``status === "partial"`` with a per-action result list.
const RevertTurnActionResultSchema = z.object({
action_id: z.number(),
tool_name: z.string(),
status: z.enum([
"reverted",
"already_reverted",
"not_reversible",
"permission_denied",
"failed",
"skipped",
]),
message: z.string().nullable().optional(),
new_action_id: z.number().nullable().optional(),
error: z.string().nullable().optional(),
});
export type RevertTurnActionResult = z.infer<typeof RevertTurnActionResultSchema>;
const RevertTurnResponseSchema = z.object({
status: z.enum(["ok", "partial"]),
chat_turn_id: z.string(),
total: z.number(),
reverted: z.number(),
already_reverted: z.number(),
not_reversible: z.number(),
// ``permission_denied`` and ``skipped`` are first-class counters so
// ``total === reverted + already_reverted +
// not_reversible + permission_denied + failed + skipped`` always
// holds. ``.default(0)`` keeps the schema backwards-compatible
// with older deployments that haven't shipped the response model
// update yet.
permission_denied: z.number().default(0),
failed: z.number(),
skipped: z.number().default(0),
results: z.array(RevertTurnActionResultSchema),
});
export type RevertTurnResponse = z.infer<typeof RevertTurnResponseSchema>;
class AgentActionsApiService {
listForThread = async (
threadId: number,
@ -59,6 +107,14 @@ class AgentActionsApiService {
{ body: {} }
);
};
revertTurn = async (threadId: number, chatTurnId: string): Promise<RevertTurnResponse> => {
return baseApiService.post(
`/api/v1/threads/${threadId}/revert-turn/${encodeURIComponent(chatTurnId)}`,
RevertTurnResponseSchema,
{ body: {} }
);
};
}
export const agentActionsApiService = new AgentActionsApiService();

View file

@ -40,7 +40,7 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
}
const metadata =
msg.author_id || msg.token_usage
msg.author_id || msg.token_usage || msg.turn_id
? {
custom: {
...(msg.author_id && {
@ -50,6 +50,10 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
},
}),
...(msg.token_usage && { usage: msg.token_usage }),
// Surface ``chat_turn_id`` so the assistant message
// footer can scope its "Revert turn" button to just
// this turn's actions. Null on legacy rows.
...(msg.turn_id && { chatTurnId: msg.turn_id }),
},
}
: undefined;

View file

@ -9,21 +9,42 @@ export interface ThinkingStepData {
export type ContentPart =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| {
type: "tool-call";
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result?: unknown;
/**
* Authoritative LangChain ``tool_call.id`` propagated by the backend
* via ``langchainToolCallId`` on tool-input-start/available and
* tool-output-available events. Used to join a card to the
* matching ``AgentActionLog`` row exposed by
* ``GET /threads/{id}/actions`` and the streamed
* ``data-action-log`` events.
*/
langchainToolCallId?: string;
}
| {
type: "data-thinking-steps";
data: { steps: ThinkingStepData[] };
}
| {
/**
* Between-step separator. Pushed by `addStepSeparator` when
* a `start-step` SSE event arrives AFTER the message already
* has non-step content. Rendered by `StepSeparatorDataUI`
* (see assistant-ui/step-separator.tsx).
*/
type: "data-step-separator";
data: { stepIndex: number };
};
export interface ContentPartsState {
contentParts: ContentPart[];
currentTextPartIndex: number;
currentReasoningPartIndex: number;
toolCallIndices: Map<string, number>;
}
@ -74,6 +95,9 @@ export function updateThinkingSteps(
if (state.currentTextPartIndex >= 0) {
state.currentTextPartIndex += 1;
}
if (state.currentReasoningPartIndex >= 0) {
state.currentReasoningPartIndex += 1;
}
for (const [id, idx] of state.toolCallIndices) {
state.toolCallIndices.set(id, idx + 1);
}
@ -131,6 +155,12 @@ export class FrameBatchedUpdater {
}
export function appendText(state: ContentPartsState, delta: string): void {
// First text delta after a reasoning block: close the reasoning so
// the assistant-ui renderer treats them as separate parts (the
// reasoning block collapses; the answer streams below).
if (state.currentReasoningPartIndex >= 0) {
state.currentReasoningPartIndex = -1;
}
if (
state.currentTextPartIndex >= 0 &&
state.contentParts[state.currentTextPartIndex]?.type === "text"
@ -143,36 +173,129 @@ export function appendText(state: ContentPartsState, delta: string): void {
}
}
export function appendReasoning(state: ContentPartsState, delta: string): void {
// Symmetric to appendText: open a fresh reasoning block on first
// delta, then accumulate into it. ``endReasoning`` simply closes
// the active block; subsequent reasoning deltas would open a new
// one (matching ``text-start/end`` semantics on the wire).
if (state.currentTextPartIndex >= 0) {
state.currentTextPartIndex = -1;
}
if (
state.currentReasoningPartIndex >= 0 &&
state.contentParts[state.currentReasoningPartIndex]?.type === "reasoning"
) {
(
state.contentParts[state.currentReasoningPartIndex] as {
type: "reasoning";
text: string;
}
).text += delta;
} else {
state.contentParts.push({ type: "reasoning", text: delta });
state.currentReasoningPartIndex = state.contentParts.length - 1;
}
}
export function endReasoning(state: ContentPartsState): void {
state.currentReasoningPartIndex = -1;
}
export function addStepSeparator(state: ContentPartsState): void {
// Push a divider between consecutive model steps within a single
// assistant turn. We only emit it when the message already has
// non-step content (so the FIRST step of a turn doesn't
// generate a leading separator) and when the previous part isn't
// itself a separator (defensive against duplicate `start-step`
// events).
const hasContent = state.contentParts.some(
(p) => p.type === "text" || p.type === "reasoning" || p.type === "tool-call"
);
if (!hasContent) return;
const last = state.contentParts[state.contentParts.length - 1];
if (last && last.type === "data-step-separator") return;
const stepIndex = state.contentParts.filter((p) => p.type === "data-step-separator").length;
state.contentParts.push({ type: "data-step-separator", data: { stepIndex } });
state.currentTextPartIndex = -1;
state.currentReasoningPartIndex = -1;
}
/**
* Allowlist of tool names that should produce a UI tool card. The
* sentinel ``"all"`` matches every tool we dropped the legacy
* ``BASE_TOOLS_WITH_UI`` gate so that ALL tool calls render via the
* generic ``ToolFallback``. The backend's ``format_thinking_step``
* summarisation and the defensive ``result_length``-only default for
* unknown tools keep persisted message JSON from ballooning.
*/
export type ToolUIGate = Set<string> | "all";
function _toolPasses(gate: ToolUIGate, toolName: string): boolean {
return gate === "all" || gate.has(toolName);
}
export function addToolCall(
state: ContentPartsState,
toolsWithUI: Set<string>,
toolsWithUI: ToolUIGate,
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
force = false
force = false,
langchainToolCallId?: string
): void {
if (force || toolsWithUI.has(toolName)) {
if (force || _toolPasses(toolsWithUI, toolName)) {
state.contentParts.push({
type: "tool-call",
toolCallId,
toolName,
args,
...(langchainToolCallId ? { langchainToolCallId } : {}),
});
state.toolCallIndices.set(toolCallId, state.contentParts.length - 1);
state.currentTextPartIndex = -1;
state.currentReasoningPartIndex = -1;
}
}
/**
* Reverse-lookup helper used by the SSE ``data-action-log`` handler:
* given the LangChain ``tool_call.id`` (set on the content part as
* ``langchainToolCallId``), return the synthetic ``toolCallId`` that
* the chat tool card uses (``call_<run-id>``). Returns ``null`` when no
* matching tool card has been seen yet the action is still recorded
* in the LC-id-keyed atom so the card can pick it up when it eventually
* arrives.
*/
export function findToolCallIdByLcId(
state: ContentPartsState,
lcToolCallId: string
): string | null {
for (const part of state.contentParts) {
if (part.type === "tool-call" && part.langchainToolCallId === lcToolCallId) {
return part.toolCallId;
}
}
return null;
}
export function updateToolCall(
state: ContentPartsState,
toolCallId: string,
update: { args?: Record<string, unknown>; result?: unknown }
update: { args?: Record<string, unknown>; result?: unknown; langchainToolCallId?: string }
): void {
const index = state.toolCallIndices.get(toolCallId);
if (index !== undefined && state.contentParts[index]?.type === "tool-call") {
const tc = state.contentParts[index] as ContentPart & { type: "tool-call" };
if (update.args) tc.args = update.args;
if (update.result !== undefined) tc.result = update.result;
// Only backfill langchainToolCallId if not already set — the
// authoritative ``on_tool_end`` value should override an earlier
// best-effort match, but a NULL late-arriving value should not
// blow away a known good early one.
if (update.langchainToolCallId && !tc.langchainToolCallId) {
tc.langchainToolCallId = update.langchainToolCallId;
}
}
}
@ -184,13 +307,15 @@ function _hasInterruptResult(part: ContentPart): boolean {
export function buildContentForUI(
state: ContentPartsState,
toolsWithUI: Set<string>
toolsWithUI: ToolUIGate
): ThreadMessageLike["content"] {
const filtered = state.contentParts.filter((part) => {
if (part.type === "text") return part.text.length > 0;
if (part.type === "reasoning") return part.text.length > 0;
if (part.type === "tool-call")
return toolsWithUI.has(part.toolName) || _hasInterruptResult(part);
return _toolPasses(toolsWithUI, part.toolName) || _hasInterruptResult(part);
if (part.type === "data-thinking-steps") return true;
if (part.type === "data-step-separator") return true;
return false;
});
return filtered.length > 0
@ -200,20 +325,28 @@ export function buildContentForUI(
export function buildContentForPersistence(
state: ContentPartsState,
toolsWithUI: Set<string>
toolsWithUI: ToolUIGate
): unknown[] {
const parts: unknown[] = [];
for (const part of state.contentParts) {
if (part.type === "text" && part.text.length > 0) {
parts.push(part);
} else if (part.type === "reasoning" && part.text.length > 0) {
// Persist reasoning blocks so a chat reload re-renders the
// collapsed thinking section instead of
// silently dropping it (mirrors the data-thinking-steps
// branch above).
parts.push(part);
} else if (
part.type === "tool-call" &&
(toolsWithUI.has(part.toolName) || _hasInterruptResult(part))
(_toolPasses(toolsWithUI, part.toolName) || _hasInterruptResult(part))
) {
parts.push(part);
} else if (part.type === "data-thinking-steps") {
parts.push(part);
} else if (part.type === "data-step-separator") {
parts.push(part);
}
}
@ -221,23 +354,122 @@ export function buildContentForPersistence(
}
export type SSEEvent =
| { type: "text-delta"; delta: string }
| { type: "tool-input-start"; toolCallId: string; toolName: string }
| { type: "start"; messageId?: string }
| { type: "finish" }
| { type: "start-step" }
| { type: "finish-step" }
| { type: "text-start"; id: string }
| { type: "text-delta"; id?: string; delta: string }
| { type: "text-end"; id: string }
| { type: "reasoning-start"; id: string }
| { type: "reasoning-delta"; id?: string; delta: string }
| { type: "reasoning-end"; id: string }
| {
type: "tool-input-start";
toolCallId: string;
toolName: string;
/** Authoritative LangChain ``tool_call.id``. Optional. */
langchainToolCallId?: string;
}
| {
type: "tool-input-available";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
langchainToolCallId?: string;
}
| {
type: "tool-output-available";
toolCallId: string;
output: Record<string, unknown>;
/** Authoritative LangChain ``tool_call.id`` extracted from
* ``ToolMessage.tool_call_id`` at on_tool_end. Backfills cards
* that didn't get the id at tool-input-start time. */
langchainToolCallId?: string;
}
| { type: "data-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
| { type: "data-interrupt-request"; data: Record<string, unknown> }
| { type: "data-documents-updated"; data: Record<string, unknown> }
| {
/**
* A freshly committed AgentActionLog row. Frontend stores
* this in a Map keyed off ``lc_tool_call_id`` so the chat
* tool card can light up its Revert button.
*/
type: "data-action-log";
data: {
id: number;
lc_tool_call_id: string | null;
chat_turn_id: string | null;
tool_name: string;
reversible: boolean;
reverse_descriptor_present: boolean;
created_at: string | null;
error: boolean;
};
}
| {
/**
* Reversibility flipped (filesystem op SAVEPOINT committed;
* cf. ``kb_persistence._dispatch_reversibility_update``).
*/
type: "data-action-log-updated";
data: { id: number; reversible: boolean };
}
| {
/**
* Emitted at the start of every stream so the frontend can
* stamp the per-turn correlation id onto the in-flight
* assistant message and replay it via
* ``appendMessage``. Pure-text turns never produce
* action-log events; this event guarantees the frontend
* always learns the turn id.
*/
type: "data-turn-info";
data: { chat_turn_id: string };
}
| {
/**
* Best-effort revert pass that ran BEFORE this regeneration.
* Per-action results are forwarded to the UI so the user
* can see which downstream actions were rolled
* back vs which couldn't be undone.
*/
type: "data-revert-results";
data: {
status: "ok" | "partial";
chat_turn_ids: string[];
total: number;
reverted: number;
already_reverted: number;
not_reversible: number;
/**
* ``permission_denied`` and ``skipped`` are first-class
* counters so the response invariant
* ``total === sum(counters)`` always holds. Optional
* for forward compatibility with older backends; the
* frontend treats missing values as ``0``.
*/
permission_denied?: number;
failed: number;
skipped?: number;
results: Array<{
action_id: number;
tool_name: string;
status:
| "reverted"
| "already_reverted"
| "not_reversible"
| "permission_denied"
| "failed"
| "skipped";
message?: string | null;
new_action_id?: number | null;
error?: string | null;
}>;
};
}
| {
type: "data-token-usage";
data: {

View file

@ -46,6 +46,11 @@ export interface MessageRecord {
author_display_name?: string | null;
author_avatar_url?: string | null;
token_usage?: TokenUsageSummary | null;
// Per-turn correlation id from ``configurable.turn_id`` at streaming
// time (added in migration 136). Used by the per-turn revert
// endpoint and edit-from-arbitrary-position. Nullable on legacy
// rows that predate the column.
turn_id?: string | null;
}
export interface ThreadListResponse {
@ -123,10 +128,20 @@ export async function getThreadMessages(threadId: number): Promise<ThreadHistory
/**
* Append a message to a thread.
*
* ``turn_id`` is the per-turn correlation id streamed by the backend
* via ``data-turn-info``. Persisting it lets later edits locate the
* matching LangGraph checkpoint without HumanMessage scanning. Older
* callers can still omit it for back-compat.
*/
export async function appendMessage(
threadId: number,
message: { role: "user" | "assistant" | "system"; content: unknown; token_usage?: unknown }
message: {
role: "user" | "assistant" | "system";
content: unknown;
token_usage?: unknown;
turn_id?: string | null;
}
): Promise<MessageRecord> {
return baseApiService.post<MessageRecord>(`/api/v1/threads/${threadId}/messages`, undefined, {
body: message,