From 1383805d666750b7f8c1035fa3df8eaea8acc074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 24 Apr 2026 02:48:28 -0400 Subject: [PATCH] refactor(renderer): split snapshot state sections --- .gitignore | 2 +- src/renderer/hooks/useDataLoader.ts | 8 +- src/renderer/hooks/useDesktopEvents.ts | 80 +++--- src/renderer/lib/ipc-batcher.ts | 113 -------- src/renderer/lib/snapshot-events.ts | 120 ++++++++ src/renderer/state/slices/company.ts | 87 ++++-- src/renderer/state/snapshot-sections.ts | 116 ++++++++ tests/unit/company-slice-patches.test.ts | 256 +++++++++++++++++ tests/unit/ipc-batcher.test.ts | 181 ------------ tests/unit/selective-ipc.test.ts | 326 +++++---------------- tests/unit/snapshot-entity-guard.test.ts | 345 +++++++++++++++++++++++ tests/unit/store.test.ts | 295 +------------------ 12 files changed, 1030 insertions(+), 899 deletions(-) delete mode 100644 src/renderer/lib/ipc-batcher.ts create mode 100644 src/renderer/lib/snapshot-events.ts create mode 100644 src/renderer/state/snapshot-sections.ts create mode 100644 tests/unit/company-slice-patches.test.ts delete mode 100644 tests/unit/ipc-batcher.test.ts create mode 100644 tests/unit/snapshot-entity-guard.test.ts diff --git a/.gitignore b/.gitignore index c79a4d95..cfdba840 100644 --- a/.gitignore +++ b/.gitignore @@ -92,4 +92,4 @@ local-release/ .omx/ # Runtime state (written by app at runtime) -state/ +/state/ diff --git a/src/renderer/hooks/useDataLoader.ts b/src/renderer/hooks/useDataLoader.ts index 94a19be6..573d9128 100644 --- a/src/renderer/hooks/useDataLoader.ts +++ b/src/renderer/hooks/useDataLoader.ts @@ -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(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)); } }, []); diff --git a/src/renderer/hooks/useDesktopEvents.ts b/src/renderer/hooks/useDesktopEvents.ts index af70b88e..b1d75c4f 100644 --- a/src/renderer/hooks/useDesktopEvents.ts +++ b/src/renderer/hooks/useDesktopEvents.ts @@ -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>, + refreshRef: MutableRefObject<(shouldApply?: () => boolean) => Promise>, logBufferRef: MutableRefObject>, 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 }, []); diff --git a/src/renderer/lib/ipc-batcher.ts b/src/renderer/lib/ipc-batcher.ts deleted file mode 100644 index ae3b26d8..00000000 --- a/src/renderer/lib/ipc-batcher.ts +++ /dev/null @@ -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([ - "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 | null = null; - let hasDomainChanged = false; - const pending = new Map(); - - 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 }; -} diff --git a/src/renderer/lib/snapshot-events.ts b/src/renderer/lib/snapshot-events.ts new file mode 100644 index 00000000..694ae458 --- /dev/null +++ b/src/renderer/lib/snapshot-events.ts @@ -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(); + + 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; + }, + }; +} diff --git a/src/renderer/state/slices/company.ts b/src/renderer/state/slices/company.ts index 14bd4a74..1f1f3dca 100644 --- a/src/renderer/state/slices/company.ts +++ b/src/renderer/state/slices/company.ts @@ -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); }), }); diff --git a/src/renderer/state/snapshot-sections.ts b/src/renderer/state/snapshot-sections.ts new file mode 100644 index 00000000..d5653b76 --- /dev/null +++ b/src/renderer/state/snapshot-sections.ts @@ -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, + }; +} diff --git a/tests/unit/company-slice-patches.test.ts b/tests/unit/company-slice-patches.test.ts new file mode 100644 index 00000000..35450976 --- /dev/null +++ b/tests/unit/company-slice-patches.test.ts @@ -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 { + 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(); + }); +}); diff --git a/tests/unit/ipc-batcher.test.ts b/tests/unit/ipc-batcher.test.ts deleted file mode 100644 index c55ca040..00000000 --- a/tests/unit/ipc-batcher.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/tests/unit/selective-ipc.test.ts b/tests/unit/selective-ipc.test.ts index 44325aea..876ae9b2 100644 --- a/tests/unit/selective-ipc.test.ts +++ b/tests/unit/selective-ipc.test.ts @@ -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; - 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", + }); }); }); diff --git a/tests/unit/snapshot-entity-guard.test.ts b/tests/unit/snapshot-entity-guard.test.ts new file mode 100644 index 00000000..279f267b --- /dev/null +++ b/tests/unit/snapshot-entity-guard.test.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((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); + }); +}); diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index 9ee6ea73..3d5d3822 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -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 { - 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(); - }); -});