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
// message) — a2ui is disabled while that pipeline matures; revisit both
// 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
class ReconcileSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
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
return new Observable<any>((observer) => {
@@ -221,7 +230,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
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
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const filtered = msgs.filter((m) => {
@@ -231,7 +240,12 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
const hasToolCalls =
(Array.isArray(m?.toolCalls) && m.toolCalls.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 true;
@@ -384,6 +398,10 @@ app.all("/api/copilotkit/*", async (c) => {
context?: { description?: string; value?: string }[];
};
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);
// MAF's AG-UI endpoint declares `context` on its request model but