Assistant: hydrate the persisted thread in the UI

- Asistente.tsx pins threadId "main" on CopilotChat (an explicit stable
  id stops the per-mount message wipe), runs a hydration pass on page
  load — an empty-messages run makes MAF replay the stored snapshot as
  MESSAGES_SNAPSHOT with no LLM call — and sends the Inicio ask-box
  question only after hydration so every normal run carries the full
  client-known history. "Nueva conversación" clears the stored thread
  behind a ConfirmDialog.
- server.ts: the BFF no longer injects context into empty-messages
  requests (that would defeat MAF's hydration trigger), and the snapshot
  reconciliation passes hydration replays through untouched and treats
  ids the client already sent as history rather than duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 18:10:35 -06:00
parent 6bce5539f7
commit bbcfaa7808
2 changed files with 103 additions and 17 deletions

View File

@@ -201,12 +201,21 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
// counterpart. Caveat: interfered with a2ui retry runs (duplicated the user // counterpart. Caveat: interfered with a2ui retry runs (duplicated the user
// message) — a2ui is disabled while that pipeline matures; revisit both // message) — a2ui is disabled while that pipeline matures; revisit both
// together. // together.
//
// Thread persistence (2026-07-04) adds two exemptions: a hydration replay
// (empty input.messages) is passed through untouched — nothing in it was
// streamed this run — and messages whose id the client already sent in
// input.messages are history, not duplicates.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
class ReconcileSnapshotMiddleware extends (Middleware as any) { class ReconcileSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> { run(input: any, next: any): Observable<any> {
const streamedTextIds = new Set<string>(); const streamedTextIds = new Set<string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
const isHydration = inputMessages.length === 0;
const clientKnownIds = new Set<string>(inputMessages.map((m) => String(m?.id ?? "")));
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => { return new Observable<any>((observer) => {
@@ -221,7 +230,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
streamedTextIds.add(String(event.messageId)); streamedTextIds.add(String(event.messageId));
} }
if (type === EventType.MESSAGES_SNAPSHOT) { if (type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : []; const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const filtered = msgs.filter((m) => { const filtered = msgs.filter((m) => {
@@ -231,7 +240,12 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
const hasToolCalls = const hasToolCalls =
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) || (Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0); (Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) { if (
hasText &&
!hasToolCalls &&
!streamedTextIds.has(id) &&
!clientKnownIds.has(id)
) {
return false; // drop orphan text-only assistant duplicate return false; // drop orphan text-only assistant duplicate
} }
return true; return true;
@@ -384,6 +398,10 @@ app.all("/api/copilotkit/*", async (c) => {
context?: { description?: string; value?: string }[]; context?: { description?: string; value?: string }[];
}; };
if (!Array.isArray(body.messages)) return; if (!Array.isArray(body.messages)) return;
// An empty messages array is MAF's hydration trigger (known threadId
// + no messages → replay the stored snapshot without invoking the
// LLM). Injecting anything would turn it into a normal run.
if (body.messages.length === 0) return;
let messages = pairOrphanToolCalls(body.messages); let messages = pairOrphanToolCalls(body.messages);
// MAF's AG-UI endpoint declares `context` on its request model but // MAF's AG-UI endpoint declares `context` on its request model but

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { import {
CopilotChat, CopilotChat,
@@ -7,10 +7,17 @@ import {
useCopilotKit, useCopilotKit,
} from "@copilotkit/react-core/v2"; } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core"; import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react"; import { MessageSquarePlus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import ConfirmDialog from "@/components/ConfirmDialog";
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards"; import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
// Single persistent conversation: the backend snapshot store keys on this
// same id (backend/app/api/v1/endpoints/chat.py). An explicit, stable
// threadId also stops CopilotChat from wiping messages on remount.
const CHAT_THREAD_ID = "main";
const STATIC_SUGGESTIONS = { const STATIC_SUGGESTIONS = {
available: "before-first-message" as const, available: "before-first-message" as const,
suggestions: [ suggestions: [
@@ -24,28 +31,66 @@ const STATIC_SUGGESTIONS = {
export default function Asistente() { export default function Asistente() {
useConfigureSuggestions(STATIC_SUGGESTIONS); useConfigureSuggestions(STATIC_SUGGESTIONS);
// A question typed on the Inicio dashboard arrives via router state:
// append it as a user message and run the agent once.
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { agent } = useAgent({ agentId: "wealthysmart" }); const { agent } = useAgent({ agentId: "wealthysmart" });
const { copilotkit } = useCopilotKit(); const { copilotkit } = useCopilotKit();
const sentRef = useRef(false); const [confirmClear, setConfirmClear] = useState(false);
const [clearing, setClearing] = useState(false);
// Boot sequence, once per page load: hydrate the persisted thread, then
// send any question that arrived from the Inicio ask-box via router state.
// Order matters — hydration first keeps the invariant that every normal
// run's input carries the full client-known history (the BFF's snapshot
// reconciliation relies on it).
const bootRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!agent || bootRef.current) return;
bootRef.current = true;
const ask = (location.state as { ask?: string } | null)?.ask; const ask = (location.state as { ask?: string } | null)?.ask;
if (!ask || sentRef.current || !agent) return; if (ask) {
sentRef.current = true; // Clear the state so back/refresh doesn't re-send the question.
// Clear the state so back/refresh doesn't re-send the question. navigate(location.pathname, { replace: true, state: null });
navigate(location.pathname, { replace: true, state: null }); }
agent.addMessage({ void (async () => {
id: crypto.randomUUID(), agent.threadId = CHAT_THREAD_ID;
role: "user", if (agent.messages.length === 0) {
content: ask, // Empty-messages run = MAF's hydration trigger: replays the stored
}); // snapshot as MESSAGES_SNAPSHOT without invoking the LLM.
void copilotkit.runAgent({ agent }); try {
await copilotkit.runAgent({ agent });
} catch (err) {
console.error("[asistente] thread hydration failed:", err);
}
}
if (ask) {
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: ask,
});
void copilotkit.runAgent({ agent });
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]); }, [agent]);
const clearThread = async () => {
setClearing(true);
try {
await fetch("/api/v1/chat/thread", {
method: "DELETE",
credentials: "include",
});
agent?.setMessages([]);
setConfirmClear(false);
} catch (err) {
console.error("[asistente] clear thread failed:", err);
} finally {
setClearing(false);
}
};
useCopilotAction({ useCopilotAction({
name: "render_spending_summary", name: "render_spending_summary",
description: description:
@@ -92,12 +137,35 @@ export default function Asistente() {
icon={Sparkles} icon={Sparkles}
title="Asistente" title="Asistente"
subtitle="Pregúntale a WealthySmart sobre tus finanzas." subtitle="Pregúntale a WealthySmart sobre tus finanzas."
actions={
<Button
variant="outline"
size="sm"
onClick={() => setConfirmClear(true)}
disabled={!agent || agent.messages.length === 0}
>
<MessageSquarePlus />
Nueva conversación
</Button>
}
/> />
</div> </div>
{confirmClear && (
<ConfirmDialog
title="¿Nueva conversación?"
message="Se borrará el historial del chat guardado. Esta acción no se puede deshacer."
confirmLabel="Borrar historial"
onConfirm={clearThread}
onCancel={() => setConfirmClear(false)}
loading={clearing}
/>
)}
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card"> <div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat <CopilotChat
className="h-full" className="h-full"
threadId={CHAT_THREAD_ID}
labels={{ labels={{
modalHeaderTitle: "WealthySmart", modalHeaderTitle: "WealthySmart",
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?", welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",