/** * Chat Finish sync invariants (regression guard — do not simplify away). * * Context (2026-06): Finish could arrive before Goose persisted assistant turns. * Blind replace with the server snapshot wiped the live chat and exposed agent-only * prefixes (routing hints / skill prefaces) when displayText was missing. * * Required behaviour: * - merge local streamed messages with server snapshot (never blind replace) * - retry sync while server has fewer messages than the local view * - user-facing text must strip agent-only prefixes when displayText is absent * * Tests: chat-finish-sync.test.mjs, conversation-display.test.mjs * Verify: scripts/verify-chat-finish-sync.mjs (source guards) * Docs: docs/regression-guards/mindspace-publish-and-chat-finish.md */ /** @typedef {{ id?: string; role?: string; metadata?: Record; content?: unknown[] }} ChatMessage */ /** * Merge an authoritative server conversation snapshot into the currently * displayed messages without erasing what the user can already see. * * @param {ChatMessage[]} current * @param {ChatMessage[]} incoming */ export function mergeConversationSnapshot(current, incoming) { if (!Array.isArray(incoming) || incoming.length === 0) { return Array.isArray(current) ? current : []; } const base = Array.isArray(current) ? current : []; const incomingIds = new Set(incoming.map((message) => message?.id).filter(Boolean)); const localOnlyByNextAnchor = new Map(); // Keep the regression-guard behaviour of retaining local streamed messages, // but put them back between the same server messages instead of appending the // whole local tail. Finish/UpdateConversation snapshots can temporarily omit // a middle message while Goose is still persisting it. for (let index = 0; index < base.length; index += 1) { const message = base[index]; if (!message?.id || incomingIds.has(message.id)) continue; let nextAnchor = null; for (let cursor = index + 1; cursor < base.length; cursor += 1) { const candidateId = base[cursor]?.id; if (candidateId && incomingIds.has(candidateId)) { nextAnchor = candidateId; break; } } const bucket = localOnlyByNextAnchor.get(nextAnchor) ?? []; bucket.push(message); localOnlyByNextAnchor.set(nextAnchor, bucket); } if (localOnlyByNextAnchor.size === 0) return incoming; const merged = []; for (const message of incoming) { const before = localOnlyByNextAnchor.get(message?.id); if (before?.length) merged.push(...before); merged.push(message); } const trailing = localOnlyByNextAnchor.get(null); if (trailing?.length) merged.push(...trailing); return merged; } /** * Finish-time sync: server may lag behind the streamed UI — keep the local tail. * * @param {ChatMessage[]} localMessages * @param {ChatMessage[]} serverMessages */ export function mergeSessionMessagesAfterFinish(localMessages, serverMessages) { return mergeConversationSnapshot(localMessages, serverMessages); }