perf: speed up initial chat, channels, skills, and cron loading (#901)

This commit is contained in:
paisley
2026-04-23 18:49:47 +08:00
committed by GitHub
parent 4b14f977fa
commit 5137e706c9
8 changed files with 317 additions and 95 deletions

View File

@@ -514,9 +514,10 @@ let lastChannelsStatusFailureAt: number | undefined;
export async function buildChannelAccountsView( export async function buildChannelAccountsView(
ctx: HostApiContext, ctx: HostApiContext,
options?: { probe?: boolean }, options?: { probe?: boolean; skipRuntime?: boolean },
): Promise<{ channels: ChannelAccountsView[]; gatewayHealth: GatewayHealthSummary }> { ): Promise<{ channels: ChannelAccountsView[]; gatewayHealth: GatewayHealthSummary }> {
const startedAt = Date.now(); const startedAt = Date.now();
const skipRuntime = options?.skipRuntime === true;
// Read config once and share across all sub-calls (was 5 readFile calls before). // Read config once and share across all sub-calls (was 5 readFile calls before).
const openClawConfig = await readOpenClawConfig(); const openClawConfig = await readOpenClawConfig();
@@ -526,29 +527,31 @@ export async function buildChannelAccountsView(
listAgentsSnapshotFromConfig(openClawConfig), listAgentsSnapshotFromConfig(openClawConfig),
]); ]);
let gatewayStatus: GatewayChannelStatusPayload | null; let gatewayStatus: GatewayChannelStatusPayload | null = null;
try { if (!skipRuntime) {
// probe=false uses cached runtime state (lighter); probe=true forces try {
// adapter-level connectivity checks for faster post-restart convergence. // probe=false uses cached runtime state (lighter); probe=true forces
const probe = options?.probe === true; // adapter-level connectivity checks for faster post-restart convergence.
// 8s timeout — fail fast when Gateway is busy with AI tasks. const probe = options?.probe === true;
const rpcStartedAt = Date.now(); // 8s timeout — fail fast when Gateway is busy with AI tasks.
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>( const rpcStartedAt = Date.now();
'channels.status', gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>(
{ probe }, 'channels.status',
probe ? 5000 : 8000, { probe },
); probe ? 5000 : 8000,
lastChannelsStatusOkAt = Date.now(); );
logger.info( lastChannelsStatusOkAt = Date.now();
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}` logger.info(
); `[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}`
} catch { );
const probe = options?.probe === true; } catch {
lastChannelsStatusFailureAt = Date.now(); const probe = options?.probe === true;
logger.warn( lastChannelsStatusFailureAt = Date.now();
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms` logger.warn(
); `[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms`
gatewayStatus = null; );
gatewayStatus = null;
}
} }
const gatewayDiagnostics = ctx.gatewayManager.getDiagnostics?.() ?? { const gatewayDiagnostics = ctx.gatewayManager.getDiagnostics?.() ?? {
@@ -563,6 +566,7 @@ export async function buildChannelAccountsView(
platform: process.platform, platform: process.platform,
}); });
const gatewayHealthState = gatewayHealthStateForChannels(gatewayHealth.state); const gatewayHealthState = gatewayHealthStateForChannels(gatewayHealth.state);
const effectiveGatewayHealthState = skipRuntime ? undefined : gatewayHealthState;
const channelTypes = new Set<string>([ const channelTypes = new Set<string>([
...configuredChannels, ...configuredChannels,
@@ -613,7 +617,7 @@ export async function buildChannelAccountsView(
const runtime = runtimeAccounts.find((item) => item.accountId === accountId); const runtime = runtimeAccounts.find((item) => item.accountId === accountId);
const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {}; const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {};
const status = computeChannelRuntimeStatus(runtimeSnapshot, { const status = computeChannelRuntimeStatus(runtimeSnapshot, {
gatewayHealthState, gatewayHealthState: effectiveGatewayHealthState,
}); });
return { return {
accountId, accountId,
@@ -646,22 +650,24 @@ export async function buildChannelAccountsView(
})); }));
const hasRuntimeError = visibleAccountSnapshots.some((account) => typeof account.lastError === 'string' && account.lastError.trim()) const hasRuntimeError = visibleAccountSnapshots.some((account) => typeof account.lastError === 'string' && account.lastError.trim())
|| Boolean(channelSummary?.error?.trim() || channelSummary?.lastError?.trim()); || Boolean(channelSummary?.error?.trim() || channelSummary?.lastError?.trim());
const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary); const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, {
const groupStatus = !gatewayStatus && ctx.gatewayManager.getStatus().state === 'running' gatewayHealthState: effectiveGatewayHealthState,
});
const groupStatus = !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
? 'degraded' ? 'degraded'
: gatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected' : effectiveGatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected'
? 'degraded' ? 'degraded'
: pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, { : pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, {
gatewayHealthState, gatewayHealthState: effectiveGatewayHealthState,
}); });
channels.push({ channels.push({
channelType: uiChannelType, channelType: uiChannelType,
defaultAccountId, defaultAccountId,
status: groupStatus, status: groupStatus,
statusReason: !gatewayStatus && ctx.gatewayManager.getStatus().state === 'running' statusReason: !gatewayStatus && !skipRuntime && ctx.gatewayManager.getStatus().state === 'running'
? 'channels_status_timeout' ? 'channels_status_timeout'
: groupStatus === 'degraded' : groupStatus === 'degraded' && effectiveGatewayHealthState
? overlayStatusReason(gatewayHealth, 'gateway_degraded') ? overlayStatusReason(gatewayHealth, 'gateway_degraded')
: undefined, : undefined,
accounts, accounts,
@@ -670,7 +676,7 @@ export async function buildChannelAccountsView(
const sorted = channels.sort((left, right) => left.channelType.localeCompare(right.channelType)); const sorted = channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
logger.info( logger.info(
`[channels.accounts] response probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}` `[channels.accounts] response mode=${skipRuntime ? 'config' : 'runtime'} probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}`
); );
return { channels: sorted, gatewayHealth }; return { channels: sorted, gatewayHealth };
} }
@@ -1266,9 +1272,13 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/accounts' && req.method === 'GET') { if (url.pathname === '/api/channels/accounts' && req.method === 'GET') {
try { try {
const probe = url.searchParams.get('probe') === '1'; const mode = url.searchParams.get('mode') === 'config' ? 'config' : 'runtime';
logger.info(`[channels.accounts] request probe=${probe ? '1' : '0'}`); const probe = mode !== 'config' && url.searchParams.get('probe') === '1';
const { channels, gatewayHealth } = await buildChannelAccountsView(ctx, { probe }); logger.info(`[channels.accounts] request mode=${mode} probe=${probe ? '1' : '0'}`);
const { channels, gatewayHealth } = await buildChannelAccountsView(ctx, {
probe,
skipRuntime: mode === 'config',
});
sendJson(res, 200, { success: true, channels, gatewayHealth }); sendJson(res, 200, { success: true, channels, gatewayHealth });
} catch (error) { } catch (error) {
sendJson(res, 500, { success: false, error: String(error) }); sendJson(res, 500, { success: false, error: String(error) });

View File

@@ -137,9 +137,11 @@ export function Sidebar() {
let cancelled = false; let cancelled = false;
const hasExistingMessages = useChatStore.getState().messages.length > 0; const hasExistingMessages = useChatStore.getState().messages.length > 0;
(async () => { (async () => {
await loadSessions(); await Promise.allSettled([
loadSessions(),
loadHistory(hasExistingMessages),
]);
if (cancelled) return; if (cancelled) return;
await loadHistory(hasExistingMessages);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;

View File

@@ -98,6 +98,12 @@ interface DeleteTarget {
accountId?: string; accountId?: string;
} }
type FetchPageDataOptions = {
probe?: boolean;
configOnly?: boolean;
forceAgentsRefresh?: boolean;
};
function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): ChannelGroupItem[] { function removeDeletedTarget(groups: ChannelGroupItem[], target: DeleteTarget): ChannelGroupItem[] {
if (target.accountId) { if (target.accountId) {
return groups return groups
@@ -143,7 +149,9 @@ export function Channels() {
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null); const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
const convergenceRefreshTimersRef = useRef<number[]>([]); const convergenceRefreshTimersRef = useRef<number[]>([]);
const fetchInFlightRef = useRef(false); const fetchInFlightRef = useRef(false);
const queuedFetchOptionsRef = useRef<{ probe?: boolean } | null>(null); const queuedFetchOptionsRef = useRef<FetchPageDataOptions | null>(null);
const agentsFetchInFlightRef = useRef<Promise<void> | null>(null);
const hasLoadedAgentsRef = useRef(false);
const displayedChannelTypes = getPrimaryChannels(); const displayedChannelTypes = getPrimaryChannels();
const visibleChannelGroups = channelGroups; const visibleChannelGroups = channelGroups;
@@ -159,16 +167,46 @@ export function Channels() {
const agentsRef = useRef(agents); const agentsRef = useRef(agents);
agentsRef.current = agents; agentsRef.current = agents;
const ensureAgentsLoaded = useCallback(async () => {
if (hasLoadedAgentsRef.current) return;
if (agentsFetchInFlightRef.current) {
await agentsFetchInFlightRef.current;
return;
}
agentsFetchInFlightRef.current = (async () => {
try {
const agentsRes = await hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents');
if (!agentsRes.success) {
throw new Error(agentsRes.error || 'Failed to load agents');
}
setAgents(agentsRes.agents || []);
hasLoadedAgentsRef.current = true;
} catch (agentsError) {
console.warn(`[channels-ui] load agents failed error=${String(agentsError)}`);
} finally {
agentsFetchInFlightRef.current = null;
}
})();
await agentsFetchInFlightRef.current;
}, []);
const mergeFetchOptions = ( const mergeFetchOptions = (
base: { probe?: boolean } | null, base: FetchPageDataOptions | null,
incoming: { probe?: boolean } | undefined, incoming: FetchPageDataOptions | undefined,
): { probe?: boolean } => { ): FetchPageDataOptions => {
if (!base) return incoming ?? {};
if (!incoming) return base;
return { return {
probe: Boolean(base?.probe) || Boolean(incoming?.probe), probe: Boolean(base?.probe) || Boolean(incoming?.probe),
// If either request needs runtime data, do not keep config-only mode.
configOnly: Boolean(base?.configOnly) && Boolean(incoming?.configOnly),
forceAgentsRefresh: Boolean(base?.forceAgentsRefresh) || Boolean(incoming?.forceAgentsRefresh),
}; };
}; };
const fetchPageData = useCallback(async (options?: { probe?: boolean }) => { const fetchPageData = useCallback(async (options?: FetchPageDataOptions) => {
if (fetchInFlightRef.current) { if (fetchInFlightRef.current) {
queuedFetchOptionsRef.current = mergeFetchOptions(queuedFetchOptionsRef.current, options); queuedFetchOptionsRef.current = mergeFetchOptions(queuedFetchOptionsRef.current, options);
return; return;
@@ -176,20 +214,27 @@ export function Channels() {
fetchInFlightRef.current = true; fetchInFlightRef.current = true;
const startedAt = Date.now(); const startedAt = Date.now();
const probe = options?.probe === true; const probe = options?.probe === true;
console.info(`[channels-ui] fetch start probe=${probe ? '1' : '0'}`); const configOnly = options?.configOnly === true;
console.info(`[channels-ui] fetch start mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'}`);
// Only show loading spinner on first load (stale-while-revalidate). // Only show loading spinner on first load (stale-while-revalidate).
const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0; const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0;
if (!hasData) { if (!hasData) {
setLoading(true); setLoading(true);
} }
setError(null); setError(null);
if (options?.forceAgentsRefresh) {
hasLoadedAgentsRef.current = false;
}
void ensureAgentsLoaded();
try { try {
const [channelsRes, agentsRes] = await Promise.all([ const channelsPath = configOnly
hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>( ? '/api/channels/accounts?mode=config'
options?.probe ? '/api/channels/accounts?probe=1' : '/api/channels/accounts' : options?.probe
), ? '/api/channels/accounts?probe=1'
hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'), : '/api/channels/accounts';
]); const channelsRes = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>(
channelsPath
);
type ChannelsResponse = { type ChannelsResponse = {
success: boolean; success: boolean;
@@ -203,23 +248,18 @@ export function Channels() {
throw new Error(channelsPayload.error || 'Failed to load channels'); throw new Error(channelsPayload.error || 'Failed to load channels');
} }
if (!agentsRes.success) {
throw new Error(agentsRes.error || 'Failed to load agents');
}
setChannelGroups(channelsPayload.channels || []); setChannelGroups(channelsPayload.channels || []);
setAgents(agentsRes.agents || []);
setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH); setGatewayHealth(channelsPayload.gatewayHealth || DEFAULT_GATEWAY_HEALTH);
setDiagnosticsSnapshot(null); setDiagnosticsSnapshot(null);
setShowDiagnostics(false); setShowDiagnostics(false);
console.info( console.info(
`[channels-ui] fetch ok probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsPayload.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}` `[channels-ui] fetch ok mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsPayload.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}`
); );
} catch (fetchError) { } catch (fetchError) {
// Preserve previous data on error — don't clear channelGroups/agents. // Preserve previous data on error — don't clear channelGroups/agents.
setError(String(fetchError)); setError(String(fetchError));
console.warn( console.warn(
`[channels-ui] fetch fail probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}` `[channels-ui] fetch fail mode=${configOnly ? 'config' : 'runtime'} probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}`
); );
} finally { } finally {
fetchInFlightRef.current = false; fetchInFlightRef.current = false;
@@ -232,7 +272,7 @@ export function Channels() {
} }
// Stable reference — reads state via refs, no deps needed. // Stable reference — reads state via refs, no deps needed.
}, []); }, [ensureAgentsLoaded]);
const clearConvergenceRefreshTimers = useCallback(() => { const clearConvergenceRefreshTimers = useCallback(() => {
convergenceRefreshTimersRef.current.forEach((timerId) => { convergenceRefreshTimersRef.current.forEach((timerId) => {
@@ -261,6 +301,7 @@ export function Channels() {
}, [clearConvergenceRefreshTimers, fetchPageData]); }, [clearConvergenceRefreshTimers, fetchPageData]);
useEffect(() => { useEffect(() => {
void fetchPageData({ configOnly: true });
void fetchPageData(); void fetchPageData();
}, [fetchPageData]); }, [fetchPageData]);
@@ -329,7 +370,7 @@ export function Channels() {
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type)); const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
const handleRefresh = () => { const handleRefresh = () => {
void fetchPageData({ probe: true }); void fetchPageData({ probe: true, forceAgentsRefresh: true });
}; };
const fetchDiagnosticsSnapshot = useCallback(async (): Promise<GatewayDiagnosticSnapshot> => { const fetchDiagnosticsSnapshot = useCallback(async (): Promise<GatewayDiagnosticSnapshot> => {

View File

@@ -7,6 +7,8 @@ import { hostApiFetch } from '@/lib/host-api';
import { useChatStore } from './chat'; import { useChatStore } from './chat';
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron'; import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
let _fetchJobsInFlight: Promise<void> | null = null;
interface CronState { interface CronState {
jobs: CronJob[]; jobs: CronJob[];
loading: boolean; loading: boolean;
@@ -28,28 +30,41 @@ export const useCronStore = create<CronState>((set) => ({
error: null, error: null,
fetchJobs: async () => { fetchJobs: async () => {
const currentJobs = useCronStore.getState().jobs; if (_fetchJobsInFlight) {
// Only show loading spinner when there's no data yet (stale-while-revalidate). await _fetchJobsInFlight;
if (currentJobs.length === 0) { return;
set({ loading: true, error: null });
} else {
set({ error: null });
} }
_fetchJobsInFlight = (async () => {
const currentJobs = useCronStore.getState().jobs;
// Only show loading spinner when there's no data yet (stale-while-revalidate).
if (currentJobs.length === 0) {
set({ loading: true, error: null });
} else {
set({ error: null });
}
try {
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
// Gateway now correctly returns agentId for all jobs.
// If Gateway returned fewer jobs than we have (e.g. race condition), preserve
// the extra ones from current state to avoid losing data.
const resultIds = new Set(result.map((j) => j.id));
const extraJobs = currentJobs.filter((j) => !resultIds.has(j.id));
const allJobs = [...result, ...extraJobs];
set({ jobs: allJobs, loading: false });
} catch (error) {
// Preserve previous jobs on error so the user sees stale data instead of nothing.
set({ error: String(error), loading: false });
}
})();
try { try {
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs'); await _fetchJobsInFlight;
} finally {
// Gateway now correctly returns agentId for all jobs. _fetchJobsInFlight = null;
// If Gateway returned fewer jobs than we have (e.g. race condition), preserve
// the extra ones from current state to avoid losing data.
const resultIds = new Set(result.map((j) => j.id));
const extraJobs = currentJobs.filter((j) => !resultIds.has(j.id));
const allJobs = [...result, ...extraJobs];
set({ jobs: allJobs, loading: false });
} catch (error) {
// Preserve previous jobs on error so the user sees stale data instead of nothing.
set({ error: String(error), loading: false });
} }
}, },

View File

@@ -92,14 +92,15 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
} }
try { try {
// 1. Fetch from Gateway (running skills) // Fetch all skill sources in parallel to reduce first-load latency.
const gatewayData = await useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status'); const gatewayDataPromise = useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status');
const clawhubResultPromise = hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list');
// 2. Fetch from ClawHub (installed on disk) const configResultPromise = hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs');
const clawhubResult = await hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list'); const [gatewayData, clawhubResult, configResult] = await Promise.all([
gatewayDataPromise,
// 3. Fetch configurations directly from Electron (since Gateway doesn't return them) clawhubResultPromise,
const configResult = await hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs'); configResultPromise,
]);
let combinedSkills: Skill[] = []; let combinedSkills: Skill[] = [];
const currentSkills = get().skills; const currentSkills = get().skills;

View File

@@ -59,7 +59,7 @@ describe('Channels page status refresh', () => {
}); });
gatewayState.status = { state: 'running', port: 18789 }; gatewayState.status = { state: 'running', port: 18789 };
hostApiFetchMock.mockImplementation(async (path: string) => { hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
gatewayHealth: { gatewayHealth: {
@@ -100,7 +100,7 @@ describe('Channels page status refresh', () => {
it('blocks saving when custom account ID is non-canonical', async () => { it('blocks saving when custom account ID is non-canonical', async () => {
subscribeHostEventMock.mockImplementation(() => vi.fn()); subscribeHostEventMock.mockImplementation(() => vi.fn());
hostApiFetchMock.mockImplementation(async (path: string) => { hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
channels: [ channels: [
@@ -210,7 +210,7 @@ describe('Channels page status refresh', () => {
const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts');
const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents'); const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents');
expect(channelFetchCalls).toHaveLength(2); expect(channelFetchCalls).toHaveLength(2);
expect(agentFetchCalls).toHaveLength(2); expect(agentFetchCalls).toHaveLength(1);
}); });
}); });
@@ -233,14 +233,59 @@ describe('Channels page status refresh', () => {
const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts'); const channelFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/channels/accounts');
const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents'); const agentFetchCalls = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agents');
expect(channelFetchCalls).toHaveLength(2); expect(channelFetchCalls).toHaveLength(2);
expect(agentFetchCalls).toHaveLength(2); expect(agentFetchCalls).toHaveLength(1);
});
});
it('renders channel data without waiting for slow agents request', async () => {
subscribeHostEventMock.mockImplementation(() => vi.fn());
const agentsDeferred = createDeferred<{
success: boolean;
agents: Array<Record<string, unknown>>;
}>();
hostApiFetchMock.mockImplementation((path: string) => {
if (path.startsWith('/api/channels/accounts')) {
return Promise.resolve({
success: true,
channels: [
{
channelType: 'feishu',
defaultAccountId: 'default',
status: 'connected',
accounts: [
{
accountId: 'default',
name: 'Primary Account',
configured: true,
status: 'connected',
isDefault: true,
},
],
},
],
});
}
if (path === '/api/agents') {
return agentsDeferred.promise;
}
throw new Error(`Unexpected host API path: ${path}`);
});
render(<Channels />);
expect(await screen.findByText('Feishu / Lark')).toBeInTheDocument();
await act(async () => {
agentsDeferred.resolve({ success: true, agents: [] });
}); });
}); });
it('treats WeChat accounts as plugin-managed QR accounts', async () => { it('treats WeChat accounts as plugin-managed QR accounts', async () => {
subscribeHostEventMock.mockImplementation(() => vi.fn()); subscribeHostEventMock.mockImplementation(() => vi.fn());
hostApiFetchMock.mockImplementation(async (path: string) => { hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
channels: [ channels: [
@@ -305,7 +350,7 @@ describe('Channels page status refresh', () => {
let refreshCallCount = 0; let refreshCallCount = 0;
hostApiFetchMock.mockImplementation((path: string) => { hostApiFetchMock.mockImplementation((path: string) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
if (refreshCallCount === 0) { if (refreshCallCount === 0) {
refreshCallCount += 1; refreshCallCount += 1;
return Promise.resolve({ return Promise.resolve({
@@ -401,7 +446,7 @@ describe('Channels page status refresh', () => {
const writeTextMock = vi.mocked(navigator.clipboard.writeText); const writeTextMock = vi.mocked(navigator.clipboard.writeText);
hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => { hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
gatewayHealth: { gatewayHealth: {
@@ -477,7 +522,7 @@ describe('Channels page status refresh', () => {
subscribeHostEventMock.mockImplementation(() => vi.fn()); subscribeHostEventMock.mockImplementation(() => vi.fn());
hostApiFetchMock.mockImplementation(async (path: string) => { hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
gatewayHealth: { gatewayHealth: {
@@ -530,7 +575,7 @@ describe('Channels page status refresh', () => {
subscribeHostEventMock.mockImplementation(() => vi.fn()); subscribeHostEventMock.mockImplementation(() => vi.fn());
hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => { hostApiFetchMock.mockImplementation(async (path: string, init?: { method?: string }) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
gatewayHealth: { gatewayHealth: {
@@ -584,7 +629,7 @@ describe('Channels page status refresh', () => {
let diagnosticsFetchCount = 0; let diagnosticsFetchCount = 0;
hostApiFetchMock.mockImplementation(async (path: string) => { hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/channels/accounts') { if (path.startsWith('/api/channels/accounts')) {
return { return {
success: true, success: true,
gatewayHealth: { gatewayHealth: {

View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiFetchMock = vi.fn();
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/stores/chat', () => ({
useChatStore: {
getState: () => ({
currentAgentId: 'main',
}),
},
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('cron store fetchJobs dedupe', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('reuses in-flight fetchJobs request when called concurrently', async () => {
const listDeferred = deferred<Array<{ id: string }>>();
hostApiFetchMock.mockReturnValueOnce(listDeferred.promise);
const { useCronStore } = await import('@/stores/cron');
useCronStore.setState({ jobs: [], loading: false, error: null });
const first = useCronStore.getState().fetchJobs();
const second = useCronStore.getState().fetchJobs();
await Promise.resolve();
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/cron/jobs');
listDeferred.resolve([{ id: 'job-1' }]);
await Promise.all([first, second]);
expect(useCronStore.getState().jobs.map((job) => job.id)).toEqual(['job-1']);
});
});

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hostApiFetchMock = vi.fn();
const rpcMock = vi.fn();
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: {
getState: () => ({
rpc: (...args: unknown[]) => rpcMock(...args),
}),
},
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('skills store fetch parallelization', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it('starts clawhub and config requests before gateway rpc resolves', async () => {
const gatewayDeferred = deferred<{ skills: Array<Record<string, unknown>> }>();
rpcMock.mockReturnValueOnce(gatewayDeferred.promise);
hostApiFetchMock.mockImplementation((path: unknown) => {
if (path === '/api/clawhub/list') return Promise.resolve({ success: true, results: [] });
if (path === '/api/skills/configs') return Promise.resolve({});
return Promise.reject(new Error(`Unexpected path: ${String(path)}`));
});
const { useSkillsStore } = await import('@/stores/skills');
useSkillsStore.setState({ skills: [], loading: false, error: null });
const fetchPromise = useSkillsStore.getState().fetchSkills();
await Promise.resolve();
expect(rpcMock).toHaveBeenCalledWith('skills.status');
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/clawhub/list');
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/skills/configs');
gatewayDeferred.resolve({ skills: [] });
await fetchPromise;
});
});