mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix: hydrate truncated chat history and unstick thinking on LLM idle timeout (#1064)
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = <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;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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: [] },
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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: [] },
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user