mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
opt(cron): cron job status (#1125)
This commit is contained in:
@@ -638,6 +638,14 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
// Disable OpenClaw's interactive-shell env snapshot. When the Gateway runs
|
||||
// as an Electron utilityProcess, `process.execPath` is the Electron binary,
|
||||
// and OpenClaw captures the shell env by spawning `process.execPath -e
|
||||
// <script>` inside a sanitized login shell that strips ELECTRON_RUN_AS_NODE.
|
||||
// Electron then treats the script as an app path and pops up "Unable to find
|
||||
// Electron app at <cwd>/const safe = new Set(...)". Turning the snapshot off
|
||||
// avoids that broken spawn; exec tools fall back to the Gateway launch env.
|
||||
OPENCLAW_EXEC_SHELL_SNAPSHOT: '0',
|
||||
};
|
||||
|
||||
// Ensure extension-specific packages (e.g. grammy from the telegram
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: render-cron-run-live-status
|
||||
title: Render live execution status for cron-triggered runs without a session switch
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: When a scheduled (cron) job fires while the user is viewing that cron session, ClawX must render the live running state (Thinking indicator, Execution Graph, tool steps) in realtime. Today the Gateway streams runtime events under the run-scoped session key (agent:<id>:cron:<jobId>:run:<sessionId>) while the UI tracks the base cron key (agent:<id>:cron:<jobId>), so events are dropped by strict session-key equality and the user must switch sessions to force a transcript reload.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/render-cron-run-live-status.md
|
||||
- src/stores/chat/cron-session-utils.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/gateway.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- tests/unit/cron-session-utils.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
expectedUserBehavior:
|
||||
- When a cron job triggers while the user is viewing that cron session, the renderer adopts the run, surfaces the running/Thinking state, and renders the Execution Graph live from streamed runtime events.
|
||||
- Runtime events whose sessionKey carries the run-scoped suffix are treated as belonging to the equivalent base cron session the user is viewing.
|
||||
- When the cron run ends, the renderer reloads the transcript for the current session so the completed graph and final reply render without a manual session switch.
|
||||
- Background :main heartbeat runs continue to NOT surface a Thinking indicator.
|
||||
- Renderer continues to use Host events / api-client boundaries; no new direct IPC or Gateway HTTP calls are added.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/cron-session-utils.test.ts
|
||||
- pnpm exec vitest run tests/unit/gateway-events.test.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- A cron session-key equivalence helper treats the base cron key and its run-scoped variant as the same session.
|
||||
- chat store handleChatEvent / handleRuntimeEvent apply cron run-scoped events to the equivalent base cron session currently in view.
|
||||
- Cron sessions are treated as trackable inbound runs so run.started arms the running state, while :main heartbeats remain suppressed.
|
||||
- gateway runtime-event dispatch reloads history for the current cron session on run end (and start) using equivalence rather than strict equality.
|
||||
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client / host-events path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -478,6 +478,7 @@ export function Sidebar() {
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
data-testid={`sidebar-session-${s.key}`}
|
||||
onClick={() => {
|
||||
if (currentSessionKey === s.key) {
|
||||
void loadHistory(false);
|
||||
|
||||
+38
-5
@@ -9,7 +9,7 @@ import { useGatewayStore } from './gateway';
|
||||
import { useAgentsStore } from './agents';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
import { buildBaselineRunKey, captureBaseline, clearBaselines } from './baseline-cache';
|
||||
import { isCronSessionKey } from './chat/cron-session-utils';
|
||||
import { isCronSessionKey, sessionKeysAreEquivalent } from './chat/cron-session-utils';
|
||||
import { fetchCronSessionHistory } from '@/lib/cron-session-history';
|
||||
import { pickStartupSessionFallback } from './chat/session-selection';
|
||||
import {
|
||||
@@ -2486,6 +2486,10 @@ function shouldTrackInboundRunLifecycle(
|
||||
): boolean {
|
||||
if (state.sending || state.activeRunId != null || state.pendingFinal) return true;
|
||||
if (sessionKey && hasCachedActiveUserRun(sessionKey)) return true;
|
||||
// Cron sessions are explicit, user-scheduled tasks. When the user is viewing
|
||||
// the cron session and it fires, surface the live running state — unlike the
|
||||
// background :main heartbeat runs this guard otherwise suppresses.
|
||||
if (sessionKey && isCronSessionKey(sessionKey)) return true;
|
||||
if (!state.lastUserMessageAt) return false;
|
||||
return Date.now() - toMs(state.lastUserMessageAt) <= USER_INITIATED_RUN_MAX_AGE_MS;
|
||||
}
|
||||
@@ -3925,8 +3929,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const eventSessionKey = event.sessionKey != null ? String(event.sessionKey) : null;
|
||||
const { activeRunId, currentSessionKey } = get();
|
||||
|
||||
// Only process events for the current session (when sessionKey is present)
|
||||
if (eventSessionKey != null && eventSessionKey !== currentSessionKey) {
|
||||
// Only process events for the current session (when sessionKey is present).
|
||||
// Cron runtime/chat events arrive under the run-scoped key
|
||||
// (agent:<id>:cron:<jobId>:run:<sessionId>) while the UI tracks the base
|
||||
// cron key — treat those as the same session via equivalence.
|
||||
const matchesCurrentSession = eventSessionKey == null
|
||||
|| sessionKeysAreEquivalent(eventSessionKey, currentSessionKey);
|
||||
if (eventSessionKey != null && !matchesCurrentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3934,7 +3943,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// Inbound channel traffic (Feishu/Telegram/etc.) on the current session uses a
|
||||
// different runId than a stale desktop activeRunId — still refresh history on finals.
|
||||
if (activeRunId && runId && runId !== activeRunId) {
|
||||
const isCurrentSession = eventSessionKey == null || eventSessionKey === currentSessionKey;
|
||||
const isCurrentSession = matchesCurrentSession;
|
||||
const inboundTerminal = eventState === 'final' || eventState === 'error'
|
||||
|| (event.message && typeof event.message === 'object'
|
||||
&& getMessageStopReason(event.message as Record<string, unknown>) != null);
|
||||
@@ -4370,7 +4379,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const eventSessionKey = event.sessionKey ?? null;
|
||||
const initialState = get();
|
||||
const { activeRunId, currentSessionKey } = initialState;
|
||||
const matchesCurrentSession = eventSessionKey != null && eventSessionKey === currentSessionKey;
|
||||
// Cron runs stream under the run-scoped session key while the UI tracks the
|
||||
// base cron key; equivalence binds those run-scoped events to the session
|
||||
// the user is viewing so the live graph/Thinking state renders in realtime.
|
||||
const matchesCurrentSession = eventSessionKey != null
|
||||
&& sessionKeysAreEquivalent(eventSessionKey, currentSessionKey);
|
||||
const matchesActiveRun = activeRunId != null && event.runId === activeRunId;
|
||||
|
||||
const runtimeRuns = applyRuntimeEventToRuns(initialState.runtimeRuns, event);
|
||||
@@ -4405,6 +4418,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Adopt an in-progress run when joining it mid-flight. Opening a cron
|
||||
// session whose scheduled run is already executing means `run.started` was
|
||||
// emitted before the renderer began tracking this session, so streamed
|
||||
// delta/tool events arrive with no `activeRunId`. Without adoption the live
|
||||
// execution graph and the running/Thinking indicator never appear until the
|
||||
// user switches sessions. Gated on `shouldTrackInboundRunLifecycle` so
|
||||
// background `:main` heartbeat runs stay silent.
|
||||
if (
|
||||
event.type !== 'run.ended'
|
||||
&& matchesCurrentSession
|
||||
&& activeRunId == null
|
||||
&& !initialState.sending
|
||||
&& shouldTrackInboundRunLifecycle(initialState, currentSessionKey)
|
||||
) {
|
||||
nextPatch.activeRunId = event.runId;
|
||||
nextPatch.sending = true;
|
||||
nextPatch.error = null;
|
||||
nextPatch.runError = null;
|
||||
}
|
||||
|
||||
if (event.type === 'assistant.delta' || event.type === 'thinking.delta') {
|
||||
if (appliesToActiveUi && (initialState.error || initialState.runError)) {
|
||||
nextPatch.error = null;
|
||||
|
||||
@@ -28,6 +28,36 @@ export function isCronSessionKey(sessionKey: string): boolean {
|
||||
return parseCronSessionKey(sessionKey) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a run-scoped cron session key
|
||||
* (`agent:<id>:cron:<jobId>:run:<sessionId>`) down to the base cron key
|
||||
* (`agent:<id>:cron:<jobId>`) the sidebar/UI tracks. Non-cron keys and base
|
||||
* cron keys are returned unchanged.
|
||||
*/
|
||||
export function getCronSessionBaseKey(sessionKey: string): string {
|
||||
const parts = parseCronSessionKey(sessionKey);
|
||||
if (!parts) return sessionKey;
|
||||
return `agent:${parts.agentId}:cron:${parts.jobId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two session keys refer to the same logical chat session. Plain keys
|
||||
* match by exact equality; cron keys also match across the base key and any of
|
||||
* its run-scoped variants so Gateway runtime events streamed under
|
||||
* `...:run:<sessionId>` bind to the base cron session the user is viewing.
|
||||
*/
|
||||
export function sessionKeysAreEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
if (a === b) return true;
|
||||
const parsedA = parseCronSessionKey(a);
|
||||
const parsedB = parseCronSessionKey(b);
|
||||
if (!parsedA || !parsedB) return false;
|
||||
return parsedA.agentId === parsedB.agentId && parsedA.jobId === parsedB.jobId;
|
||||
}
|
||||
|
||||
export function buildCronSessionHistoryPath(sessionKey: string, limit = 200): string {
|
||||
const params = new URLSearchParams({ sessionKey });
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
|
||||
+20
-6
@@ -7,6 +7,7 @@ import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
import type { GatewayNotification, GatewayHealth, GatewayStatus } from '../types/gateway';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
import { getCronSessionBaseKey, sessionKeysAreEquivalent } from './chat/cron-session-utils';
|
||||
|
||||
let gatewayInitPromise: Promise<void> | null = null;
|
||||
let gatewayEventUnsubscribers: Array<() => void> | null = null;
|
||||
@@ -156,12 +157,15 @@ function maybeLoadHistory(
|
||||
/** Bump sidebar ordering when any session receives gateway traffic (e.g. Feishu DM). */
|
||||
function touchSessionActivity(sessionKey: string | null | undefined, activityMs = Date.now()): void {
|
||||
if (!sessionKey) return;
|
||||
// Cron runs stream under the run-scoped key; the sidebar only carries the
|
||||
// base cron entry, so normalize before bumping activity.
|
||||
const activityKey = getCronSessionBaseKey(sessionKey);
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.setState((state) => ({
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
[sessionKey]: Math.max(state.sessionLastActivity[sessionKey] ?? 0, activityMs),
|
||||
[activityKey]: Math.max(state.sessionLastActivity[activityKey] ?? 0, activityMs),
|
||||
},
|
||||
}));
|
||||
})
|
||||
@@ -228,15 +232,27 @@ function handleChatRuntimeEvent(event: ChatRuntimeEvent): void {
|
||||
const state = useChatStore.getState();
|
||||
state.handleRuntimeEvent(event);
|
||||
|
||||
const shouldRefreshSessions = resolvedSessionKey != null && (
|
||||
resolvedSessionKey !== state.currentSessionKey
|
||||
|| !state.sessions.some((session) => session.key === resolvedSessionKey)
|
||||
// Cron runs stream under the run-scoped key; treat it as the equivalent
|
||||
// base cron session the user is viewing instead of an unknown session.
|
||||
const matchesCurrentSession = resolvedSessionKey != null
|
||||
&& sessionKeysAreEquivalent(resolvedSessionKey, state.currentSessionKey);
|
||||
const matchesActiveRun = state.activeRunId != null && event.runId === state.activeRunId;
|
||||
const isKnownSession = resolvedSessionKey != null && state.sessions.some(
|
||||
(session) => sessionKeysAreEquivalent(session.key, resolvedSessionKey),
|
||||
);
|
||||
const shouldRefreshSessions = resolvedSessionKey != null
|
||||
&& !matchesCurrentSession
|
||||
&& !isKnownSession;
|
||||
|
||||
if (event.type === 'run.started') {
|
||||
if (shouldRefreshSessions) {
|
||||
maybeLoadSessions(state, true);
|
||||
}
|
||||
// Surface the freshly-written cron trigger message so the Execution
|
||||
// Graph has a run segment to anchor its live steps to.
|
||||
if (matchesCurrentSession) {
|
||||
maybeLoadHistory(state, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,8 +264,6 @@ function handleChatRuntimeEvent(event: ChatRuntimeEvent): void {
|
||||
maybeLoadSessions(state, true);
|
||||
}
|
||||
|
||||
const matchesCurrentSession = resolvedSessionKey != null && resolvedSessionKey === state.currentSessionKey;
|
||||
const matchesActiveRun = state.activeRunId != null && event.runId === state.activeRunId;
|
||||
if (matchesCurrentSession || matchesActiveRun) {
|
||||
maybeLoadHistory(state, true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
|
||||
|
||||
const MAIN_SESSION_KEY = 'agent:main:main';
|
||||
const CRON_BASE_KEY = 'agent:main:cron:job-cron-live';
|
||||
const CRON_RUN_KEY = `${CRON_BASE_KEY}:run:run-session-1`;
|
||||
const CRON_RUN_ID = 'run-cron-live';
|
||||
const CRON_TRIGGER_TEXT = '[cron:job-cron-live] Summarize today important AI news';
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
const cronTriggerHistory = [
|
||||
{
|
||||
role: 'user',
|
||||
id: 'cron-trigger',
|
||||
content: [{ type: 'text', text: CRON_TRIGGER_TEXT }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
test.describe('ClawX cron run live status', () => {
|
||||
test('renders the execution graph live for a cron run without switching sessions', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const cronSession = {
|
||||
key: CRON_BASE_KEY,
|
||||
displayName: 'Cron: 早报',
|
||||
label: 'Cron: 早报',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'main' },
|
||||
cronSession,
|
||||
],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: CRON_BASE_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: cronTriggerHistory },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
},
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Open the cron session (default startup lands on the main session).
|
||||
const cronSidebarButton = page.getByTestId(`sidebar-session-${CRON_BASE_KEY}`);
|
||||
await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 });
|
||||
await cronSidebarButton.click();
|
||||
|
||||
// Transcript loads the cron trigger message; the run has not started yet,
|
||||
// so no live execution graph is present.
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
|
||||
// The Gateway streams runtime events under the run-scoped session key.
|
||||
// They must bind to the base cron session currently in view.
|
||||
await app.evaluate(({ BrowserWindow }, { runId, sessionKey }) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('chat:runtime-event', {
|
||||
type: 'run.started',
|
||||
runId,
|
||||
sessionKey,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}, { runId: CRON_RUN_ID, sessionKey: CRON_RUN_KEY });
|
||||
|
||||
await app.evaluate(({ BrowserWindow }, { runId, sessionKey }) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('chat:runtime-event', {
|
||||
type: 'tool.started',
|
||||
runId,
|
||||
sessionKey,
|
||||
toolCallId: 'call-web-search',
|
||||
name: 'web_search',
|
||||
args: { query: 'AI news June 2026' },
|
||||
});
|
||||
}
|
||||
}, { runId: CRON_RUN_ID, sessionKey: CRON_RUN_KEY });
|
||||
|
||||
// The live execution graph renders without any session switch — this is
|
||||
// the regression being guarded against.
|
||||
await expect(page.getByTestId('chat-execution-graph')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The run settles back to idle when the Gateway reports run.ended.
|
||||
await app.evaluate(({ BrowserWindow }, { runId, sessionKey }) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('chat:runtime-event', {
|
||||
type: 'run.ended',
|
||||
runId,
|
||||
sessionKey,
|
||||
status: 'completed',
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}, { runId: CRON_RUN_ID, sessionKey: CRON_RUN_KEY });
|
||||
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('adopts an already-running cron run joined mid-flight (no run.started received)', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
const cronSession = {
|
||||
key: CRON_BASE_KEY,
|
||||
displayName: 'Cron: 早报',
|
||||
label: 'Cron: 早报',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await installIpcMocks(app, {
|
||||
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
|
||||
gatewayRpc: {
|
||||
[stableStringify(['sessions.list', {}])]: {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: [
|
||||
{ key: MAIN_SESSION_KEY, displayName: 'main' },
|
||||
cronSession,
|
||||
],
|
||||
},
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: [] },
|
||||
},
|
||||
[stableStringify(['chat.history', { sessionKey: CRON_BASE_KEY, limit: 200, maxChars: 500000 }])]: {
|
||||
success: true,
|
||||
result: { messages: cronTriggerHistory },
|
||||
},
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: {
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true } },
|
||||
},
|
||||
[stableStringify(['/api/agents', 'GET'])]: {
|
||||
ok: true,
|
||||
data: { status: 200, ok: true, json: { success: true, agents: [{ id: 'main', name: 'Main' }] } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible({ timeout: 30_000 });
|
||||
const cronSidebarButton = page.getByTestId(`sidebar-session-${CRON_BASE_KEY}`);
|
||||
await expect(cronSidebarButton).toBeVisible({ timeout: 30_000 });
|
||||
await cronSidebarButton.click();
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0);
|
||||
|
||||
// Simulate joining a run already in progress: the first runtime event the
|
||||
// renderer sees is a tool event (run.started happened before the user
|
||||
// opened the session). The run must still be adopted and rendered live.
|
||||
await app.evaluate(({ BrowserWindow }, { runId, sessionKey }) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('chat:runtime-event', {
|
||||
type: 'tool.started',
|
||||
runId,
|
||||
sessionKey,
|
||||
toolCallId: 'call-read-skill',
|
||||
name: 'read',
|
||||
args: { path: '~/.openclaw/skills/docx/SKILL.md' },
|
||||
});
|
||||
}
|
||||
}, { runId: CRON_RUN_ID, sessionKey: CRON_RUN_KEY });
|
||||
|
||||
await expect(page.getByTestId('chat-execution-graph')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await app.evaluate(({ BrowserWindow }, { runId, sessionKey }) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send('chat:runtime-event', {
|
||||
type: 'run.ended',
|
||||
runId,
|
||||
sessionKey,
|
||||
status: 'completed',
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}, { runId: CRON_RUN_ID, sessionKey: CRON_RUN_KEY });
|
||||
|
||||
await expect(page.getByText(CRON_TRIGGER_TEXT)).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCronSessionBaseKey,
|
||||
isCronSessionKey,
|
||||
parseCronSessionKey,
|
||||
sessionKeysAreEquivalent,
|
||||
} from '@/stores/chat/cron-session-utils';
|
||||
|
||||
const BASE = 'agent:product:cron:294717ee-6dde-45a8-8f67-900e2831cc4f';
|
||||
const RUN = `${BASE}:run:0bfbc08a-7582-4c88-9fd3-47c484e17660`;
|
||||
|
||||
describe('parseCronSessionKey', () => {
|
||||
it('parses a base cron session key', () => {
|
||||
expect(parseCronSessionKey(BASE)).toEqual({
|
||||
agentId: 'product',
|
||||
jobId: '294717ee-6dde-45a8-8f67-900e2831cc4f',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a run-scoped cron session key', () => {
|
||||
expect(parseCronSessionKey(RUN)).toEqual({
|
||||
agentId: 'product',
|
||||
jobId: '294717ee-6dde-45a8-8f67-900e2831cc4f',
|
||||
runSessionId: '0bfbc08a-7582-4c88-9fd3-47c484e17660',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-cron keys', () => {
|
||||
expect(parseCronSessionKey('agent:main:main')).toBeNull();
|
||||
expect(isCronSessionKey('agent:main:main')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCronSessionBaseKey', () => {
|
||||
it('collapses a run-scoped cron key to its base key', () => {
|
||||
expect(getCronSessionBaseKey(RUN)).toBe(BASE);
|
||||
});
|
||||
|
||||
it('returns a base cron key unchanged', () => {
|
||||
expect(getCronSessionBaseKey(BASE)).toBe(BASE);
|
||||
});
|
||||
|
||||
it('returns non-cron keys unchanged', () => {
|
||||
expect(getCronSessionBaseKey('agent:main:main')).toBe('agent:main:main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionKeysAreEquivalent', () => {
|
||||
it('matches identical keys', () => {
|
||||
expect(sessionKeysAreEquivalent(BASE, BASE)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a base cron key against its run-scoped variant', () => {
|
||||
expect(sessionKeysAreEquivalent(BASE, RUN)).toBe(true);
|
||||
expect(sessionKeysAreEquivalent(RUN, BASE)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match cron keys for different jobs', () => {
|
||||
const otherRun = 'agent:product:cron:other-job:run:abc';
|
||||
expect(sessionKeysAreEquivalent(BASE, otherRun)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match cron keys across different agents', () => {
|
||||
const otherAgent = 'agent:main:cron:294717ee-6dde-45a8-8f67-900e2831cc4f';
|
||||
expect(sessionKeysAreEquivalent(BASE, otherAgent)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match plain sessions that are not identical', () => {
|
||||
expect(sessionKeysAreEquivalent('agent:main:main', 'agent:main:other')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for nullish keys', () => {
|
||||
expect(sessionKeysAreEquivalent(null, BASE)).toBe(false);
|
||||
expect(sessionKeysAreEquivalent(BASE, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -678,4 +678,208 @@ describe('gateway store event wiring', () => {
|
||||
|
||||
expect(handleChatEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders a cron run live when its run-scoped events bind to the base cron session in view', async () => {
|
||||
const baseKey = 'agent:product:cron:294717ee-6dde-45a8-8f67-900e2831cc4f';
|
||||
const runKey = `${baseKey}:run:0bfbc08a-7582-4c88-9fd3-47c484e17660`;
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
const loadHistory = vi.fn(async () => {});
|
||||
useChatStore.setState({
|
||||
currentSessionKey: baseKey,
|
||||
sessions: [{ key: baseKey }],
|
||||
messages: [{ role: 'user', content: '[cron:294717ee 早报] 执行ai-news-summarizer' }],
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
runtimeRuns: {},
|
||||
loadHistory,
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'run.started',
|
||||
runId: 'run-cron',
|
||||
sessionKey: runKey,
|
||||
startedAt: 1,
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
expect(useChatStore.getState().sending).toBe(true);
|
||||
expect(useChatStore.getState().activeRunId).toBe('run-cron');
|
||||
expect(loadHistory).toHaveBeenCalledTimes(1);
|
||||
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'tool.started',
|
||||
runId: 'run-cron',
|
||||
sessionKey: runKey,
|
||||
toolCallId: 'call-1',
|
||||
name: 'web_search',
|
||||
args: { query: 'AI news' },
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
expect(useChatStore.getState().sending).toBe(true);
|
||||
expect(useChatStore.getState().runtimeRuns['run-cron']?.events).toContainEqual(
|
||||
expect.objectContaining({ type: 'tool.started', toolCallId: 'call-1', name: 'web_search' }),
|
||||
);
|
||||
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'run.ended',
|
||||
runId: 'run-cron',
|
||||
sessionKey: runKey,
|
||||
status: 'completed',
|
||||
endedAt: 2,
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
expect(useChatStore.getState().sending).toBe(false);
|
||||
expect(useChatStore.getState().activeRunId).toBeNull();
|
||||
expect(loadHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('adopts an in-progress cron run when joining mid-flight without a run.started event', async () => {
|
||||
const baseKey = 'agent:main:cron:job-cron-midflight';
|
||||
const runKey = `${baseKey}:run:session-mid`;
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
const loadHistory = vi.fn(async () => {});
|
||||
useChatStore.setState({
|
||||
currentSessionKey: baseKey,
|
||||
sessions: [{ key: baseKey }],
|
||||
messages: [{ role: 'user', content: '[cron:job-cron-midflight] write a doc' }],
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
runtimeRuns: {},
|
||||
loadHistory,
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
// First event the renderer sees for this session is a mid-run tool event
|
||||
// (run.started was emitted before the user opened the cron session).
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'tool.completed',
|
||||
runId: 'run-cron-mid',
|
||||
sessionKey: runKey,
|
||||
toolCallId: 'call-read',
|
||||
name: 'read',
|
||||
result: { summary: 'SKILL.md' },
|
||||
isError: false,
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
expect(useChatStore.getState().sending).toBe(true);
|
||||
expect(useChatStore.getState().activeRunId).toBe('run-cron-mid');
|
||||
expect(useChatStore.getState().runtimeRuns['run-cron-mid']?.events).toContainEqual(
|
||||
expect.objectContaining({ type: 'tool.completed', toolCallId: 'call-read' }),
|
||||
);
|
||||
|
||||
// The run still settles when the terminal event finally arrives.
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'run.ended',
|
||||
runId: 'run-cron-mid',
|
||||
sessionKey: runKey,
|
||||
status: 'completed',
|
||||
endedAt: 10,
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
expect(useChatStore.getState().sending).toBe(false);
|
||||
expect(useChatStore.getState().activeRunId).toBeNull();
|
||||
});
|
||||
|
||||
it('does not adopt a background :main inbound run from a mid-flight tool event', async () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [],
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
runtimeRuns: {},
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'tool.completed',
|
||||
runId: 'run-inbound',
|
||||
sessionKey: 'agent:main:main',
|
||||
toolCallId: 'call-x',
|
||||
name: 'read',
|
||||
result: { summary: 'done' },
|
||||
isError: false,
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
// Background inbound runs on the main session must not flip into a tracked
|
||||
// "Thinking" state from a stray tool event.
|
||||
expect(useChatStore.getState().sending).toBe(false);
|
||||
expect(useChatStore.getState().activeRunId).toBeNull();
|
||||
});
|
||||
|
||||
it('does not surface a Thinking state for background :main heartbeat runs', async () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
hostEventSubscriptionMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
const loadHistory = vi.fn(async () => {});
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sessions: [{ key: 'agent:main:main' }],
|
||||
messages: [],
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
runtimeRuns: {},
|
||||
loadHistory,
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
handlers.get('chat:runtime-event')?.({
|
||||
type: 'run.started',
|
||||
runId: 'run-heartbeat',
|
||||
sessionKey: 'agent:main:main',
|
||||
startedAt: 1,
|
||||
});
|
||||
await flushAsyncImports();
|
||||
|
||||
// The background heartbeat must not flip the composer into a "Thinking"
|
||||
// (sending) state — that gate is what suppresses the indicator.
|
||||
expect(useChatStore.getState().sending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user