fix: hydrate truncated chat history and unstick thinking on LLM idle timeout (#1064)

This commit is contained in:
paisley
2026-05-25 17:57:19 +08:00
committed by GitHub
parent d15e2330d5
commit e79a4f923e
27 changed files with 926 additions and 110 deletions
+3
View File
@@ -42,6 +42,9 @@ coverage/
playwright-report/
test-results/
# Local session transcript fixtures (may contain private conversation data)
tests/fixtures/transcripts/
# Cache
.cache/
.turbo/
+150 -45
View File
@@ -19,6 +19,12 @@ import {
shouldRetryStartupHistoryLoad,
sleep,
} from './chat/history-startup-retry';
import {
buildChatHistoryRpcParams,
getChatHistoryMaxChars,
} from './chat/history-rpc-params';
import { loadSessionTranscriptFallback } from './chat/history-transcript-fallback';
import { hydrateGatewayHistoryFromTranscript } from './chat/history-transcript-hydrate';
import {
LABEL_FETCH_RETRY_DELAYS_MS,
abandonSessionLabelHydration,
@@ -113,6 +119,10 @@ const OPTIMISTIC_USER_MESSAGE_TTL_MS = 30 * 60 * 1000;
const OPTIMISTIC_USER_TIMESTAMP_MATCH_MS = 120_000;
/** Grace period before surfacing mid-run Gateway errors that often self-recover. */
const ERROR_RECOVERY_DELAY_MS = 12_000;
/** OpenClaw LLM idle timeout before an internal retry. */
const LLM_IDLE_HINT_MS = 120_000;
/** Wait past one LLM idle window before declaring a hard no-response failure. */
const NO_RESPONSE_SAFETY_TIMEOUT_MS = 130_000;
type PendingOptimisticUserMessage = {
message: RawMessage;
@@ -251,19 +261,6 @@ function toSessionLabel(text: string, maxLength = 50): string {
return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength)}` : cleaned;
}
async function loadSessionTranscriptFallback(sessionKey: string, limit = 200): Promise<RawMessage[]> {
try {
const params = new URLSearchParams({ sessionKey, limit: String(limit) });
const response = await hostApiFetch<{ messages?: RawMessage[] }>(
`/api/sessions/transcript?${params.toString()}`,
);
return Array.isArray(response.messages) ? response.messages : [];
} catch (error) {
console.warn('[chat.history] transcript fallback failed:', error);
return [];
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
@@ -1995,9 +1992,7 @@ function isRealUserBoundaryMessage(msg: RawMessage): boolean {
return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult');
}
/** True when the post-user segment has real run output (not a thinking-only stub). */
function hasMeaningfulAssistantProgressAfterLastUser(messages: RawMessage[]): boolean {
const segment = postUserSegmentMessages(messages);
function segmentHasMeaningfulAssistantProgress(segment: RawMessage[]): boolean {
return segment.some((msg) => {
if (msg.role !== 'assistant') return false;
if (isTerminalAssistantErrorMessage(msg)) return true;
@@ -2006,6 +2001,40 @@ function hasMeaningfulAssistantProgressAfterLastUser(messages: RawMessage[]): bo
});
}
/** True when the post-user segment has real run output (not a thinking-only stub). */
function hasMeaningfulAssistantProgressAfterLastUser(messages: RawMessage[]): boolean {
return segmentHasMeaningfulAssistantProgress(postUserSegmentMessages(messages));
}
/** True when streaming state carries visible progress (not a role-only placeholder). */
function hasMeaningfulStreamingActivity(
streamingMessage: unknown | null,
streamingText: string,
streamingTools: ToolStatus[],
): boolean {
if (streamingText.trim()) return true;
if (streamingTools.length > 0) return true;
if (!streamingMessage || typeof streamingMessage !== 'object') return false;
const msg = streamingMessage as RawMessage;
if (typeof msg.content === 'string' && msg.content.trim()) return true;
const content = msg.content;
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'text' && block.text?.trim()) return true;
if (block.type === 'thinking' && block.thinking?.trim()) return true;
if (block.type === 'tool_use' || block.type === 'toolCall') return true;
if (block.type === 'image') return true;
}
}
const raw = msg as unknown as Record<string, unknown>;
if (typeof raw.text === 'string' && raw.text.trim()) return true;
const toolCalls = raw.tool_calls ?? raw.toolCalls;
return Array.isArray(toolCalls) && toolCalls.length > 0;
}
function hasAssistantProgressSinceSend(messages: RawMessage[], lastUserMessageAt: number | null): boolean {
if (!lastUserMessageAt) return false;
const normalized = [...messages];
@@ -2029,6 +2058,28 @@ function postUserSegmentMessages(filteredMessages: RawMessage[]): RawMessage[] {
return [];
}
/** Segment after the user turn that matches the in-flight send (not prior history). */
function getOpenRunSegmentFromHistory(
filteredMessages: RawMessage[],
lastUserMessageAt: number | null,
): RawMessage[] {
if (lastUserMessageAt == null) {
return postUserSegmentMessages(filteredMessages);
}
const userMsTs = toMs(lastUserMessageAt);
const CLOCK_SKEW_MS = 5_000;
for (let i = filteredMessages.length - 1; i >= 0; i -= 1) {
const message = filteredMessages[i];
if (!isRealUserBoundaryMessage(message)) continue;
const ts = message.timestamp ? toMs(message.timestamp as number) : null;
if (ts == null) continue;
if (ts + CLOCK_SKEW_MS >= userMsTs && ts <= userMsTs + OPTIMISTIC_USER_TIMESTAMP_MATCH_MS) {
return filteredMessages.slice(i + 1);
}
}
return [];
}
/** Only treat inbound runs as user-visible for this long after the last user send. */
const USER_INITIATED_RUN_MAX_AGE_MS = 10 * 60 * 1000;
@@ -2602,9 +2653,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
finalMessages = dropRedundantOptimisticUserMessages(currentSessionKey, finalMessages);
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0;
const userMsTs = lastUserMessageAt != null ? toMs(lastUserMessageAt) : 0;
const isAfterUserMsg = (msg: RawMessage): boolean => {
if (!userMsTs || !msg.timestamp) return true;
if (lastUserMessageAt == null) return true;
if (!msg.timestamp) return false;
return toMs(msg.timestamp) >= userMsTs;
};
const isRealUserBoundary = (msg: RawMessage): boolean => {
@@ -2613,16 +2665,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
const blocks = msg.content as Array<{ type?: string }>;
return blocks.length === 0 || !blocks.every((block) => block.type === 'tool_result' || block.type === 'toolResult');
};
const postBoundaryMessages = userMsTs
? filteredMessages.filter((msg) => isAfterUserMsg(msg))
: (() => {
for (let i = filteredMessages.length - 1; i >= 0; i -= 1) {
if (isRealUserBoundary(filteredMessages[i])) {
return filteredMessages.slice(i + 1);
const openRunSegment = isSendingNow && lastUserMessageAt != null
? getOpenRunSegmentFromHistory(filteredMessages, lastUserMessageAt)
: postUserSegmentMessages(filteredMessages);
const postBoundaryMessages = isSendingNow && lastUserMessageAt != null
? openRunSegment
: (lastUserMessageAt != null
? filteredMessages.filter((msg) => isAfterUserMsg(msg))
: (() => {
for (let i = filteredMessages.length - 1; i >= 0; i -= 1) {
if (isRealUserBoundary(filteredMessages[i])) {
return filteredMessages.slice(i + 1);
}
}
}
return filteredMessages;
})();
return filteredMessages;
})());
const lastAssistantAfterBoundary = [...postBoundaryMessages].reverse().find((msg) => msg.role === 'assistant');
const latestTerminalAssistantErrorMessage = lastAssistantAfterBoundary
&& (getMessageStopReason(lastAssistantAfterBoundary) === 'error'
@@ -2699,10 +2756,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
// (WS disconnect, console-only runs, etc.). Any assistant turn after the
// user's message counts as progress so the safety timeout does not emit a
// false "No response received" error while tool chains are still running.
if (isSendingNow && hasMeaningfulAssistantProgressAfterLastUser(filteredMessages)) {
const progressSegment = openRunSegment;
if (isSendingNow && segmentHasMeaningfulAssistantProgress(progressSegment)) {
_lastChatEventAt = Date.now();
if (get().error) {
set({ error: null });
if (get().error || get().runError) {
set({ error: null, runError: null });
}
}
@@ -2713,9 +2771,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
// very first `[thinking, toolCall]` intermediate turn and then paired
// with the closer below to clobber the entire run state.
if (isSendingNow && !pendingFinal) {
const hasFinalLikeAssistant = [...filteredMessages].reverse().some((msg) => {
const hasFinalLikeAssistant = openRunSegment.some((msg) => {
if (msg.role !== 'assistant') return false;
if (!isAfterUserMsg(msg)) return false;
if (hasPendingToolUse(msg)) return false;
return hasNonToolAssistantContent(msg);
});
@@ -2732,22 +2789,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
// count as non-tool content), clears `sending` / `activeRunId` /
// `pendingFinal`, and makes the Thinking… indicator vanish mid-chain.
if (pendingFinal || get().pendingFinal) {
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
const recentAssistant = [...openRunSegment].reverse().find((msg) => {
if (msg.role !== 'assistant') return false;
if (!isAfterUserMsg(msg)) return false;
if (hasPendingToolUse(msg)) return false;
return hasNonToolAssistantContent(msg);
});
if (recentAssistant) {
clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false });
set({ sending: false, activeRunId: null, pendingFinal: false, runError: null });
}
}
// Unstick lifecycle when history already has a conclusive reply but the
// Gateway never emitted a terminal phase event (WS drop, console run, etc.).
if (isSendingNow && !get().streamingMessage && get().streamingTools.length === 0) {
const openSegment = postUserSegmentMessages(filteredMessages);
const openSegment = openRunSegment;
const hasConclusiveReply = openSegment.some((message) => {
if (message.role !== 'assistant') return false;
if (hasPendingToolUse(message)) return false;
@@ -2760,6 +2816,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
activeRunId: null,
pendingFinal: false,
lastUserMessageAt: null,
runError: null,
});
}
}
@@ -2800,6 +2857,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
try {
const fallbackMessages: RawMessage[] = [];
const gatewayRpc = useGatewayStore.getState().rpc.bind(useGatewayStore.getState());
const chatHistoryParams = buildChatHistoryRpcParams(
currentSessionKey,
HISTORY_PAGE_SIZE,
getChatHistoryMaxChars(gatewayRpc),
);
let data: Record<string, unknown> | null = null;
let lastError: unknown = null;
@@ -2810,9 +2873,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
try {
data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
data = await gatewayRpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: currentSessionKey, limit: HISTORY_PAGE_SIZE },
chatHistoryParams,
historyTimeoutOverride,
);
lastError = null;
@@ -2851,6 +2914,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
rawMessages = fallbackMessages.length > 0
? fallbackMessages
: await loadLocalHistoryFallback(currentSessionKey, 200);
} else if (rawMessages.length > 0) {
rawMessages = await hydrateGatewayHistoryFromTranscript(
currentSessionKey,
rawMessages,
HISTORY_PAGE_SIZE,
get().messages,
);
}
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
@@ -3074,33 +3144,68 @@ export const useChatStore = create<ChatState>((set, get) => ({
};
_historyPollTimer = setTimeout(pollHistory, POLL_START_DELAY);
const SAFETY_TIMEOUT_MS = 90_000;
const checkStuck = () => {
const state = get();
if (!state.sending) return;
if (state.streamingMessage || state.streamingText) return;
if (state.pendingFinal) {
const hasStream = hasMeaningfulStreamingActivity(
state.streamingMessage,
state.streamingText,
state.streamingTools,
);
if (hasStream) {
setTimeout(checkStuck, 10_000);
return;
}
if (hasAssistantProgressSinceSend(state.messages, state.lastUserMessageAt)) {
// Gateway run-start / model-switch deltas can set `{ role: 'assistant' }`
// with no payload. That placeholder must not block the safety timeout.
if (state.streamingMessage || state.streamingText) {
set({ streamingMessage: null, streamingText: '' });
}
const sendAgeMs = state.lastUserMessageAt
? Date.now() - toMs(state.lastUserMessageAt)
: 0;
const hasProgress = hasAssistantProgressSinceSend(state.messages, state.lastUserMessageAt);
if (sendAgeMs >= LLM_IDLE_HINT_MS && !state.runError && !hasProgress) {
set({
runError: 'The model did not respond within 120 seconds. Retrying…',
});
}
if (state.pendingFinal) {
if (hasProgress) {
setTimeout(checkStuck, 10_000);
return;
}
set({ pendingFinal: false });
}
if (hasProgress) {
_lastChatEventAt = Date.now();
if (state.error) {
set({ error: null });
if (state.error || state.runError) {
set({ error: null, runError: null });
}
setTimeout(checkStuck, 10_000);
return;
}
if (Date.now() - _lastChatEventAt < SAFETY_TIMEOUT_MS) {
if (Date.now() - _lastChatEventAt < NO_RESPONSE_SAFETY_TIMEOUT_MS) {
setTimeout(checkStuck, 10_000);
return;
}
clearHistoryPoll();
set({
error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.',
sending: false,
activeRunId: null,
lastUserMessageAt: null,
pendingFinal: false,
streamingMessage: null,
streamingText: '',
});
};
setTimeout(checkStuck, 30_000);
+35 -1
View File
@@ -28,6 +28,11 @@ import {
shouldRetryStartupHistoryLoad,
sleep,
} from './history-startup-retry';
import {
buildChatHistoryRpcParams,
getChatHistoryMaxChars,
} from './history-rpc-params';
import { hydrateGatewayHistoryFromTranscript } from './history-transcript-hydrate';
import type { RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
@@ -237,6 +242,28 @@ export function createHistoryActions(
};
try {
const gatewayRpc = async <T>(
method: string,
params?: unknown,
timeoutMs?: number,
): Promise<T> => {
const result = await invokeIpc(
'gateway:rpc',
method,
params,
...(timeoutMs != null ? [timeoutMs] as const : []),
) as { success: boolean; result?: T; error?: string };
if (!result.success) {
throw new Error(result.error || `RPC ${method} failed`);
}
return result.result as T;
};
const chatHistoryParams = buildChatHistoryRpcParams(
currentSessionKey,
200,
getChatHistoryMaxChars(gatewayRpc),
);
let result: { success: boolean; result?: Record<string, unknown>; error?: string } | null = null;
let lastError: unknown = null;
@@ -249,7 +276,7 @@ export function createHistoryActions(
result = await invokeIpc(
'gateway:rpc',
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 },
chatHistoryParams,
...(historyTimeoutOverride != null ? [historyTimeoutOverride] as const : []),
) as { success: boolean; result?: Record<string, unknown>; error?: string };
@@ -293,6 +320,13 @@ export function createHistoryActions(
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
} else if (rawMessages.length > 0) {
rawMessages = await hydrateGatewayHistoryFromTranscript(
currentSessionKey,
rawMessages,
200,
get().messages,
);
}
const applied = applyLoadedMessages(rawMessages, thinkingLevel);
if (applied && isInitialForegroundLoad) {
+78
View File
@@ -0,0 +1,78 @@
/** OpenClaw accepts chat.history maxChars in the range 1500_000. */
export const OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP = 500_000;
export const DEFAULT_CHAT_HISTORY_MAX_CHARS = OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP;
export type ChatHistoryRpc = <T>(
method: string,
params?: unknown,
timeoutMs?: number,
) => Promise<T>;
let cachedMaxChars: number | null = null;
let configRefreshPromise: Promise<void> | null = null;
function extractChatHistoryMaxChars(snapshot: unknown): number | null {
if (!snapshot || typeof snapshot !== 'object') return null;
const root = snapshot as Record<string, unknown>;
const config = (root.config ?? root.parsed ?? root) as Record<string, unknown>;
const gateway = config.gateway as Record<string, unknown> | undefined;
const webchat = gateway?.webchat as Record<string, unknown> | undefined;
const value = webchat?.chatHistoryMaxChars;
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return Math.min(
OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP,
Math.max(1, Math.floor(value)),
);
}
export function resetChatHistoryMaxCharsCache(): void {
cachedMaxChars = null;
configRefreshPromise = null;
}
function scheduleChatHistoryMaxCharsRefresh(rpc: ChatHistoryRpc): void {
if (configRefreshPromise) return;
configRefreshPromise = rpc<unknown>('config.get', {}, 5_000)
.then((snapshot) => {
const fromConfig = extractChatHistoryMaxChars(snapshot);
if (fromConfig != null) {
cachedMaxChars = fromConfig;
}
})
.catch(() => {
// Keep the default cap when config.get is unavailable during startup.
})
.finally(() => {
configRefreshPromise = null;
});
}
export async function resolveChatHistoryMaxChars(
rpc?: ChatHistoryRpc,
): Promise<number> {
return getChatHistoryMaxChars(rpc);
}
export function getChatHistoryMaxChars(rpc?: ChatHistoryRpc): number {
if (cachedMaxChars != null) return cachedMaxChars;
cachedMaxChars = DEFAULT_CHAT_HISTORY_MAX_CHARS;
if (rpc) {
scheduleChatHistoryMaxCharsRefresh(rpc);
}
return cachedMaxChars;
}
export function buildChatHistoryRpcParams(
sessionKey: string,
limit: number,
maxChars: number,
): { sessionKey: string; limit: number; maxChars: number } {
return {
sessionKey,
limit,
maxChars,
};
}
@@ -0,0 +1,18 @@
import { hostApiFetch } from '@/lib/host-api';
import type { RawMessage } from './types';
export async function loadSessionTranscriptFallback(
sessionKey: string,
limit = 200,
): Promise<RawMessage[]> {
try {
const params = new URLSearchParams({ sessionKey, limit: String(limit) });
const response = await hostApiFetch<{ messages?: RawMessage[] }>(
`/api/sessions/transcript?${params.toString()}`,
);
return Array.isArray(response.messages) ? response.messages : [];
} catch (error) {
console.warn('[chat.history] transcript fallback failed:', error);
return [];
}
}
@@ -0,0 +1,26 @@
import { loadSessionTranscriptFallback } from './history-transcript-fallback';
import {
gatewayHistoryNeedsTranscriptHydration,
mergeGatewayHistoryWithTranscript,
} from './history-transcript-merge';
import type { RawMessage } from './types';
export async function hydrateGatewayHistoryFromTranscript(
sessionKey: string,
gatewayMessages: RawMessage[],
limit: number,
localMessages?: RawMessage[],
): Promise<RawMessage[]> {
if (!gatewayHistoryNeedsTranscriptHydration(gatewayMessages)) {
return gatewayMessages;
}
const transcriptMessages = await loadSessionTranscriptFallback(sessionKey, limit);
let merged = mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages);
if (gatewayHistoryNeedsTranscriptHydration(merged) && localMessages?.length) {
merged = mergeGatewayHistoryWithTranscript(merged, localMessages);
}
return merged;
}
+114
View File
@@ -0,0 +1,114 @@
import { getMessageText } from './helpers';
import type { RawMessage } from './types';
const TRUNCATION_SUFFIXES = [
/\n?\.\.\.\(truncated\)\.\.\.$/,
/\n?…\(truncated\)…$/,
/\n?\[chat\.history omitted: message too large\]$/,
];
export function isTruncatedHistoryText(text: string): boolean {
if (!text) return false;
return TRUNCATION_SUFFIXES.some((pattern) => pattern.test(text));
}
function stripTruncationSuffix(text: string): string {
let result = text;
for (const pattern of TRUNCATION_SUFFIXES) {
result = result.replace(pattern, '');
}
return result;
}
function replaceTruncatedContent(
gatewayContent: unknown,
transcriptContent: unknown,
): unknown {
if (typeof gatewayContent === 'string' && typeof transcriptContent === 'string') {
if (!isTruncatedHistoryText(gatewayContent)) return gatewayContent;
if (isTruncatedHistoryText(transcriptContent)) return gatewayContent;
const gatewayPrefix = stripTruncationSuffix(gatewayContent);
if (
transcriptContent.length > gatewayPrefix.length
&& (transcriptContent.startsWith(gatewayPrefix) || gatewayPrefix.length >= 64)
) {
return transcriptContent;
}
return gatewayContent;
}
if (!Array.isArray(gatewayContent) || !Array.isArray(transcriptContent)) {
return gatewayContent;
}
const gatewayBlocks = gatewayContent as Array<{ type?: string; text?: string }>;
const transcriptBlocks = transcriptContent as Array<{ type?: string; text?: string }>;
if (gatewayBlocks.length !== transcriptBlocks.length) {
const gatewayText = getMessageText(gatewayContent);
const transcriptText = getMessageText(transcriptContent);
if (isTruncatedHistoryText(gatewayText) && !isTruncatedHistoryText(transcriptText)) {
const gatewayPrefix = stripTruncationSuffix(gatewayText);
if (
transcriptText.length > gatewayPrefix.length
&& (transcriptText.startsWith(gatewayPrefix) || gatewayPrefix.length >= 64)
) {
return transcriptContent;
}
}
return gatewayContent;
}
let changed = false;
const mergedBlocks = gatewayBlocks.map((block, index) => {
if (block.type !== 'text' || typeof block.text !== 'string') return block;
const transcriptBlock = transcriptBlocks[index];
if (transcriptBlock?.type !== 'text' || typeof transcriptBlock.text !== 'string') {
return block;
}
const nextText = replaceTruncatedContent(block.text, transcriptBlock.text);
if (nextText !== block.text) {
changed = true;
return { ...block, text: nextText as string };
}
return block;
});
return changed ? mergedBlocks : gatewayContent;
}
function messageMatchKey(message: RawMessage): string {
if (message.id) return `id:${message.id}`;
return `rt:${message.role}|${message.timestamp ?? ''}`;
}
function buildTranscriptLookup(transcriptMessages: RawMessage[]): Map<string, RawMessage> {
const lookup = new Map<string, RawMessage>();
for (const message of transcriptMessages) {
lookup.set(messageMatchKey(message), message);
}
return lookup;
}
export function gatewayHistoryNeedsTranscriptHydration(messages: RawMessage[]): boolean {
return messages.some((message) => isTruncatedHistoryText(getMessageText(message.content)));
}
export function mergeGatewayHistoryWithTranscript(
gatewayMessages: RawMessage[],
transcriptMessages: RawMessage[],
): RawMessage[] {
if (gatewayMessages.length === 0 || transcriptMessages.length === 0) {
return gatewayMessages;
}
const lookup = buildTranscriptLookup(transcriptMessages);
return gatewayMessages.map((message, index) => {
const transcriptMatch = lookup.get(messageMatchKey(message))
?? transcriptMessages[index];
if (!transcriptMatch) return message;
const nextContent = replaceTruncatedContent(message.content, transcriptMatch.content);
if (nextContent === message.content) return message;
return { ...message, content: nextContent };
});
}
@@ -52,11 +52,11 @@ test.describe('ClawX assistant reply Markdown styling', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
+6 -6
View File
@@ -94,11 +94,11 @@ test.describe('ClawX chat file changes', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: history },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: history },
},
@@ -184,11 +184,11 @@ test.describe('ClawX chat file changes', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: htmlFileHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: htmlFileHistory },
},
@@ -278,11 +278,11 @@ test.describe('ClawX chat file changes', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: attachedFileHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: attachedFileHistory },
},
+11
View File
@@ -0,0 +1,11 @@
import { DEFAULT_CHAT_HISTORY_MAX_CHARS } from '@/stores/chat/history-rpc-params';
export function chatHistoryRpcParams(sessionKey: string, limit: number) {
return {
sessionKey,
limit,
maxChars: DEFAULT_CHAT_HISTORY_MAX_CHARS,
};
}
export { DEFAULT_CHAT_HISTORY_MAX_CHARS as CHAT_HISTORY_MAX_CHARS };
+1 -1
View File
@@ -61,7 +61,7 @@ test.describe('ClawX startup chat history recovery', () => {
},
};
}
if (key === stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200 }])) {
if (key === stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200, maxChars: 500000 }])) {
chatHistoryCallCount += 1;
if (chatHistoryCallCount === 1) {
return {
+2 -2
View File
@@ -53,11 +53,11 @@ test.describe('ClawX chat LaTeX rendering', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
+4 -4
View File
@@ -35,11 +35,11 @@ test.describe('ClawX chat session date grouping', () => {
success: true,
result: { sessions },
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
@@ -114,11 +114,11 @@ test.describe('ClawX chat session date grouping', () => {
}],
},
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
+2 -2
View File
@@ -42,11 +42,11 @@ test.describe('ClawX chat question directory', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
+1 -1
View File
@@ -25,7 +25,7 @@ test.describe('ClawX chat run state events', () => {
sessions: [{ key: MAIN_SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
+2 -2
View File
@@ -31,11 +31,11 @@ test.describe('ClawX chat scroll-to-latest affordance', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
+4 -4
View File
@@ -25,11 +25,11 @@ test.describe('ClawX chat skill trigger', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
@@ -138,11 +138,11 @@ test.describe('ClawX chat skill trigger', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: [] },
},
+2 -2
View File
@@ -70,11 +70,11 @@ test.describe('ClawX chat table header styling', () => {
sessions: [{ key: SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: { messages: seededHistory },
},
+6 -6
View File
@@ -203,13 +203,13 @@ test.describe('ClawX chat execution graph', () => {
sessions: [{ key: PROJECT_MANAGER_SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: {
messages: seededHistory,
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: {
messages: seededHistory,
@@ -299,13 +299,13 @@ test.describe('ClawX chat execution graph', () => {
sessions: [{ key: PROJECT_MANAGER_SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: {
messages: longRunHistory,
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: {
messages: longRunHistory,
@@ -369,13 +369,13 @@ test.describe('ClawX chat execution graph', () => {
sessions: [{ key: PROJECT_MANAGER_SESSION_KEY, displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
success: true,
result: {
messages: errorRunHistory,
},
},
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000 }])]: {
[stableStringify(['chat.history', { sessionKey: PROJECT_MANAGER_SESSION_KEY, limit: 1000, maxChars: 500000 }])]: {
success: true,
result: {
messages: errorRunHistory,
+1 -1
View File
@@ -146,7 +146,7 @@ test.describe('ClawX gateway lifecycle resilience', () => {
sessions: [{ key: 'agent:main:main', displayName: 'main' }],
},
},
[stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200 }])]: {
[stableStringify(['chat.history', { sessionKey: 'agent:main:main', limit: 200, maxChars: 500000 }])]: {
success: true,
result: {
messages: [
+7 -3
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { chatHistoryRpcParams } from './gateway-rpc-test-utils';
const invokeIpcMock = vi.fn();
const hostApiFetchMock = vi.fn();
@@ -156,12 +157,15 @@ function makeHarness(initial?: Partial<ChatLikeState>) {
}
describe('chat history actions', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetAllMocks();
vi.resetModules();
vi.useRealTimers();
invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } });
hostApiFetchMock.mockResolvedValue({ messages: [] });
const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import('@/stores/chat/history-rpc-params');
resetChatHistoryMaxCharsCache();
await resolveChatHistoryMaxChars();
gatewayStoreGetStateMock.mockReturnValue({
status: { state: 'running', port: 18789, connectedAt: Date.now() },
});
@@ -397,14 +401,14 @@ describe('chat history actions', () => {
1,
'gateway:rpc',
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
);
expect(invokeIpcMock).toHaveBeenNthCalledWith(
2,
'gateway:rpc',
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
);
expect(h.read().messages.map((message) => message.content)).toEqual(['restored after retry']);
+164 -27
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chatHistoryRpcParams } from './gateway-rpc-test-utils';
const { gatewayRpcMock, agentsState, hostApiFetchMock } = vi.hoisted(() => ({
gatewayRpcMock: vi.fn(),
@@ -28,7 +29,7 @@ vi.mock('@/lib/host-api', () => ({
}));
describe('useChatStore startup history retry', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers();
window.localStorage.clear();
@@ -36,6 +37,9 @@ describe('useChatStore startup history retry', () => {
gatewayRpcMock.mockReset();
hostApiFetchMock.mockReset();
hostApiFetchMock.mockResolvedValue({ messages: [] });
const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import('@/stores/chat/history-rpc-params');
resetChatHistoryMaxCharsCache();
await resolveChatHistoryMaxChars();
});
afterEach(() => {
@@ -80,13 +84,13 @@ describe('useChatStore startup history retry', () => {
expect(gatewayRpcMock).toHaveBeenNthCalledWith(
1,
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
);
expect(gatewayRpcMock).toHaveBeenNthCalledWith(
2,
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
undefined,
);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
@@ -141,10 +145,15 @@ describe('useChatStore startup history retry', () => {
message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' },
});
await Promise.resolve();
await Promise.resolve();
await vi.waitFor(() => {
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
'hello',
'Real answer',
]);
});
expect(gatewayRpcMock).toHaveBeenCalledTimes(2);
const historyCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history');
expect(historyCalls).toHaveLength(2);
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
'hello',
'Real answer',
@@ -191,7 +200,7 @@ describe('useChatStore startup history retry', () => {
expect(gatewayRpcMock).toHaveBeenNthCalledWith(
2,
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
undefined,
);
expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 15_000);
@@ -479,7 +488,7 @@ describe('useChatStore startup history retry', () => {
expect(gatewayRpcMock).toHaveBeenLastCalledWith(
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
@@ -487,6 +496,7 @@ describe('useChatStore startup history retry', () => {
});
it('does not burn the first-load retry path when the first attempt becomes stale', async () => {
vi.useRealTimers();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { useChatStore } = await import('@/stores/chat');
@@ -511,14 +521,25 @@ describe('useChatStore startup history retry', () => {
});
let resolveFirstAttempt: ((value: { messages: Array<{ role: string; content: string; timestamp: number }> }) => void) | null = null;
gatewayRpcMock
.mockImplementationOnce(() => new Promise((resolve) => {
resolveFirstAttempt = resolve;
}))
.mockRejectedValueOnce(new Error('RPC timeout: chat.history'))
.mockResolvedValueOnce({
let historyAttempt = 0;
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'config.get') return {};
if (method !== 'chat.history') {
throw new Error(`Unexpected gateway RPC: ${method}`);
}
historyAttempt += 1;
if (historyAttempt === 1) {
return await new Promise<{ messages: Array<{ role: string; content: string; timestamp: number }> }>((resolve) => {
resolveFirstAttempt = resolve;
});
}
if (historyAttempt === 2) {
throw new Error('RPC timeout: chat.history');
}
return {
messages: [{ role: 'assistant', content: 'restored after retry', timestamp: 1002 }],
});
};
});
const firstLoad = useChatStore.getState().loadHistory(false);
useChatStore.setState({
@@ -535,23 +556,25 @@ describe('useChatStore startup history retry', () => {
messages: [],
});
const secondLoad = useChatStore.getState().loadHistory(false);
await vi.runAllTimersAsync();
await new Promise((resolve) => setTimeout(resolve, 900));
await secondLoad;
vi.useFakeTimers();
expect(gatewayRpcMock).toHaveBeenCalledTimes(3);
expect(gatewayRpcMock.mock.calls[0]).toEqual([
const historyCalls = gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history');
expect(historyCalls).toHaveLength(3);
expect(historyCalls[0]).toEqual([
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
]);
expect(gatewayRpcMock.mock.calls[1]).toEqual([
expect(historyCalls[1]).toEqual([
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
]);
expect(gatewayRpcMock.mock.calls[2]).toEqual([
expect(historyCalls[2]).toEqual([
'chat.history',
{ sessionKey: 'agent:main:main', limit: 200 },
chatHistoryRpcParams('agent:main:main', 200),
35_000,
]);
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['restored after retry']);
@@ -600,7 +623,7 @@ describe('useChatStore startup history retry', () => {
await useChatStore.getState().loadHistory(false);
expect(gatewayRpcMock).toHaveBeenCalledTimes(1);
expect(gatewayRpcMock.mock.calls.filter(([method]) => method === 'chat.history')).toHaveLength(1);
expect(useChatStore.getState().currentSessionKey).toBe('agent:main:other');
expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['other session']);
expect(useChatStore.getState().error).toBeNull();
@@ -1108,6 +1131,7 @@ describe('useChatStore startup history retry', () => {
// absent (WS drop, long tool execution) but chat.history still surfaces
// intermediate assistant turns — those must count as progress.
it('clears a stale no-response error when history poll shows tool progress', async () => {
const sendAtMs = 1_700_000_000_000;
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:session-stuck',
@@ -1122,7 +1146,7 @@ describe('useChatStore startup history retry', () => {
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: Date.now(),
lastUserMessageAt: sendAtMs,
pendingToolImages: [],
error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.',
loading: false,
@@ -1131,7 +1155,7 @@ describe('useChatStore startup history retry', () => {
gatewayRpcMock.mockResolvedValueOnce({
messages: [
{ id: 'user-stuck', role: 'user', content: 'weather check', timestamp: 1000 },
{ id: 'user-stuck', role: 'user', content: 'weather check', timestamp: sendAtMs },
{
id: 'assistant-tool-stuck',
role: 'assistant',
@@ -1140,7 +1164,7 @@ describe('useChatStore startup history retry', () => {
{ type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } },
],
stopReason: 'toolUse',
timestamp: 1500,
timestamp: sendAtMs + 500,
},
],
});
@@ -1215,4 +1239,117 @@ describe('useChatStore startup history retry', () => {
resolveSend?.({ runId: 'run-stuck-test' });
await sendPromise;
});
it('surfaces an idle-timeout hint when a role-only stream placeholder stalls the run', async () => {
let resolveSend: ((value: { runId: string }) => void) | undefined;
gatewayRpcMock.mockImplementation((method: string) => {
if (method === 'chat.send') {
return new Promise((resolve) => {
resolveSend = resolve;
});
}
if (method === 'chat.history') {
return {
messages: [
{ id: 'user-old', role: 'user', content: 'hello', timestamp: 1000 },
{ id: 'assistant-old', role: 'assistant', content: 'hi there', timestamp: 1500 },
],
};
}
return { messages: [] };
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:session-idle',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:session-idle' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
runError: null,
loading: false,
thinkingLevel: null,
});
const sendPromise = useChatStore.getState().sendMessage('long question');
useChatStore.setState({
streamingMessage: { role: 'assistant' },
});
await vi.advanceTimersByTimeAsync(121_000);
expect(useChatStore.getState().runError).toContain('120 seconds');
expect(useChatStore.getState().sending).toBe(true);
expect(useChatStore.getState().streamingMessage).toBeNull();
await vi.advanceTimersByTimeAsync(20_000);
expect(useChatStore.getState().sending).toBe(false);
expect(useChatStore.getState().error).toContain('No response received');
resolveSend?.({ runId: 'run-idle-test' });
await sendPromise;
});
it('does not treat prior-turn assistant history as progress for a new send', async () => {
let resolveSend: ((value: { runId: string }) => void) | undefined;
gatewayRpcMock.mockImplementation((method: string) => {
if (method === 'chat.send') {
return new Promise((resolve) => {
resolveSend = resolve;
});
}
if (method === 'chat.history') {
return {
messages: [
{ id: 'user-old', role: 'user', content: 'hello', timestamp: 1000 },
{ id: 'assistant-old', role: 'assistant', content: 'hi there', timestamp: 1500 },
],
};
}
return { messages: [] };
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:session-idle',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:session-idle' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
runError: null,
loading: false,
thinkingLevel: null,
});
const sendPromise = useChatStore.getState().sendMessage('new question');
await vi.advanceTimersByTimeAsync(121_000);
expect(useChatStore.getState().runError).toContain('120 seconds');
expect(useChatStore.getState().error).toBeNull();
expect(useChatStore.getState().sending).toBe(true);
resolveSend?.({ runId: 'run-idle-test-2' });
await sendPromise;
});
});
+5 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chatHistoryRpcParams } from './gateway-rpc-test-utils';
const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({
gatewayRpcMock: vi.fn(),
@@ -60,6 +61,9 @@ describe('chat target routing', () => {
gatewayRpcMock.mockReset();
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'config.get') {
return { messages: [] };
}
if (method === 'chat.history') {
return { messages: [] };
}
@@ -115,7 +119,7 @@ describe('chat target routing', () => {
expect(state.messages.at(-1)?.content).toBe('Hello direct agent');
const historyCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.history');
expect(historyCall?.[1]).toEqual({ sessionKey: 'agent:research:desk', limit: 200 });
expect(historyCall?.[1]).toEqual(chatHistoryRpcParams('agent:research:desk', 200));
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
expect(sendCall?.[1]).toMatchObject({
+42
View File
@@ -0,0 +1,42 @@
import { DEFAULT_CHAT_HISTORY_MAX_CHARS } from '@/stores/chat/history-rpc-params';
export function chatHistoryRpcParams(sessionKey: string, limit: number) {
return {
sessionKey,
limit,
maxChars: DEFAULT_CHAT_HISTORY_MAX_CHARS,
};
}
type GatewayRpcMock = {
mockImplementation: (
fn: (method: string, params?: unknown, timeoutMs?: number) => unknown,
) => unknown;
};
export function installGatewayRpcDefaults(mock: GatewayRpcMock): void {
mock.mockImplementation(async (method: string) => {
if (method === 'config.get') return {};
if (method === 'chat.history') return { messages: [] };
throw new Error(`Unexpected gateway RPC: ${method}`);
});
}
export function mockGatewayChatHistory(
mock: GatewayRpcMock,
result: Record<string, unknown>,
): void {
mock.mockImplementation(async (method: string) => {
if (method === 'config.get') return {};
if (method === 'chat.history') return result;
throw new Error(`Unexpected gateway RPC: ${method}`);
});
}
export async function prewarmChatHistoryMaxCharsCache(): Promise<void> {
const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import(
'@/stores/chat/history-rpc-params'
);
resetChatHistoryMaxCharsCache();
await resolveChatHistoryMaxChars();
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildChatHistoryRpcParams,
DEFAULT_CHAT_HISTORY_MAX_CHARS,
getChatHistoryMaxChars,
OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP,
resetChatHistoryMaxCharsCache,
resolveChatHistoryMaxChars,
} from '@/stores/chat/history-rpc-params';
describe('history-rpc-params', () => {
it('builds chat.history params with maxChars', () => {
expect(buildChatHistoryRpcParams('agent:main:main', 200, 120_000)).toEqual({
sessionKey: 'agent:main:main',
limit: 200,
maxChars: 120_000,
});
});
it('defaults to the OpenClaw cap when config is unavailable', async () => {
resetChatHistoryMaxCharsCache();
await expect(resolveChatHistoryMaxChars()).resolves.toBe(DEFAULT_CHAT_HISTORY_MAX_CHARS);
expect(DEFAULT_CHAT_HISTORY_MAX_CHARS).toBe(OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP);
});
it('reads gateway.webchat.chatHistoryMaxChars from config.get', async () => {
resetChatHistoryMaxCharsCache();
const rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { gateway: { webchat: { chatHistoryMaxChars: 250_000 } } } };
}
throw new Error(`Unexpected RPC: ${method}`);
});
await expect(resolveChatHistoryMaxChars(rpc)).resolves.toBe(DEFAULT_CHAT_HISTORY_MAX_CHARS);
await vi.waitFor(async () => {
await expect(resolveChatHistoryMaxChars()).resolves.toBe(250_000);
});
});
it('clamps configured maxChars to the OpenClaw cap', async () => {
resetChatHistoryMaxCharsCache();
const rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { gateway: { webchat: { chatHistoryMaxChars: 900_000 } } } };
}
throw new Error(`Unexpected RPC: ${method}`);
});
await expect(resolveChatHistoryMaxChars(rpc)).resolves.toBe(DEFAULT_CHAT_HISTORY_MAX_CHARS);
await vi.waitFor(async () => {
await expect(resolveChatHistoryMaxChars()).resolves.toBe(OPENCLAW_CHAT_HISTORY_MAX_CHARS_CAP);
});
});
it('returns the default cap immediately and refreshes config in the background', async () => {
resetChatHistoryMaxCharsCache();
const rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { gateway: { webchat: { chatHistoryMaxChars: 180_000 } } } };
}
throw new Error(`Unexpected RPC: ${method}`);
});
expect(getChatHistoryMaxChars(rpc)).toBe(500_000);
await vi.waitFor(() => {
expect(getChatHistoryMaxChars()).toBe(180_000);
});
});
});
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest';
import { hydrateGatewayHistoryFromTranscript } from '@/stores/chat/history-transcript-hydrate';
import {
gatewayHistoryNeedsTranscriptHydration,
mergeGatewayHistoryWithTranscript,
} from '@/stores/chat/history-transcript-merge';
import type { RawMessage } from '@/stores/chat/types';
const { hostApiFetchMock } = vi.hoisted(() => ({
hostApiFetchMock: vi.fn(),
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
const SESSION_KEY = 'agent:main:session-long-reply';
const OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS = 8_000;
const LONG_REPLY_HEAD = 'Cooper,我先给一个直接判断:**你们现在最有价值的不是“AI Agent 客户端”,而是“企业无人办公转型的落地系统”**。';
const LONG_REPLY_TAIL = '这句话我觉得挺稳,也适合放进商业计划书。';
const LONG_REPLY_LENGTH = 8360;
function buildLongAssistantText(): string {
const fillerLength = Math.max(0, LONG_REPLY_LENGTH - LONG_REPLY_HEAD.length - LONG_REPLY_TAIL.length);
return `${LONG_REPLY_HEAD}${'x'.repeat(fillerLength)}${LONG_REPLY_TAIL}`;
}
function buildLongAssistantMessage(): RawMessage {
return {
id: 'assistant-long-reply',
role: 'assistant',
content: [
{ type: 'thinking', thinking: '' },
{ type: 'text', text: buildLongAssistantText() },
],
timestamp: 1779695766656,
};
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return (content as Array<{ type?: string; text?: string }>)
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text!)
.join('\n');
}
function simulateGatewayHistoryTruncation(text: string, maxChars = OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n...(truncated)...`;
}
function simulateGatewayHistoryTruncationContent(
content: unknown,
maxChars = OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS,
): unknown {
if (typeof content === 'string') {
return simulateGatewayHistoryTruncation(content, maxChars);
}
if (!Array.isArray(content)) return content;
return (content as Array<{ type?: string; text?: string }>).map((block) => {
if (block.type !== 'text' || typeof block.text !== 'string') return block;
return {
...block,
text: simulateGatewayHistoryTruncation(block.text, maxChars),
};
});
}
describe('long assistant transcript hydration regression', () => {
it('restores a long assistant reply truncated by the default chat.history limit', () => {
const assistant = buildLongAssistantMessage();
const fullText = extractText(assistant.content);
expect(fullText.length).toBe(LONG_REPLY_LENGTH);
expect(fullText.length).toBeGreaterThan(OPENCLAW_DEFAULT_HISTORY_TEXT_MAX_CHARS);
expect(fullText).toContain('企业无人办公转型的落地系统');
expect(fullText).toContain(LONG_REPLY_TAIL);
const transcriptMessages: RawMessage[] = [assistant];
const gatewayMessages: RawMessage[] = [{
...assistant,
content: simulateGatewayHistoryTruncationContent(assistant.content),
}];
expect(gatewayHistoryNeedsTranscriptHydration(gatewayMessages)).toBe(true);
const merged = mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages);
const mergedText = extractText(merged[0]?.content);
expect(mergedText).toBe(fullText);
expect(mergedText).not.toContain('...(truncated)...');
expect(mergedText.length).toBe(LONG_REPLY_LENGTH);
});
it('hydrates truncated gateway history through the transcript fallback path', async () => {
const assistant = buildLongAssistantMessage();
const fullText = extractText(assistant.content);
const transcriptMessages: RawMessage[] = [assistant];
hostApiFetchMock.mockResolvedValueOnce({ messages: transcriptMessages });
const gatewayMessages: RawMessage[] = [{
...assistant,
content: simulateGatewayHistoryTruncationContent(assistant.content),
}];
const hydrated = await hydrateGatewayHistoryFromTranscript(
SESSION_KEY,
gatewayMessages,
200,
);
expect(hostApiFetchMock).toHaveBeenCalledWith(
`/api/sessions/transcript?sessionKey=${encodeURIComponent(SESSION_KEY)}&limit=200`,
);
expect(extractText(hydrated[0]?.content)).toBe(fullText);
expect(gatewayHistoryNeedsTranscriptHydration(hydrated)).toBe(false);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
gatewayHistoryNeedsTranscriptHydration,
isTruncatedHistoryText,
mergeGatewayHistoryWithTranscript,
} from '@/stores/chat/history-transcript-merge';
import type { RawMessage } from '@/stores/chat/types';
describe('history-transcript-merge', () => {
it('detects OpenClaw truncation markers', () => {
expect(isTruncatedHistoryText('hello\n...(truncated)...')).toBe(true);
expect(isTruncatedHistoryText('hello\n…(truncated)…')).toBe(true);
expect(isTruncatedHistoryText('[chat.history omitted: message too large]')).toBe(true);
expect(isTruncatedHistoryText('complete response')).toBe(false);
});
it('merges full transcript text over truncated gateway history', () => {
const gatewayMessages: RawMessage[] = [{
id: 'm1',
role: 'assistant',
content: `${'a'.repeat(100)}\n...(truncated)...`,
timestamp: 1000,
}];
const transcriptMessages: RawMessage[] = [{
id: 'm1',
role: 'assistant',
content: 'a'.repeat(500),
timestamp: 1000,
}];
expect(gatewayHistoryNeedsTranscriptHydration(gatewayMessages)).toBe(true);
const merged = mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages);
expect(merged[0]?.content).toBe('a'.repeat(500));
expect(gatewayHistoryNeedsTranscriptHydration(merged)).toBe(false);
});
it('leaves non-truncated gateway messages unchanged', () => {
const gatewayMessages: RawMessage[] = [{
role: 'assistant',
content: 'short reply',
timestamp: 1000,
}];
const transcriptMessages: RawMessage[] = [{
role: 'assistant',
content: 'different reply',
timestamp: 1000,
}];
expect(mergeGatewayHistoryWithTranscript(gatewayMessages, transcriptMessages)).toEqual(gatewayMessages);
});
});