diff --git a/src/main/database.ts b/src/main/database.ts index 7dc9f034..15f914f5 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -1159,6 +1159,17 @@ export class AppDatabase { return row ? { ...row, isPrimary: toBoolean(row.isPrimary) } : null; } + getRun(id: string): RunRecord { + const row = this.db + .prepare( + "select id, company_id as companyId, task_id as taskId, agent_id as agentId, workspace_id as workspaceId, connector_id as connectorId, status, summary, error_message as errorMessage, exit_code as exitCode, signal, model, session_display_id as sessionDisplayId, cost_usd as costUsd, unattributed_cost as unattributedCost, started_at as startedAt, finished_at as finishedAt, log_path as logPath, created_at as createdAt, updated_at as updatedAt from runs where id = ?", + ) + .get(id) as RunRecord | undefined; + if (!row) throw new Error(`Run not found: ${id}`); + row.unattributedCost = Boolean(row.unattributedCost); + return row; + } + getRunAuthState(runId: string): { id: string; companyId: string; agentId: string; status: RunRecord["status"] } | null { const row = this.db .prepare("select id, company_id as companyId, agent_id as agentId, status from runs where id = ?") diff --git a/src/main/ipc-core-read-handlers.ts b/src/main/ipc-core-read-handlers.ts index fff3cd4a..5c5bf167 100644 --- a/src/main/ipc-core-read-handlers.ts +++ b/src/main/ipc-core-read-handlers.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { IPC_CHANNELS, connectorInputSchema, + entityGetterSchema, metricsQuerySchema, settingsSchema, } from "@shared/contracts"; @@ -142,4 +143,58 @@ export function registerCoreReadHandlers({ if (err) return err; return ok(db.getStandupReport(companyId)); }); + + // --- Entity getters for granular IPC updates --- + + registerHandle(IPC_CHANNELS.getTask, (_event, payload) => { + const parsed = entityGetterSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + try { + const task = db.getTask(parsed.id); + if (task.companyId !== parsed.companyId) return fail("TASK_NOT_FOUND", "Task not found in current company."); + return ok(task); + } catch { + return fail("TASK_NOT_FOUND", "Task not found."); + } + }); + + registerHandle(IPC_CHANNELS.getAgent, (_event, payload) => { + const parsed = entityGetterSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + try { + const agent = db.getAgent(parsed.id); + if (agent.companyId !== parsed.companyId) return fail("AGENT_NOT_FOUND", "Agent not found in current company."); + return ok(agent); + } catch { + return fail("AGENT_NOT_FOUND", "Agent not found."); + } + }); + + registerHandle(IPC_CHANNELS.getRun, (_event, payload) => { + const parsed = entityGetterSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + try { + const run = db.getRun(parsed.id); + if (run.companyId !== parsed.companyId) return fail("RUN_NOT_FOUND", "Run not found in current company."); + return ok(run); + } catch { + return fail("RUN_NOT_FOUND", "Run not found."); + } + }); + + registerHandle(IPC_CHANNELS.getApproval, (_event, payload) => { + const parsed = entityGetterSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + try { + const approval = db.getApprovalRecord(parsed.id); + if (approval.companyId !== parsed.companyId) return fail("APPROVAL_NOT_FOUND", "Approval not found in current company."); + return ok(approval); + } catch { + return fail("APPROVAL_NOT_FOUND", "Approval not found."); + } + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index d55e5e99..cd722019 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -82,6 +82,10 @@ const api: DesktopApi = { ipcRenderer.invoke("recovery:dismiss", { taskId }), recoveryFail: (taskId: string) => ipcRenderer.invoke("recovery:fail", { taskId }), + getTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.getTask, input), + getAgent: (input) => ipcRenderer.invoke(IPC_CHANNELS.getAgent, input), + getRun: (input) => ipcRenderer.invoke(IPC_CHANNELS.getRun, input), + getApproval: (input) => ipcRenderer.invoke(IPC_CHANNELS.getApproval, input), subscribe: (callback: (event: DesktopEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload); ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener); diff --git a/src/renderer/state/slices/company.ts b/src/renderer/state/slices/company.ts index 13be6f1f..a33a18f6 100644 --- a/src/renderer/state/slices/company.ts +++ b/src/renderer/state/slices/company.ts @@ -1,10 +1,14 @@ import type { StateCreator } from "zustand"; import type { AppState } from "../store"; import type { + AgentRecord, + ApprovalRecord, CompanyMetrics, InboxItem, ProfileSnapshot, + RunRecord, StandupReport, + TaskRecord, } from "@shared/types"; import type { SidebarBadges } from "@shared/contracts"; @@ -24,6 +28,15 @@ export interface CompanySlice { setBadges: (badges: SidebarBadges | null) => void; setError: (error: string | null) => void; refreshSnapshot: () => Promise; + patchTask: (task: TaskRecord) => void; + removeTask: (taskId: string) => void; + patchRun: (run: RunRecord) => void; + removeRun: (runId: string) => void; + patchAgent: (agent: AgentRecord) => void; + removeAgent: (agentId: string) => void; + patchApproval: (approval: ApprovalRecord) => void; + removeApproval: (approvalId: string) => void; + refreshBadges: () => Promise; } export const createCompanySlice: StateCreator< @@ -31,7 +44,7 @@ export const createCompanySlice: StateCreator< [["zustand/immer", never]], [], CompanySlice -> = (set) => ({ +> = (set, get) => ({ snapshot: null, currentCompanyId: null, companyMetrics: null, @@ -85,4 +98,85 @@ export const createCompanySlice: StateCreator< }); } }, + + // --- Granular patch methods for selective IPC updates --- + + patchTask: (task) => + set((state) => { + if (!state.snapshot) return; + const idx = state.snapshot.tasks.findIndex((t) => t.id === task.id); + if (idx >= 0) { + state.snapshot.tasks[idx] = task; + } else { + state.snapshot.tasks.unshift(task); + } + }), + removeTask: (taskId) => + set((state) => { + if (!state.snapshot) return; + state.snapshot.tasks = state.snapshot.tasks.filter((t) => t.id !== taskId); + }), + + patchRun: (run) => + set((state) => { + if (!state.snapshot) return; + const idx = state.snapshot.runs.findIndex((r) => r.id === run.id); + if (idx >= 0) { + state.snapshot.runs[idx] = run; + } else { + state.snapshot.runs.unshift(run); + } + }), + removeRun: (runId) => + set((state) => { + if (!state.snapshot) return; + state.snapshot.runs = state.snapshot.runs.filter((r) => r.id !== runId); + }), + + patchAgent: (agent) => + set((state) => { + if (!state.snapshot) return; + const idx = state.snapshot.agents.findIndex((a) => a.id === agent.id); + if (idx >= 0) { + state.snapshot.agents[idx] = agent; + } else { + state.snapshot.agents.unshift(agent); + } + }), + removeAgent: (agentId) => + set((state) => { + if (!state.snapshot) return; + state.snapshot.agents = state.snapshot.agents.filter((a) => a.id !== agentId); + }), + + patchApproval: (approval) => + set((state) => { + if (!state.snapshot) return; + const idx = state.snapshot.approvals.findIndex((a) => a.id === approval.id); + if (idx >= 0) { + state.snapshot.approvals[idx] = approval; + } else { + state.snapshot.approvals.unshift(approval); + } + }), + removeApproval: (approvalId) => + set((state) => { + if (!state.snapshot) return; + state.snapshot.approvals = state.snapshot.approvals.filter((a) => a.id !== approvalId); + }), + + refreshBadges: async () => { + const companyId = get().currentCompanyId; + if (!companyId) return; + try { + const result = await window.agentCompany.getSidebarBadges(companyId); + if (result.ok) { + set((state) => { + state.badges = result.data; + }); + } + } catch { + /* ignore -- badges are non-critical */ + } + }, }); diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 5ff2552a..202affb8 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, StandupReport } from "./types"; +import type { AgentMessageRecord, AgentMetrics, AgentRecord, ApprovalRecord, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, RunRecord, StandupReport, TaskRecord } from "./types"; export const IPC_CHANNELS = { loadSnapshot: "agent-company:load-snapshot", @@ -73,6 +73,10 @@ export const IPC_CHANNELS = { runWorkflow: "agent-company:run-workflow", autoAssignTask: "agent-company:auto-assign-task", searchMessages: "agent-company:search-messages", + getTask: "agent-company:get-task", + getAgent: "agent-company:get-agent", + getRun: "agent-company:get-run", + getApproval: "agent-company:get-approval", } as const; const optionalId = z.preprocess( @@ -514,6 +518,11 @@ export const autoAssignSchema = z.object({ companyId: z.string().uuid(), }); +export const entityGetterSchema = z.object({ + companyId: z.string().uuid(), + id: z.string().uuid(), +}); + export type DesktopResult = | { ok: true; data: T } | { ok: false; error: { code: string; message: string } }; @@ -592,5 +601,9 @@ export type DesktopApi = { recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise>; recoveryDismiss: (taskId: string) => Promise>; recoveryFail: (taskId: string) => Promise>; + getTask: (input: z.infer) => Promise>; + getAgent: (input: z.infer) => Promise>; + getRun: (input: z.infer) => Promise>; + getApproval: (input: z.infer) => Promise>; subscribe: (callback: (event: DesktopEvent) => void) => () => void; }; diff --git a/src/shared/types.ts b/src/shared/types.ts index c3cefc6a..37dc5a53 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -385,6 +385,35 @@ export interface NotificationClickEvent { entityId?: string; } +export interface TaskUpdatedEvent { + type: "task-updated"; + taskId: string; + action: "created" | "updated" | "deleted"; +} + +export interface RunUpdatedEvent { + type: "run-updated"; + runId: string; + action: "created" | "status_changed" | "completed"; +} + +export interface ApprovalUpdatedEvent { + type: "approval-updated"; + approvalId: string; + action: "created" | "decided" | "deleted"; +} + +export interface AgentUpdatedEvent { + type: "agent-updated"; + agentId: string; + action: "updated" | "paused"; +} + +export interface BudgetChangedEvent { + type: "budget-changed"; + companyId: string; +} + export type DesktopEvent = | LogChunkEvent | DomainChangedEvent @@ -394,7 +423,12 @@ export type DesktopEvent = | AgentMessageEvent | AutomationFiredEvent | NewMessageEvent - | NotificationClickEvent; + | NotificationClickEvent + | TaskUpdatedEvent + | RunUpdatedEvent + | ApprovalUpdatedEvent + | AgentUpdatedEvent + | BudgetChangedEvent; export type InboxItemType = "pending_approval" | "failed_run" | "budget_warning" | "blocked_task" | "review_needed" | "agent_message"; diff --git a/tests/unit/selective-ipc.test.ts b/tests/unit/selective-ipc.test.ts new file mode 100644 index 00000000..2dea90bc --- /dev/null +++ b/tests/unit/selective-ipc.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import type { + DesktopEvent, + TaskUpdatedEvent, + RunUpdatedEvent, + ApprovalUpdatedEvent, + AgentUpdatedEvent, + BudgetChangedEvent, +} from "@shared/types"; + +describe("selective IPC event types", () => { + it("TaskUpdatedEvent discriminated by type field", () => { + const event: TaskUpdatedEvent = { + type: "task-updated", + taskId: "t-1", + action: "created", + }; + expect(event.type).toBe("task-updated"); + expect(event.taskId).toBe("t-1"); + expect(event.action).toBe("created"); + }); + + it("RunUpdatedEvent discriminated by type field", () => { + const event: RunUpdatedEvent = { + type: "run-updated", + runId: "r-1", + action: "completed", + }; + expect(event.type).toBe("run-updated"); + expect(event.runId).toBe("r-1"); + expect(event.action).toBe("completed"); + }); + + it("ApprovalUpdatedEvent discriminated by type field", () => { + const event: ApprovalUpdatedEvent = { + type: "approval-updated", + approvalId: "a-1", + action: "decided", + }; + expect(event.type).toBe("approval-updated"); + expect(event.approvalId).toBe("a-1"); + expect(event.action).toBe("decided"); + }); + + it("AgentUpdatedEvent discriminated by type field", () => { + const event: AgentUpdatedEvent = { + type: "agent-updated", + agentId: "ag-1", + action: "updated", + }; + expect(event.type).toBe("agent-updated"); + expect(event.agentId).toBe("ag-1"); + expect(event.action).toBe("updated"); + }); + + it("BudgetChangedEvent discriminated by type field", () => { + const event: BudgetChangedEvent = { + type: "budget-changed", + companyId: "c-1", + }; + expect(event.type).toBe("budget-changed"); + expect(event.companyId).toBe("c-1"); + }); + + it("all five new 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" }, + { type: "approval-updated", approvalId: "a-1", action: "created" }, + { type: "agent-updated", agentId: "ag-1", action: "paused" }, + { type: "budget-changed", companyId: "c-1" }, + ]; + 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 + const exhaustiveCheck = (event: DesktopEvent): string => { + switch (event.type) { + case "run-log": + return "run-log"; + case "domain-changed": + return "domain-changed"; + case "shortcut": + return "shortcut"; + case "run-status": + return "run-status"; + case "heartbeat-status": + return "heartbeat-status"; + case "agent-message": + return "agent-message"; + case "automation-fired": + return "automation-fired"; + case "new-message": + return "new-message"; + case "notification-click": + return "notification-click"; + case "task-updated": + return "task-updated"; + case "run-updated": + return "run-updated"; + case "approval-updated": + return "approval-updated"; + case "agent-updated": + return "agent-updated"; + case "budget-changed": + return "budget-changed"; + default: { + const _exhaustive: never = event; + return _exhaustive; + } + } + }; + // Dummy call to avoid unused-var warning + expect(typeof exhaustiveCheck).toBe("function"); + }); +}); diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index eeb21168..87cc1054 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -1,12 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { useAppStore } from "@renderer/state/store"; -import type { ProfileSnapshot } from "@shared/types"; +import type { ProfileSnapshot, TaskRecord, RunRecord, AgentRecord, ApprovalRecord } from "@shared/types"; -// Mock window.agentCompany for refreshSnapshot test +// Mock window.agentCompany for refreshSnapshot and refreshBadges tests const mockLoadSnapshot = vi.fn(); +const mockGetSidebarBadges = vi.fn(); vi.stubGlobal("window", { agentCompany: { loadSnapshot: mockLoadSnapshot, + getSidebarBadges: mockGetSidebarBadges, }, }); @@ -218,3 +220,299 @@ describe("useAppStore", () => { expect(useAppStore.getState().socialAccounts).toEqual([account]); }); }); + +// -- 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: [], + knowledgeBase: [], + sprints: [], + automationRules: [], + agentMessages: [], + workflows: [], + ...overrides, + }; +} + +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, + sessionDisplayId: null, + costUsd: 0, + requiresUserReview: false, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", +} as TaskRecord); + +const fakeRun = (id: string): RunRecord => ({ + id, + companyId: "company-1", + taskId: "t-1", + agentId: "ag-1", + workspaceId: null, + connectorId: "claude_local", + status: "queued", + summary: "", + errorMessage: null, + exitCode: null, + signal: null, + model: null, + sessionDisplayId: null, + costUsd: 0, + 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_local", + 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", + requestedByAgentId: "ag-1", + type: "task_completion", + payloadSummary: "", + impactSummary: "", + decisionNote: null, + state: "pending", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", +} 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"); + }); + + // -- refreshBadges -- + it("refreshBadges calls window.agentCompany.getSidebarBadges and updates badges", async () => { + const mockBadges = { tasks: 5, approvals: 2, runs: 0, messages: 3 }; + mockGetSidebarBadges.mockResolvedValue({ ok: true, data: mockBadges }); + + // Set current company ID first + useAppStore.getState().setCurrentCompanyId("company-1"); + + await useAppStore.getState().refreshBadges(); + + expect(mockGetSidebarBadges).toHaveBeenCalledWith("company-1"); + expect(useAppStore.getState().badges).toEqual(mockBadges); + }); + + // -- 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(); + }); +});