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

@@ -7,6 +7,7 @@ import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway';
import { useAgentsStore } from './agents';
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
// ── Types ────────────────────────────────────────────────────────
@@ -56,6 +57,7 @@ export interface ChatSession {
displayName?: string;
thinkingLevel?: string;
model?: string;
updatedAt?: number;
}
export interface ToolStatus {
@@ -669,6 +671,32 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
return parts[1] || 'main';
}
function parseSessionUpdatedAtMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return toMs(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
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 [];
}
}
function normalizeAgentId(value: string | undefined | null): string {
return (value ?? '').trim().toLowerCase() || 'main';
}
@@ -1022,6 +1050,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
displayName: s.displayName ? String(s.displayName) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
})).filter((s: ChatSession) => s.key);
const canonicalBySuffix = new Map<string, string>();
@@ -1068,11 +1097,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
]
: dedupedSessions;
set({
const discoveredActivity = Object.fromEntries(
sessionsWithCurrent
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
.map((session) => [session.key, session.updatedAt!]),
);
set((state) => ({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
});
sessionLastActivity: {
...state.sessionLastActivity,
...discoveredActivity,
},
}));
if (currentSessionKey !== nextSessionKey) {
get().loadHistory();
@@ -1251,124 +1290,141 @@ export const useChatStore = create<ChatState>((set, get) => ({
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 data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 },
);
if (data) {
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];
}
}
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
}
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 });
}
}
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 });
}
}
},