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,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 });

View File

@@ -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,20 +1290,12 @@ 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 });
try { const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
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 // Before filtering: attach images/files from tool_result messages to the next assistant message
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role)); const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));
// Restore file attachments for user/assistant messages (from cache + text patterns) // Restore file attachments for user/assistant messages (from cache + text patterns)
const enrichedMessages = enrichWithCachedImages(filteredMessages); const enrichedMessages = enrichWithCachedImages(filteredMessages);
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
// Preserve the optimistic user message during an active send. // Preserve the optimistic user message during an active send.
// The Gateway may not include the user's message in chat.history // The Gateway may not include the user's message in chat.history
@@ -1363,13 +1394,38 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ sending: false, activeRunId: null, pendingFinal: false }); 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) {
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
}
applyLoadedMessages(rawMessages, thinkingLevel);
} else {
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else { } else {
set({ messages: [], loading: false }); set({ messages: [], loading: false });
} }
}
} catch (err) { } catch (err) {
console.warn('Failed to load chat history:', err); console.warn('Failed to load chat history:', err);
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else {
set({ messages: [], loading: false }); set({ messages: [], loading: false });
} }
}
}, },
// ── Send message ── // ── Send message ──

View 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()}`;
}

View File

@@ -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,23 +36,12 @@ export function createHistoryActions(
const { currentSessionKey } = get(); const { currentSessionKey } = get();
if (!quiet) set({ loading: true, error: null }); if (!quiet) set({ loading: true, error: null });
try { const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => {
const result = await invokeIpc(
'gateway:rpc',
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
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 // Before filtering: attach images/files from tool_result messages to the next assistant message
const messagesWithToolImages = enrichWithToolResultFiles(rawMessages); const messagesWithToolImages = enrichWithToolResultFiles(rawMessages);
const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role)); const filteredMessages = messagesWithToolImages.filter((msg) => !isToolResultRole(msg.role));
// Restore file attachments for user/assistant messages (from cache + text patterns) // Restore file attachments for user/assistant messages (from cache + text patterns)
const enrichedMessages = enrichWithCachedImages(filteredMessages); const enrichedMessages = enrichWithCachedImages(filteredMessages);
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
// Preserve the optimistic user message during an active send. // Preserve the optimistic user message during an active send.
// The Gateway may not include the user's message in chat.history // The Gateway may not include the user's message in chat.history
@@ -136,13 +140,40 @@ export function createHistoryActions(
set({ sending: false, activeRunId: null, pendingFinal: false }); set({ sending: false, activeRunId: null, pendingFinal: false });
} }
} }
};
try {
const result = await invokeIpc(
'gateway:rpc',
'chat.history',
{ sessionKey: currentSessionKey, limit: 200 }
) as { success: boolean; result?: Record<string, unknown>; error?: string };
if (result.success && result.result) {
const data = result.result;
let rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
const thinkingLevel = data.thinkingLevel ? String(data.thinkingLevel) : null;
if (rawMessages.length === 0 && isCronSessionKey(currentSessionKey)) {
rawMessages = await loadCronFallbackMessages(currentSessionKey, 200);
}
applyLoadedMessages(rawMessages, thinkingLevel);
} else {
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else { } else {
set({ messages: [], loading: false }); set({ messages: [], loading: false });
} }
}
} catch (err) { } catch (err) {
console.warn('Failed to load chat history:', err); console.warn('Failed to load chat history:', err);
const fallbackMessages = await loadCronFallbackMessages(currentSessionKey, 200);
if (fallbackMessages.length > 0) {
applyLoadedMessages(fallbackMessages, null);
} else {
set({ messages: [], loading: false }); set({ messages: [], loading: false });
} }
}
}, },
}; };
} }

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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,

View 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);
});
});

View File

@@ -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);
});
}); });