feat(cron): implement cron session management and logging features, including session key parsing and fallback message handling (#429)

This commit is contained in:
Haze
2026-03-12 11:20:56 +08:00
committed by GitHub
Unverified
parent 882da7b904
commit 38391dd093
9 changed files with 836 additions and 220 deletions

View File

@@ -1,4 +1,5 @@
import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import {
clearHistoryPoll,
enrichWithCachedImages,
@@ -9,9 +10,23 @@ import {
loadMissingPreviews,
toMs,
} from './helpers';
import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils';
import type { RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
async function loadCronFallbackMessages(sessionKey: string, limit = 200): Promise<RawMessage[]> {
if (!isCronSessionKey(sessionKey)) return [];
try {
const response = await hostApiFetch<{ messages?: RawMessage[] }>(
buildCronSessionHistoryPath(sessionKey, limit),
);
return Array.isArray(response.messages) ? response.messages : [];
} catch (error) {
console.warn('Failed to load cron fallback history:', error);
return [];
}
}
export function createHistoryActions(
set: ChatSet,
get: ChatGet,
@@ -21,6 +36,112 @@ export function createHistoryActions(
const { currentSessionKey } = get();
if (!quiet) set({ loading: true, error: null });
const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
// Before filtering: attach images/files from tool_result messages to the next assistant message
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));
// Restore file attachments for user/assistant messages (from cache + text patterns)
const enrichedMessages = enrichWithCachedImages(filteredMessages);
// Preserve the optimistic user message during an active send.
// The Gateway may not include the user's message in chat.history
// until the run completes, causing it to flash out of the UI.
let finalMessages = enrichedMessages;
const userMsgAt = get().lastUserMessageAt;
if (get().sending && userMsgAt) {
const userMsMs = toMs(userMsgAt);
const hasRecentUser = enrichedMessages.some(
(m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000,
);
if (!hasRecentUser) {
const currentMsgs = get().messages;
const optimistic = [...currentMsgs].reverse().find(
(m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000,
);
if (optimistic) {
finalMessages = [...enrichedMessages, optimistic];
}
}
}
set({ messages: finalMessages, thinkingLevel, loading: false });
// Extract first user message text as a session label for display in the toolbar.
// Skip main sessions (key ends with ":main") — they rely on the Gateway-provided
// displayName (e.g. the configured agent name "ClawX") instead.
const isMainSession = currentSessionKey.endsWith(':main');
if (!isMainSession) {
const firstUserMsg = finalMessages.find((m) => m.role === 'user');
if (firstUserMsg) {
const labelText = getMessageText(firstUserMsg.content).trim();
if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
set((s) => ({
sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated },
}));
}
}
}
// Record last activity time from the last message in history
const lastMsg = finalMessages[finalMessages.length - 1];
if (lastMsg?.timestamp) {
const lastAt = toMs(lastMsg.timestamp);
set((s) => ({
sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt },
}));
}
// Async: load missing image previews from disk (updates in background)
loadMissingPreviews(finalMessages).then((updated) => {
if (updated) {
// Create new object references so React.memo detects changes.
// loadMissingPreviews mutates AttachedFileMeta in place, so we
// must produce fresh message + file references for each affected msg.
set({
messages: finalMessages.map(msg =>
msg._attachedFiles
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
: msg
),
});
}
});
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
// If we're sending but haven't received streaming events, check
// whether the loaded history reveals intermediate tool-call activity.
// This surfaces progress via the pendingFinal → ActivityIndicator path.
const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0;
const isAfterUserMsg = (msg: RawMessage): boolean => {
if (!userMsTs || !msg.timestamp) return true;
return toMs(msg.timestamp) >= userMsTs;
};
if (isSendingNow && !pendingFinal) {
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
if (msg.role !== 'assistant') return false;
return isAfterUserMsg(msg);
});
if (hasRecentAssistantActivity) {
set({ pendingFinal: true });
}
}
// If pendingFinal, check whether the AI produced a final text response.
if (pendingFinal || get().pendingFinal) {
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
if (msg.role !== 'assistant') return false;
if (!hasNonToolAssistantContent(msg)) return false;
return isAfterUserMsg(msg);
});
if (recentAssistant) {
clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false });
}
}
};
try {
const result = await invokeIpc(
'gateway:rpc',
@@ -30,118 +151,28 @@ export function createHistoryActions(
if (result.success && result.result) {
const data = result.result;
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
// Before filtering: attach images/files from tool_result messages to the next assistant message
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));
// Restore file attachments for user/assistant messages (from cache + text patterns)
const enrichedMessages = enrichWithCachedImages(filteredMessages);
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
// Preserve the optimistic user message during an active send.
// The Gateway may not include the user's message in chat.history
// until the run completes, causing it to flash out of the UI.
let finalMessages = enrichedMessages;
const userMsgAt = get().lastUserMessageAt;
if (get().sending && userMsgAt) {
const userMsMs = toMs(userMsgAt);
const hasRecentUser = enrichedMessages.some(
(m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000,
);
if (!hasRecentUser) {
const currentMsgs = get().messages;
const optimistic = [...currentMsgs].reverse().find(
(m) => m.role === 'user' && m.timestamp && Math.abs(toMs(m.timestamp) - userMsMs) < 5000,
);
if (optimistic) {
finalMessages = [...enrichedMessages, optimistic];
}
}
}
set({ messages: finalMessages, thinkingLevel, loading: false });
// Extract first user message text as a session label for display in the toolbar.
// Skip main sessions (key ends with ":main") — they rely on the Gateway-provided
// displayName (e.g. the configured agent name "ClawX") instead.
const isMainSession = currentSessionKey.endsWith(':main');
if (!isMainSession) {
const firstUserMsg = finalMessages.find((m) => m.role === 'user');
if (firstUserMsg) {
const labelText = getMessageText(firstUserMsg.content).trim();
if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
set((s) => ({
sessionLabels: { ...s.sessionLabels, [currentSessionKey]: truncated },
}));
}
}
}
// Record last activity time from the last message in history
const lastMsg = finalMessages[finalMessages.length - 1];
if (lastMsg?.timestamp) {
const lastAt = toMs(lastMsg.timestamp);
set((s) => ({
sessionLastActivity: { ...s.sessionLastActivity, [currentSessionKey]: lastAt },
}));
}
// Async: load missing image previews from disk (updates in background)
loadMissingPreviews(finalMessages).then((updated) => {
if (updated) {
// Create new object references so React.memo detects changes.
// loadMissingPreviews mutates AttachedFileMeta in place, so we
// must produce fresh message + file references for each affected msg.
set({
messages: finalMessages.map(msg =>
msg._attachedFiles
? { ...msg, _attachedFiles: msg._attachedFiles.map(f => ({ ...f })) }
: msg
),
});
}
});
const { pendingFinal, lastUserMessageAt, sending: isSendingNow } = get();
// If we're sending but haven't received streaming events, check
// whether the loaded history reveals intermediate tool-call activity.
// This surfaces progress via the pendingFinal → ActivityIndicator path.
const userMsTs = lastUserMessageAt ? toMs(lastUserMessageAt) : 0;
const isAfterUserMsg = (msg: RawMessage): boolean => {
if (!userMsTs || !msg.timestamp) return true;
return toMs(msg.timestamp) >= userMsTs;
};
if (isSendingNow && !pendingFinal) {
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
if (msg.role !== 'assistant') return false;
return isAfterUserMsg(msg);
});
if (hasRecentAssistantActivity) {
set({ pendingFinal: true });
}
}
// If pendingFinal, check whether the AI produced a final text response.
if (pendingFinal || get().pendingFinal) {
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
if (msg.role !== 'assistant') return false;
if (!hasNonToolAssistantContent(msg)) return false;
return isAfterUserMsg(msg);
});
if (recentAssistant) {
clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false });
}
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
}
applyLoadedMessages(rawMessages, thinkingLevel);
} else {
set({ messages: [], loading: false });
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else {
set({ messages: [], loading: false });
}
}
} catch (err) {
console.warn('Failed to load chat history:', err);
set({ messages: [], loading: false });
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else {
set({ messages: [], loading: false });
}
}
},
};