import { onlineManager } from "@tanstack/query-core"; import { describe, expect, it, vi } from "vitest"; import type { CatalogModel, CatalogSkillMetadata } from "../../src/app-server/protocol/catalog"; import { AppServerQueryCache } from "../../src/app-server/query/cache"; import type { AppServerQueryContext } from "../../src/app-server/query/keys"; import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics"; import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions"; import type { Thread } from "../../src/domain/threads/model"; describe("AppServerQueryCache", () => { it("stores successful empty thread list snapshots as shared cache truth", async () => { const fetchThreads = vi.fn().mockResolvedValue([]); const cache = cacheWithThreads(fetchThreads); await expect(cache.refreshActiveThreads()).resolves.toEqual([]); expect(cache.activeThreadsSnapshot()).toEqual([]); expect(fetchThreads).toHaveBeenCalledOnce(); }); it("preserves the last-known-good active thread list when a refresh fails", async () => { const fetchThreads = vi .fn() .mockResolvedValueOnce([thread("cached")]) .mockRejectedValueOnce(new Error("offline")); const cache = cacheWithThreads(fetchThreads); await cache.refreshActiveThreads(); await expect(cache.refreshActiveThreads()).rejects.toThrow("offline"); expect(cache.activeThreadsSnapshot()).toEqual([thread("cached")]); }); it("shares concurrent active thread refreshes within one resource identity", async () => { const pending = deferred[]>(); const fetchThreads = vi.fn(() => pending.promise); const cache = cacheWithThreads(fetchThreads); const first = cache.refreshActiveThreads(); const second = cache.refreshActiveThreads(); await flushMicrotasks(); expect(fetchThreads).toHaveBeenCalledOnce(); pending.resolve([thread("shared")]); await expect(Promise.all([first, second])).resolves.toEqual([[thread("shared")], [thread("shared")]]); }); it("joins a refresh that starts after an existing refresh has begun", async () => { const pending = deferred[]>(); const fetchThreads = vi .fn() .mockResolvedValueOnce([thread("cached")]) .mockImplementationOnce(() => pending.promise); const cache = cacheWithThreads(fetchThreads); await cache.refreshActiveThreads(); const first = cache.refreshActiveThreads(); await flushMicrotasks(); const second = cache.refreshActiveThreads(); await flushMicrotasks(); expect(fetchThreads).toHaveBeenCalledTimes(2); pending.resolve([thread("fresh")]); await expect(Promise.all([first, second])).resolves.toEqual([[thread("fresh")], [thread("fresh")]]); }); it("keeps active and archived thread list snapshots separate", async () => { const fetchThreads = vi.fn((_context: AppServerQueryContext, archived: boolean) => Promise.resolve(archived ? [thread("archived", true)] : [thread("active")]), ); const cache = cacheWithThreads(fetchThreads); await expect(cache.refreshActiveThreads()).resolves.toEqual([thread("active")]); await expect(cache.refreshArchivedThreads()).resolves.toEqual([thread("archived", true)]); expect(cache.activeThreadsSnapshot()).toEqual([thread("active")]); expect(cache.archivedThreadsSnapshot()).toEqual([thread("archived", true)]); expect(fetchThreads).toHaveBeenNthCalledWith(1, cacheContext(), false); expect(fetchThreads).toHaveBeenNthCalledWith(2, cacheContext(), true); }); it("loads active thread history one page at a time", async () => { const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" }) .mockResolvedValueOnce({ data: [thread("second")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await expect(cache.refreshActiveThreads()).resolves.toEqual([thread("first")]); expect(cache.hasMoreActiveThreads()).toBe(true); expect(listThreads).toHaveBeenCalledOnce(); await expect(cache.loadMoreActiveThreads()).resolves.toEqual([thread("first"), thread("second")]); expect(cache.hasMoreActiveThreads()).toBe(false); expect(cache.recentActiveThreadsSnapshot()).toEqual([thread("first")]); expect(listThreads).toHaveBeenNthCalledWith(2, { cwd: "/vault", cursor: "page-2", archived: false, sortKey: "recency_at", sortDirection: "desc", }); }); it("moves an opened older thread to the front without discarding loaded history", async () => { const recent = { ...thread("recent"), recencyAt: 20 }; const older = { ...thread("older"), recencyAt: 10 }; const listThreads = vi .fn() .mockResolvedValueOnce({ data: [recent], nextCursor: "page-2" }) .mockResolvedValueOnce({ data: [older], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); await cache.loadMoreActiveThreads(); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "older", changes: { recencyAt: 30 } }]); expect(cache.activeThreadsSnapshot()?.map((item) => item.id)).toEqual(["older", "recent"]); expect(cache.recentActiveThreadsSnapshot()?.map((item) => item.id)).toEqual(["older"]); expect(listThreads).toHaveBeenCalledTimes(2); }); it("keeps the recent window stable across event projections", async () => { const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" }) .mockResolvedValueOnce({ data: [thread("second")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); await cache.loadMoreActiveThreads(); cache.applyThreadListMutations([{ kind: "upsert", list: "active", thread: { ...thread("new"), recencyAt: 30 } }]); expect(cache.recentActiveThreadsSnapshot()?.map((item) => item.id)).toEqual(["new"]); cache.applyThreadListMutations([ { kind: "remove", list: "active", threadId: "new" }, { kind: "remove", list: "active", threadId: "first" }, ]); expect(cache.recentActiveThreadsSnapshot()?.map((item) => item.id)).toEqual(["second"]); }); it("shares concurrent Load more requests through the InfiniteQuery", async () => { const nextPage = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" }) .mockImplementationOnce(() => nextPage.promise); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); const first = cache.loadMoreActiveThreads(); const second = cache.loadMoreActiveThreads(); await flushMicrotasks(); expect(listThreads).toHaveBeenCalledTimes(2); nextPage.resolve({ data: [thread("second")], nextCursor: null }); await expect(Promise.all([first, second])).resolves.toEqual([ [thread("first"), thread("second")], [thread("first"), thread("second")], ]); }); it("does not append a stale load-more page after a newer first-page refresh", async () => { const oldPage = deferred<{ data: ReturnType[]; nextCursor: string | null }>(); const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("old-first")], nextCursor: "old-page-2" }) .mockImplementationOnce(() => oldPage.promise) .mockResolvedValueOnce({ data: [thread("new-first")], nextCursor: "new-page-2" }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); const loadMore = cache.loadMoreActiveThreads(); await flushMicrotasks(); await cache.refreshActiveThreads(); oldPage.resolve({ data: [thread("old-second")], nextCursor: null }); await expect(loadMore).resolves.toEqual([thread("old-first")]); expect(cache.activeThreadsSnapshot()).toEqual([thread("new-first")]); expect(cache.hasMoreActiveThreads()).toBe(true); }); it("does not append a load-more page invalidated by an exact event", async () => { const oldPage = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" }) .mockImplementationOnce(() => oldPage.promise) .mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); const loadMore = cache.loadMoreActiveThreads(); await flushMicrotasks(); cache.applyThreadListMutations([{ kind: "remove", list: "active", threadId: "deleted-on-page-2" }]); oldPage.resolve({ data: [thread("deleted-on-page-2")], nextCursor: null }); await expect(loadMore).resolves.toEqual([thread("first")]); expect(cache.activeThreadsSnapshot()).toEqual([thread("first")]); expect(cache.hasMoreActiveThreads()).toBe(true); }); it("projects an exact event without preserving a synthetic page boundary", async () => { const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("old-first"), thread("old-second")], nextCursor: "page-2" }) .mockResolvedValueOnce({ data: [thread("new-first"), thread("old-first")], nextCursor: "page-2" }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); cache.applyThreadListMutations([{ kind: "upsert", list: "active", thread: thread("new-first") }]); expect(cache.activeThreadsSnapshot()?.map((item) => item.id)).toEqual(["new-first", "old-first", "old-second"]); expect(listThreads).toHaveBeenCalledOnce(); await cache.refreshActiveThreads(); expect(cache.activeThreadsSnapshot()?.map((item) => item.id)).toEqual(["new-first", "old-first"]); expect(cache.hasMoreActiveThreads()).toBe(true); }); it("continues the existing cursor chain after an exact event", async () => { const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("old-first")], nextCursor: "old-page-2" }) .mockResolvedValueOnce({ data: [thread("old-second")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); cache.applyThreadListMutations([{ kind: "upsert", list: "active", thread: thread("event-thread") }]); await expect(cache.loadMoreActiveThreads()).resolves.toEqual([thread("event-thread"), thread("old-first"), thread("old-second")]); expect(listThreads).toHaveBeenNthCalledWith(2, { cwd: "/vault", cursor: "old-page-2", archived: false, sortKey: "recency_at", sortDirection: "desc", }); }); it("updates a loaded thread in place instead of inventing a new rank", async () => { const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" }) .mockResolvedValueOnce({ data: [thread("second")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); await cache.loadMoreActiveThreads(); cache.applyThreadListMutations([ { kind: "upsert", list: "active", thread: { ...thread("second"), name: "Updated without re-ranking" } }, ]); expect(cache.activeThreadsSnapshot()).toEqual([thread("first"), { ...thread("second"), name: "Updated without re-ranking" }]); expect(listThreads).toHaveBeenCalledTimes(2); }); it("cancels a stale thread read before applying an exact event", async () => { const staleRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockResolvedValueOnce({ data: [{ ...thread("target"), name: "initial" }], nextCursor: null }) .mockImplementationOnce(() => staleRead.promise) .mockResolvedValueOnce({ data: [{ ...thread("target"), name: "authoritative" }], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshActiveThreads(); const refresh = cache.refreshActiveThreads(); await flushMicrotasks(); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "target", changes: { name: "from-event" } }]); expect(cache.activeThreadsSnapshot()?.[0]?.name).toBe("from-event"); staleRead.resolve({ data: [{ ...thread("target"), name: "stale" }], nextCursor: null }); await expect(refresh).resolves.toEqual([{ ...thread("target"), name: "from-event" }]); await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(3)); expect(cache.activeThreadsSnapshot()?.[0]?.name).toBe("authoritative"); }); it("restarts an initial thread read when an exact event arrives before any snapshot", async () => { const staleRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockImplementationOnce(() => staleRead.promise) .mockResolvedValueOnce({ data: [{ ...thread("target"), name: "authoritative" }], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); const initial = cache.refreshActiveThreads(); await flushMicrotasks(); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "target", changes: { name: "from-event" } }]); staleRead.resolve({ data: [{ ...thread("target"), name: "stale" }], nextCursor: null }); await expect(initial).resolves.toEqual([{ ...thread("target"), name: "authoritative" }]); expect(cache.activeThreadsSnapshot()?.[0]?.name).toBe("authoritative"); expect(listThreads).toHaveBeenCalledTimes(2); }); it("rejoins the active thread query through repeated event cancellations", async () => { const firstRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const secondRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockImplementationOnce(() => firstRead.promise) .mockImplementationOnce(() => secondRead.promise) .mockResolvedValueOnce({ data: [thread("authoritative")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); const initial = cache.refreshActiveThreads(); await flushMicrotasks(); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "first-event", changes: { name: "first" } }]); await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(2)); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "second-event", changes: { name: "second" } }]); await expect(initial).resolves.toEqual([thread("authoritative")]); expect(listThreads).toHaveBeenCalledTimes(3); firstRead.resolve({ data: [thread("obsolete-first")], nextCursor: null }); secondRead.resolve({ data: [thread("obsolete-second")], nextCursor: null }); }); it("rejoins an archived thread refresh cancelled by an exact event", async () => { const staleRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("cached", true)], nextCursor: null }) .mockImplementationOnce(() => staleRead.promise) .mockResolvedValueOnce({ data: [thread("authoritative", true)], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.refreshArchivedThreads(); const refresh = cache.refreshArchivedThreads(); await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(2)); cache.applyThreadListMutations([{ kind: "update", list: "archived", threadId: "cached", changes: { name: "from-event" } }]); await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(3)); await expect(refresh).resolves.toEqual([thread("authoritative", true)]); expect(cache.archivedThreadsSnapshot()).toEqual([thread("authoritative", true)]); staleRead.resolve({ data: [thread("stale", true)], nextCursor: null }); }); it("keeps a thread-picker inventory read out of the shared recent list", async () => { const oldInventory = deferred<{ data: ReturnType[]; nextCursor: string | null }>(); const listThreads = vi .fn() .mockImplementationOnce(() => oldInventory.promise) .mockResolvedValueOnce({ data: [thread("new-first")], nextCursor: "new-page-2" }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); const inventory = cache.fetchActiveThreadSearchInventory(); await flushMicrotasks(); await cache.refreshActiveThreads(); oldInventory.resolve({ data: [thread("old")], nextCursor: null }); await expect(inventory).resolves.toEqual([thread("old")]); expect(cache.activeThreadsSnapshot()).toEqual([thread("new-first")]); expect(cache.hasMoreActiveThreads()).toBe(true); }); it("refreshes the complete thread-picker inventory for each operation", async () => { const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("first")], nextCursor: null }) .mockResolvedValueOnce({ data: [thread("second")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await expect(cache.fetchActiveThreadSearchInventory()).resolves.toEqual([thread("first")]); await expect(cache.fetchActiveThreadSearchInventory()).resolves.toEqual([thread("second")]); expect(listThreads).toHaveBeenCalledTimes(2); }); it("rejoins the complete thread-picker inventory through repeated event cancellations", async () => { const firstRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const secondRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockImplementationOnce(() => firstRead.promise) .mockImplementationOnce(() => secondRead.promise) .mockResolvedValueOnce({ data: [thread("authoritative")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); const inventory = cache.fetchActiveThreadSearchInventory(); await flushMicrotasks(); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "first-event", changes: { name: "first" } }]); await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(2)); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "second-event", changes: { name: "second" } }]); await expect(inventory).resolves.toEqual([thread("authoritative")]); expect(listThreads).toHaveBeenCalledTimes(3); firstRead.resolve({ data: [thread("obsolete-first")], nextCursor: null }); secondRead.resolve({ data: [thread("obsolete-second")], nextCursor: null }); }); it("does not reuse a cached complete inventory after an event cancels its refresh", async () => { const staleRead = deferred<{ data: ReturnType[]; nextCursor: null }>(); const listThreads = vi .fn() .mockResolvedValueOnce({ data: [thread("cached")], nextCursor: null }) .mockImplementationOnce(() => staleRead.promise) .mockResolvedValueOnce({ data: [thread("authoritative")], nextCursor: null }); const cache = cacheWithRequestHandlers({ "thread/list": listThreads }); await cache.fetchActiveThreadSearchInventory(); const inventory = cache.fetchActiveThreadSearchInventory(); await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(2)); cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "event", changes: { name: "changed" } }]); await expect(inventory).resolves.toEqual([thread("authoritative")]); expect(listThreads).toHaveBeenCalledTimes(3); staleRead.resolve({ data: [thread("stale")], nextCursor: null }); }); it("runs local app-server queries independently of browser network state", async () => { const listModels = vi.fn().mockResolvedValue({ data: [catalogModel("local")] }); const cache = cacheWithRequestHandlers({ "model/list": listModels }); onlineManager.setOnline(false); try { await expect(cache.fetchModels()).resolves.toMatchObject([{ model: "local" }]); expect(listModels).toHaveBeenCalledOnce(); } finally { onlineManager.setOnline(true); } }); it("clears snapshots and rejects new reads after disposal", async () => { const listModels = vi.fn().mockResolvedValue({ data: [catalogModel("cached")] }); const cache = cacheWithRequestHandlers({ "model/list": listModels }); await cache.fetchModels(); cache.dispose(); expect(cache.modelsSnapshot()).toBeNull(); await expect(cache.fetchModels()).rejects.toThrow("query cache was disposed"); expect(listModels).toHaveBeenCalledOnce(); }); it("freezes its lease context before starting requests", async () => { const context = { codexPath: "codex-captured", vaultPath: "/vault" }; const capturedContext = { ...context }; const refresh = deferred[]>(); const fetchThreads = vi.fn(() => refresh.promise); const cache = cacheWithThreads(fetchThreads, context); const promise = cache.refreshActiveThreads(); context.codexPath = "codex-mutated"; refresh.resolve([thread("captured")]); await expect(promise).resolves.toEqual([thread("captured")]); expect(fetchThreads).toHaveBeenCalledWith(capturedContext, false); expect(cache.activeThreadsSnapshot()?.map((item) => item.id)).toEqual(["captured"]); }); it("fetches app-server metadata and models through their respective query records", async () => { const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockResolvedValue({}), "model/list": vi.fn().mockResolvedValue({ data: [catalogModel("gpt-meta")] }), "skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [catalogSkill("writer")] }] }), "permissionProfile/list": vi.fn().mockResolvedValue({ data: [permissionProfile(":workspace")], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(64), rateLimitsByLimitId: null }), }); await cache.refreshAppServerMetadata(); const metadata = cache.appServerMetadataSnapshot(); expect(metadata?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]); expect(metadata?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":workspace"]); expect(metadata?.rateLimit?.primary?.usedPercent).toBe(64); expect(metadata?.serverDiagnostics.probes.models.status).toBe("ok"); expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-meta"]); }); it("publishes each metadata resource without waiting for unrelated refreshes", async () => { const skills = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockResolvedValue({}), "model/list": vi.fn().mockResolvedValue({ data: [] }), "skills/list": vi.fn(() => skills.promise), "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }), }); const listener = vi.fn(); const unsubscribe = cache.observeAppServerMetadataResources(listener, { emitCurrent: false }); const refresh = cache.refreshAppServerMetadata(); await flushMicrotasks(); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: "runtimeConfig", value: expect.any(Object) })); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: "models", value: [] })); expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({ id: "skills", value: expect.any(Array) })); skills.resolve({ data: [{ skills: [catalogSkill("writer")] }] }); await refresh; expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: "skills", value: [expect.objectContaining({ name: "writer" })] })); unsubscribe(); }); it("publishes metadata resources only after a successful retry settles", async () => { const retry = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const nextRetry = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const listSkills = vi .fn() .mockRejectedValueOnce(new Error("skills offline")) .mockImplementationOnce(() => retry.promise) .mockImplementationOnce(() => nextRetry.promise); const cache = cacheWithRequestHandlers({ "skills/list": listSkills }); await expect(cache.refreshSkills()).rejects.toThrow("skills offline"); const listener = vi.fn(); const unsubscribe = cache.observeAppServerMetadataResources(listener, { emitCurrent: false }); const refresh = cache.refreshSkills(); await flushMicrotasks(); expect(listener).not.toHaveBeenCalled(); retry.resolve({ data: [{ skills: [catalogSkill("writer")] }] }); await refresh; expect(listener).toHaveBeenCalledOnce(); expect(listener).toHaveBeenCalledWith({ id: "skills", value: [expect.objectContaining({ name: "writer" })], probe: expect.objectContaining({ id: "skills", status: "ok" }), }); const nextRefresh = cache.refreshSkills(); await flushMicrotasks(); expect(listener).toHaveBeenCalledOnce(); nextRetry.resolve({ data: [{ skills: [catalogSkill("editor")] }] }); await nextRefresh; expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenLastCalledWith({ id: "skills", value: [expect.objectContaining({ name: "editor" })], probe: expect.objectContaining({ id: "skills", status: "ok" }), }); unsubscribe(); }); it("deduplicates metadata resource RPCs across concurrent full refreshes", async () => { const config = deferred>(); const models = deferred<{ data: CatalogModel[] }>(); const skills = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const profiles = deferred<{ data: RuntimePermissionProfileSummary[]; nextCursor: null }>(); const limits = deferred<{ rateLimits: ReturnType; rateLimitsByLimitId: null }>(); const handlers = { "config/read": vi.fn(() => config.promise), "model/list": vi.fn(() => models.promise), "skills/list": vi.fn(() => skills.promise), "permissionProfile/list": vi.fn(() => profiles.promise), "account/rateLimits/read": vi.fn(() => limits.promise), }; const cache = cacheWithRequestHandlers(handlers); const first = cache.refreshAppServerMetadata(); const second = cache.refreshAppServerMetadata(); await flushMicrotasks(); for (const handler of Object.values(handlers)) expect(handler).toHaveBeenCalledOnce(); config.resolve({}); models.resolve({ data: [] }); skills.resolve({ data: [{ skills: [] }] }); profiles.resolve({ data: [], nextCursor: null }); limits.resolve({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }); await Promise.all([first, second]); }); it("uses the regular skills query when a notification overlaps a full refresh", async () => { const first = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const second = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const listSkills = vi .fn() .mockImplementationOnce(() => first.promise) .mockImplementationOnce(() => second.promise); const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockResolvedValue({}), "model/list": vi.fn().mockResolvedValue({ data: [] }), "skills/list": listSkills, "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }), }); const fullRefresh = cache.refreshAppServerMetadata(); await flushMicrotasks(); const notificationRefresh = cache.refreshSkills(); first.resolve({ data: [{ skills: [catalogSkill("old")] }] }); await vi.waitFor(() => expect(listSkills).toHaveBeenCalledTimes(2)); expect(listSkills).toHaveBeenNthCalledWith(2, { cwds: ["/vault"], forceReload: false }); second.resolve({ data: [{ skills: [catalogSkill("new")] }] }); await Promise.all([fullRefresh, notificationRefresh]); expect(cache.appServerMetadataSnapshot()?.availableSkills.map((skill) => skill.name)).toEqual(["new"]); }); it("does not surface cancellation when a newer skills notification replaces an older refresh", async () => { const first = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const listSkills = vi .fn() .mockImplementationOnce(() => first.promise) .mockResolvedValueOnce({ data: [{ skills: [catalogSkill("latest")] }] }); const cache = cacheWithRequestHandlers({ "skills/list": listSkills }); const listener = vi.fn(); const unsubscribe = cache.observeAppServerMetadataResources(listener, { emitCurrent: false }); const older = cache.refreshSkills(); await flushMicrotasks(); const newer = cache.refreshSkills(); first.resolve({ data: [{ skills: [catalogSkill("stale")] }] }); await expect(Promise.all([older, newer])).resolves.toEqual([undefined, undefined]); expect(listener).toHaveBeenLastCalledWith({ id: "skills", value: [expect.objectContaining({ name: "latest" })], probe: expect.objectContaining({ status: "ok" }), }); expect(listSkills).toHaveBeenCalledTimes(2); unsubscribe(); }); it("rejects an initial metadata refresh when runtime config fails after optional resources settle", async () => { const skills = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>(); const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockRejectedValue(new Error("config offline")), "model/list": vi.fn().mockResolvedValue({ data: [] }), "skills/list": vi.fn(() => skills.promise), "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }), }); let settled = false; const refresh = cache.refreshAppServerMetadata().finally(() => { settled = true; }); await flushMicrotasks(); expect(settled).toBe(false); skills.resolve({ data: [{ skills: [] }] }); await expect(refresh).rejects.toThrow("config offline"); expect(cache.appServerMetadataSnapshot()).toBeNull(); }); it("rejects runtime config refresh failures while preserving prior config and refreshed optional resources", async () => { const readConfig = vi.fn().mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("config offline")); const listSkills = vi .fn() .mockResolvedValueOnce({ data: [{ skills: [catalogSkill("old")] }] }) .mockResolvedValueOnce({ data: [{ skills: [catalogSkill("new")] }] }); const cache = cacheWithRequestHandlers({ "config/read": readConfig, "model/list": vi.fn().mockResolvedValue({ data: [] }), "skills/list": listSkills, "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }), }); await cache.refreshAppServerMetadata(); await expect(cache.refreshAppServerMetadata()).rejects.toThrow("config offline"); const snapshot = cache.appServerMetadataSnapshot(); expect(snapshot?.runtimeConfig).not.toBeNull(); expect(snapshot?.availableSkills.map((skill) => skill.name)).toEqual(["new"]); }); it("shares in-flight model fetches between metadata and models queries", async () => { const modelRefresh = deferred<{ data: CatalogModel[] }>(); const listModels = vi.fn(() => modelRefresh.promise); const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockResolvedValue({}), "model/list": listModels, "skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }), "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }), }); const metadataPromise = cache.refreshAppServerMetadata(); await flushMicrotasks(); const modelsPromise = cache.fetchModels(); await flushMicrotasks(); expect(listModels).toHaveBeenCalledOnce(); modelRefresh.resolve({ data: [catalogModel("gpt-shared")] }); await expect(modelsPromise).resolves.toMatchObject([{ model: "gpt-shared" }]); await expect(metadataPromise).resolves.toBeUndefined(); expect(listModels).toHaveBeenCalledOnce(); expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-shared"]); }); it("keeps query-cached models when app-server metadata model refresh fails", async () => { const listModels = vi .fn() .mockResolvedValueOnce({ data: [catalogModel("gpt-cached")] }) .mockRejectedValueOnce(new Error("offline")); const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockResolvedValue({}), "model/list": listModels, "skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }), "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }), }); await cache.fetchModels(); await cache.refreshAppServerMetadata(); const metadataSnapshot = cache.appServerMetadataSnapshot(); expect(metadataSnapshot?.serverDiagnostics.probes.models.status).toBe("failed"); expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-cached"]); }); it("keeps every last-known-good resource through the full metadata refresh path", async () => { const listModels = vi .fn() .mockResolvedValueOnce({ data: [catalogModel("gpt-cached")] }) .mockRejectedValueOnce(new Error("models offline")); const listSkills = vi .fn() .mockResolvedValueOnce({ data: [{ skills: [catalogSkill("cached-skill")] }] }) .mockRejectedValueOnce(new Error("skills offline")); const listProfiles = vi .fn() .mockResolvedValueOnce({ data: [permissionProfile(":cached")], nextCursor: null }) .mockRejectedValueOnce(new Error("profiles offline")); const readRateLimits = vi .fn() .mockResolvedValueOnce({ rateLimits: appServerRateLimit(17), rateLimitsByLimitId: null }) .mockRejectedValueOnce(new Error("limits offline")); const cache = cacheWithRequestHandlers({ "config/read": vi.fn().mockResolvedValue({}), "model/list": listModels, "skills/list": listSkills, "permissionProfile/list": listProfiles, "account/rateLimits/read": readRateLimits, }); await cache.refreshAppServerMetadata(); await cache.refreshAppServerMetadata(); const refreshed = cache.appServerMetadataSnapshot(); expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-cached"]); expect(refreshed?.availableSkills.map((skill) => skill.name)).toEqual(["cached-skill"]); expect(refreshed?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":cached"]); expect(refreshed?.rateLimit?.primary?.usedPercent).toBe(17); expect(refreshed?.serverDiagnostics.probes).toMatchObject({ models: { status: "failed" }, skills: { status: "failed" }, permissionProfiles: { status: "failed" }, rateLimits: { status: "failed" }, }); expect(cache.appServerMetadataSnapshot()).toEqual(refreshed); }); it("stores an in-flight app-server snapshot as raw thread-list truth", async () => { const refresh = deferred[]>(); const cache = cacheWithThreads(() => refresh.promise); const promise = cache.refreshActiveThreads(); await flushMicrotasks(); refresh.resolve([thread("thread"), thread("other")]); await expect(promise).resolves.toEqual([thread("thread"), thread("other")]); expect(cache.activeThreadsSnapshot()).toEqual([thread("thread"), thread("other")]); }); }); function cacheContext(overrides: Partial = {}): AppServerQueryContext { return { codexPath: "codex", vaultPath: "/vault", ...overrides, }; } function cacheWithThreads( fetchThreads: (context: AppServerQueryContext, archived: boolean) => Promise[]>, context: AppServerQueryContext = cacheContext(), ): AppServerQueryCache { const runtimeContext = { ...context }; return new AppServerQueryCache(context, { clientRunner: { runWithClient: async (operation) => { return operation({ request: async (method: string, params: { archived?: boolean }) => { if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`); return { data: await fetchThreads(runtimeContext, params.archived ?? false), nextCursor: null, }; }, } as never); }, }, }); } function cacheWithRequestHandlers( handlers: Record Promise>, context: AppServerQueryContext = cacheContext(), ): AppServerQueryCache { const requestClient = { request: async (method: string, params: unknown) => { const handler = handlers[method]; if (!handler) throw new Error(`Unexpected app-server request: ${method}`); return handler(params); }, }; return new AppServerQueryCache(context, { clientRunner: { runWithClient: async (operation) => operation(requestClient as never), }, }); } function permissionProfile(id: string): RuntimePermissionProfileSummary { return { id, description: null, allowed: true }; } function catalogModel(model: string): CatalogModel { return { id: model, model, displayName: model, description: "", hidden: false, supportedReasoningEfforts: [], defaultReasoningEffort: "medium", inputModalities: ["text"], serviceTiers: [], defaultServiceTier: null, isDefault: false, }; } function catalogSkill(name: string): CatalogSkillMetadata { return { name, description: "", path: `/tmp/${name}`, enabled: true, }; } function appServerRateLimit(usedPercent: number): RateLimitSnapshot { return { limitId: "codex", limitName: "Codex", primary: { usedPercent, windowDurationMins: 300, resetsAt: null }, secondary: null, individualLimit: null, rateLimitReachedType: null, }; } function thread(id: string, archived = false): Thread { return { id, preview: "", name: null, archived, createdAt: 1, updatedAt: 1, provenance: { kind: "interactive" as const }, }; } interface Deferred { promise: Promise; resolve: (value: T) => void; } function deferred(): Deferred { let resolve: (value: T) => void = () => undefined; const promise = new Promise((settle) => { resolve = settle; }); return { promise, resolve }; } async function flushMicrotasks(): Promise { for (let index = 0; index < 10; index += 1) { await Promise.resolve(); } }