feat(cron): implement cron session management and logging features, including session key parsing and fallback message handling (#429)
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { join } from 'node:path';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody, sendJson } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
|
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||||
|
|
||||||
interface GatewayCronJob {
|
interface GatewayCronJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +18,7 @@ interface GatewayCronJob {
|
|||||||
sessionTarget?: string;
|
sessionTarget?: string;
|
||||||
state: {
|
state: {
|
||||||
nextRunAtMs?: number;
|
nextRunAtMs?: number;
|
||||||
|
runningAtMs?: number;
|
||||||
lastRunAtMs?: number;
|
lastRunAtMs?: number;
|
||||||
lastStatus?: string;
|
lastStatus?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
@@ -22,6 +26,241 @@ interface GatewayCronJob {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CronRunLogEntry {
|
||||||
|
jobId?: string;
|
||||||
|
action?: string;
|
||||||
|
status?: string;
|
||||||
|
error?: string;
|
||||||
|
summary?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
|
ts?: number;
|
||||||
|
runAtMs?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CronSessionKeyParts {
|
||||||
|
agentId: string;
|
||||||
|
jobId: string;
|
||||||
|
runSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CronSessionFallbackMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant' | 'system';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||||
|
if (!sessionKey.startsWith('agent:')) return null;
|
||||||
|
const parts = sessionKey.split(':');
|
||||||
|
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||||
|
|
||||||
|
const agentId = parts[1] || 'main';
|
||||||
|
const jobId = parts[3];
|
||||||
|
if (!jobId) return null;
|
||||||
|
|
||||||
|
if (parts.length === 4) {
|
||||||
|
return { agentId, jobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||||
|
return { agentId, jobId, runSessionId: parts[5] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value < 1e12 ? value * 1000 : value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(durationMs: number | undefined): string | null {
|
||||||
|
if (!durationMs || !Number.isFinite(durationMs)) return null;
|
||||||
|
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
||||||
|
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
||||||
|
return `${Math.round(durationMs / 1000)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
|
||||||
|
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
|
||||||
|
if (!timestamp) return null;
|
||||||
|
|
||||||
|
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
|
||||||
|
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||||
|
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
|
||||||
|
let content = summary || error;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
content = status === 'error'
|
||||||
|
? 'Scheduled task failed.'
|
||||||
|
: 'Scheduled task completed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
|
||||||
|
content = `Run failed: ${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: string[] = [];
|
||||||
|
const duration = formatDuration(entry.durationMs);
|
||||||
|
if (duration) meta.push(`Duration: ${duration}`);
|
||||||
|
if (entry.provider && entry.model) {
|
||||||
|
meta.push(`Model: ${entry.provider}/${entry.model}`);
|
||||||
|
} else if (entry.model) {
|
||||||
|
meta.push(`Model: ${entry.model}`);
|
||||||
|
}
|
||||||
|
if (meta.length > 0) {
|
||||||
|
content = `${content}\n\n${meta.join(' | ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
|
||||||
|
role: status === 'error' ? 'system' : 'assistant',
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
...(status === 'error' ? { isError: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
|
||||||
|
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
|
||||||
|
const raw = await readFile(logPath, 'utf8').catch(() => '');
|
||||||
|
if (!raw.trim()) return [];
|
||||||
|
|
||||||
|
const entries: CronRunLogEntry[] = [];
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(trimmed) as CronRunLogEntry;
|
||||||
|
if (!entry || entry.jobId !== jobId) continue;
|
||||||
|
if (entry.action && entry.action !== 'finished') continue;
|
||||||
|
entries.push(entry);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed log lines so one bad entry does not hide the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSessionStoreEntry(
|
||||||
|
agentId: string,
|
||||||
|
sessionKey: string,
|
||||||
|
): Promise<Record<string, unknown> | undefined> {
|
||||||
|
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
||||||
|
const raw = await readFile(storePath, 'utf8').catch(() => '');
|
||||||
|
if (!raw.trim()) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const directEntry = store[sessionKey];
|
||||||
|
if (directEntry && typeof directEntry === 'object') {
|
||||||
|
return directEntry as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = (store as { sessions?: unknown }).sessions;
|
||||||
|
if (Array.isArray(sessions)) {
|
||||||
|
const arrayEntry = sessions.find((entry) => {
|
||||||
|
if (!entry || typeof entry !== 'object') return false;
|
||||||
|
const record = entry as Record<string, unknown>;
|
||||||
|
return record.key === sessionKey || record.sessionKey === sessionKey;
|
||||||
|
});
|
||||||
|
if (arrayEntry && typeof arrayEntry === 'object') {
|
||||||
|
return arrayEntry as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCronSessionFallbackMessages(params: {
|
||||||
|
sessionKey: string;
|
||||||
|
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
|
||||||
|
runs: CronRunLogEntry[];
|
||||||
|
sessionEntry?: { label?: string; updatedAt?: number };
|
||||||
|
limit?: number;
|
||||||
|
}): CronSessionFallbackMessage[] {
|
||||||
|
const parsed = parseCronSessionKey(params.sessionKey);
|
||||||
|
if (!parsed) return [];
|
||||||
|
|
||||||
|
const matchingRuns = params.runs
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!parsed.runSessionId) return true;
|
||||||
|
return entry.sessionId === parsed.runSessionId
|
||||||
|
|| entry.sessionKey === `${params.sessionKey}`;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
|
||||||
|
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
|
||||||
|
return left - right;
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: CronSessionFallbackMessage[] = [];
|
||||||
|
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
|
||||||
|
const taskName = params.job?.name?.trim()
|
||||||
|
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|
||||||
|
|| '';
|
||||||
|
const firstRelevantTimestamp = matchingRuns.length > 0
|
||||||
|
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
|
||||||
|
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
|
||||||
|
|
||||||
|
if (taskName || prompt) {
|
||||||
|
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
|
||||||
|
if (prompt) lines.push(`Prompt: ${prompt}`);
|
||||||
|
messages.push({
|
||||||
|
id: `cron-meta-${parsed.jobId}`,
|
||||||
|
role: 'system',
|
||||||
|
content: lines.join('\n'),
|
||||||
|
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingRuns.forEach((entry, index) => {
|
||||||
|
const message = buildCronRunMessage(entry, index);
|
||||||
|
if (message) messages.push(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingRuns.length === 0) {
|
||||||
|
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
|
||||||
|
if (runningAt) {
|
||||||
|
messages.push({
|
||||||
|
id: `cron-running-${parsed.jobId}`,
|
||||||
|
role: 'system',
|
||||||
|
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
|
||||||
|
timestamp: runningAt,
|
||||||
|
});
|
||||||
|
} else if (messages.length === 0) {
|
||||||
|
messages.push({
|
||||||
|
id: `cron-empty-${parsed.jobId}`,
|
||||||
|
role: 'system',
|
||||||
|
content: 'No chat transcript is available for this scheduled task yet.',
|
||||||
|
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
|
||||||
|
? Math.max(1, Math.floor(params.limit))
|
||||||
|
: messages.length;
|
||||||
|
return messages.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
function transformCronJob(job: GatewayCronJob) {
|
function transformCronJob(job: GatewayCronJob) {
|
||||||
const message = job.payload?.message || job.payload?.text || '';
|
const message = job.payload?.message || job.payload?.text || '';
|
||||||
const channelType = job.delivery?.channel;
|
const channelType = job.delivery?.channel;
|
||||||
@@ -60,6 +299,47 @@ export async function handleCronRoutes(
|
|||||||
url: URL,
|
url: URL,
|
||||||
ctx: HostApiContext,
|
ctx: HostApiContext,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (url.pathname === '/api/cron/session-history' && req.method === 'GET') {
|
||||||
|
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
|
||||||
|
const parsedSession = parseCronSessionKey(sessionKey);
|
||||||
|
if (!parsedSession) {
|
||||||
|
sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLimit = Number(url.searchParams.get('limit') || '200');
|
||||||
|
const limit = Number.isFinite(rawLimit)
|
||||||
|
? Math.min(Math.max(Math.floor(rawLimit), 1), 200)
|
||||||
|
: 200;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [jobsResult, runs, sessionEntry] = await Promise.all([
|
||||||
|
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true })
|
||||||
|
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
|
||||||
|
readCronRunLog(parsedSession.jobId),
|
||||||
|
readSessionStoreEntry(parsedSession.agentId, sessionKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||||
|
const job = jobs.find((item) => item.id === parsedSession.jobId);
|
||||||
|
const messages = buildCronSessionFallbackMessages({
|
||||||
|
sessionKey,
|
||||||
|
job,
|
||||||
|
runs,
|
||||||
|
sessionEntry: sessionEntry ? {
|
||||||
|
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
|
||||||
|
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
|
||||||
|
} : undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
sendJson(res, 200, { messages });
|
||||||
|
} catch (error) {
|
||||||
|
sendJson(res, 500, { success: false, error: String(error) });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
|
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
|
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { create } from 'zustand';
|
|||||||
import { hostApiFetch } from '@/lib/host-api';
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import { useGatewayStore } from './gateway';
|
import { useGatewayStore } from './gateway';
|
||||||
import { useAgentsStore } from './agents';
|
import { useAgentsStore } from './agents';
|
||||||
|
import { buildCronSessionHistoryPath, isCronSessionKey } from './chat/cron-session-utils';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export interface ChatSession {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolStatus {
|
export interface ToolStatus {
|
||||||
@@ -669,6 +671,32 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
|
|||||||
return parts[1] || 'main';
|
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 {
|
function normalizeAgentId(value: string | undefined | null): string {
|
||||||
return (value ?? '').trim().toLowerCase() || 'main';
|
return (value ?? '').trim().toLowerCase() || 'main';
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1050,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
displayName: s.displayName ? String(s.displayName) : undefined,
|
displayName: s.displayName ? String(s.displayName) : undefined,
|
||||||
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
||||||
model: s.model ? String(s.model) : undefined,
|
model: s.model ? String(s.model) : undefined,
|
||||||
|
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
|
||||||
})).filter((s: ChatSession) => s.key);
|
})).filter((s: ChatSession) => s.key);
|
||||||
|
|
||||||
const canonicalBySuffix = new Map<string, string>();
|
const canonicalBySuffix = new Map<string, string>();
|
||||||
@@ -1068,11 +1097,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
]
|
]
|
||||||
: dedupedSessions;
|
: 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,
|
sessions: sessionsWithCurrent,
|
||||||
currentSessionKey: nextSessionKey,
|
currentSessionKey: nextSessionKey,
|
||||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||||
});
|
sessionLastActivity: {
|
||||||
|
...state.sessionLastActivity,
|
||||||
|
...discoveredActivity,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (currentSessionKey !== nextSessionKey) {
|
if (currentSessionKey !== nextSessionKey) {
|
||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
@@ -1251,124 +1290,141 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
if (!quiet) set({ loading: true, error: null });
|
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 {
|
try {
|
||||||
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
||||||
'chat.history',
|
'chat.history',
|
||||||
{ sessionKey: currentSessionKey, limit: 200 },
|
{ sessionKey: currentSessionKey, limit: 200 },
|
||||||
);
|
);
|
||||||
if (data) {
|
if (data) {
|
||||||
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
let 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);
|
|
||||||
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
||||||
|
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
|
||||||
// Preserve the optimistic user message during an active send.
|
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
// 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 });
|
applyLoadedMessages(rawMessages, thinkingLevel);
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} 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) {
|
} catch (err) {
|
||||||
console.warn('Failed to load chat history:', 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
37
src/stores/chat/cron-session-utils.ts
Normal file
37
src/stores/chat/cron-session-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface CronSessionKeyParts {
|
||||||
|
agentId: string;
|
||||||
|
jobId: string;
|
||||||
|
runSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||||
|
if (!sessionKey.startsWith('agent:')) return null;
|
||||||
|
const parts = sessionKey.split(':');
|
||||||
|
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||||
|
|
||||||
|
const agentId = parts[1] || 'main';
|
||||||
|
const jobId = parts[3];
|
||||||
|
if (!jobId) return null;
|
||||||
|
|
||||||
|
if (parts.length === 4) {
|
||||||
|
return { agentId, jobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||||
|
return { agentId, jobId, runSessionId: parts[5] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCronSessionKey(sessionKey: string): boolean {
|
||||||
|
return parseCronSessionKey(sessionKey) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCronSessionHistoryPath(sessionKey: string, limit = 200): string {
|
||||||
|
const params = new URLSearchParams({ sessionKey });
|
||||||
|
if (Number.isFinite(limit) && limit > 0) {
|
||||||
|
params.set('limit', String(Math.floor(limit)));
|
||||||
|
}
|
||||||
|
return `/api/cron/session-history?${params.toString()}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { invokeIpc } from '@/lib/api-client';
|
import { invokeIpc } from '@/lib/api-client';
|
||||||
|
import { hostApiFetch } from '@/lib/host-api';
|
||||||
import {
|
import {
|
||||||
clearHistoryPoll,
|
clearHistoryPoll,
|
||||||
enrichWithCachedImages,
|
enrichWithCachedImages,
|
||||||
@@ -9,9 +10,23 @@ import {
|
|||||||
loadMissingPreviews,
|
loadMissingPreviews,
|
||||||
toMs,
|
toMs,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
import { buildCronSessionHistoryPath, isCronSessionKey } from './cron-session-utils';
|
||||||
import type { RawMessage } from './types';
|
import type { RawMessage } from './types';
|
||||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
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(
|
export function createHistoryActions(
|
||||||
set: ChatSet,
|
set: ChatSet,
|
||||||
get: ChatGet,
|
get: ChatGet,
|
||||||
@@ -21,6 +36,112 @@ export function createHistoryActions(
|
|||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
if (!quiet) set({ loading: true, error: null });
|
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 {
|
try {
|
||||||
const result = await invokeIpc(
|
const result = await invokeIpc(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
@@ -30,118 +151,28 @@ export function createHistoryActions(
|
|||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
const data = result.result;
|
const data = result.result;
|
||||||
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
let 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);
|
|
||||||
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
|
||||||
|
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
|
||||||
// Preserve the optimistic user message during an active send.
|
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
|
||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
applyLoadedMessages(rawMessages, thinkingLevel);
|
||||||
} else {
|
} 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) {
|
} catch (err) {
|
||||||
console.warn('Failed to load chat history:', 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
|
|||||||
return agentId || 'main';
|
return agentId || '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;
|
||||||
|
}
|
||||||
|
|
||||||
export function createSessionActions(
|
export function createSessionActions(
|
||||||
set: ChatSet,
|
set: ChatSet,
|
||||||
get: ChatGet,
|
get: ChatGet,
|
||||||
@@ -31,6 +44,7 @@ export function createSessionActions(
|
|||||||
displayName: s.displayName ? String(s.displayName) : undefined,
|
displayName: s.displayName ? String(s.displayName) : undefined,
|
||||||
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
|
||||||
model: s.model ? String(s.model) : undefined,
|
model: s.model ? String(s.model) : undefined,
|
||||||
|
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
|
||||||
})).filter((s: ChatSession) => s.key);
|
})).filter((s: ChatSession) => s.key);
|
||||||
|
|
||||||
const canonicalBySuffix = new Map<string, string>();
|
const canonicalBySuffix = new Map<string, string>();
|
||||||
@@ -76,11 +90,21 @@ export function createSessionActions(
|
|||||||
]
|
]
|
||||||
: dedupedSessions;
|
: 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,
|
sessions: sessionsWithCurrent,
|
||||||
currentSessionKey: nextSessionKey,
|
currentSessionKey: nextSessionKey,
|
||||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||||
});
|
sessionLastActivity: {
|
||||||
|
...state.sessionLastActivity,
|
||||||
|
...discoveredActivity,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
if (currentSessionKey !== nextSessionKey) {
|
if (currentSessionKey !== nextSessionKey) {
|
||||||
get().loadHistory();
|
get().loadHistory();
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface ChatSession {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolStatus {
|
export interface ToolStatus {
|
||||||
|
|||||||
@@ -65,10 +65,19 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
|
|||||||
if (phase === 'started' && runId != null && sessionKey != null) {
|
if (phase === 'started' && runId != null && sessionKey != null) {
|
||||||
import('./chat')
|
import('./chat')
|
||||||
.then(({ useChatStore }) => {
|
.then(({ useChatStore }) => {
|
||||||
useChatStore.getState().handleChatEvent({
|
const state = useChatStore.getState();
|
||||||
|
const resolvedSessionKey = String(sessionKey);
|
||||||
|
const shouldRefreshSessions =
|
||||||
|
resolvedSessionKey !== state.currentSessionKey
|
||||||
|
|| !state.sessions.some((session) => session.key === resolvedSessionKey);
|
||||||
|
if (shouldRefreshSessions) {
|
||||||
|
void state.loadSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.handleChatEvent({
|
||||||
state: 'started',
|
state: 'started',
|
||||||
runId,
|
runId,
|
||||||
sessionKey,
|
sessionKey: resolvedSessionKey,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -78,8 +87,22 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
|
|||||||
import('./chat')
|
import('./chat')
|
||||||
.then(({ useChatStore }) => {
|
.then(({ useChatStore }) => {
|
||||||
const state = useChatStore.getState();
|
const state = useChatStore.getState();
|
||||||
state.loadHistory(true);
|
const resolvedSessionKey = sessionKey != null ? String(sessionKey) : null;
|
||||||
if (state.sending) {
|
const shouldRefreshSessions = resolvedSessionKey != null && (
|
||||||
|
resolvedSessionKey !== state.currentSessionKey
|
||||||
|
|| !state.sessions.some((session) => session.key === resolvedSessionKey)
|
||||||
|
);
|
||||||
|
if (shouldRefreshSessions) {
|
||||||
|
void state.loadSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesCurrentSession = resolvedSessionKey == null || resolvedSessionKey === state.currentSessionKey;
|
||||||
|
const matchesActiveRun = runId != null && state.activeRunId != null && String(runId) === state.activeRunId;
|
||||||
|
|
||||||
|
if (matchesCurrentSession || matchesActiveRun) {
|
||||||
|
void state.loadHistory(true);
|
||||||
|
}
|
||||||
|
if ((matchesCurrentSession || matchesActiveRun) && state.sending) {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sending: false,
|
sending: false,
|
||||||
activeRunId: null,
|
activeRunId: null,
|
||||||
|
|||||||
131
tests/unit/chat-history-actions.test.ts
Normal file
131
tests/unit/chat-history-actions.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const invokeIpcMock = vi.fn();
|
||||||
|
const hostApiFetchMock = vi.fn();
|
||||||
|
const clearHistoryPoll = vi.fn();
|
||||||
|
const enrichWithCachedImages = vi.fn((messages) => messages);
|
||||||
|
const enrichWithToolResultFiles = vi.fn((messages) => messages);
|
||||||
|
const getMessageText = vi.fn((content: unknown) => typeof content === 'string' ? content : '');
|
||||||
|
const hasNonToolAssistantContent = vi.fn((message: { content?: unknown } | undefined) => {
|
||||||
|
if (!message) return false;
|
||||||
|
return typeof message.content === 'string' ? message.content.trim().length > 0 : true;
|
||||||
|
});
|
||||||
|
const isToolResultRole = vi.fn((role: unknown) => role === 'toolresult' || role === 'tool_result');
|
||||||
|
const loadMissingPreviews = vi.fn(async () => false);
|
||||||
|
const toMs = vi.fn((ts: number) => ts < 1e12 ? ts * 1000 : ts);
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/host-api', () => ({
|
||||||
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/chat/helpers', () => ({
|
||||||
|
clearHistoryPoll: (...args: unknown[]) => clearHistoryPoll(...args),
|
||||||
|
enrichWithCachedImages: (...args: unknown[]) => enrichWithCachedImages(...args),
|
||||||
|
enrichWithToolResultFiles: (...args: unknown[]) => enrichWithToolResultFiles(...args),
|
||||||
|
getMessageText: (...args: unknown[]) => getMessageText(...args),
|
||||||
|
hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args),
|
||||||
|
isToolResultRole: (...args: unknown[]) => isToolResultRole(...args),
|
||||||
|
loadMissingPreviews: (...args: unknown[]) => loadMissingPreviews(...args),
|
||||||
|
toMs: (...args: unknown[]) => toMs(...args as Parameters<typeof toMs>),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ChatLikeState = {
|
||||||
|
currentSessionKey: string;
|
||||||
|
messages: Array<{ role: string; timestamp?: number; content?: unknown; _attachedFiles?: unknown[] }>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
sending: boolean;
|
||||||
|
lastUserMessageAt: number | null;
|
||||||
|
pendingFinal: boolean;
|
||||||
|
sessionLabels: Record<string, string>;
|
||||||
|
sessionLastActivity: Record<string, number>;
|
||||||
|
thinkingLevel: string | null;
|
||||||
|
activeRunId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeHarness(initial?: Partial<ChatLikeState>) {
|
||||||
|
let state: ChatLikeState = {
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
messages: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
sending: false,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
pendingFinal: false,
|
||||||
|
sessionLabels: {},
|
||||||
|
sessionLastActivity: {},
|
||||||
|
thinkingLevel: null,
|
||||||
|
activeRunId: null,
|
||||||
|
...initial,
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (partial: Partial<ChatLikeState> | ((s: ChatLikeState) => Partial<ChatLikeState>)) => {
|
||||||
|
const patch = typeof partial === 'function' ? partial(state) : partial;
|
||||||
|
state = { ...state, ...patch };
|
||||||
|
};
|
||||||
|
const get = () => state;
|
||||||
|
return { set, get, read: () => state };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat history actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
invokeIpcMock.mockResolvedValue({ success: true, result: { messages: [] } });
|
||||||
|
hostApiFetchMock.mockResolvedValue({ messages: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cron session fallback when gateway history is empty', async () => {
|
||||||
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:main:cron:job-1',
|
||||||
|
});
|
||||||
|
const actions = createHistoryActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
hostApiFetchMock.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'cron-meta-job-1',
|
||||||
|
role: 'system',
|
||||||
|
content: 'Scheduled task: Drink water',
|
||||||
|
timestamp: 1773281731495,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cron-run-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Drink water 💧',
|
||||||
|
timestamp: 1773281732751,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.loadHistory();
|
||||||
|
|
||||||
|
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||||
|
'/api/cron/session-history?sessionKey=agent%3Amain%3Acron%3Ajob-1&limit=200',
|
||||||
|
);
|
||||||
|
expect(h.read().messages.map((message) => message.content)).toEqual([
|
||||||
|
'Scheduled task: Drink water',
|
||||||
|
'Drink water 💧',
|
||||||
|
]);
|
||||||
|
expect(h.read().sessionLastActivity['agent:main:cron:job-1']).toBe(1773281732751);
|
||||||
|
expect(h.read().loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use cron fallback for normal sessions', async () => {
|
||||||
|
const { createHistoryActions } = await import('@/stores/chat/history-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
});
|
||||||
|
const actions = createHistoryActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
await actions.loadHistory();
|
||||||
|
|
||||||
|
expect(hostApiFetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(h.read().messages).toEqual([]);
|
||||||
|
expect(h.read().loading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ vi.mock('@/lib/api-client', () => ({
|
|||||||
|
|
||||||
type ChatLikeState = {
|
type ChatLikeState = {
|
||||||
currentSessionKey: string;
|
currentSessionKey: string;
|
||||||
sessions: Array<{ key: string; displayName?: string }>;
|
sessions: Array<{ key: string; displayName?: string; updatedAt?: number }>;
|
||||||
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
|
messages: Array<{ role: string; timestamp?: number; content?: unknown }>;
|
||||||
sessionLabels: Record<string, string>;
|
sessionLabels: Record<string, string>;
|
||||||
sessionLastActivity: Record<string, number>;
|
sessionLastActivity: Record<string, number>;
|
||||||
@@ -119,5 +119,38 @@ describe('chat session actions', () => {
|
|||||||
expect(next.pendingFinal).toBe(false);
|
expect(next.pendingFinal).toBe(false);
|
||||||
nowSpy.mockRestore();
|
nowSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('seeds sessionLastActivity from backend updatedAt metadata', async () => {
|
||||||
|
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||||
|
const h = makeHarness({
|
||||||
|
currentSessionKey: 'agent:main:main',
|
||||||
|
sessions: [],
|
||||||
|
});
|
||||||
|
const actions = createSessionActions(h.set as never, h.get as never);
|
||||||
|
|
||||||
|
invokeIpcMock.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
key: 'agent:main:main',
|
||||||
|
displayName: 'Main',
|
||||||
|
updatedAt: 1773281700000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'agent:main:cron:job-1',
|
||||||
|
label: 'Cron: Drink water',
|
||||||
|
updatedAt: 1773281731621,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.loadSessions();
|
||||||
|
|
||||||
|
expect(h.read().sessionLastActivity['agent:main:main']).toBe(1773281700000);
|
||||||
|
expect(h.read().sessionLastActivity['agent:main:cron:job-1']).toBe(1773281731621);
|
||||||
|
expect(h.read().sessions.find((session) => session.key === 'agent:main:cron:job-1')?.updatedAt).toBe(1773281731621);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user