refactor(renderer): split snapshot state sections

This commit is contained in:
ZacharyZhang-NY committed 2026-04-24 02:48:28 -04:00
1 parent 236714e17a
commit 1383805d66
12 files changed
+1030 -899

No files matched your search

+1 -1
View File
@@ -92,4 +92,4 @@ local-release/
.omx/
# Runtime state (written by app at runtime)
state/
/state/
+7 -1
View File
@@ -3,6 +3,8 @@ import type { ProfileSnapshot, SectionId } from "@shared/types";
import { describeDesktopError, unwrap } from "../lib/desktop";
import { useAppStore } from "../state/store";
export type SnapshotRefreshGuard = () => boolean;
export function useDataLoader(
snapshot: ProfileSnapshot | null,
section: SectionId,
@@ -11,10 +13,11 @@ export function useDataLoader(
// selectedRunId via getState() at call time so its identity stays stable
// across connector/run selection changes and AppActions consumers do not
// cascade re-render.
const refresh = useCallback(async () => {
const refresh = useCallback(async (shouldApply: SnapshotRefreshGuard = () => true) => {
const store = useAppStore.getState();
try {
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
if (!shouldApply()) return;
store.setSnapshot(next);
if (next.companies.length === 0) {
store.setInitialOnboardingLocked(true);
@@ -26,12 +29,15 @@ export function useDataLoader(
if (next.currentCompanyId) {
store.setSocialAccounts(next.socialAccounts?.filter((a) => a.companyId === next.currentCompanyId) ?? []);
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
if (!shouldApply()) return;
if (browserActionResult.ok) {
store.setBrowserActions(browserActionResult.data);
}
}
if (!shouldApply()) return;
store.setError(null);
} catch (nextError) {
if (!shouldApply()) return;
store.setError(describeDesktopError(nextError));
}
}, []);
+37 -43
View File
@@ -1,37 +1,44 @@
import { useEffect, type MutableRefObject } from "react";
import type { DesktopEvent } from "@shared/types";
import {
createDomainEventBatcher,
isSnapshotIrrelevantEvent,
classifySnapshotEvent,
createSnapshotEventGuard,
type GranularEventEntry,
} from "../lib/ipc-batcher";
} from "../lib/snapshot-events";
import { useAppStore } from "../state/store";
export function useDesktopEvents(
refreshRef: MutableRefObject<() => Promise<void>>,
refreshRef: MutableRefObject<(shouldApply?: () => boolean) => Promise<void>>,
logBufferRef: MutableRefObject<Record<string, string>>,
incrementLogRevision: () => void,
) {
useEffect(() => {
void refreshRef.current();
const store = useAppStore;
const eventGuard = createSnapshotEventGuard();
async function handleGranularBatch(events: GranularEventEntry[]) {
function requestFullRefresh() {
eventGuard.invalidateSnapshot();
const token = eventGuard.captureSnapshot();
void refreshRef.current(() => eventGuard.canApplySnapshot(token));
}
requestFullRefresh();
async function handleSnapshotEntityEvent(entry: GranularEventEntry) {
const companyId = store.getState().snapshot?.currentCompanyId ?? null;
if (!companyId) {
void refreshRef.current();
requestFullRefresh();
return;
}
let needsFullRefresh = false;
eventGuard.invalidateSnapshot();
eventGuard.invalidateEntity(entry);
const token = eventGuard.captureEntity(entry);
for (const entry of events) {
// run-updated:completed touches too many entity types -- full refresh
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
break;
}
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
} else {
try {
switch (entry.type) {
case "task-updated": {
@@ -39,6 +46,7 @@ export function useDesktopEvents(
store.getState().removeTask(entry.entityId);
} else {
const result = await window.agentCompany.getTask({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchTask(result.data);
} else {
@@ -52,6 +60,7 @@ export function useDesktopEvents(
store.getState().removeRun(entry.entityId);
} else {
const result = await window.agentCompany.getRun({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchRun(result.data);
} else {
@@ -65,6 +74,7 @@ export function useDesktopEvents(
store.getState().removeApproval(entry.entityId);
} else {
const result = await window.agentCompany.getApproval({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchApproval(result.data);
} else {
@@ -75,6 +85,7 @@ export function useDesktopEvents(
}
case "agent-updated": {
const result = await window.agentCompany.getAgent({ companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
store.getState().patchAgent(result.data);
} else {
@@ -83,44 +94,24 @@ export function useDesktopEvents(
break;
}
case "budget-changed": {
// Budget is a computed aggregate -- full refresh is needed
needsFullRefresh = true;
break;
}
default: {
// Unknown granular event type -- fall back (per D-05 / PERF-03)
needsFullRefresh = true;
break;
}
}
} catch (error) {
console.warn(
`[renderer/useDesktopEvents] granular fetch failed for ${entry.type}/${entry.entityId}; falling back to full refresh: ${error instanceof Error ? error.message : String(error)}`,
);
} catch {
needsFullRefresh = true;
break;
}
if (needsFullRefresh) break;
}
if (needsFullRefresh) {
void refreshRef.current();
requestFullRefresh();
}
// Granular patches already update the snapshot arrays, and
// sidebar badges derive from the snapshot live — no separate
// badge refresh is needed.
}
// 500ms window for full-refresh events (e.g. new-message,
// thread-wait-state-updated) so bursts coalesce into one reload.
// Granular entity events still flush at this window but are cheap.
const batcher = createDomainEventBatcher(
() => { void refreshRef.current(); },
(events) => { void handleGranularBatch(events); },
500,
);
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
if (event.type === "run-log") {
const MAX_LOG_BYTES_PER_RUN = 512_000;
@@ -141,24 +132,27 @@ export function useDesktopEvents(
}
if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") store.getState().toggleCommandPalette();
if (event.action === "refresh") void refreshRef.current();
if (event.action === "refresh") requestFullRefresh();
return;
}
if (event.type === "notification-click") {
store.getState().navigateTo(event.section, event.entityId);
return;
}
// One-shot events that do not affect ProfileSnapshot -- handled by
// their own listeners elsewhere, never trigger a snapshot refresh.
if (isSnapshotIrrelevantEvent(event)) {
const decision = classifySnapshotEvent(event);
if (decision.kind === "ignore") {
return;
}
// All remaining data-mutating events go through the enhanced batcher
batcher.push(event);
if (decision.kind === "full-refresh") {
requestFullRefresh();
return;
}
void handleSnapshotEntityEvent(decision.entry);
});
return () => {
unsubscribe();
batcher.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refreshRef and logBufferRef are stable refs; incrementLogRevision is a stable Zustand setter
}, []);
-113
View File
@@ -1,113 +0,0 @@
import type { DesktopEvent } from "@shared/types";
export interface GranularEventEntry {
type: string;
entityId: string;
action: string;
}
const MAX_GRANULAR_EVENTS = 10;
export function entityKeyFromEvent(event: DesktopEvent): GranularEventEntry | null {
switch (event.type) {
case "task-updated":
return { type: "task-updated", entityId: event.taskId, action: event.action };
case "run-updated":
return { type: "run-updated", entityId: event.runId, action: event.action };
case "approval-updated":
return { type: "approval-updated", entityId: event.approvalId, action: event.action };
case "agent-updated":
return { type: "agent-updated", entityId: event.agentId, action: event.action };
case "budget-changed":
return { type: "budget-changed", entityId: event.companyId, action: "changed" };
// run-status is a run-level state change -- map onto run entity so
// granular patchRun refetches the run record.
case "run-status":
return { type: "run-updated", entityId: event.runId, action: "status_changed" };
// heartbeat-status mutates agent metadata (nextWakeAt / enabled) -- map
// onto agent entity so granular patchAgent refetches.
case "heartbeat-status":
return { type: "agent-updated", entityId: event.agentId, action: "updated" };
// agent-message posts a new comment under a task; patchTask refetch will
// pick up the task's updated comment list.
case "agent-message":
return { type: "task-updated", entityId: event.taskId, action: "updated" };
default:
return null;
}
}
// Events that do NOT affect ProfileSnapshot and therefore must not trigger
// a snapshot refresh -- they are handled exclusively by their own listeners.
const SNAPSHOT_IRRELEVANT_EVENT_TYPES = new Set<DesktopEvent["type"]>([
"automation-fired",
"recovery-needed",
]);
export function isSnapshotIrrelevantEvent(event: DesktopEvent): boolean {
return SNAPSHOT_IRRELEVANT_EVENT_TYPES.has(event.type);
}
export function createDomainEventBatcher(
onFullRefresh: () => void,
onGranularBatch: (events: GranularEventEntry[]) => void,
windowMs = 50,
maxGranular = MAX_GRANULAR_EVENTS,
) {
let timer: ReturnType<typeof setTimeout> | null = null;
let hasDomainChanged = false;
const pending = new Map<string, GranularEventEntry>();
function push(event: DesktopEvent) {
if (event.type === "domain-changed") {
hasDomainChanged = true;
} else {
const entry = entityKeyFromEvent(event);
if (entry) {
const key = `${entry.type}:${entry.entityId}`;
const existing = pending.get(key);
// If any event in window has action "deleted", preserve that action
if (existing && existing.action === "deleted" && entry.action !== "deleted") {
// Keep the delete -- it takes priority
} else {
pending.set(key, entry);
}
} else {
// Unknown event type -- treat as domain-changed for safety (per D-05)
hasDomainChanged = true;
}
}
if (timer === null) {
timer = setTimeout(flushInternal, windowMs);
}
}
function flushInternal() {
timer = null;
if (hasDomainChanged || pending.size > maxGranular) {
onFullRefresh();
} else if (pending.size > 0) {
onGranularBatch([...pending.values()]);
}
hasDomainChanged = false;
pending.clear();
}
function flush() {
if (timer !== null) {
clearTimeout(timer);
flushInternal();
}
}
function dispose() {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
hasDomainChanged = false;
pending.clear();
}
return { push, flush, dispose };
}
+120
View File
@@ -0,0 +1,120 @@
import type { DesktopEvent } from "@shared/types";
export type SnapshotEntityEventType =
| "task-updated"
| "run-updated"
| "approval-updated"
| "agent-updated"
| "budget-changed";
export interface SnapshotEntityEvent {
type: SnapshotEntityEventType;
entityId: string;
action: string;
}
export type GranularEventEntry = SnapshotEntityEvent;
export type SnapshotEventDecision =
| { kind: "entity"; entry: SnapshotEntityEvent }
| { kind: "full-refresh" }
| { kind: "ignore" };
export interface SnapshotEntityToken {
snapshotEpoch: number;
entityVersion: number;
}
export interface SnapshotRefreshToken {
snapshotEpoch: number;
}
export interface SnapshotEventGuard {
invalidateSnapshot: () => void;
captureSnapshot: () => SnapshotRefreshToken;
canApplySnapshot: (token: SnapshotRefreshToken) => boolean;
invalidateEntity: (entry: SnapshotEntityEvent) => void;
captureEntity: (entry: SnapshotEntityEvent) => SnapshotEntityToken;
canApplyEntity: (entry: SnapshotEntityEvent, token: SnapshotEntityToken) => boolean;
}
export function classifySnapshotEvent(event: DesktopEvent): SnapshotEventDecision {
switch (event.type) {
case "task-updated":
return { kind: "entity", entry: { type: "task-updated", entityId: event.taskId, action: event.action } };
case "run-updated":
return { kind: "entity", entry: { type: "run-updated", entityId: event.runId, action: event.action } };
case "approval-updated":
return {
kind: "entity",
entry: { type: "approval-updated", entityId: event.approvalId, action: event.action },
};
case "agent-updated":
return { kind: "entity", entry: { type: "agent-updated", entityId: event.agentId, action: event.action } };
case "budget-changed":
return { kind: "entity", entry: { type: "budget-changed", entityId: event.companyId, action: "changed" } };
case "run-status":
return { kind: "entity", entry: { type: "run-updated", entityId: event.runId, action: "status_changed" } };
case "heartbeat-status":
return { kind: "entity", entry: { type: "agent-updated", entityId: event.agentId, action: "updated" } };
case "agent-message":
return { kind: "entity", entry: { type: "task-updated", entityId: event.taskId, action: "updated" } };
case "automation-fired":
case "recovery-needed":
case "run-log":
case "shortcut":
case "notification-click":
return { kind: "ignore" };
case "domain-changed":
case "new-message":
case "thread-wait-state-updated":
case "credentials-changed":
case "agent-event":
case "oauth:status":
case "oauth:auth-url":
case "oauth:progress":
case "oauth:prompt-request":
case "oauth:manual-code-request":
return { kind: "full-refresh" };
default:
return { kind: "full-refresh" };
}
}
export function createSnapshotEventGuard(): SnapshotEventGuard {
let snapshotEpoch = 0;
const entityVersions = new Map<string, number>();
function entityKey(entry: SnapshotEntityEvent): string {
return `${entry.type}:${entry.entityId}`;
}
function entityVersion(entry: SnapshotEntityEvent): number {
return entityVersions.get(entityKey(entry)) ?? 0;
}
return {
invalidateSnapshot() {
snapshotEpoch += 1;
},
captureSnapshot() {
return { snapshotEpoch };
},
canApplySnapshot(token) {
return snapshotEpoch === token.snapshotEpoch;
},
invalidateEntity(entry) {
const key = entityKey(entry);
entityVersions.set(key, (entityVersions.get(key) ?? 0) + 1);
},
captureEntity(entry) {
return {
snapshotEpoch,
entityVersion: entityVersion(entry),
};
},
canApplyEntity(entry, token) {
return snapshotEpoch === token.snapshotEpoch && entityVersion(entry) === token.entityVersion;
},
};
}
+61 -26
View File
@@ -16,6 +16,11 @@ import {
type AppErrorInput,
type AppErrorNotice,
} from "../../lib/desktop";
import {
assembleProfileSnapshot,
splitProfileSnapshot,
type ProfileSnapshotSections,
} from "../snapshot-sections";
// Sidebar badge counts and inbox items both live in the renderer —
// derived on every read from the snapshot (see `@shared/sidebar-badges`
@@ -24,6 +29,7 @@ import {
// slice is what let the counters drift against the rendered content.
export interface CompanySlice {
snapshot: ProfileSnapshot | null;
snapshotSections: ProfileSnapshotSections | null;
/**
* Bumps only on full-snapshot replacements (setSnapshot / refreshSnapshot).
* Granular patch/remove methods DO NOT bump this counter so that
@@ -49,6 +55,14 @@ export interface CompanySlice {
removeApproval: (approvalId: string) => void;
}
function applySnapshotSections(
state: CompanySlice,
sections: ProfileSnapshotSections | null,
) {
state.snapshotSections = sections;
state.snapshot = sections ? assembleProfileSnapshot(sections) : null;
}
export const createCompanySlice: StateCreator<
AppState,
[["zustand/immer", never]],
@@ -56,13 +70,14 @@ export const createCompanySlice: StateCreator<
CompanySlice
> = (set) => ({
snapshot: null,
snapshotSections: null,
snapshotRevision: 0,
companyMetrics: null,
standupReport: null,
error: null,
setSnapshot: (snapshot) =>
set((state) => {
state.snapshot = snapshot;
applySnapshotSections(state, snapshot ? splitProfileSnapshot(snapshot) : null);
state.snapshotRevision += 1;
}),
setCompanyMetrics: (metrics) =>
@@ -82,7 +97,7 @@ export const createCompanySlice: StateCreator<
const result = await window.agentCompany.loadSnapshot();
if (result.ok) {
set((state) => {
state.snapshot = result.data;
applySnapshotSections(state, splitProfileSnapshot(result.data));
state.snapshotRevision += 1;
});
} else {
@@ -101,65 +116,85 @@ export const createCompanySlice: StateCreator<
patchTask: (task) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.tasks.findIndex((t) => t.id === task.id);
if (!state.snapshotSections) return;
const tasks = state.snapshotSections.company.tasks;
const idx = tasks.findIndex((t) => t.id === task.id);
if (idx >= 0) {
state.snapshot.tasks[idx] = task;
tasks[idx] = task;
} else {
state.snapshot.tasks.unshift(task);
tasks.unshift(task);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeTask: (taskId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.tasks = state.snapshot.tasks.filter((t) => t.id !== taskId);
if (!state.snapshotSections) return;
state.snapshotSections.company.tasks = state.snapshotSections.company.tasks.filter(
(t) => t.id !== taskId,
);
applySnapshotSections(state, state.snapshotSections);
}),
patchRun: (run) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.runs.findIndex((r) => r.id === run.id);
if (!state.snapshotSections) return;
const runs = state.snapshotSections.company.runs;
const idx = runs.findIndex((r) => r.id === run.id);
if (idx >= 0) {
state.snapshot.runs[idx] = run;
runs[idx] = run;
} else {
state.snapshot.runs.unshift(run);
runs.unshift(run);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeRun: (runId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.runs = state.snapshot.runs.filter((r) => r.id !== runId);
if (!state.snapshotSections) return;
state.snapshotSections.company.runs = state.snapshotSections.company.runs.filter(
(r) => r.id !== runId,
);
applySnapshotSections(state, state.snapshotSections);
}),
patchAgent: (agent) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.agents.findIndex((a) => a.id === agent.id);
if (!state.snapshotSections) return;
const agents = state.snapshotSections.company.agents;
const idx = agents.findIndex((a) => a.id === agent.id);
if (idx >= 0) {
state.snapshot.agents[idx] = agent;
agents[idx] = agent;
} else {
state.snapshot.agents.unshift(agent);
agents.unshift(agent);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeAgent: (agentId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.agents = state.snapshot.agents.filter((a) => a.id !== agentId);
if (!state.snapshotSections) return;
state.snapshotSections.company.agents = state.snapshotSections.company.agents.filter(
(a) => a.id !== agentId,
);
applySnapshotSections(state, state.snapshotSections);
}),
patchApproval: (approval) =>
set((state) => {
if (!state.snapshot) return;
const idx = state.snapshot.approvals.findIndex((a) => a.id === approval.id);
if (!state.snapshotSections) return;
const approvals = state.snapshotSections.company.approvals;
const idx = approvals.findIndex((a) => a.id === approval.id);
if (idx >= 0) {
state.snapshot.approvals[idx] = approval;
approvals[idx] = approval;
} else {
state.snapshot.approvals.unshift(approval);
approvals.unshift(approval);
}
applySnapshotSections(state, state.snapshotSections);
}),
removeApproval: (approvalId) =>
set((state) => {
if (!state.snapshot) return;
state.snapshot.approvals = state.snapshot.approvals.filter((a) => a.id !== approvalId);
if (!state.snapshotSections) return;
state.snapshotSections.company.approvals = state.snapshotSections.company.approvals.filter(
(a) => a.id !== approvalId,
);
applySnapshotSections(state, state.snapshotSections);
}),
});
+116
View File
@@ -0,0 +1,116 @@
import type { ProfileSnapshot } from "@shared/types";
export interface ProfileMetadataSection {
currentCompanyId: ProfileSnapshot["currentCompanyId"];
profilePath: ProfileSnapshot["profilePath"];
updatesEnabled: ProfileSnapshot["updatesEnabled"];
theme: ProfileSnapshot["theme"];
locale: ProfileSnapshot["locale"];
backend: ProfileSnapshot["backend"];
}
export interface ProfileCatalogSection {
companies: ProfileSnapshot["companies"];
connectors: ProfileSnapshot["connectors"];
}
export interface CompanyCollectionsSection {
workspaces: ProfileSnapshot["workspaces"];
agents: ProfileSnapshot["agents"];
goals: ProfileSnapshot["goals"];
projects: ProfileSnapshot["projects"];
tasks: ProfileSnapshot["tasks"];
approvals: ProfileSnapshot["approvals"];
runs: ProfileSnapshot["runs"];
costs: ProfileSnapshot["costs"];
activity: ProfileSnapshot["activity"];
secrets: ProfileSnapshot["secrets"];
comments: ProfileSnapshot["comments"];
socialAccounts: ProfileSnapshot["socialAccounts"];
meetings: ProfileSnapshot["meetings"];
documents: ProfileSnapshot["documents"];
workProducts: ProfileSnapshot["workProducts"];
knowledgeBase: ProfileSnapshot["knowledgeBase"];
sprints: ProfileSnapshot["sprints"];
automationRules: ProfileSnapshot["automationRules"];
agentMessages: ProfileSnapshot["agentMessages"];
workflows: ProfileSnapshot["workflows"];
}
export interface ProfileSnapshotSections {
metadata: ProfileMetadataSection;
catalog: ProfileCatalogSection;
company: CompanyCollectionsSection;
}
export function splitProfileSnapshot(snapshot: ProfileSnapshot): ProfileSnapshotSections {
return {
metadata: {
currentCompanyId: snapshot.currentCompanyId,
profilePath: snapshot.profilePath,
updatesEnabled: snapshot.updatesEnabled,
theme: snapshot.theme,
locale: snapshot.locale,
backend: snapshot.backend,
},
catalog: {
companies: snapshot.companies,
connectors: snapshot.connectors,
},
company: {
workspaces: snapshot.workspaces,
agents: snapshot.agents,
goals: snapshot.goals,
projects: snapshot.projects,
tasks: snapshot.tasks,
approvals: snapshot.approvals,
runs: snapshot.runs,
costs: snapshot.costs,
activity: snapshot.activity,
secrets: snapshot.secrets,
comments: snapshot.comments,
socialAccounts: snapshot.socialAccounts,
meetings: snapshot.meetings,
documents: snapshot.documents,
workProducts: snapshot.workProducts,
knowledgeBase: snapshot.knowledgeBase,
sprints: snapshot.sprints,
automationRules: snapshot.automationRules,
agentMessages: snapshot.agentMessages,
workflows: snapshot.workflows,
},
};
}
export function assembleProfileSnapshot(sections: ProfileSnapshotSections): ProfileSnapshot {
return {
currentCompanyId: sections.metadata.currentCompanyId,
profilePath: sections.metadata.profilePath,
updatesEnabled: sections.metadata.updatesEnabled,
theme: sections.metadata.theme,
locale: sections.metadata.locale,
backend: sections.metadata.backend,
companies: sections.catalog.companies,
connectors: sections.catalog.connectors,
workspaces: sections.company.workspaces,
agents: sections.company.agents,
goals: sections.company.goals,
projects: sections.company.projects,
tasks: sections.company.tasks,
approvals: sections.company.approvals,
runs: sections.company.runs,
costs: sections.company.costs,
activity: sections.company.activity,
secrets: sections.company.secrets,
comments: sections.company.comments,
socialAccounts: sections.company.socialAccounts,
meetings: sections.company.meetings,
documents: sections.company.documents,
workProducts: sections.company.workProducts,
knowledgeBase: sections.company.knowledgeBase,
sprints: sections.company.sprints,
automationRules: sections.company.automationRules,
agentMessages: sections.company.agentMessages,
workflows: sections.company.workflows,
};
}
+256
View File
@@ -0,0 +1,256 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { assembleProfileSnapshot } from "@renderer/state/snapshot-sections";
import { useAppStore } from "@renderer/state/store";
import type { AgentRecord, ApprovalRecord, ProfileSnapshot, RunRecord, TaskRecord } from "@shared/types";
function makeSnapshot(overrides: Partial<ProfileSnapshot> = {}): ProfileSnapshot {
return {
currentCompanyId: "company-1",
profilePath: "/test/path",
updatesEnabled: true,
theme: "system",
locale: "en",
backend: { secretBackend: "safe_storage" },
companies: [],
connectors: [],
workspaces: [],
agents: [],
goals: [],
projects: [],
tasks: [],
approvals: [],
runs: [],
costs: [],
activity: [],
secrets: [],
comments: [],
socialAccounts: [],
meetings: [],
documents: [],
workProducts: [],
knowledgeBase: [],
sprints: [],
automationRules: [],
agentMessages: [],
workflows: [],
...overrides,
};
}
function expectSnapshotSectionsMatchSnapshot() {
const { snapshot, snapshotSections } = useAppStore.getState();
expect(snapshot).toEqual(snapshotSections ? assembleProfileSnapshot(snapshotSections) : null);
}
const fakeTask = (id: string, title = "Task"): TaskRecord => ({
id,
companyId: "company-1",
projectId: null,
goalId: null,
parentId: null,
title,
description: "",
assigneeAgentId: null,
workspaceId: null,
status: "open",
priority: "medium",
activeRunId: null,
sessionJson: null,
requiresUserReview: false,
startedAt: null,
completedAt: null,
cancelledAt: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as unknown as TaskRecord);
const fakeRun = (id: string): RunRecord => ({
id,
companyId: "company-1",
taskId: "t-1",
agentId: "ag-1",
workspaceId: null,
connectorId: "claude-pro",
status: "queued",
summary: "",
errorMessage: null,
exitCode: null,
signal: null,
model: null,
unattributedCost: false,
startedAt: null,
finishedAt: null,
logPath: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as RunRecord);
const fakeAgent = (id: string, name = "Agent"): AgentRecord => ({
id,
companyId: "company-1",
name,
role: "engineer",
title: "",
department: "engineering",
status: "idle",
reportsTo: null,
connectorId: "claude-pro",
workspaceId: null,
model: null,
capabilities: "",
budgetMonthlyUsd: 100,
spentMonthlyUsd: 0,
metadataJson: "{}",
heartbeatEnabled: false,
heartbeatIntervalSec: 0,
lastHeartbeatAt: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as AgentRecord);
const fakeApproval = (id: string): ApprovalRecord => ({
id,
companyId: "company-1",
relatedTaskId: "t-1",
relatedAgentId: null,
requestedByAgentId: "ag-1",
type: "task_completion",
payloadSummary: "",
impactSummary: "",
payloadJson: null,
decisionNote: null,
state: "pending",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as unknown as ApprovalRecord);
describe("CompanySlice patch methods", () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState());
vi.clearAllMocks();
});
it("patchTask inserts new task at beginning when task ID is missing", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-existing")] }));
useAppStore.getState().patchTask(fakeTask("t-new", "New Task"));
const tasks = useAppStore.getState().snapshot!.tasks;
expect(tasks.map((task) => task.id)).toEqual(["t-new", "t-existing"]);
expect(useAppStore.getState().snapshotSections!.company.tasks.map((task) => task.id)).toEqual([
"t-new",
"t-existing",
]);
expectSnapshotSectionsMatchSnapshot();
});
it("patchTask updates existing task in both section and compatibility snapshot", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-1", "Original")] }));
useAppStore.getState().patchTask(fakeTask("t-1", "Updated"));
expect(useAppStore.getState().snapshot!.tasks[0]!.title).toBe("Updated");
expect(useAppStore.getState().snapshotSections!.company.tasks[0]!.title).toBe("Updated");
expectSnapshotSectionsMatchSnapshot();
});
it("removeTask filters task by ID in both section and compatibility snapshot", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ tasks: [fakeTask("t-1"), fakeTask("t-2")] }));
useAppStore.getState().removeTask("t-1");
expect(useAppStore.getState().snapshot!.tasks.map((task) => task.id)).toEqual(["t-2"]);
expect(useAppStore.getState().snapshotSections!.company.tasks.map((task) => task.id)).toEqual(["t-2"]);
expectSnapshotSectionsMatchSnapshot();
});
it("patchRun inserts and updates run records through snapshot sections", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ runs: [fakeRun("r-existing")] }));
useAppStore.getState().patchRun(fakeRun("r-new"));
useAppStore.getState().patchRun({ ...fakeRun("r-new"), status: "running" });
expect(useAppStore.getState().snapshot!.runs.map((run) => `${run.id}:${run.status}`)).toEqual([
"r-new:running",
"r-existing:queued",
]);
expect(useAppStore.getState().snapshotSections!.company.runs[0]!.status).toBe("running");
expectSnapshotSectionsMatchSnapshot();
});
it("removeRun filters run by ID in both section and compatibility snapshot", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ runs: [fakeRun("r-1"), fakeRun("r-2")] }));
useAppStore.getState().removeRun("r-1");
expect(useAppStore.getState().snapshot!.runs.map((run) => run.id)).toEqual(["r-2"]);
expect(useAppStore.getState().snapshotSections!.company.runs.map((run) => run.id)).toEqual(["r-2"]);
expectSnapshotSectionsMatchSnapshot();
});
it("patchAgent inserts and updates agent records through snapshot sections", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ agents: [fakeAgent("ag-existing")] }));
useAppStore.getState().patchAgent(fakeAgent("ag-new", "New Agent"));
useAppStore.getState().patchAgent(fakeAgent("ag-new", "Renamed Agent"));
expect(useAppStore.getState().snapshot!.agents.map((agent) => `${agent.id}:${agent.name}`)).toEqual([
"ag-new:Renamed Agent",
"ag-existing:Agent",
]);
expect(useAppStore.getState().snapshotSections!.company.agents[0]!.name).toBe("Renamed Agent");
expectSnapshotSectionsMatchSnapshot();
});
it("removeAgent filters agent by ID in both section and compatibility snapshot", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ agents: [fakeAgent("ag-1"), fakeAgent("ag-2")] }));
useAppStore.getState().removeAgent("ag-1");
expect(useAppStore.getState().snapshot!.agents.map((agent) => agent.id)).toEqual(["ag-2"]);
expect(useAppStore.getState().snapshotSections!.company.agents.map((agent) => agent.id)).toEqual(["ag-2"]);
expectSnapshotSectionsMatchSnapshot();
});
it("patchApproval inserts and updates approval records through snapshot sections", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ approvals: [fakeApproval("ap-existing")] }));
useAppStore.getState().patchApproval(fakeApproval("ap-new"));
useAppStore.getState().patchApproval({ ...fakeApproval("ap-new"), state: "approved" });
expect(useAppStore.getState().snapshot!.approvals.map((approval) => `${approval.id}:${approval.state}`)).toEqual([
"ap-new:approved",
"ap-existing:pending",
]);
expect(useAppStore.getState().snapshotSections!.company.approvals[0]!.state).toBe("approved");
expectSnapshotSectionsMatchSnapshot();
});
it("removeApproval filters approval by ID in both section and compatibility snapshot", () => {
useAppStore.getState().setSnapshot(makeSnapshot({ approvals: [fakeApproval("ap-1"), fakeApproval("ap-2")] }));
useAppStore.getState().removeApproval("ap-1");
expect(useAppStore.getState().snapshot!.approvals.map((approval) => approval.id)).toEqual(["ap-2"]);
expect(useAppStore.getState().snapshotSections!.company.approvals.map((approval) => approval.id)).toEqual([
"ap-2",
]);
expectSnapshotSectionsMatchSnapshot();
});
it("patch methods leave null snapshot state unchanged", () => {
expect(useAppStore.getState().snapshot).toBeNull();
useAppStore.getState().patchTask(fakeTask("t-1"));
useAppStore.getState().removeTask("t-1");
useAppStore.getState().patchRun(fakeRun("r-1"));
useAppStore.getState().removeRun("r-1");
useAppStore.getState().patchAgent(fakeAgent("ag-1"));
useAppStore.getState().removeAgent("ag-1");
useAppStore.getState().patchApproval(fakeApproval("ap-1"));
useAppStore.getState().removeApproval("ap-1");
expect(useAppStore.getState().snapshot).toBeNull();
expect(useAppStore.getState().snapshotSections).toBeNull();
});
});
-181
View File
@@ -1,181 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createDomainEventBatcher, type GranularEventEntry } from "@renderer/lib/ipc-batcher";
import type { DesktopEvent } from "@shared/types";
describe("createDomainEventBatcher", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("domain-changed event triggers full refresh callback after 50ms window", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
const domainEvent: DesktopEvent = { type: "domain-changed" };
batcher.push(domainEvent);
expect(onFull).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(onFull).toHaveBeenCalledTimes(1);
expect(onGranular).not.toHaveBeenCalled();
});
it("multiple domain-changed events in same window coalesce to single full refresh", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "domain-changed" } as DesktopEvent);
batcher.push({ type: "domain-changed" } as DesktopEvent);
batcher.push({ type: "domain-changed" } as DesktopEvent);
vi.advanceTimersByTime(50);
expect(onFull).toHaveBeenCalledTimes(1);
});
it("single task-updated event triggers granular batch callback with one entry after 50ms", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
const taskEvent: DesktopEvent = { type: "task-updated", taskId: "t-1", action: "updated" };
batcher.push(taskEvent);
vi.advanceTimersByTime(50);
expect(onGranular).toHaveBeenCalledTimes(1);
expect(onFull).not.toHaveBeenCalled();
const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0];
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual({ type: "task-updated", entityId: "t-1", action: "updated" });
});
it("multiple task-updated events for SAME taskId coalesce to single entry (last-write-wins on action)", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "task-updated", taskId: "t-1", action: "created" } as DesktopEvent);
batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent);
vi.advanceTimersByTime(50);
const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0];
expect(entries).toHaveLength(1);
expect(entries[0]!.action).toBe("updated");
});
it("multiple task-updated events for DIFFERENT taskIds produce separate entries", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent);
batcher.push({ type: "task-updated", taskId: "t-2", action: "created" } as DesktopEvent);
vi.advanceTimersByTime(50);
const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0];
expect(entries).toHaveLength(2);
});
it("domain-changed in same window as granular events causes full refresh (domain-changed overrides)", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent);
batcher.push({ type: "run-updated", runId: "r-1", action: "completed" } as DesktopEvent);
batcher.push({ type: "domain-changed" } as DesktopEvent);
vi.advanceTimersByTime(50);
expect(onFull).toHaveBeenCalledTimes(1);
expect(onGranular).not.toHaveBeenCalled();
});
it("more than 10 unique granular entities in one window triggers full refresh instead of granular batch", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
// Push 11 unique task-updated events
for (let i = 0; i < 11; i++) {
batcher.push({ type: "task-updated", taskId: `t-${i}`, action: "updated" } as DesktopEvent);
}
vi.advanceTimersByTime(50);
expect(onFull).toHaveBeenCalledTimes(1);
expect(onGranular).not.toHaveBeenCalled();
});
it("flush() immediately processes pending events", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "task-updated", taskId: "t-1", action: "created" } as DesktopEvent);
batcher.flush();
expect(onGranular).toHaveBeenCalledTimes(1);
// Should not fire again on timer
vi.advanceTimersByTime(100);
expect(onGranular).toHaveBeenCalledTimes(1);
});
it("dispose() cancels pending events without firing callbacks", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "task-updated", taskId: "t-1", action: "created" } as DesktopEvent);
batcher.dispose();
vi.advanceTimersByTime(100);
expect(onFull).not.toHaveBeenCalled();
expect(onGranular).not.toHaveBeenCalled();
});
it("run-log, shortcut, notification-click events are NOT handled by batcher (treated as unknown, triggers full refresh)", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
// These events are not recognized as granular -- per D-05, unknown triggers full refresh
batcher.push({ type: "run-log", runId: "r-1", stream: "stdout", chunk: "hi" } as DesktopEvent);
vi.advanceTimersByTime(50);
expect(onFull).toHaveBeenCalledTimes(1);
expect(onGranular).not.toHaveBeenCalled();
});
it("budget-changed event appears in granular batch with companyId as entity key", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "budget-changed", companyId: "c-1" } as DesktopEvent);
vi.advanceTimersByTime(50);
expect(onGranular).toHaveBeenCalledTimes(1);
const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0];
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual({ type: "budget-changed", entityId: "c-1", action: "changed" });
});
it("if any event in window has action 'deleted', that action survives coalescing for that entity", () => {
const onFull = vi.fn();
const onGranular = vi.fn();
const batcher = createDomainEventBatcher(onFull, onGranular, 50);
batcher.push({ type: "task-updated", taskId: "t-1", action: "deleted" } as DesktopEvent);
batcher.push({ type: "task-updated", taskId: "t-1", action: "updated" } as DesktopEvent);
vi.advanceTimersByTime(50);
const entries: GranularEventEntry[] = onGranular.mock.calls[0]![0];
expect(entries).toHaveLength(1);
expect(entries[0]!.action).toBe("deleted");
});
});
+75 -251
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import type {
DesktopEvent,
TaskUpdatedEvent,
@@ -7,7 +7,9 @@ import type {
AgentUpdatedEvent,
BudgetChangedEvent,
} from "@shared/types";
import type { GranularEventEntry } from "@renderer/lib/ipc-batcher";
import {
classifySnapshotEvent,
} from "@renderer/lib/snapshot-events";
describe("selective IPC event types", () => {
it("TaskUpdatedEvent discriminated by type field", () => {
@@ -63,7 +65,7 @@ describe("selective IPC event types", () => {
expect(event.companyId).toBe("c-1");
});
it("all five new event types are assignable to DesktopEvent union", () => {
it("all five entity event types are assignable to DesktopEvent union", () => {
const events: DesktopEvent[] = [
{ type: "task-updated", taskId: "t-1", action: "created" },
{ type: "run-updated", runId: "r-1", action: "status_changed" },
@@ -74,8 +76,7 @@ describe("selective IPC event types", () => {
expect(events).toHaveLength(5);
});
it("DesktopEvent union has all 14 members (9 original + 5 new)", () => {
// This is a compile-time check -- if any type is missing, TypeScript will error
it("DesktopEvent union has all members used by snapshot classification", () => {
const exhaustiveCheck = (event: DesktopEvent): string => {
switch (event.type) {
case "run-log":
@@ -130,262 +131,85 @@ describe("selective IPC event types", () => {
}
}
};
// Dummy call to avoid unused-var warning
expect(typeof exhaustiveCheck).toBe("function");
});
});
// Extracted granular batch handler logic for testability
// Mirrors the handleGranularBatch function in App.tsx
async function handleGranularBatch(
events: GranularEventEntry[],
deps: {
companyId: string | null;
getTask: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
patchTask: (task: unknown) => void;
removeTask: (id: string) => void;
patchRun: (run: unknown) => void;
removeRun: (id: string) => void;
patchApproval: (approval: unknown) => void;
removeApproval: (id: string) => void;
patchAgent: (agent: unknown) => void;
refreshBadges: () => Promise<void>;
fullRefresh: () => void;
},
) {
if (!deps.companyId) {
deps.fullRefresh();
return;
}
describe("classifySnapshotEvent", () => {
it("maps direct entity events to one immediate entity decision", () => {
expect(classifySnapshotEvent({ type: "task-updated", taskId: "t-1", action: "updated" })).toEqual({
kind: "entity",
entry: { type: "task-updated", entityId: "t-1", action: "updated" },
});
expect(classifySnapshotEvent({ type: "run-updated", runId: "r-1", action: "status_changed" })).toEqual({
kind: "entity",
entry: { type: "run-updated", entityId: "r-1", action: "status_changed" },
});
expect(classifySnapshotEvent({ type: "approval-updated", approvalId: "a-1", action: "decided" })).toEqual({
kind: "entity",
entry: { type: "approval-updated", entityId: "a-1", action: "decided" },
});
expect(classifySnapshotEvent({ type: "agent-updated", agentId: "ag-1", action: "paused" })).toEqual({
kind: "entity",
entry: { type: "agent-updated", entityId: "ag-1", action: "paused" },
});
expect(classifySnapshotEvent({ type: "budget-changed", companyId: "company-1" })).toEqual({
kind: "entity",
entry: { type: "budget-changed", entityId: "company-1", action: "changed" },
});
});
let needsFullRefresh = false;
it("maps legacy progress events to their owning entities", () => {
expect(classifySnapshotEvent({ type: "run-status", runId: "r-1", status: "running", message: "Running" })).toEqual({
kind: "entity",
entry: { type: "run-updated", entityId: "r-1", action: "status_changed" },
});
expect(classifySnapshotEvent({ type: "heartbeat-status", agentId: "ag-1", enabled: true, nextWakeAt: null })).toEqual({
kind: "entity",
entry: { type: "agent-updated", entityId: "ag-1", action: "updated" },
});
expect(classifySnapshotEvent({ type: "agent-message", taskId: "t-1", comment: {} } as DesktopEvent)).toEqual({
kind: "entity",
entry: { type: "task-updated", entityId: "t-1", action: "updated" },
});
});
for (const entry of events) {
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
break;
it("routes domain-wide events to full refresh", () => {
const events: DesktopEvent[] = [
{ type: "domain-changed" },
{ type: "new-message" } as DesktopEvent,
{ type: "thread-wait-state-updated", threadId: "th-1", companyId: "c-1", waitStateId: "w-1", action: "created" },
{ type: "credentials-changed", connectorId: "claude-pro" },
{ type: "agent-event", runId: "r-1", agentId: "ag-1", companyId: "c-1", taskId: "t-1", emittedAt: "2026-01-01", event: { type: "x" } },
{ type: "oauth:status", connectorId: "claude-pro", status: "active" },
{ type: "oauth:auth-url", connectorId: "claude-pro", url: "https://example.com" },
{ type: "oauth:progress", connectorId: "claude-pro", message: "Working" },
{ type: "oauth:prompt-request", connectorId: "claude-pro", requestId: "req-1", message: "Code" },
{ type: "oauth:manual-code-request", connectorId: "claude-pro", requestId: "req-1" },
];
for (const event of events) {
expect(classifySnapshotEvent(event)).toEqual({ kind: "full-refresh" });
}
});
try {
switch (entry.type) {
case "task-updated": {
if (entry.action === "deleted") {
deps.removeTask(entry.entityId);
} else {
const result = await deps.getTask({ companyId: deps.companyId, id: entry.entityId });
if (result.ok) {
deps.patchTask(result.data);
} else {
deps.removeTask(entry.entityId);
}
}
break;
}
case "run-updated": {
if (entry.action === "deleted") {
deps.removeRun(entry.entityId);
} else {
const result = await deps.getRun({ companyId: deps.companyId, id: entry.entityId });
if (result.ok) {
deps.patchRun(result.data);
} else {
deps.removeRun(entry.entityId);
}
}
break;
}
case "approval-updated": {
if (entry.action === "deleted") {
deps.removeApproval(entry.entityId);
} else {
const result = await deps.getApproval({ companyId: deps.companyId, id: entry.entityId });
if (result.ok) {
deps.patchApproval(result.data);
} else {
deps.removeApproval(entry.entityId);
}
}
break;
}
case "agent-updated": {
const result = await deps.getAgent({ companyId: deps.companyId, id: entry.entityId });
if (result.ok) {
deps.patchAgent(result.data);
} else {
needsFullRefresh = true;
}
break;
}
case "budget-changed": {
needsFullRefresh = true;
break;
}
default: {
needsFullRefresh = true;
break;
}
}
} catch {
needsFullRefresh = true;
break;
it("ignores events with independent listeners", () => {
const events: DesktopEvent[] = [
{ type: "automation-fired", ruleId: "rule-1", ruleName: "Rule", trigger: "schedule", action: "run_task" },
{ type: "recovery-needed", interruptedRuns: [] },
{ type: "run-log", runId: "r-1", stream: "stdout", chunk: "chunk" },
{ type: "shortcut", action: "refresh" },
{ type: "notification-click", section: "tasks", entityId: "t-1" },
];
for (const event of events) {
expect(classifySnapshotEvent(event)).toEqual({ kind: "ignore" });
}
if (needsFullRefresh) break;
}
if (needsFullRefresh) {
deps.fullRefresh();
} else {
void deps.refreshBadges();
}
}
function createMockDeps() {
return {
companyId: "company-1" as string | null,
getTask: vi.fn().mockResolvedValue({ ok: true, data: { id: "t-1", title: "Test task" } }),
getRun: vi.fn().mockResolvedValue({ ok: true, data: { id: "r-1", status: "running" } }),
getApproval: vi.fn().mockResolvedValue({ ok: true, data: { id: "a-1", state: "pending" } }),
getAgent: vi.fn().mockResolvedValue({ ok: true, data: { id: "ag-1", name: "Agent" } }),
patchTask: vi.fn(),
removeTask: vi.fn(),
patchRun: vi.fn(),
removeRun: vi.fn(),
patchApproval: vi.fn(),
removeApproval: vi.fn(),
patchAgent: vi.fn(),
refreshBadges: vi.fn().mockResolvedValue(undefined),
fullRefresh: vi.fn(),
};
}
describe("handleGranularBatch", () => {
it("task-updated event calls getTask and patchTask", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
deps,
);
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Test task" });
expect(deps.fullRefresh).not.toHaveBeenCalled();
expect(deps.refreshBadges).toHaveBeenCalled();
});
it("deleted task calls removeTask without fetching", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "task-updated", entityId: "t-1", action: "deleted" }],
deps,
);
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
expect(deps.getTask).not.toHaveBeenCalled();
expect(deps.fullRefresh).not.toHaveBeenCalled();
});
it("run-updated:completed triggers full refresh", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "run-updated", entityId: "r-1", action: "completed" }],
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
expect(deps.getRun).not.toHaveBeenCalled();
expect(deps.refreshBadges).not.toHaveBeenCalled();
});
it("failed getTask fetch falls back to removeTask", async () => {
const deps = createMockDeps();
deps.getTask.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", message: "nope" } });
await handleGranularBatch(
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
deps,
);
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
expect(deps.patchTask).not.toHaveBeenCalled();
});
it("calls refreshBadges after successful granular processing", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "task-updated", entityId: "t-1", action: "created" }],
deps,
);
expect(deps.refreshBadges).toHaveBeenCalled();
expect(deps.fullRefresh).not.toHaveBeenCalled();
});
it("approval-updated event calls getApproval and patchApproval", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "approval-updated", entityId: "a-1", action: "decided" }],
deps,
);
expect(deps.getApproval).toHaveBeenCalledWith({ companyId: "company-1", id: "a-1" });
expect(deps.patchApproval).toHaveBeenCalledWith({ id: "a-1", state: "pending" });
});
it("agent-updated event calls getAgent and patchAgent", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "agent-updated", entityId: "ag-1", action: "paused" }],
deps,
);
expect(deps.getAgent).toHaveBeenCalledWith({ companyId: "company-1", id: "ag-1" });
expect(deps.patchAgent).toHaveBeenCalledWith({ id: "ag-1", name: "Agent" });
});
it("budget-changed triggers full refresh", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "budget-changed", entityId: "c-1", action: "changed" }],
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
expect(deps.refreshBadges).not.toHaveBeenCalled();
});
it("no companyId triggers full refresh", async () => {
const deps = createMockDeps();
deps.companyId = null;
await handleGranularBatch(
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
expect(deps.getTask).not.toHaveBeenCalled();
});
it("IPC error falls back to full refresh", async () => {
const deps = createMockDeps();
deps.getTask.mockRejectedValue(new Error("IPC failed"));
await handleGranularBatch(
[{ type: "task-updated", entityId: "t-1", action: "updated" }],
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
});
it("unknown event type triggers full refresh", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "some-unknown-event", entityId: "x-1", action: "created" }],
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
});
it("run-updated:status_changed fetches and patches run", async () => {
const deps = createMockDeps();
await handleGranularBatch(
[{ type: "run-updated", entityId: "r-1", action: "status_changed" }],
deps,
);
expect(deps.getRun).toHaveBeenCalledWith({ companyId: "company-1", id: "r-1" });
expect(deps.patchRun).toHaveBeenCalledWith({ id: "r-1", status: "running" });
expect(deps.fullRefresh).not.toHaveBeenCalled();
it("routes unknown runtime event shapes to full refresh", () => {
expect(classifySnapshotEvent({ type: "future-event" } as unknown as DesktopEvent)).toEqual({
kind: "full-refresh",
});
});
});
+345
View File
@@ -0,0 +1,345 @@
import { describe, expect, it, vi } from "vitest";
import {
createSnapshotEventGuard,
type GranularEventEntry,
type SnapshotEventGuard,
} from "@renderer/lib/snapshot-events";
async function handleSnapshotEntityEvent(
entry: GranularEventEntry,
deps: {
companyId: string | null;
getTask: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
getRun: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
getApproval: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
getAgent: (input: { companyId: string; id: string }) => Promise<{ ok: boolean; data?: unknown }>;
patchTask: (task: unknown) => void;
removeTask: (id: string) => void;
patchRun: (run: unknown) => void;
removeRun: (id: string) => void;
patchApproval: (approval: unknown) => void;
removeApproval: (id: string) => void;
patchAgent: (agent: unknown) => void;
fullRefresh: () => void;
},
eventGuard: SnapshotEventGuard = createSnapshotEventGuard(),
) {
function requestFullRefresh() {
eventGuard.invalidateSnapshot();
deps.fullRefresh();
}
if (!deps.companyId) {
requestFullRefresh();
return;
}
let needsFullRefresh = false;
eventGuard.invalidateSnapshot();
eventGuard.invalidateEntity(entry);
const token = eventGuard.captureEntity(entry);
if (entry.type === "run-updated" && entry.action === "completed") {
needsFullRefresh = true;
} else {
try {
switch (entry.type) {
case "task-updated": {
if (entry.action === "deleted") {
deps.removeTask(entry.entityId);
} else {
const result = await deps.getTask({ companyId: deps.companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
deps.patchTask(result.data);
} else {
deps.removeTask(entry.entityId);
}
}
break;
}
case "run-updated": {
if (entry.action === "deleted") {
deps.removeRun(entry.entityId);
} else {
const result = await deps.getRun({ companyId: deps.companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
deps.patchRun(result.data);
} else {
deps.removeRun(entry.entityId);
}
}
break;
}
case "approval-updated": {
if (entry.action === "deleted") {
deps.removeApproval(entry.entityId);
} else {
const result = await deps.getApproval({ companyId: deps.companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
deps.patchApproval(result.data);
} else {
deps.removeApproval(entry.entityId);
}
}
break;
}
case "agent-updated": {
const result = await deps.getAgent({ companyId: deps.companyId, id: entry.entityId });
if (!eventGuard.canApplyEntity(entry, token)) return;
if (result.ok) {
deps.patchAgent(result.data);
} else {
needsFullRefresh = true;
}
break;
}
case "budget-changed": {
needsFullRefresh = true;
break;
}
default: {
needsFullRefresh = true;
break;
}
}
} catch {
needsFullRefresh = true;
}
}
if (needsFullRefresh) {
requestFullRefresh();
}
}
function createMockDeps() {
return {
companyId: "company-1" as string | null,
getTask: vi.fn().mockResolvedValue({ ok: true, data: { id: "t-1", title: "Test task" } }),
getRun: vi.fn().mockResolvedValue({ ok: true, data: { id: "r-1", status: "running" } }),
getApproval: vi.fn().mockResolvedValue({ ok: true, data: { id: "a-1", state: "pending" } }),
getAgent: vi.fn().mockResolvedValue({ ok: true, data: { id: "ag-1", name: "Agent" } }),
patchTask: vi.fn(),
removeTask: vi.fn(),
patchRun: vi.fn(),
removeRun: vi.fn(),
patchApproval: vi.fn(),
removeApproval: vi.fn(),
patchAgent: vi.fn(),
fullRefresh: vi.fn(),
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
describe("snapshot entity guard", () => {
it("task-updated event calls getTask and patchTask", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
);
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Test task" });
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
});
it("deleted task calls removeTask without fetching", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "deleted" },
deps,
);
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
expect(deps.getTask).toHaveBeenCalledTimes(0);
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
});
it("task delete invalidates a pending full snapshot refresh token", async () => {
const deps = createMockDeps();
const eventGuard = createSnapshotEventGuard();
eventGuard.invalidateSnapshot();
const pendingRefresh = eventGuard.captureSnapshot();
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "deleted" },
deps,
eventGuard,
);
expect(eventGuard.canApplySnapshot(pendingRefresh)).toBe(false);
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
});
it("delayed task update cannot resurrect a task deleted by a later event", async () => {
const deps = createMockDeps();
const eventGuard = createSnapshotEventGuard();
const taskFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
deps.getTask.mockReturnValue(taskFetch.promise);
const update = handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
eventGuard,
);
expect(deps.getTask).toHaveBeenCalledWith({ companyId: "company-1", id: "t-1" });
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "deleted" },
deps,
eventGuard,
);
taskFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
await update;
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
expect(deps.patchTask).toHaveBeenCalledTimes(0);
});
it("newer full refresh invalidates a delayed task update", async () => {
const deps = createMockDeps();
const eventGuard = createSnapshotEventGuard();
const taskFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
deps.getTask.mockReturnValue(taskFetch.promise);
const update = handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
eventGuard,
);
eventGuard.invalidateSnapshot();
taskFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
await update;
expect(deps.patchTask).toHaveBeenCalledTimes(0);
});
it("later task update wins over an earlier delayed task update", async () => {
const deps = createMockDeps();
const eventGuard = createSnapshotEventGuard();
const firstFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
const secondFetch = deferred<{ ok: boolean; data: { id: string; title: string } }>();
deps.getTask
.mockReturnValueOnce(firstFetch.promise)
.mockReturnValueOnce(secondFetch.promise);
const firstUpdate = handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
eventGuard,
);
const secondUpdate = handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
eventGuard,
);
secondFetch.resolve({ ok: true, data: { id: "t-1", title: "Fresh task" } });
await secondUpdate;
firstFetch.resolve({ ok: true, data: { id: "t-1", title: "Stale task" } });
await firstUpdate;
expect(deps.patchTask).toHaveBeenCalledTimes(1);
expect(deps.patchTask).toHaveBeenCalledWith({ id: "t-1", title: "Fresh task" });
});
it("run-updated completed triggers full refresh", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "run-updated", entityId: "r-1", action: "completed" },
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
expect(deps.getRun).toHaveBeenCalledTimes(0);
});
it("failed getTask fetch removes the missing task", async () => {
const deps = createMockDeps();
deps.getTask.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", message: "missing" } });
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
);
expect(deps.removeTask).toHaveBeenCalledWith("t-1");
expect(deps.patchTask).toHaveBeenCalledTimes(0);
});
it("approval-updated event calls getApproval and patchApproval", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "approval-updated", entityId: "a-1", action: "decided" },
deps,
);
expect(deps.getApproval).toHaveBeenCalledWith({ companyId: "company-1", id: "a-1" });
expect(deps.patchApproval).toHaveBeenCalledWith({ id: "a-1", state: "pending" });
});
it("agent-updated event calls getAgent and patchAgent", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "agent-updated", entityId: "ag-1", action: "paused" },
deps,
);
expect(deps.getAgent).toHaveBeenCalledWith({ companyId: "company-1", id: "ag-1" });
expect(deps.patchAgent).toHaveBeenCalledWith({ id: "ag-1", name: "Agent" });
});
it("budget-changed triggers full refresh", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "budget-changed", entityId: "c-1", action: "changed" },
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
});
it("missing companyId triggers full refresh", async () => {
const deps = createMockDeps();
deps.companyId = null;
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
expect(deps.getTask).toHaveBeenCalledTimes(0);
});
it("IPC error triggers full refresh", async () => {
const deps = createMockDeps();
deps.getTask.mockRejectedValue(new Error("IPC failed"));
await handleSnapshotEntityEvent(
{ type: "task-updated", entityId: "t-1", action: "updated" },
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
});
it("unknown entity event triggers full refresh", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "future-entity", entityId: "x-1", action: "created" } as unknown as GranularEventEntry,
deps,
);
expect(deps.fullRefresh).toHaveBeenCalled();
});
it("run-updated status change fetches and patches run", async () => {
const deps = createMockDeps();
await handleSnapshotEntityEvent(
{ type: "run-updated", entityId: "r-1", action: "status_changed" },
deps,
);
expect(deps.getRun).toHaveBeenCalledWith({ companyId: "company-1", id: "r-1" });
expect(deps.patchRun).toHaveBeenCalledWith({ id: "r-1", status: "running" });
expect(deps.fullRefresh).toHaveBeenCalledTimes(0);
});
});
+12 -283
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useAppStore } from "@renderer/state/store";
import type { ProfileSnapshot, TaskRecord, RunRecord, AgentRecord, ApprovalRecord } from "@shared/types";
import { assembleProfileSnapshot } from "@renderer/state/snapshot-sections";
import type { ProfileSnapshot } from "@shared/types";
// Mock window.agentCompany for refreshSnapshot tests. Sidebar badges
// no longer traverse IPC — they are derived from the snapshot in
@@ -31,6 +32,7 @@ describe("useAppStore", () => {
// CompanySlice
expect(state).toHaveProperty("snapshot");
expect(state).toHaveProperty("snapshotSections");
expect(state).toHaveProperty("setSnapshot");
expect(state).toHaveProperty("refreshSnapshot");
@@ -149,6 +151,10 @@ describe("useAppStore", () => {
useAppStore.getState().setSnapshot(mockSnapshot);
expect(useAppStore.getState().snapshot).toEqual(mockSnapshot);
expect(useAppStore.getState().snapshotSections?.metadata.profilePath).toBe("/test/path");
expect(useAppStore.getState().snapshotSections?.catalog.companies).toEqual([]);
expect(useAppStore.getState().snapshotSections?.company.tasks).toEqual([]);
expectSnapshotSectionsMatchSnapshot();
});
it("CompanySlice.refreshSnapshot() calls window.agentCompany.loadSnapshot()", async () => {
@@ -189,6 +195,8 @@ describe("useAppStore", () => {
expect(mockLoadSnapshot).toHaveBeenCalledTimes(1);
expect(useAppStore.getState().snapshot).toEqual(mockSnapshot);
expect(useAppStore.getState().snapshotSections?.metadata.currentCompanyId).toBe("company-1");
expectSnapshotSectionsMatchSnapshot();
});
it("UiSlice.setLocale('zh') updates locale to 'zh'", () => {
@@ -224,286 +232,7 @@ describe("useAppStore", () => {
});
});
// -- Minimal snapshot fixture for patch tests --
function makeSnapshot(overrides: Partial<ProfileSnapshot> = {}): ProfileSnapshot {
return {
currentCompanyId: "company-1",
profilePath: "/test/path",
updatesEnabled: true,
theme: "system",
locale: "en",
backend: { secretBackend: "safe_storage" },
companies: [],
connectors: [],
workspaces: [],
agents: [],
goals: [],
projects: [],
tasks: [],
approvals: [],
runs: [],
costs: [],
activity: [],
secrets: [],
comments: [],
socialAccounts: [],
meetings: [],
documents: [],
workProducts: [],
knowledgeBase: [],
sprints: [],
automationRules: [],
agentMessages: [],
workflows: [],
...overrides,
};
function expectSnapshotSectionsMatchSnapshot() {
const { snapshot, snapshotSections } = useAppStore.getState();
expect(snapshot).toEqual(snapshotSections ? assembleProfileSnapshot(snapshotSections) : null);
}
const fakeTask = (id: string, title = "Task"): TaskRecord => ({
id,
companyId: "company-1",
projectId: null,
goalId: null,
parentId: null,
title,
description: "",
assigneeAgentId: null,
workspaceId: null,
status: "open",
priority: "medium",
activeRunId: null,
sessionJson: null,
requiresUserReview: false,
startedAt: null,
completedAt: null,
cancelledAt: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as unknown as TaskRecord);
const fakeRun = (id: string): RunRecord => ({
id,
companyId: "company-1",
taskId: "t-1",
agentId: "ag-1",
workspaceId: null,
connectorId: "claude-pro",
status: "queued",
summary: "",
errorMessage: null,
exitCode: null,
signal: null,
model: null,
unattributedCost: false,
startedAt: null,
finishedAt: null,
logPath: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as RunRecord);
const fakeAgent = (id: string, name = "Agent"): AgentRecord => ({
id,
companyId: "company-1",
name,
role: "engineer",
title: "",
department: "engineering",
status: "idle",
reportsTo: null,
connectorId: "claude-pro",
workspaceId: null,
model: null,
capabilities: "",
budgetMonthlyUsd: 100,
spentMonthlyUsd: 0,
metadataJson: "{}",
heartbeatEnabled: false,
heartbeatIntervalSec: 0,
lastHeartbeatAt: null,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as AgentRecord);
const fakeApproval = (id: string): ApprovalRecord => ({
id,
companyId: "company-1",
relatedTaskId: "t-1",
relatedAgentId: null,
requestedByAgentId: "ag-1",
type: "task_completion",
payloadSummary: "",
impactSummary: "",
payloadJson: null,
decisionNote: null,
state: "pending",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
} as unknown as ApprovalRecord);
describe("CompanySlice patch methods", () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState());
vi.clearAllMocks();
});
// -- patchTask --
it("patchTask inserts new task at beginning when task ID is not found", () => {
const snapshot = makeSnapshot({ tasks: [fakeTask("t-existing")] });
useAppStore.getState().setSnapshot(snapshot);
const newTask = fakeTask("t-new", "New Task");
useAppStore.getState().patchTask(newTask);
const tasks = useAppStore.getState().snapshot!.tasks;
expect(tasks).toHaveLength(2);
expect(tasks[0]!.id).toBe("t-new");
expect(tasks[1]!.id).toBe("t-existing");
});
it("patchTask updates existing task in-place when task ID matches", () => {
const snapshot = makeSnapshot({ tasks: [fakeTask("t-1", "Original")] });
useAppStore.getState().setSnapshot(snapshot);
const updated = fakeTask("t-1", "Updated");
useAppStore.getState().patchTask(updated);
const tasks = useAppStore.getState().snapshot!.tasks;
expect(tasks).toHaveLength(1);
expect(tasks[0]!.title).toBe("Updated");
});
it("removeTask filters out task by ID", () => {
const snapshot = makeSnapshot({ tasks: [fakeTask("t-1"), fakeTask("t-2")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().removeTask("t-1");
const tasks = useAppStore.getState().snapshot!.tasks;
expect(tasks).toHaveLength(1);
expect(tasks[0]!.id).toBe("t-2");
});
// -- patchRun --
it("patchRun inserts new run when run ID is not found", () => {
const snapshot = makeSnapshot({ runs: [fakeRun("r-existing")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().patchRun(fakeRun("r-new"));
const runs = useAppStore.getState().snapshot!.runs;
expect(runs).toHaveLength(2);
expect(runs[0]!.id).toBe("r-new");
});
it("patchRun updates existing run in-place", () => {
const run = fakeRun("r-1");
const snapshot = makeSnapshot({ runs: [run] });
useAppStore.getState().setSnapshot(snapshot);
const updated = { ...fakeRun("r-1"), status: "running" as const };
useAppStore.getState().patchRun(updated);
const runs = useAppStore.getState().snapshot!.runs;
expect(runs).toHaveLength(1);
expect(runs[0]!.status).toBe("running");
});
it("removeRun filters out run by ID", () => {
const snapshot = makeSnapshot({ runs: [fakeRun("r-1"), fakeRun("r-2")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().removeRun("r-1");
const runs = useAppStore.getState().snapshot!.runs;
expect(runs).toHaveLength(1);
expect(runs[0]!.id).toBe("r-2");
});
// -- patchAgent --
it("patchAgent inserts new agent when agent ID is not found", () => {
const snapshot = makeSnapshot({ agents: [fakeAgent("ag-existing")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().patchAgent(fakeAgent("ag-new", "New Agent"));
const agents = useAppStore.getState().snapshot!.agents;
expect(agents).toHaveLength(2);
expect(agents[0]!.id).toBe("ag-new");
});
it("patchAgent updates existing agent in-place", () => {
const snapshot = makeSnapshot({ agents: [fakeAgent("ag-1", "Old")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().patchAgent(fakeAgent("ag-1", "New"));
const agents = useAppStore.getState().snapshot!.agents;
expect(agents).toHaveLength(1);
expect(agents[0]!.name).toBe("New");
});
it("removeAgent filters out agent by ID", () => {
const snapshot = makeSnapshot({ agents: [fakeAgent("ag-1"), fakeAgent("ag-2")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().removeAgent("ag-1");
const agents = useAppStore.getState().snapshot!.agents;
expect(agents).toHaveLength(1);
expect(agents[0]!.id).toBe("ag-2");
});
// -- patchApproval --
it("patchApproval inserts new approval when approval ID is not found", () => {
const snapshot = makeSnapshot({ approvals: [fakeApproval("ap-existing")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().patchApproval(fakeApproval("ap-new"));
const approvals = useAppStore.getState().snapshot!.approvals;
expect(approvals).toHaveLength(2);
expect(approvals[0]!.id).toBe("ap-new");
});
it("patchApproval updates existing approval in-place", () => {
const approval = fakeApproval("ap-1");
const snapshot = makeSnapshot({ approvals: [approval] });
useAppStore.getState().setSnapshot(snapshot);
const updated = { ...fakeApproval("ap-1"), state: "approved" as const };
useAppStore.getState().patchApproval(updated);
const approvals = useAppStore.getState().snapshot!.approvals;
expect(approvals).toHaveLength(1);
expect(approvals[0]!.state).toBe("approved");
});
it("removeApproval filters out approval by ID", () => {
const snapshot = makeSnapshot({ approvals: [fakeApproval("ap-1"), fakeApproval("ap-2")] });
useAppStore.getState().setSnapshot(snapshot);
useAppStore.getState().removeApproval("ap-1");
const approvals = useAppStore.getState().snapshot!.approvals;
expect(approvals).toHaveLength(1);
expect(approvals[0]!.id).toBe("ap-2");
});
// -- null-snapshot safety --
it("patch methods are no-ops when snapshot is null", () => {
expect(useAppStore.getState().snapshot).toBeNull();
// These should not crash
useAppStore.getState().patchTask(fakeTask("t-1"));
useAppStore.getState().removeTask("t-1");
useAppStore.getState().patchRun(fakeRun("r-1"));
useAppStore.getState().removeRun("r-1");
useAppStore.getState().patchAgent(fakeAgent("ag-1"));
useAppStore.getState().removeAgent("ag-1");
useAppStore.getState().patchApproval(fakeApproval("ap-1"));
useAppStore.getState().removeApproval("ap-1");
expect(useAppStore.getState().snapshot).toBeNull();
});
});