feat(cron): implement cron session management and logging features, including session key parsing and fallback message handling (#429)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user