2026-07-20 14:36:23 +00:00
|
|
|
import { onlineManager } from "@tanstack/query-core";
|
2026-06-11 15:03:23 +00:00
|
|
|
import { describe, expect, it, vi } from "vitest";
|
2026-07-20 14:26:14 +00:00
|
|
|
import type { AppServerClientAccess } from "../../src/app-server/connection/client-access";
|
2026-07-20 14:36:23 +00:00
|
|
|
import type { AppServerExecutionContext } from "../../src/app-server/connection/execution-context";
|
2026-06-24 05:44:16 +00:00
|
|
|
import type { CatalogModel, CatalogSkillMetadata } from "../../src/app-server/protocol/catalog";
|
2026-07-20 14:36:23 +00:00
|
|
|
import { AppServerQueryCache } from "../../src/app-server/query/cache";
|
2026-06-24 05:44:16 +00:00
|
|
|
import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics";
|
2026-07-02 04:43:19 +00:00
|
|
|
import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions";
|
2026-07-17 22:59:56 +00:00
|
|
|
import type { Thread } from "../../src/domain/threads/model";
|
2026-07-20 14:36:23 +00:00
|
|
|
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
|
2026-06-11 15:03:23 +00:00
|
|
|
|
2026-06-15 08:44:34 +00:00
|
|
|
describe("AppServerQueryCache", () => {
|
2026-07-20 14:26:14 +00:00
|
|
|
it("uses its required runtime-owned client access", async () => {
|
|
|
|
|
const withClient = vi.fn(async (operation) =>
|
|
|
|
|
operation({
|
|
|
|
|
request: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
|
|
|
|
} as never),
|
|
|
|
|
);
|
|
|
|
|
const cache = createCache({ withClient });
|
|
|
|
|
|
|
|
|
|
await expect(cache.fetchActiveThreads()).resolves.toEqual([]);
|
|
|
|
|
|
|
|
|
|
expect(withClient).toHaveBeenCalledOnce();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-20 14:36:23 +00:00
|
|
|
it("copies its execution context before performing requests", async () => {
|
2026-07-20 14:26:14 +00:00
|
|
|
const context = { codexPath: "/opt/codex", vaultPath: "/vault-a" };
|
|
|
|
|
const request = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
|
2026-07-20 14:36:23 +00:00
|
|
|
const cache = new AppServerQueryCache(context, { withClient: async (operation) => operation({ request } as never) });
|
2026-07-20 14:26:14 +00:00
|
|
|
|
|
|
|
|
context.codexPath = "/changed";
|
|
|
|
|
context.vaultPath = "/vault-b";
|
|
|
|
|
await cache.fetchActiveThreads();
|
|
|
|
|
|
|
|
|
|
expect(request).toHaveBeenCalledWith("thread/list", {
|
|
|
|
|
cwd: "/vault-a",
|
|
|
|
|
archived: false,
|
|
|
|
|
sortKey: "recency_at",
|
|
|
|
|
sortDirection: "desc",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("rejects new work after disposal", () => {
|
|
|
|
|
const cache = createCache({
|
|
|
|
|
withClient: vi.fn(() => Promise.resolve([])) as AppServerClientAccess["withClient"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cache.dispose();
|
|
|
|
|
cache.dispose();
|
|
|
|
|
|
2026-07-20 14:36:23 +00:00
|
|
|
expect(() => cache.fetchModels()).toThrow(StaleExecutionRuntimeError);
|
2026-07-20 14:26:14 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-20 14:36:23 +00:00
|
|
|
it("classifies late completion after disposal as a stale execution runtime", async () => {
|
2026-07-20 14:26:14 +00:00
|
|
|
const pending = deferred<readonly []>();
|
|
|
|
|
const cache = createCache({
|
|
|
|
|
withClient: vi.fn(() => pending.promise) as AppServerClientAccess["withClient"],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const fetch = cache.fetchModels();
|
|
|
|
|
cache.dispose();
|
|
|
|
|
pending.resolve([]);
|
|
|
|
|
|
2026-07-20 14:36:23 +00:00
|
|
|
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
2026-07-20 14:26:14 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("tears down observers without notifying them after disposal", async () => {
|
|
|
|
|
const pending = deferred<readonly []>();
|
|
|
|
|
const cache = createCache({
|
|
|
|
|
withClient: vi.fn(() => pending.promise) as AppServerClientAccess["withClient"],
|
|
|
|
|
});
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
cache.observeModelsResult(listener, { emitCurrent: false });
|
|
|
|
|
|
|
|
|
|
const fetch = cache.fetchModels();
|
|
|
|
|
listener.mockClear();
|
|
|
|
|
cache.dispose();
|
|
|
|
|
pending.resolve([]);
|
2026-07-20 14:36:23 +00:00
|
|
|
await expect(fetch).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
|
2026-07-20 14:26:14 +00:00
|
|
|
|
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 11:08:08 +00:00
|
|
|
it("stores successful empty thread list snapshots as shared cache truth", async () => {
|
2026-06-15 11:17:33 +00:00
|
|
|
const fetchThreads = vi.fn().mockResolvedValue([]);
|
|
|
|
|
const cache = cacheWithThreads(fetchThreads);
|
2026-06-11 15:03:23 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
await expect(cache.refreshActiveThreads()).resolves.toEqual([]);
|
|
|
|
|
expect(cache.activeThreadsSnapshot()).toEqual([]);
|
2026-06-15 11:17:33 +00:00
|
|
|
expect(fetchThreads).toHaveBeenCalledOnce();
|
2026-06-11 15:03:23 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-16 22:39:16 +00:00
|
|
|
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")]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-16 13:15:45 +00:00
|
|
|
it("shares concurrent active thread refreshes within one resource identity", async () => {
|
|
|
|
|
const pending = deferred<readonly ReturnType<typeof thread>[]>();
|
|
|
|
|
const fetchThreads = vi.fn(() => pending.promise);
|
|
|
|
|
const cache = cacheWithThreads(fetchThreads);
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const first = cache.refreshActiveThreads();
|
|
|
|
|
const second = cache.refreshActiveThreads();
|
2026-07-16 13:15:45 +00:00
|
|
|
await flushMicrotasks();
|
|
|
|
|
|
|
|
|
|
expect(fetchThreads).toHaveBeenCalledOnce();
|
|
|
|
|
pending.resolve([thread("shared")]);
|
|
|
|
|
await expect(Promise.all([first, second])).resolves.toEqual([[thread("shared")], [thread("shared")]]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-19 23:36:11 +00:00
|
|
|
it("joins a refresh that starts after an existing refresh has begun", async () => {
|
|
|
|
|
const pending = deferred<readonly ReturnType<typeof thread>[]>();
|
|
|
|
|
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")]]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-20 03:10:43 +00:00
|
|
|
it("keeps active and archived thread list snapshots separate", async () => {
|
2026-07-20 14:36:23 +00:00
|
|
|
const fetchThreads = vi.fn((_context: AppServerExecutionContext, archived: boolean) =>
|
2026-06-20 03:10:43 +00:00
|
|
|
Promise.resolve(archived ? [thread("archived", true)] : [thread("active")]),
|
|
|
|
|
);
|
|
|
|
|
const cache = cacheWithThreads(fetchThreads);
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
await expect(cache.refreshActiveThreads()).resolves.toEqual([thread("active")]);
|
|
|
|
|
await expect(cache.refreshArchivedThreads()).resolves.toEqual([thread("archived", true)]);
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
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);
|
2026-06-20 03:10:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-10 03:54:24 +00:00
|
|
|
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 });
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
await expect(cache.refreshActiveThreads()).resolves.toEqual([thread("first")]);
|
|
|
|
|
expect(cache.hasMoreActiveThreads()).toBe(true);
|
2026-07-10 03:54:24 +00:00
|
|
|
expect(listThreads).toHaveBeenCalledOnce();
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
await expect(cache.loadMoreActiveThreads()).resolves.toEqual([thread("first"), thread("second")]);
|
|
|
|
|
expect(cache.hasMoreActiveThreads()).toBe(false);
|
2026-07-19 23:36:11 +00:00
|
|
|
expect(cache.recentActiveThreadsSnapshot()).toEqual([thread("first")]);
|
2026-07-10 03:54:24 +00:00
|
|
|
expect(listThreads).toHaveBeenNthCalledWith(2, {
|
|
|
|
|
cwd: "/vault",
|
|
|
|
|
cursor: "page-2",
|
|
|
|
|
archived: false,
|
|
|
|
|
sortKey: "recency_at",
|
|
|
|
|
sortDirection: "desc",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-19 23:36:11 +00:00
|
|
|
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"]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("shares concurrent Load more requests through the InfiniteQuery", async () => {
|
|
|
|
|
const nextPage = deferred<{ data: ReturnType<typeof thread>[]; 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")],
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 23:45:43 +00:00
|
|
|
it("does not append a stale load-more page after a newer first-page refresh", async () => {
|
|
|
|
|
const oldPage = deferred<{ data: ReturnType<typeof thread>[]; 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 });
|
2026-07-16 21:08:53 +00:00
|
|
|
await cache.refreshActiveThreads();
|
2026-07-11 23:45:43 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const loadMore = cache.loadMoreActiveThreads();
|
2026-07-18 13:11:38 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-16 21:08:53 +00:00
|
|
|
await cache.refreshActiveThreads();
|
2026-07-11 23:45:43 +00:00
|
|
|
oldPage.resolve({ data: [thread("old-second")], nextCursor: null });
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
await expect(loadMore).resolves.toEqual([thread("old-first")]);
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.activeThreadsSnapshot()).toEqual([thread("new-first")]);
|
|
|
|
|
expect(cache.hasMoreActiveThreads()).toBe(true);
|
2026-07-11 23:45:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("does not append a load-more page invalidated by an exact event", async () => {
|
|
|
|
|
const oldPage = deferred<{ data: ReturnType<typeof thread>[]; 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 () => {
|
2026-07-17 22:59:56 +00:00
|
|
|
const staleRead = deferred<{ data: ReturnType<typeof thread>[]; nextCursor: null }>();
|
|
|
|
|
const listThreads = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce({ data: [{ ...thread("target"), name: "initial" }], nextCursor: null })
|
|
|
|
|
.mockImplementationOnce(() => staleRead.promise)
|
2026-07-18 13:11:38 +00:00
|
|
|
.mockResolvedValueOnce({ data: [{ ...thread("target"), name: "authoritative" }], nextCursor: null });
|
2026-07-17 22:59:56 +00:00
|
|
|
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 });
|
2026-07-18 13:11:38 +00:00
|
|
|
await expect(refresh).resolves.toEqual([{ ...thread("target"), name: "from-event" }]);
|
2026-07-17 22:59:56 +00:00
|
|
|
await vi.waitFor(() => expect(listThreads).toHaveBeenCalledTimes(3));
|
|
|
|
|
|
|
|
|
|
expect(cache.activeThreadsSnapshot()?.[0]?.name).toBe("authoritative");
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("restarts an initial thread read when an exact event arrives before any snapshot", async () => {
|
|
|
|
|
const staleRead = deferred<{ data: ReturnType<typeof thread>[]; nextCursor: null }>();
|
2026-07-11 23:45:43 +00:00
|
|
|
const listThreads = vi
|
|
|
|
|
.fn()
|
2026-07-18 13:11:38 +00:00
|
|
|
.mockImplementationOnce(() => staleRead.promise)
|
|
|
|
|
.mockResolvedValueOnce({ data: [{ ...thread("target"), name: "authoritative" }], nextCursor: null });
|
2026-07-11 23:45:43 +00:00
|
|
|
const cache = cacheWithRequestHandlers({ "thread/list": listThreads });
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
const initial = cache.refreshActiveThreads();
|
2026-07-11 23:45:43 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-18 13:11:38 +00:00
|
|
|
cache.applyThreadListMutations([{ kind: "update", list: "active", threadId: "target", changes: { name: "from-event" } }]);
|
|
|
|
|
staleRead.resolve({ data: [{ ...thread("target"), name: "stale" }], nextCursor: null });
|
2026-07-11 23:45:43 +00:00
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
await expect(initial).resolves.toEqual([{ ...thread("target"), name: "authoritative" }]);
|
|
|
|
|
expect(cache.activeThreadsSnapshot()?.[0]?.name).toBe("authoritative");
|
|
|
|
|
expect(listThreads).toHaveBeenCalledTimes(2);
|
2026-07-11 23:45:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-20 00:47:15 +00:00
|
|
|
it("rejoins the active thread query through repeated event cancellations", async () => {
|
|
|
|
|
const firstRead = deferred<{ data: ReturnType<typeof thread>[]; nextCursor: null }>();
|
|
|
|
|
const secondRead = deferred<{ data: ReturnType<typeof thread>[]; 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<typeof thread>[]; 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 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("keeps a thread-picker inventory read out of the shared recent list", async () => {
|
|
|
|
|
const oldInventory = deferred<{ data: ReturnType<typeof thread>[]; nextCursor: string | null }>();
|
|
|
|
|
const listThreads = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockImplementationOnce(() => oldInventory.promise)
|
|
|
|
|
.mockResolvedValueOnce({ data: [thread("new-first")], nextCursor: "new-page-2" });
|
2026-07-16 21:08:53 +00:00
|
|
|
const cache = cacheWithRequestHandlers({ "thread/list": listThreads });
|
2026-06-11 15:03:23 +00:00
|
|
|
|
2026-07-19 23:36:11 +00:00
|
|
|
const inventory = cache.fetchActiveThreadSearchInventory();
|
2026-07-16 21:08:53 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-18 13:11:38 +00:00
|
|
|
await cache.refreshActiveThreads();
|
|
|
|
|
oldInventory.resolve({ data: [thread("old")], nextCursor: null });
|
2026-06-11 15:03:23 +00:00
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
await expect(inventory).resolves.toEqual([thread("old")]);
|
|
|
|
|
expect(cache.activeThreadsSnapshot()).toEqual([thread("new-first")]);
|
|
|
|
|
expect(cache.hasMoreActiveThreads()).toBe(true);
|
2026-06-11 15:03:23 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-20 00:47:15 +00:00
|
|
|
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<typeof thread>[]; nextCursor: null }>();
|
|
|
|
|
const secondRead = deferred<{ data: ReturnType<typeof thread>[]; 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<typeof thread>[]; 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("clears snapshots and rejects new reads after disposal", async () => {
|
2026-07-16 21:08:53 +00:00
|
|
|
const listModels = vi.fn().mockResolvedValue({ data: [catalogModel("cached")] });
|
|
|
|
|
const cache = cacheWithRequestHandlers({ "model/list": listModels });
|
|
|
|
|
await cache.fetchModels();
|
2026-07-16 12:44:28 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
cache.dispose();
|
2026-07-16 12:44:28 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.modelsSnapshot()).toBeNull();
|
2026-07-20 14:36:23 +00:00
|
|
|
expect(() => cache.fetchModels()).toThrow(StaleExecutionRuntimeError);
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(listModels).toHaveBeenCalledOnce();
|
2026-07-16 12:44:28 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
it("freezes its lease context before starting requests", async () => {
|
2026-07-20 08:45:06 +00:00
|
|
|
const context = { codexPath: "codex-captured", vaultPath: "/vault" };
|
2026-06-11 15:03:23 +00:00
|
|
|
const capturedContext = { ...context };
|
|
|
|
|
const refresh = deferred<readonly ReturnType<typeof thread>[]>();
|
2026-07-16 21:08:53 +00:00
|
|
|
const fetchThreads = vi.fn(() => refresh.promise);
|
|
|
|
|
const cache = cacheWithThreads(fetchThreads, context);
|
2026-06-11 15:03:23 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const promise = cache.refreshActiveThreads();
|
2026-06-14 23:33:06 +00:00
|
|
|
context.codexPath = "codex-mutated";
|
2026-06-11 15:03:23 +00:00
|
|
|
|
|
|
|
|
refresh.resolve([thread("captured")]);
|
|
|
|
|
await expect(promise).resolves.toEqual([thread("captured")]);
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(fetchThreads).toHaveBeenCalledWith(capturedContext, false);
|
|
|
|
|
expect(cache.activeThreadsSnapshot()?.map((item) => item.id)).toEqual(["captured"]);
|
2026-06-11 15:03:23 +00:00
|
|
|
});
|
2026-06-15 21:46:29 +00:00
|
|
|
|
2026-07-10 03:37:05 +00:00
|
|
|
it("fetches app-server metadata and models through their respective query records", async () => {
|
2026-06-28 03:19:13 +00:00
|
|
|
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")] }] }),
|
2026-07-02 04:43:19 +00:00
|
|
|
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [permissionProfile(":workspace")], nextCursor: null }),
|
2026-06-28 03:19:13 +00:00
|
|
|
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(64), rateLimitsByLimitId: null }),
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-17 22:49:52 +00:00
|
|
|
await cache.refreshAppServerMetadata();
|
|
|
|
|
const metadata = cache.appServerMetadataSnapshot();
|
2026-06-15 21:46:29 +00:00
|
|
|
|
|
|
|
|
expect(metadata?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
|
2026-07-02 04:43:19 +00:00
|
|
|
expect(metadata?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":workspace"]);
|
2026-06-15 21:46:29 +00:00
|
|
|
expect(metadata?.rateLimit?.primary?.usedPercent).toBe(64);
|
2026-06-30 22:43:35 +00:00
|
|
|
expect(metadata?.serverDiagnostics.probes.models.status).toBe("ok");
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-meta"]);
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-17 22:49:52 +00:00
|
|
|
it("publishes each metadata resource without waiting for unrelated refreshes", async () => {
|
2026-07-15 04:45:00 +00:00
|
|
|
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();
|
2026-07-17 22:49:52 +00:00
|
|
|
const unsubscribe = cache.observeAppServerMetadataResources(listener, { emitCurrent: false });
|
2026-07-15 04:45:00 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const refresh = cache.refreshAppServerMetadata();
|
2026-07-15 04:45:00 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-17 22:49:52 +00:00
|
|
|
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) }));
|
2026-07-15 04:45:00 +00:00
|
|
|
|
|
|
|
|
skills.resolve({ data: [{ skills: [catalogSkill("writer")] }] });
|
|
|
|
|
await refresh;
|
|
|
|
|
|
2026-07-17 22:49:52 +00:00
|
|
|
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ id: "skills", value: [expect.objectContaining({ name: "writer" })] }));
|
2026-07-15 04:45:00 +00:00
|
|
|
unsubscribe();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("publishes metadata resources only after a successful retry settles", async () => {
|
2026-07-17 22:59:56 +00:00
|
|
|
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 });
|
2026-07-18 13:11:38 +00:00
|
|
|
await expect(cache.refreshSkills()).rejects.toThrow("skills offline");
|
2026-07-17 22:59:56 +00:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
it("deduplicates metadata resource RPCs across concurrent full refreshes", async () => {
|
|
|
|
|
const config = deferred<Record<string, never>>();
|
|
|
|
|
const models = deferred<{ data: CatalogModel[] }>();
|
|
|
|
|
const skills = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>();
|
|
|
|
|
const profiles = deferred<{ data: RuntimePermissionProfileSummary[]; nextCursor: null }>();
|
|
|
|
|
const limits = deferred<{ rateLimits: ReturnType<typeof appServerRateLimit>; 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);
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const first = cache.refreshAppServerMetadata();
|
|
|
|
|
const second = cache.refreshAppServerMetadata();
|
2026-07-15 04:45:00 +00:00
|
|
|
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]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("uses the regular skills query when a notification overlaps a full refresh", async () => {
|
2026-07-15 04:45:00 +00:00
|
|
|
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 }),
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const fullRefresh = cache.refreshAppServerMetadata();
|
2026-07-15 04:45:00 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-16 21:08:53 +00:00
|
|
|
const notificationRefresh = cache.refreshSkills();
|
2026-07-15 04:45:00 +00:00
|
|
|
first.resolve({ data: [{ skills: [catalogSkill("old")] }] });
|
|
|
|
|
await vi.waitFor(() => expect(listSkills).toHaveBeenCalledTimes(2));
|
2026-07-18 13:11:38 +00:00
|
|
|
expect(listSkills).toHaveBeenNthCalledWith(2, { cwds: ["/vault"], forceReload: false });
|
2026-07-15 04:45:00 +00:00
|
|
|
second.resolve({ data: [{ skills: [catalogSkill("new")] }] });
|
|
|
|
|
await Promise.all([fullRefresh, notificationRefresh]);
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.appServerMetadataSnapshot()?.availableSkills.map((skill) => skill.name)).toEqual(["new"]);
|
2026-07-15 04:45:00 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
it("does not surface cancellation when a newer skills notification replaces an older refresh", async () => {
|
2026-07-15 04:45:00 +00:00
|
|
|
const first = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>();
|
|
|
|
|
const listSkills = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockImplementationOnce(() => first.promise)
|
2026-07-18 13:11:38 +00:00
|
|
|
.mockResolvedValueOnce({ data: [{ skills: [catalogSkill("latest")] }] });
|
2026-07-15 04:45:00 +00:00
|
|
|
const cache = cacheWithRequestHandlers({ "skills/list": listSkills });
|
2026-07-18 13:11:38 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
const unsubscribe = cache.observeAppServerMetadataResources(listener, { emitCurrent: false });
|
2026-07-15 04:45:00 +00:00
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
const older = cache.refreshSkills();
|
2026-07-15 04:45:00 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-18 13:11:38 +00:00
|
|
|
const newer = cache.refreshSkills();
|
2026-07-15 04:45:00 +00:00
|
|
|
first.resolve({ data: [{ skills: [catalogSkill("stale")] }] });
|
|
|
|
|
|
2026-07-18 13:11:38 +00:00
|
|
|
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();
|
2026-07-15 04:45:00 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-16 21:08:53 +00:00
|
|
|
const refresh = cache.refreshAppServerMetadata().finally(() => {
|
2026-07-15 04:45:00 +00:00
|
|
|
settled = true;
|
|
|
|
|
});
|
|
|
|
|
await flushMicrotasks();
|
|
|
|
|
expect(settled).toBe(false);
|
|
|
|
|
|
|
|
|
|
skills.resolve({ data: [{ skills: [] }] });
|
|
|
|
|
await expect(refresh).rejects.toThrow("config offline");
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.appServerMetadataSnapshot()).toBeNull();
|
2026-07-15 04:45:00 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 }),
|
|
|
|
|
});
|
2026-07-16 21:08:53 +00:00
|
|
|
await cache.refreshAppServerMetadata();
|
2026-07-15 04:45:00 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
await expect(cache.refreshAppServerMetadata()).rejects.toThrow("config offline");
|
2026-07-15 04:45:00 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const snapshot = cache.appServerMetadataSnapshot();
|
2026-07-15 04:45:00 +00:00
|
|
|
expect(snapshot?.runtimeConfig).not.toBeNull();
|
|
|
|
|
expect(snapshot?.availableSkills.map((skill) => skill.name)).toEqual(["new"]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-15 21:46:29 +00:00
|
|
|
it("shares in-flight model fetches between metadata and models queries", async () => {
|
|
|
|
|
const modelRefresh = deferred<{ data: CatalogModel[] }>();
|
|
|
|
|
const listModels = vi.fn(() => modelRefresh.promise);
|
2026-06-28 03:19:13 +00:00
|
|
|
const cache = cacheWithRequestHandlers({
|
|
|
|
|
"config/read": vi.fn().mockResolvedValue({}),
|
|
|
|
|
"model/list": listModels,
|
|
|
|
|
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }),
|
2026-07-02 04:43:19 +00:00
|
|
|
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
2026-06-28 03:19:13 +00:00
|
|
|
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }),
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const metadataPromise = cache.refreshAppServerMetadata();
|
2026-06-15 21:46:29 +00:00
|
|
|
await flushMicrotasks();
|
2026-07-16 21:08:53 +00:00
|
|
|
const modelsPromise = cache.fetchModels();
|
2026-06-15 21:46:29 +00:00
|
|
|
await flushMicrotasks();
|
|
|
|
|
|
|
|
|
|
expect(listModels).toHaveBeenCalledOnce();
|
|
|
|
|
|
|
|
|
|
modelRefresh.resolve({ data: [catalogModel("gpt-shared")] });
|
|
|
|
|
|
|
|
|
|
await expect(modelsPromise).resolves.toMatchObject([{ model: "gpt-shared" }]);
|
2026-07-17 22:49:52 +00:00
|
|
|
await expect(metadataPromise).resolves.toBeUndefined();
|
2026-06-15 21:46:29 +00:00
|
|
|
expect(listModels).toHaveBeenCalledOnce();
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-shared"]);
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps query-cached models when app-server metadata model refresh fails", async () => {
|
2026-07-10 03:37:05 +00:00
|
|
|
const listModels = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce({ data: [catalogModel("gpt-cached")] })
|
|
|
|
|
.mockRejectedValueOnce(new Error("offline"));
|
2026-06-28 03:19:13 +00:00
|
|
|
const cache = cacheWithRequestHandlers({
|
|
|
|
|
"config/read": vi.fn().mockResolvedValue({}),
|
2026-07-10 03:37:05 +00:00
|
|
|
"model/list": listModels,
|
2026-06-28 03:19:13 +00:00
|
|
|
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }),
|
2026-07-02 04:43:19 +00:00
|
|
|
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
2026-06-28 03:19:13 +00:00
|
|
|
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }),
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
2026-07-16 21:08:53 +00:00
|
|
|
await cache.fetchModels();
|
2026-06-15 21:46:29 +00:00
|
|
|
|
2026-07-17 22:49:52 +00:00
|
|
|
await cache.refreshAppServerMetadata();
|
|
|
|
|
const metadataSnapshot = cache.appServerMetadataSnapshot();
|
2026-06-15 21:46:29 +00:00
|
|
|
|
2026-06-30 22:43:35 +00:00
|
|
|
expect(metadataSnapshot?.serverDiagnostics.probes.models.status).toBe("failed");
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-cached"]);
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-10 03:09:22 +00:00
|
|
|
it("keeps every last-known-good resource through the full metadata refresh path", async () => {
|
2026-07-10 03:37:05 +00:00
|
|
|
const listModels = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce({ data: [catalogModel("gpt-cached")] })
|
|
|
|
|
.mockRejectedValueOnce(new Error("models offline"));
|
2026-07-15 04:45:00 +00:00
|
|
|
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"));
|
2026-07-10 03:09:22 +00:00
|
|
|
const cache = cacheWithRequestHandlers({
|
|
|
|
|
"config/read": vi.fn().mockResolvedValue({}),
|
2026-07-10 03:37:05 +00:00
|
|
|
"model/list": listModels,
|
2026-07-15 04:45:00 +00:00
|
|
|
"skills/list": listSkills,
|
|
|
|
|
"permissionProfile/list": listProfiles,
|
|
|
|
|
"account/rateLimits/read": readRateLimits,
|
2026-07-10 03:09:22 +00:00
|
|
|
});
|
2026-07-16 21:08:53 +00:00
|
|
|
await cache.refreshAppServerMetadata();
|
2026-07-10 03:09:22 +00:00
|
|
|
|
2026-07-17 22:49:52 +00:00
|
|
|
await cache.refreshAppServerMetadata();
|
|
|
|
|
const refreshed = cache.appServerMetadataSnapshot();
|
2026-07-10 03:09:22 +00:00
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.modelsSnapshot()?.map((model) => model.model)).toEqual(["gpt-cached"]);
|
2026-07-10 03:09:22 +00:00
|
|
|
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" },
|
|
|
|
|
});
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.appServerMetadataSnapshot()).toEqual(refreshed);
|
2026-07-10 03:09:22 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-12 11:13:39 +00:00
|
|
|
it("stores an in-flight app-server snapshot as raw thread-list truth", async () => {
|
2026-06-16 00:43:13 +00:00
|
|
|
const refresh = deferred<readonly ReturnType<typeof thread>[]>();
|
|
|
|
|
const cache = cacheWithThreads(() => refresh.promise);
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
const promise = cache.refreshActiveThreads();
|
2026-06-16 00:43:13 +00:00
|
|
|
await flushMicrotasks();
|
|
|
|
|
|
|
|
|
|
refresh.resolve([thread("thread"), thread("other")]);
|
|
|
|
|
|
2026-07-12 11:13:39 +00:00
|
|
|
await expect(promise).resolves.toEqual([thread("thread"), thread("other")]);
|
2026-07-16 21:08:53 +00:00
|
|
|
expect(cache.activeThreadsSnapshot()).toEqual([thread("thread"), thread("other")]);
|
2026-06-16 12:14:42 +00:00
|
|
|
});
|
2026-06-11 15:03:23 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-20 14:36:23 +00:00
|
|
|
function cacheContext(overrides: Partial<AppServerExecutionContext> = {}): AppServerExecutionContext {
|
2026-06-11 15:03:23 +00:00
|
|
|
return {
|
|
|
|
|
codexPath: "codex",
|
|
|
|
|
vaultPath: "/vault",
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 11:17:33 +00:00
|
|
|
function cacheWithThreads(
|
2026-07-20 14:36:23 +00:00
|
|
|
fetchThreads: (context: AppServerExecutionContext, archived: boolean) => Promise<readonly ReturnType<typeof thread>[]>,
|
|
|
|
|
context: AppServerExecutionContext = cacheContext(),
|
2026-06-15 11:17:33 +00:00
|
|
|
): AppServerQueryCache {
|
2026-07-20 08:45:06 +00:00
|
|
|
const runtimeContext = { ...context };
|
2026-07-16 21:08:53 +00:00
|
|
|
return new AppServerQueryCache(context, {
|
2026-07-20 14:36:23 +00:00
|
|
|
withClient: 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);
|
2026-06-15 11:17:33 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 21:08:53 +00:00
|
|
|
function cacheWithRequestHandlers(
|
|
|
|
|
handlers: Record<string, (params: unknown) => Promise<unknown>>,
|
2026-07-20 14:36:23 +00:00
|
|
|
context: AppServerExecutionContext = cacheContext(),
|
2026-07-16 21:08:53 +00:00
|
|
|
): AppServerQueryCache {
|
2026-06-28 03:19:13 +00:00
|
|
|
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);
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-16 21:08:53 +00:00
|
|
|
return new AppServerQueryCache(context, {
|
2026-07-20 14:36:23 +00:00
|
|
|
withClient: async (operation) => operation(requestClient as never),
|
2026-06-15 21:46:29 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 14:26:14 +00:00
|
|
|
function createCache(clientAccess: AppServerClientAccess): AppServerQueryCache {
|
2026-07-20 14:36:23 +00:00
|
|
|
return new AppServerQueryCache(cacheContext(), clientAccess);
|
2026-07-20 14:26:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 04:43:19 +00:00
|
|
|
function permissionProfile(id: string): RuntimePermissionProfileSummary {
|
|
|
|
|
return { id, description: null, allowed: true };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 21:46:29 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 22:59:56 +00:00
|
|
|
function thread(id: string, archived = false): Thread {
|
2026-06-11 15:03:23 +00:00
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
preview: "",
|
|
|
|
|
name: null,
|
2026-06-20 03:10:43 +00:00
|
|
|
archived,
|
2026-06-11 15:03:23 +00:00
|
|
|
createdAt: 1,
|
|
|
|
|
updatedAt: 1,
|
2026-07-22 00:15:45 +00:00
|
|
|
canAcceptDirectInput: null,
|
2026-07-11 14:51:16 +00:00
|
|
|
provenance: { kind: "interactive" as const },
|
2026-06-11 15:03:23 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Deferred<T> {
|
|
|
|
|
promise: Promise<T>;
|
|
|
|
|
resolve: (value: T) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
|
|
|
let resolve: (value: T) => void = () => undefined;
|
|
|
|
|
const promise = new Promise<T>((settle) => {
|
|
|
|
|
resolve = settle;
|
|
|
|
|
});
|
|
|
|
|
return { promise, resolve };
|
|
|
|
|
}
|
2026-06-15 21:46:29 +00:00
|
|
|
|
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
|
|
|
for (let index = 0; index < 10; index += 1) {
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|