diff --git a/.gitignore b/.gitignore index 49c98bec..56278bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ coverage/ playwright-report/ test-results/ +# Local session transcript fixtures (may contain private conversation data) +tests/fixtures/transcripts/ + # Cache .cache/ .turbo/ diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 825f7b27..7d2e2631 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -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 { - 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(promise: Promise, timeoutMs: number): Promise { let timer: ReturnType | 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; + 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((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((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((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((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((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((set, get) => ({ activeRunId: null, pendingFinal: false, lastUserMessageAt: null, + runError: null, }); } } @@ -2800,6 +2857,12 @@ export const useChatStore = create((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 | null = null; let lastError: unknown = null; @@ -2810,9 +2873,9 @@ export const useChatStore = create((set, get) => ({ } try { - data = await useGatewayStore.getState().rpc>( + data = await gatewayRpc>( 'chat.history', - { sessionKey: currentSessionKey, limit: HISTORY_PAGE_SIZE }, + chatHistoryParams, historyTimeoutOverride, ); lastError = null; @@ -2851,6 +2914,13 @@ export const useChatStore = create((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((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); diff --git a/src/stores/chat/history-actions.ts b/src/stores/chat/history-actions.ts index d2628c6e..e7257665 100644 --- a/src/stores/chat/history-actions.ts +++ b/src/stores/chat/history-actions.ts @@ -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 ( + method: string, + params?: unknown, + timeoutMs?: number, + ): Promise => { + 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; 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; 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) { diff --git a/src/stores/chat/history-rpc-params.ts b/src/stores/chat/history-rpc-params.ts new file mode 100644 index 00000000..62a1456c --- /dev/null +++ b/src/stores/chat/history-rpc-params.ts @@ -0,0 +1,78 @@ +/** OpenClaw accepts chat.history maxChars in the range 1–500_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 = ( + method: string, + params?: unknown, + timeoutMs?: number, +) => Promise; + +let cachedMaxChars: number | null = null; +let configRefreshPromise: Promise | null = null; + +function extractChatHistoryMaxChars(snapshot: unknown): number | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const root = snapshot as Record; + const config = (root.config ?? root.parsed ?? root) as Record; + const gateway = config.gateway as Record | undefined; + const webchat = gateway?.webchat as Record | 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('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 { + 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, + }; +} diff --git a/src/stores/chat/history-transcript-fallback.ts b/src/stores/chat/history-transcript-fallback.ts new file mode 100644 index 00000000..1bcf5a82 --- /dev/null +++ b/src/stores/chat/history-transcript-fallback.ts @@ -0,0 +1,18 @@ +import { hostApiFetch } from '@/lib/host-api'; +import type { RawMessage } from './types'; + +export async function loadSessionTranscriptFallback( + sessionKey: string, + limit = 200, +): Promise { + 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 []; + } +} diff --git a/src/stores/chat/history-transcript-hydrate.ts b/src/stores/chat/history-transcript-hydrate.ts new file mode 100644 index 00000000..1f69df35 --- /dev/null +++ b/src/stores/chat/history-transcript-hydrate.ts @@ -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 { + 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; +} diff --git a/src/stores/chat/history-transcript-merge.ts b/src/stores/chat/history-transcript-merge.ts new file mode 100644 index 00000000..a5ede633 --- /dev/null +++ b/src/stores/chat/history-transcript-merge.ts @@ -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 { + const lookup = new Map(); + 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 }; + }); +} diff --git a/tests/e2e/chat-assistant-markdown-plain.spec.ts b/tests/e2e/chat-assistant-markdown-plain.spec.ts index 6533049f..affaf6d4 100644 --- a/tests/e2e/chat-assistant-markdown-plain.spec.ts +++ b/tests/e2e/chat-assistant-markdown-plain.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-file-changes.spec.ts b/tests/e2e/chat-file-changes.spec.ts index 4db84586..8069e555 100644 --- a/tests/e2e/chat-file-changes.spec.ts +++ b/tests/e2e/chat-file-changes.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-history-rpc.ts b/tests/e2e/chat-history-rpc.ts new file mode 100644 index 00000000..bda5dc37 --- /dev/null +++ b/tests/e2e/chat-history-rpc.ts @@ -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 }; diff --git a/tests/e2e/chat-history-startup-retry.spec.ts b/tests/e2e/chat-history-startup-retry.spec.ts index 5d5c5e69..4cff542f 100644 --- a/tests/e2e/chat-history-startup-retry.spec.ts +++ b/tests/e2e/chat-history-startup-retry.spec.ts @@ -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 { diff --git a/tests/e2e/chat-latex-rendering.spec.ts b/tests/e2e/chat-latex-rendering.spec.ts index 0a9f013b..360aff80 100644 --- a/tests/e2e/chat-latex-rendering.spec.ts +++ b/tests/e2e/chat-latex-rendering.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-new-session-date.spec.ts b/tests/e2e/chat-new-session-date.spec.ts index 10a9fe41..3f1c1675 100644 --- a/tests/e2e/chat-new-session-date.spec.ts +++ b/tests/e2e/chat-new-session-date.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-question-directory.spec.ts b/tests/e2e/chat-question-directory.spec.ts index 15d5248d..1ed69613 100644 --- a/tests/e2e/chat-question-directory.spec.ts +++ b/tests/e2e/chat-question-directory.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts index e302231b..d45d7638 100644 --- a/tests/e2e/chat-run-state-events.spec.ts +++ b/tests/e2e/chat-run-state-events.spec.ts @@ -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: [] }, }, diff --git a/tests/e2e/chat-scroll-to-latest.spec.ts b/tests/e2e/chat-scroll-to-latest.spec.ts index 46c77ce8..7ac2ba26 100644 --- a/tests/e2e/chat-scroll-to-latest.spec.ts +++ b/tests/e2e/chat-scroll-to-latest.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-skill-trigger-i18n.spec.ts b/tests/e2e/chat-skill-trigger-i18n.spec.ts index a5bacb11..9a82527a 100644 --- a/tests/e2e/chat-skill-trigger-i18n.spec.ts +++ b/tests/e2e/chat-skill-trigger-i18n.spec.ts @@ -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: [] }, }, diff --git a/tests/e2e/chat-table-header-light.spec.ts b/tests/e2e/chat-table-header-light.spec.ts index 8c8cdb3b..1d49acc7 100644 --- a/tests/e2e/chat-table-header-light.spec.ts +++ b/tests/e2e/chat-table-header-light.spec.ts @@ -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 }, }, diff --git a/tests/e2e/chat-task-visualizer.spec.ts b/tests/e2e/chat-task-visualizer.spec.ts index 094597a7..77fc7557 100644 --- a/tests/e2e/chat-task-visualizer.spec.ts +++ b/tests/e2e/chat-task-visualizer.spec.ts @@ -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, diff --git a/tests/e2e/gateway-lifecycle.spec.ts b/tests/e2e/gateway-lifecycle.spec.ts index 0cd2f644..0d3c4703 100644 --- a/tests/e2e/gateway-lifecycle.spec.ts +++ b/tests/e2e/gateway-lifecycle.spec.ts @@ -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: [ diff --git a/tests/unit/chat-history-actions.test.ts b/tests/unit/chat-history-actions.test.ts index abe911f4..5e5e7399 100644 --- a/tests/unit/chat-history-actions.test.ts +++ b/tests/unit/chat-history-actions.test.ts @@ -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) { } 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']); diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index 5213bc33..c823a0cd 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -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; + }); }); diff --git a/tests/unit/chat-target-routing.test.ts b/tests/unit/chat-target-routing.test.ts index 3d70a5c4..4a166e79 100644 --- a/tests/unit/chat-target-routing.test.ts +++ b/tests/unit/chat-target-routing.test.ts @@ -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({ diff --git a/tests/unit/gateway-rpc-test-utils.ts b/tests/unit/gateway-rpc-test-utils.ts new file mode 100644 index 00000000..f38085b2 --- /dev/null +++ b/tests/unit/gateway-rpc-test-utils.ts @@ -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, +): 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 { + const { resetChatHistoryMaxCharsCache, resolveChatHistoryMaxChars } = await import( + '@/stores/chat/history-rpc-params' + ); + resetChatHistoryMaxCharsCache(); + await resolveChatHistoryMaxChars(); +} diff --git a/tests/unit/history-rpc-params.test.ts b/tests/unit/history-rpc-params.test.ts new file mode 100644 index 00000000..55cc1fba --- /dev/null +++ b/tests/unit/history-rpc-params.test.ts @@ -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); + }); + }); +}); diff --git a/tests/unit/history-transcript-fa3ccd85-fixture.test.ts b/tests/unit/history-transcript-fa3ccd85-fixture.test.ts new file mode 100644 index 00000000..5d514fd8 --- /dev/null +++ b/tests/unit/history-transcript-fa3ccd85-fixture.test.ts @@ -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); + }); +}); diff --git a/tests/unit/history-transcript-merge.test.ts b/tests/unit/history-transcript-merge.test.ts new file mode 100644 index 00000000..7427bfe7 --- /dev/null +++ b/tests/unit/history-transcript-merge.test.ts @@ -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); + }); +});