mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:31:06 +00:00
fix(control-ui): preserve optimistic chat tail
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user