79 lines
3.0 KiB
JavaScript
79 lines
3.0 KiB
JavaScript
/**
|
|
* 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<string, unknown>; 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);
|
|
}
|