fix(control-ui): preserve optimistic chat tail

This commit is contained in:
Peter Steinberger
2026-04-25 06:15:47 +01:00
parent 86dc820560
commit 1afbfdf451
4 changed files with 144 additions and 1 deletions

View File

@@ -786,6 +786,68 @@ describe("loadChatHistory", () => {
]);
});
it("keeps local optimistic tail messages when history reload returns a stale snapshot", async () => {
const persistedUser = {
role: "user",
content: [{ type: "text", text: "first" }],
__openclaw: { seq: 1 },
};
const optimisticUser = {
role: "user",
content: [{ type: "text", text: "latest ask" }],
timestamp: 10,
};
const optimisticAssistant = {
role: "assistant",
content: [{ type: "text", text: "latest answer" }],
timestamp: 11,
};
const request = vi.fn().mockResolvedValue({
messages: [persistedUser],
thinkingLevel: "low",
});
const state = createState({
connected: true,
client: { request } as unknown as ChatState["client"],
chatMessages: [persistedUser, optimisticUser, optimisticAssistant],
});
await loadChatHistory(state);
expect(state.chatMessages).toEqual([persistedUser, optimisticUser, optimisticAssistant]);
expect(state.chatStream).toBeNull();
});
it("does not duplicate optimistic tail messages after history catches up", async () => {
const optimisticUser = {
role: "user",
content: [{ type: "text", text: "latest ask" }],
timestamp: 10,
};
const historyUser = {
role: "user",
content: [{ type: "text", text: "latest ask" }],
__openclaw: { seq: 1 },
};
const historyAssistant = {
role: "assistant",
content: [{ type: "text", text: "latest answer" }],
__openclaw: { seq: 2 },
};
const request = vi.fn().mockResolvedValue({
messages: [historyUser, historyAssistant],
});
const state = createState({
connected: true,
client: { request } as unknown as ChatState["client"],
chatMessages: [optimisticUser],
});
await loadChatHistory(state);
expect(state.chatMessages).toEqual([historyUser, historyAssistant]);
});
it("shows a targeted message when chat history is unauthorized", async () => {
const request = vi.fn().mockRejectedValue(
new GatewayRequestError({

View File

@@ -135,6 +135,80 @@ function shouldHideHistoryMessage(message: unknown): boolean {
);
}
function hasTranscriptMeta(message: unknown): boolean {
return Boolean(
message &&
typeof message === "object" &&
(message as { __openclaw?: unknown }).__openclaw &&
typeof (message as { __openclaw?: unknown }).__openclaw === "object",
);
}
function isLocallyOptimisticHistoryMessage(message: unknown): boolean {
if (!message || typeof message !== "object" || hasTranscriptMeta(message)) {
return false;
}
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
return role === "user" || role === "assistant";
}
function messageDisplaySignature(message: unknown): string | null {
if (!message || typeof message !== "object") {
return null;
}
const role = normalizeLowercaseStringOrEmpty((message as { role?: unknown }).role);
if (!role) {
return null;
}
const text = extractText(message)?.trim();
if (text) {
return `${role}:text:${text}`;
}
try {
const content = JSON.stringify((message as { content?: unknown }).content ?? null);
return `${role}:content:${content}`;
} catch {
return null;
}
}
function preserveOptimisticTailMessages(
historyMessages: unknown[],
previousMessages: unknown[],
): unknown[] {
if (historyMessages.length === 0 || previousMessages.length === 0) {
return historyMessages;
}
const historySignatures = new Set(
historyMessages
.map((message) => messageDisplaySignature(message))
.filter((signature): signature is string => Boolean(signature)),
);
let sharedPreviousIndex = -1;
for (let index = previousMessages.length - 1; index >= 0; index--) {
const signature = messageDisplaySignature(previousMessages[index]);
if (signature && historySignatures.has(signature)) {
sharedPreviousIndex = index;
break;
}
}
if (sharedPreviousIndex < 0) {
return historyMessages;
}
const optimisticTail: unknown[] = [];
for (const message of previousMessages.slice(sharedPreviousIndex + 1)) {
if (!isLocallyOptimisticHistoryMessage(message) || shouldHideHistoryMessage(message)) {
return historyMessages;
}
const signature = messageDisplaySignature(message);
if (!signature || historySignatures.has(signature)) {
return historyMessages;
}
optimisticTail.push(message);
}
return optimisticTail.length > 0 ? [...historyMessages, ...optimisticTail] : historyMessages;
}
function isRetryableStartupUnavailable(err: unknown, method: string): err is GatewayRequestError {
if (!(err instanceof GatewayRequestError)) {
return false;
@@ -203,6 +277,7 @@ export async function loadChatHistory(state: ChatState) {
const sessionKey = state.sessionKey;
const requestVersion = beginChatHistoryRequest(state);
const startedAt = Date.now();
const previousMessages = state.chatMessages;
state.chatLoading = true;
state.lastError = null;
try {
@@ -237,7 +312,8 @@ export async function loadChatHistory(state: ChatState) {
return;
}
const messages = Array.isArray(res.messages) ? res.messages : [];
state.chatMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
const visibleMessages = messages.filter((message) => !shouldHideHistoryMessage(message));
state.chatMessages = preserveOptimisticTailMessages(visibleMessages, previousMessages);
state.chatThinkingLevel = res.thinkingLevel ?? null;
// Clear all streaming state — history includes tool results and text
// inline, so keeping streaming artifacts would cause duplicates.