2026-06-24 05:44:16 +00:00
|
|
|
import { describe, expect, it, type Mock, vi } from "vitest";
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-06-30 03:09:14 +00:00
|
|
|
import { AppServerQueryCache } from "../../../../src/app-server/query/cache";
|
2026-07-16 12:44:28 +00:00
|
|
|
import type { AppServerQueryContext, AppServerQueryContextIdentity } from "../../../../src/app-server/query/keys";
|
2026-07-15 04:45:00 +00:00
|
|
|
import type { ObservedResult, ObservedResultListener } from "../../../../src/app-server/query/observed-result";
|
2026-07-16 12:44:28 +00:00
|
|
|
import { AppServerResourceStore } from "../../../../src/app-server/query/resource-store";
|
2026-06-30 03:09:14 +00:00
|
|
|
import type { Thread } from "../../../../src/domain/threads/model";
|
2026-07-06 05:16:52 +00:00
|
|
|
import {
|
|
|
|
|
createThreadCatalog,
|
|
|
|
|
type ThreadCatalog,
|
|
|
|
|
type ThreadCatalogEventObserver,
|
|
|
|
|
} from "../../../../src/features/threads/catalog/thread-catalog";
|
2026-06-20 03:10:43 +00:00
|
|
|
|
|
|
|
|
describe("ThreadCatalog", () => {
|
2026-07-15 04:45:00 +00:00
|
|
|
it("projects active snapshots received from the store observer", () => {
|
2026-06-20 03:10:43 +00:00
|
|
|
const { catalog } = catalogFixture();
|
|
|
|
|
const threads = [thread("thread")];
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, threads);
|
2026-06-20 03:10:43 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual(threads);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: threads }));
|
2026-06-20 03:10:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
it("projects archived snapshots received from the store observer", () => {
|
2026-06-23 08:09:21 +00:00
|
|
|
const { catalog } = catalogFixture();
|
|
|
|
|
const threads = [thread("thread", true)];
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeArchived(listener);
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveArchived(catalog, threads);
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual(threads);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: threads }));
|
2026-06-23 08:09:21 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-20 03:10:43 +00:00
|
|
|
it("refreshes thread snapshots through the cache single-flight and notifies observers once", async () => {
|
|
|
|
|
const fetchThreads = vi.fn().mockResolvedValue([thread("thread")]);
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
|
|
|
|
|
const first = catalog.refreshActive();
|
|
|
|
|
const second = catalog.refreshActive();
|
|
|
|
|
|
|
|
|
|
await expect(first).resolves.toEqual([thread("thread")]);
|
|
|
|
|
await expect(second).resolves.toEqual([thread("thread")]);
|
|
|
|
|
expect(fetchThreads).toHaveBeenCalledOnce();
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("thread")]);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener.mock.calls.filter(([result]) => result.value !== null)).toHaveLength(1);
|
|
|
|
|
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("thread")] }));
|
2026-06-20 03:10:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-27 10:33:32 +00:00
|
|
|
it("notifies applied catalog events through a generic observer", () => {
|
|
|
|
|
const onEventApplied = vi.fn();
|
|
|
|
|
const { catalog } = catalogFixture({ onEventApplied });
|
|
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-started", thread: thread("thread") });
|
|
|
|
|
|
|
|
|
|
expect(onEventApplied).toHaveBeenCalledWith({ type: "thread-started", thread: thread("thread") });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies rename mutations after updating the catalog cache", () => {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-06-20 03:10:43 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("thread"), thread("other")]);
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
catalog.apply({ type: "thread-renamed", threadId: "thread", name: "Renamed" });
|
2026-06-20 03:10:43 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
|
|
|
|
expect(listener).toHaveBeenLastCalledWith(
|
2026-06-26 11:23:37 +00:00
|
|
|
expect.objectContaining({ value: [{ ...thread("thread"), name: "Renamed" }, thread("other")] }),
|
2026-06-20 03:10:43 +00:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-27 10:33:32 +00:00
|
|
|
it("applies archive mutations after updating catalog membership", () => {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-06-20 03:10:43 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
const archivedListener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
catalog.observeArchived(archivedListener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("thread"), thread("other")]);
|
|
|
|
|
receiveArchived(catalog, [thread("archived", true)]);
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-06-30 03:09:14 +00:00
|
|
|
catalog.apply({ type: "thread-archived", threadId: "thread" });
|
2026-06-20 03:10:43 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([{ ...thread("thread"), archived: true }, thread("archived", true)]);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("other")] }));
|
2026-06-20 03:10:43 +00:00
|
|
|
expect(archivedListener).toHaveBeenLastCalledWith(
|
2026-06-26 11:23:37 +00:00
|
|
|
expect.objectContaining({ value: [{ ...thread("thread"), archived: true }, thread("archived", true)] }),
|
2026-06-20 03:10:43 +00:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("refreshes archived snapshots after unknown archive mutations even when an older archived refresh is in flight", async () => {
|
|
|
|
|
const staleArchivedRefresh = deferred<readonly Thread[]>();
|
|
|
|
|
const secondArchivedRefreshStarted = deferred<undefined>();
|
|
|
|
|
let archivedRefreshCount = 0;
|
|
|
|
|
const fetchThreads = vi.fn((_context: { codexPath: string; vaultPath: string }, archived: boolean) => {
|
|
|
|
|
if (!archived) return Promise.resolve([]);
|
|
|
|
|
archivedRefreshCount += 1;
|
|
|
|
|
if (archivedRefreshCount === 1) return staleArchivedRefresh.promise;
|
|
|
|
|
secondArchivedRefreshStarted.resolve(undefined);
|
|
|
|
|
return Promise.resolve([thread("thread", true)]);
|
|
|
|
|
});
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
|
|
|
|
|
|
|
|
|
const staleRefresh = catalog.refreshArchived();
|
2026-06-23 08:09:21 +00:00
|
|
|
catalog.apply({ type: "thread-archived", threadId: "thread" });
|
2026-06-20 03:10:43 +00:00
|
|
|
|
|
|
|
|
staleArchivedRefresh.resolve([thread("old", true)]);
|
|
|
|
|
await staleRefresh;
|
|
|
|
|
await secondArchivedRefreshStarted.promise;
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([thread("thread", true)]);
|
|
|
|
|
});
|
|
|
|
|
expect(fetchThreads).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-27 10:33:32 +00:00
|
|
|
it("applies known delete mutations to cache", () => {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-06-20 03:10:43 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
const archivedListener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
catalog.observeArchived(archivedListener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("thread"), thread("other")]);
|
|
|
|
|
receiveArchived(catalog, [thread("thread", true), thread("archived", true)]);
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
catalog.apply({ type: "thread-deleted", threadId: "thread" });
|
2026-06-20 03:10:43 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([thread("archived", true)]);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("other")] }));
|
|
|
|
|
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("archived", true)] }));
|
2026-06-20 03:10:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-27 10:33:32 +00:00
|
|
|
it("records started, forked, and restored thread membership", () => {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-06-20 22:37:01 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
const archivedListener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
catalog.observeArchived(archivedListener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("existing")]);
|
|
|
|
|
receiveArchived(catalog, [thread("restored", true), thread("archived", true)]);
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started") });
|
|
|
|
|
catalog.apply({ type: "thread-forked", thread: thread("forked") });
|
|
|
|
|
catalog.apply({ type: "thread-restored", thread: thread("restored") });
|
2026-06-20 03:10:43 +00:00
|
|
|
|
2026-06-20 22:37:01 +00:00
|
|
|
expect(catalog.activeSnapshot()?.map((item) => item.id)).toEqual(["restored", "forked", "started", "existing"]);
|
2026-06-20 03:10:43 +00:00
|
|
|
expect(catalog.archivedSnapshot()?.map((item) => item.id)).toEqual(["archived"]);
|
2026-06-20 22:37:01 +00:00
|
|
|
expect(listener).toHaveBeenLastCalledWith(
|
2026-06-26 11:23:37 +00:00
|
|
|
expect.objectContaining({ value: [thread("restored"), thread("forked"), thread("started"), thread("existing")] }),
|
2026-06-20 22:37:01 +00:00
|
|
|
);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("archived", true)] }));
|
2026-06-20 03:10:43 +00:00
|
|
|
});
|
2026-06-23 07:49:46 +00:00
|
|
|
|
|
|
|
|
it("keeps app-server lifecycle threads visible until the server list acknowledges them", async () => {
|
|
|
|
|
const fetchThreads = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce([thread("other")])
|
|
|
|
|
.mockResolvedValueOnce([thread("started"), thread("other")])
|
|
|
|
|
.mockResolvedValueOnce([thread("other")]);
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started") });
|
2026-06-23 07:49:46 +00:00
|
|
|
|
|
|
|
|
await expect(catalog.refreshActive()).resolves.toEqual([thread("started"), thread("other")]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("started"), thread("other")]);
|
|
|
|
|
expect(observedActiveThreadIds(listener)).not.toContainEqual(["other"]);
|
|
|
|
|
|
|
|
|
|
await expect(catalog.refreshActive()).resolves.toEqual([thread("started"), thread("other")]);
|
|
|
|
|
await expect(catalog.refreshActive()).resolves.toEqual([thread("other")]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-16 12:44:28 +00:00
|
|
|
it("starts a fresh lifecycle overlay for every resource lease", () => {
|
|
|
|
|
const { catalog, replaceContext } = catalogFixture({ context: () => ({ codexPath: "codex-a", vaultPath: "/vault" }) });
|
|
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started-a1") });
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("started-a1")]);
|
2026-06-28 02:42:28 +00:00
|
|
|
|
2026-07-16 12:44:28 +00:00
|
|
|
replaceContext({ codexPath: "codex-b", vaultPath: "/vault" });
|
2026-06-28 02:42:28 +00:00
|
|
|
expect(catalog.activeSnapshot()).toBeNull();
|
2026-07-16 12:44:28 +00:00
|
|
|
replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
2026-07-14 12:15:21 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-16 12:44:28 +00:00
|
|
|
it("does not publish connection events captured by an earlier lease", () => {
|
|
|
|
|
const { catalog, contextIdentity, replaceContext } = catalogFixture({
|
|
|
|
|
context: () => ({ codexPath: "codex-a", vaultPath: "/vault" }),
|
2026-07-14 12:15:21 +00:00
|
|
|
});
|
2026-07-16 12:44:28 +00:00
|
|
|
const sourceA1 = contextIdentity();
|
2026-07-12 09:17:13 +00:00
|
|
|
|
2026-07-16 12:44:28 +00:00
|
|
|
replaceContext({ codexPath: "codex-b", vaultPath: "/vault" });
|
|
|
|
|
catalog.applyConnectionEvent(sourceA1, { type: "thread-started", thread: thread("stale-a1") });
|
|
|
|
|
replaceContext({ codexPath: "codex-a", vaultPath: "/vault" });
|
2026-07-12 09:17:13 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toBeNull();
|
2026-07-12 11:13:39 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves raw query status when lifecycle overlays publish", async () => {
|
|
|
|
|
const refresh = deferred<readonly Thread[]>();
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads: () => refresh.promise });
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
const refreshing = catalog.refreshActive();
|
|
|
|
|
await flushMicrotasks();
|
|
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started") });
|
|
|
|
|
|
|
|
|
|
expect(listener).toHaveBeenLastCalledWith({ value: [thread("started")], error: null, isFetching: true });
|
|
|
|
|
refresh.resolve([]);
|
|
|
|
|
await refreshing;
|
2026-07-10 03:44:46 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-26 08:14:12 +00:00
|
|
|
it("keeps rollback fork metadata until active snapshots catch up to the rollback version", () => {
|
|
|
|
|
const { catalog } = catalogFixture();
|
|
|
|
|
const forkBeforeRollback = thread("forked", false, { name: "Before", preview: "Before rollback", updatedAt: 20 });
|
|
|
|
|
const forkAfterRollback = thread("forked", false, { name: "After", preview: "After rollback", updatedAt: 20 });
|
|
|
|
|
const forkAfterFutureUpdate = thread("forked", false, { name: "Future", preview: "Future update", updatedAt: 21 });
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("existing")]);
|
2026-06-26 08:14:12 +00:00
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-forked", thread: forkAfterRollback });
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [forkBeforeRollback, thread("existing")]);
|
2026-06-26 08:14:12 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([forkAfterRollback, thread("existing")]);
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [forkAfterFutureUpdate, thread("existing")]);
|
2026-06-26 08:14:12 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([forkAfterFutureUpdate, thread("existing")]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
it("keeps app-server rename facts when an older active list resolves later", async () => {
|
|
|
|
|
const staleRefresh = deferred<readonly Thread[]>();
|
|
|
|
|
const fetchThreads = vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockReturnValueOnce(staleRefresh.promise)
|
|
|
|
|
.mockResolvedValueOnce([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("thread"), thread("other")]);
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
const refresh = catalog.refreshActive();
|
|
|
|
|
await flushMicrotasks();
|
|
|
|
|
catalog.apply({ type: "thread-renamed", threadId: "thread", name: "Renamed" });
|
|
|
|
|
staleRefresh.resolve([thread("thread"), thread("other")]);
|
|
|
|
|
|
|
|
|
|
await expect(refresh).resolves.toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
|
|
|
|
|
|
|
|
|
await expect(catalog.refreshActive()).resolves.toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps app-server removal facts when older active and archived lists resolve later", async () => {
|
|
|
|
|
const staleActiveRefresh = deferred<readonly Thread[]>();
|
|
|
|
|
const staleArchivedRefresh = deferred<readonly Thread[]>();
|
|
|
|
|
const fetchThreads = vi.fn((_context: { codexPath: string; vaultPath: string }, archived: boolean) =>
|
|
|
|
|
archived ? staleArchivedRefresh.promise : staleActiveRefresh.promise,
|
|
|
|
|
);
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("active"), thread("other")]);
|
|
|
|
|
receiveArchived(catalog, [thread("archived", true), thread("kept", true)]);
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
const activeRefresh = catalog.refreshActive();
|
|
|
|
|
const archivedRefresh = catalog.refreshArchived();
|
|
|
|
|
await flushMicrotasks();
|
|
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-archived", threadId: "active" });
|
|
|
|
|
catalog.apply({ type: "thread-deleted", threadId: "archived" });
|
|
|
|
|
staleActiveRefresh.resolve([thread("active"), thread("other")]);
|
|
|
|
|
staleArchivedRefresh.resolve([thread("archived", true), thread("kept", true)]);
|
|
|
|
|
|
|
|
|
|
await expect(activeRefresh).resolves.toEqual([thread("other")]);
|
|
|
|
|
await expect(archivedRefresh).resolves.toEqual([thread("active", true), thread("kept", true)]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([thread("active", true), thread("kept", true)]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("archives unacknowledged active lifecycle threads without waiting for list acknowledgement", async () => {
|
|
|
|
|
const fetchThreads = vi.fn().mockResolvedValue([thread("other")]);
|
2026-06-27 10:33:32 +00:00
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
2026-06-23 08:09:21 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
const archivedListener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
catalog.observeArchived(archivedListener);
|
|
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started") });
|
|
|
|
|
catalog.apply({ type: "thread-archived", threadId: "started" });
|
|
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([thread("started", true)]);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("started", true)] }));
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
await expect(catalog.refreshActive()).resolves.toEqual([thread("other")]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
|
|
|
|
expect(observedActiveThreadIds(listener)).not.toContainEqual(["started", "other"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("deletes unacknowledged active lifecycle threads", async () => {
|
|
|
|
|
const fetchThreads = vi.fn().mockResolvedValue([thread("other")]);
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
|
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started") });
|
|
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-deleted", threadId: "started" });
|
|
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([]);
|
|
|
|
|
await expect(catalog.refreshActive()).resolves.toEqual([thread("other")]);
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-27 10:33:32 +00:00
|
|
|
it("records active thread touches as catalog ordering facts", () => {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-06-23 07:49:46 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [
|
|
|
|
|
thread("active", false, { updatedAt: 1, recencyAt: 1 }),
|
|
|
|
|
thread("other", false, { updatedAt: 10, recencyAt: 10 }),
|
|
|
|
|
]);
|
2026-06-23 07:49:46 +00:00
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
catalog.apply({ type: "thread-touched", threadId: "active", recencyAt: 20 });
|
2026-06-23 07:49:46 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([
|
|
|
|
|
thread("active", false, { updatedAt: 1, recencyAt: 20 }),
|
|
|
|
|
thread("other", false, { updatedAt: 10, recencyAt: 10 }),
|
|
|
|
|
]);
|
|
|
|
|
expect(listener).toHaveBeenLastCalledWith(
|
|
|
|
|
expect.objectContaining({
|
2026-06-26 11:23:37 +00:00
|
|
|
value: [thread("active", false, { updatedAt: 1, recencyAt: 20 }), thread("other", false, { updatedAt: 10, recencyAt: 10 })],
|
2026-06-23 07:49:46 +00:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
it("applies thread lifecycle events through one catalog event boundary", () => {
|
2026-06-27 10:33:32 +00:00
|
|
|
const { catalog } = catalogFixture();
|
2026-06-23 08:09:21 +00:00
|
|
|
const listener = vi.fn();
|
|
|
|
|
const archivedListener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
catalog.observeArchived(archivedListener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("existing")]);
|
|
|
|
|
receiveArchived(catalog, [thread("archived", true)]);
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-started", thread: thread("started") });
|
|
|
|
|
catalog.apply({ type: "thread-touched", threadId: "existing", recencyAt: 20 });
|
|
|
|
|
catalog.apply({ type: "thread-renamed", threadId: "started", name: "Started" });
|
2026-06-30 03:09:14 +00:00
|
|
|
catalog.apply({ type: "thread-archived", threadId: "existing" });
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([{ ...thread("started"), name: "Started" }]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([thread("existing", true, { recencyAt: 20 }), thread("archived", true)]);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [{ ...thread("started"), name: "Started" }] }));
|
2026-06-23 08:09:21 +00:00
|
|
|
expect(archivedListener).toHaveBeenLastCalledWith(
|
2026-06-26 11:23:37 +00:00
|
|
|
expect.objectContaining({ value: [thread("existing", true, { recencyAt: 20 }), thread("archived", true)] }),
|
2026-06-23 08:09:21 +00:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 05:16:52 +00:00
|
|
|
it("model-checks stale snapshots around unacknowledged rename and archive facts", () => {
|
|
|
|
|
const maxEventDepth = 3;
|
|
|
|
|
|
|
|
|
|
for (const sequence of eventSequences(renameOrderingEvents(), maxEventDepth)) {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("target"), thread("other")]);
|
|
|
|
|
receiveArchived(catalog, []);
|
2026-07-06 05:16:52 +00:00
|
|
|
catalog.apply({ type: "thread-renamed", threadId: "target", name: "Renamed" });
|
|
|
|
|
|
|
|
|
|
let renameAcknowledged = false;
|
|
|
|
|
for (const event of sequence) {
|
|
|
|
|
event.apply(catalog);
|
|
|
|
|
renameAcknowledged = renameAcknowledged || event.acknowledgesRename;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!renameAcknowledged) {
|
|
|
|
|
expectTargetName(catalog.activeSnapshot(), "Renamed", sequenceDescription(sequence));
|
|
|
|
|
expectTargetName(catalog.archivedSnapshot(), "Renamed", sequenceDescription(sequence));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const sequence of eventSequences(archiveOrderingEvents(), maxEventDepth)) {
|
|
|
|
|
const { catalog } = catalogFixture();
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("target"), thread("other")]);
|
|
|
|
|
receiveArchived(catalog, [thread("archived", true)]);
|
2026-07-06 05:16:52 +00:00
|
|
|
catalog.apply({ type: "thread-archived", threadId: "target" });
|
|
|
|
|
|
|
|
|
|
let activeRemovalAcknowledged = false;
|
|
|
|
|
let archivedUpsertAcknowledged = false;
|
|
|
|
|
for (const event of sequence) {
|
|
|
|
|
event.apply(catalog);
|
|
|
|
|
activeRemovalAcknowledged = activeRemovalAcknowledged || event.acknowledgesActiveRemoval;
|
|
|
|
|
archivedUpsertAcknowledged = archivedUpsertAcknowledged || event.acknowledgesArchivedUpsert;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sequenceName = sequenceDescription(sequence);
|
|
|
|
|
if (!activeRemovalAcknowledged) expectNoTarget(catalog.activeSnapshot(), sequenceName);
|
|
|
|
|
if (!archivedUpsertAcknowledged) expectHasArchivedTarget(catalog.archivedSnapshot(), sequenceName);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-23 08:09:21 +00:00
|
|
|
it("moves known unarchived threads through the catalog and refreshes unknown unarchives", async () => {
|
|
|
|
|
const unknownActiveRefreshStarted = deferred<undefined>();
|
|
|
|
|
const unknownArchivedRefreshStarted = deferred<undefined>();
|
|
|
|
|
const fetchThreads = vi.fn((_context: { codexPath: string; vaultPath: string }, archived: boolean) => {
|
|
|
|
|
if (archived) {
|
|
|
|
|
unknownArchivedRefreshStarted.resolve(undefined);
|
|
|
|
|
return Promise.resolve([]);
|
|
|
|
|
}
|
|
|
|
|
unknownActiveRefreshStarted.resolve(undefined);
|
|
|
|
|
return Promise.resolve([thread("unknown")]);
|
|
|
|
|
});
|
|
|
|
|
const { catalog } = catalogFixture({ fetchThreads });
|
|
|
|
|
const listener = vi.fn();
|
|
|
|
|
const archivedListener = vi.fn();
|
|
|
|
|
catalog.observeActive(listener);
|
|
|
|
|
catalog.observeArchived(archivedListener);
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("active")]);
|
|
|
|
|
receiveArchived(catalog, [thread("known", true)]);
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-unarchived", threadId: "known" });
|
|
|
|
|
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("known"), thread("active")]);
|
|
|
|
|
expect(catalog.archivedSnapshot()).toEqual([]);
|
2026-06-26 11:23:37 +00:00
|
|
|
expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [thread("known"), thread("active")] }));
|
|
|
|
|
expect(archivedListener).toHaveBeenLastCalledWith(expect.objectContaining({ value: [] }));
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
catalog.apply({ type: "thread-unarchived", threadId: "unknown" });
|
|
|
|
|
|
|
|
|
|
await unknownActiveRefreshStarted.promise;
|
|
|
|
|
await unknownArchivedRefreshStarted.promise;
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(catalog.activeSnapshot()).toEqual([thread("known"), thread("unknown")]);
|
|
|
|
|
});
|
|
|
|
|
expect(fetchThreads).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
2026-06-20 03:10:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 05:16:52 +00:00
|
|
|
interface ModeledCatalogEvent {
|
|
|
|
|
readonly name: string;
|
|
|
|
|
readonly acknowledgesRename: boolean;
|
|
|
|
|
readonly acknowledgesActiveRemoval: boolean;
|
|
|
|
|
readonly acknowledgesArchivedUpsert: boolean;
|
|
|
|
|
apply(catalog: ThreadCatalog): void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renameOrderingEvents(): readonly ModeledCatalogEvent[] {
|
|
|
|
|
return [
|
|
|
|
|
modeledCatalogEvent("stale active snapshot", (catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("target"), thread("other")]);
|
2026-07-06 05:16:52 +00:00
|
|
|
}),
|
|
|
|
|
modeledCatalogEvent(
|
|
|
|
|
"rename-ack active snapshot",
|
|
|
|
|
(catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [{ ...thread("target"), name: "Renamed" }, thread("other")]);
|
2026-07-06 05:16:52 +00:00
|
|
|
},
|
|
|
|
|
{ acknowledgesRename: true },
|
|
|
|
|
),
|
|
|
|
|
modeledCatalogEvent("empty archived snapshot", (catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveArchived(catalog, []);
|
2026-07-06 05:16:52 +00:00
|
|
|
}),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function archiveOrderingEvents(): readonly ModeledCatalogEvent[] {
|
|
|
|
|
return [
|
|
|
|
|
modeledCatalogEvent("stale active snapshot", (catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("target"), thread("other")]);
|
2026-07-06 05:16:52 +00:00
|
|
|
}),
|
|
|
|
|
modeledCatalogEvent(
|
|
|
|
|
"archive-ack active snapshot",
|
|
|
|
|
(catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveActive(catalog, [thread("other")]);
|
2026-07-06 05:16:52 +00:00
|
|
|
},
|
|
|
|
|
{ acknowledgesActiveRemoval: true },
|
|
|
|
|
),
|
|
|
|
|
modeledCatalogEvent("stale archived snapshot", (catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveArchived(catalog, [thread("archived", true)]);
|
2026-07-06 05:16:52 +00:00
|
|
|
}),
|
|
|
|
|
modeledCatalogEvent(
|
|
|
|
|
"archive-ack archived snapshot",
|
|
|
|
|
(catalog) => {
|
2026-07-15 04:45:00 +00:00
|
|
|
receiveArchived(catalog, [thread("target", true), thread("archived", true)]);
|
2026-07-06 05:16:52 +00:00
|
|
|
},
|
|
|
|
|
{ acknowledgesArchivedUpsert: true },
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modeledCatalogEvent(
|
|
|
|
|
name: string,
|
|
|
|
|
apply: (catalog: ThreadCatalog) => void,
|
|
|
|
|
acknowledgements: Partial<
|
|
|
|
|
Pick<ModeledCatalogEvent, "acknowledgesRename" | "acknowledgesActiveRemoval" | "acknowledgesArchivedUpsert">
|
|
|
|
|
> = {},
|
|
|
|
|
): ModeledCatalogEvent {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
apply,
|
|
|
|
|
acknowledgesRename: acknowledgements.acknowledgesRename ?? false,
|
|
|
|
|
acknowledgesActiveRemoval: acknowledgements.acknowledgesActiveRemoval ?? false,
|
|
|
|
|
acknowledgesArchivedUpsert: acknowledgements.acknowledgesArchivedUpsert ?? false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function eventSequences<T>(events: readonly T[], maxDepth: number): T[][] {
|
|
|
|
|
const sequences: T[][] = [[]];
|
|
|
|
|
for (let depth = 1; depth <= maxDepth; depth += 1) {
|
|
|
|
|
for (const prefix of sequences.filter((sequence) => sequence.length === depth - 1)) {
|
|
|
|
|
for (const event of events) {
|
|
|
|
|
sequences.push([...prefix, event]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return sequences;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectTargetName(threads: readonly Thread[] | null, expectedName: string, sequence: string): void {
|
|
|
|
|
const target = threads?.find((item) => item.id === "target") ?? null;
|
|
|
|
|
if (!target) return;
|
|
|
|
|
expect(target.name, sequence).toBe(expectedName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectNoTarget(threads: readonly Thread[] | null, sequence: string): void {
|
|
|
|
|
expect(threads?.some((item) => item.id === "target") ?? false, sequence).toBe(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expectHasArchivedTarget(threads: readonly Thread[] | null, sequence: string): void {
|
|
|
|
|
expect(threads?.some((item) => item.id === "target" && item.archived) ?? false, sequence).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sequenceDescription(sequence: readonly ModeledCatalogEvent[]): string {
|
|
|
|
|
return sequence.length === 0 ? "no additional snapshots" : sequence.map((event) => event.name).join(" -> ");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
const catalogStores = new WeakMap<ThreadCatalog, TestThreadCatalogStore>();
|
|
|
|
|
|
|
|
|
|
function receiveActive(catalog: ThreadCatalog, threads: readonly Thread[]): void {
|
|
|
|
|
catalogStores.get(catalog)?.receiveActive(threads);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function receiveArchived(catalog: ThreadCatalog, threads: readonly Thread[]): void {
|
|
|
|
|
catalogStores.get(catalog)?.receiveArchived(threads);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 03:10:43 +00:00
|
|
|
function catalogFixture(
|
2026-06-27 10:33:32 +00:00
|
|
|
options: {
|
|
|
|
|
fetchThreads?: (context: { codexPath: string; vaultPath: string }, archived: boolean) => Promise<readonly Thread[]>;
|
2026-06-28 02:42:28 +00:00
|
|
|
context?: () => { codexPath: string; vaultPath: string };
|
2026-06-27 10:33:32 +00:00
|
|
|
onEventApplied?: ThreadCatalogEventObserver;
|
|
|
|
|
} = {},
|
2026-06-20 03:10:43 +00:00
|
|
|
) {
|
2026-07-12 09:17:13 +00:00
|
|
|
const cache = cacheWithThreads(options.fetchThreads ?? (() => Promise.resolve([])));
|
2026-07-15 04:45:00 +00:00
|
|
|
const context = options.context ?? (() => ({ codexPath: "codex", vaultPath: "/vault" }));
|
2026-07-16 12:44:28 +00:00
|
|
|
const queries = new AppServerResourceStore({ cache });
|
|
|
|
|
queries.initialize(context());
|
|
|
|
|
const store = new TestThreadCatalogStore(queries);
|
2026-06-27 10:33:32 +00:00
|
|
|
const catalog = createThreadCatalog(
|
|
|
|
|
options.onEventApplied
|
|
|
|
|
? {
|
|
|
|
|
store,
|
|
|
|
|
onEventApplied: options.onEventApplied,
|
|
|
|
|
}
|
|
|
|
|
: { store },
|
|
|
|
|
);
|
2026-07-15 04:45:00 +00:00
|
|
|
catalogStores.set(catalog, store);
|
|
|
|
|
catalog.observeActive(() => undefined);
|
|
|
|
|
catalog.observeArchived(() => undefined);
|
2026-07-16 12:44:28 +00:00
|
|
|
return {
|
|
|
|
|
cache,
|
|
|
|
|
catalog,
|
|
|
|
|
contextIdentity: () => queries.contextIdentity(),
|
|
|
|
|
replaceContext: (nextContext: AppServerQueryContext) => queries.replaceContext(nextContext),
|
|
|
|
|
};
|
2026-06-20 03:10:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 04:45:00 +00:00
|
|
|
class TestThreadCatalogStore {
|
|
|
|
|
private readonly activeSnapshots = new Map<string, readonly Thread[]>();
|
|
|
|
|
private readonly archivedSnapshots = new Map<string, readonly Thread[]>();
|
|
|
|
|
private readonly activeObservers = new Set<ObservedResultListener<readonly Thread[]>>();
|
|
|
|
|
private readonly archivedObservers = new Set<ObservedResultListener<readonly Thread[]>>();
|
|
|
|
|
|
2026-07-16 12:44:28 +00:00
|
|
|
constructor(private readonly queries: AppServerResourceStore) {}
|
2026-07-15 04:45:00 +00:00
|
|
|
|
|
|
|
|
contextKey(): string {
|
2026-07-16 12:44:28 +00:00
|
|
|
return this.queries.contextKey();
|
2026-07-15 04:45:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 12:44:28 +00:00
|
|
|
contextKeyFor(context: AppServerQueryContextIdentity): string {
|
|
|
|
|
return this.queries.contextKeyFor(context);
|
2026-07-15 04:45:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activeThreadsSnapshot(): readonly Thread[] | null {
|
|
|
|
|
return this.activeSnapshots.get(this.contextKey()) ?? this.queries.activeThreadsSnapshot();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
archivedThreadsSnapshot(): readonly Thread[] | null {
|
|
|
|
|
return this.archivedSnapshots.get(this.contextKey()) ?? this.queries.archivedThreadsSnapshot();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fetchAllActiveThreads(): Promise<readonly Thread[]> {
|
|
|
|
|
return this.receiveLoadedActive(await this.queries.fetchAllActiveThreads());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hasMoreActiveThreads(): boolean {
|
|
|
|
|
return this.queries.hasMoreActiveThreads();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
|
|
|
|
|
return this.receiveLoadedActive(await this.queries.loadMoreActiveThreads());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async refreshActiveThreads(): Promise<readonly Thread[]> {
|
|
|
|
|
return this.receiveLoadedActive(await this.queries.refreshActiveThreads());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async refreshArchivedThreads(): Promise<readonly Thread[]> {
|
|
|
|
|
return this.receiveLoadedArchived(await this.queries.refreshArchivedThreads());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
observeActiveThreadsResult(observer: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
|
|
|
|
this.activeObservers.add(observer);
|
|
|
|
|
const unsubscribe = this.queries.observeActiveThreadsResult((result) => {
|
|
|
|
|
if (result.value && !result.isFetching) this.activeSnapshots.delete(this.contextKey());
|
|
|
|
|
observer(result);
|
|
|
|
|
}, options);
|
|
|
|
|
return () => {
|
|
|
|
|
this.activeObservers.delete(observer);
|
|
|
|
|
unsubscribe();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
observeArchivedThreadsResult(observer: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
|
|
|
|
|
this.archivedObservers.add(observer);
|
|
|
|
|
const unsubscribe = this.queries.observeArchivedThreadsResult((result) => {
|
|
|
|
|
if (result.value && !result.isFetching) this.archivedSnapshots.delete(this.contextKey());
|
|
|
|
|
observer(result);
|
|
|
|
|
}, options);
|
|
|
|
|
return () => {
|
|
|
|
|
this.archivedObservers.delete(observer);
|
|
|
|
|
unsubscribe();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
receiveActive(threads: readonly Thread[]): void {
|
|
|
|
|
this.activeSnapshots.set(this.contextKey(), threads);
|
|
|
|
|
const result = observedSnapshot(threads);
|
|
|
|
|
for (const observer of this.activeObservers) observer(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
receiveArchived(threads: readonly Thread[]): void {
|
|
|
|
|
this.archivedSnapshots.set(this.contextKey(), threads);
|
|
|
|
|
const result = observedSnapshot(threads);
|
|
|
|
|
for (const observer of this.archivedObservers) observer(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private receiveLoadedActive(threads: readonly Thread[]): readonly Thread[] {
|
|
|
|
|
this.activeSnapshots.delete(this.contextKey());
|
|
|
|
|
return threads;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private receiveLoadedArchived(threads: readonly Thread[]): readonly Thread[] {
|
|
|
|
|
this.archivedSnapshots.delete(this.contextKey());
|
|
|
|
|
return threads;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function observedSnapshot(threads: readonly Thread[]): ObservedResult<readonly Thread[]> {
|
|
|
|
|
return { value: threads, error: null, isFetching: false };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 03:10:43 +00:00
|
|
|
function cacheWithThreads(
|
|
|
|
|
fetchThreads: (context: { codexPath: string; vaultPath: string }, archived: boolean) => Promise<readonly Thread[]>,
|
|
|
|
|
): AppServerQueryCache {
|
|
|
|
|
return new AppServerQueryCache({
|
|
|
|
|
clientRunner: {
|
|
|
|
|
runWithClient: async (context, operation) => {
|
|
|
|
|
return operation({
|
2026-06-28 02:44:08 +00:00
|
|
|
request: async (method: string, params: { archived?: boolean } = {}) => {
|
|
|
|
|
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
|
|
|
|
|
return {
|
|
|
|
|
data: await fetchThreads(context, params.archived ?? false),
|
|
|
|
|
nextCursor: null,
|
|
|
|
|
};
|
|
|
|
|
},
|
2026-06-20 03:10:43 +00:00
|
|
|
} as never);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 07:49:46 +00:00
|
|
|
function observedActiveThreadIds(listener: Mock): string[][] {
|
|
|
|
|
return listener.mock.calls
|
|
|
|
|
.map((call) => {
|
2026-06-26 11:23:37 +00:00
|
|
|
const result = call[0] as { value: readonly Thread[] | null };
|
|
|
|
|
return result.value?.map((item) => item.id) ?? null;
|
2026-06-23 07:49:46 +00:00
|
|
|
})
|
|
|
|
|
.filter((ids): ids is string[] => ids !== null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function thread(id: string, archived = false, overrides: Partial<Thread> = {}): Thread {
|
2026-06-20 03:10:43 +00:00
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
preview: id,
|
|
|
|
|
createdAt: 1,
|
|
|
|
|
updatedAt: 1,
|
|
|
|
|
name: null,
|
|
|
|
|
archived,
|
2026-07-11 14:51:16 +00:00
|
|
|
provenance: { kind: "interactive" },
|
2026-06-23 07:49:46 +00:00
|
|
|
...overrides,
|
2026-06-20 03:10:43 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deferred<T>(): {
|
|
|
|
|
promise: Promise<T>;
|
|
|
|
|
resolve: (value: T) => void;
|
|
|
|
|
reject: (reason?: unknown) => void;
|
|
|
|
|
} {
|
|
|
|
|
let resolve!: (value: T) => void;
|
|
|
|
|
let reject!: (reason?: unknown) => void;
|
|
|
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
|
|
|
resolve = promiseResolve;
|
|
|
|
|
reject = promiseReject;
|
|
|
|
|
});
|
|
|
|
|
return { promise, resolve, reject };
|
|
|
|
|
}
|
2026-06-23 08:09:21 +00:00
|
|
|
|
|
|
|
|
async function flushMicrotasks(): Promise<void> {
|
|
|
|
|
for (let index = 0; index < 10; index += 1) {
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
}
|