1import {
2 EnvironmentId,
3 ProviderInstanceId,
4 ProviderDriverKind,
5 type ServerConfig,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";
9import { Atom, AtomRegistry } from "effect/reactivity";
10import { createThreadListEnvironmentsAtom } from "./thread-list-environments";
11
12const ID = EnvironmentId.make("one");
13const OTHER_ID = EnvironmentId.make("two");
14const provider: ServerProvider = {
15 instanceId: ProviderInstanceId.make("codex"),
16 driver: ProviderDriverKind.make("codex"),
17 enabled: true,
18 installed: true,
19 version: null,
20 status: "ready",
21 auth: { status: "authenticated" },
22 checkedAt: "2026-09-01T00:00:00.000Z",
23 models: [],
24 slashCommands: [],
25 skills: [],
26};
27const config = {
28 providers: [provider],
29 environment: { capabilities: {}, platform: { machine: "laptop" } },
30 settings: {},
31} as unknown as ServerConfig;
32
33function harness() {
34 const registry = AtomRegistry.make();
35 const configs = Atom.make<ReadonlyMap<EnvironmentId, ServerConfig>>(
36 new Map([
37 [ID, config],
38 [OTHER_ID, config],
39 ]),
40 );
41 const capabilities = Atom.make(
42 (get) =>
43 new Map([...get(configs)].map(([id, config]) => [id, config.environment.capabilities])),
44 );
45 const list = createThreadListEnvironmentsAtom(configs, capabilities);
46 let sourceNotifications = 0;
47 let listNotifications = 0;
48 registry.subscribe(configs, () => sourceNotifications++);
49 registry.subscribe(list, () => listNotifications++);
50 registry.get(list);
51 sourceNotifications = 0;
52 listNotifications = 0;
53 return {
54 registry,
55 configs,
56 list,
57 read: () => registry.get(list),
58 write: (next: ServerConfig) =>
59 registry.set(
60 configs,
61 new Map([
62 [ID, next],
63 [OTHER_ID, config],
64 ]),
65 ),
66 notifications: () => ({ source: sourceNotifications, list: listNotifications }),
67 };
68}
69
70describe("thread list environment projection", () => {
71 it("keeps navigation stable while freshness, catalog and workspace updates reach full-config consumers", () => {
72 const h = harness();
73 try {
74 const initial = h.read();
75 const changed: ServerConfig = {
76 ...config,
77 providers: [
78 {
79 ...provider,
80 checkedAt: "2026-09-02T00:00:00.000Z",
81 usageLimits: { checkedAt: "2026-09-02T00:00:00.000Z", windows: [] },
82 models: [{ slug: "new-model", name: "New model", isCustom: false, capabilities: null }],
83 workspaceSnapshots: [
84 {
85 cwd: "/repo",
86 checkedAt: "2026-09-02T00:00:00.000Z",
87 slashCommands: [],
88 skills: [],
89 },
90 ],
91 },
92 ],
93 };
94 h.write(changed);
95 expect(h.read()).toBe(initial);
96 expect(h.registry.get(h.configs).get(ID)?.providers[0]?.usageLimits?.checkedAt).toBe(
97 "2026-09-02T00:00:00.000Z",
98 );
99 expect(h.notifications()).toEqual({ source: 1, list: 0 });
100 } finally {
101 h.registry.dispose();
102 }
103 });
104
105 it.each([
106 ["threadSettlement", "settlementEnvironmentIds"],
107 ["threadSnooze", "snoozeEnvironmentIds"],
108 ["threadPinning", "pinningEnvironmentIds"],
109 ["threadPinReorder", "pinReorderEnvironmentIds"],
110 ["threadActiveReorder", "activeReorderEnvironmentIds"],
111 ["threadTitleRegeneration", "titleRegenerationEnvironmentIds"],
112 ] as const)("tracks enabling and removing %s", (capability, collection) => {
113 const h = harness();
114 try {
115 h.write({
116 ...config,
117 environment: {
118 ...config.environment,
119 capabilities: { ...config.environment.capabilities, [capability]: true },
120 },
121 });
122 expect([...h.read()[collection]]).toEqual([ID]);
123 h.write({
124 ...config,
125 environment: {
126 ...config.environment,
127 capabilities: { ...config.environment.capabilities, [capability]: false },
128 },
129 });
130 expect(h.read()[collection].size).toBe(0);
131 const disabled = h.read();
132 h.write(config);
133 expect(h.read()).toBe(disabled);
134 expect(h.notifications().list).toBe(2);
135 } finally {
136 h.registry.dispose();
137 }
138 });
139
140 it.each([
141 ["instanceId", ProviderInstanceId.make("other")],
142 ["driver", ProviderDriverKind.make("claudeAgent")],
143 ["displayName", "Work account"],
144 ["accentColor", "#123456"],
145 ["iconUrl", "https://example.test/icon.png"],
146 ] as const)(
147 "updates and restores provider %s without changing another environment's providers",
148 (key, value) => {
149 const h = harness();
150 try {
151 const otherProviders = h.read().providersByEnvironmentId.get(OTHER_ID);
152 h.write({ ...config, providers: [{ ...provider, [key]: value }] });
153 expect(h.read().providersByEnvironmentId.get(ID)?.[0]?.[key]).toBe(value);
154 expect(h.read().providersByEnvironmentId.get(OTHER_ID)).toBe(otherProviders);
155 h.write(config);
156 expect(h.read().providersByEnvironmentId.get(ID)?.[0]?.[key]).toBe(provider[key]);
157 expect(h.notifications().list).toBe(2);
158 } finally {
159 h.registry.dispose();
160 }
161 },
162 );
163
164 it("tracks provider membership and order in both directions", () => {
165 const h = harness();
166 try {
167 const second = { ...provider, instanceId: ProviderInstanceId.make("work") };
168 h.write({ ...config, providers: [provider, second] });
169 expect(
170 h
171 .read()
172 .providersByEnvironmentId.get(ID)
173 ?.map((p) => p.instanceId),
174 ).toEqual(["codex", "work"]);
175 h.write({ ...config, providers: [second, provider] });
176 expect(
177 h
178 .read()
179 .providersByEnvironmentId.get(ID)
180 ?.map((p) => p.instanceId),
181 ).toEqual(["work", "codex"]);
182 h.write({ ...config, providers: [] });
183 expect(h.read().providersByEnvironmentId.get(ID)).toEqual([]);
184 h.write(config);
185 expect(
186 h
187 .read()
188 .providersByEnvironmentId.get(ID)
189 ?.map((p) => p.instanceId),
190 ).toEqual(["codex"]);
191 expect(h.notifications().list).toBe(4);
192 } finally {
193 h.registry.dispose();
194 }
195 });
196
197 it("restores detected machine kind after removing an override", () => {
198 const h = harness();
199 try {
200 expect(h.read().machineByEnvironmentId.get(ID)).toBe("laptop");
201 h.write({ ...config, settings: { ...config.settings, environmentIcon: "server" } });
202 expect(h.read().machineByEnvironmentId.get(ID)).toBe("server");
203 h.write(config);
204 expect(h.read().machineByEnvironmentId.get(ID)).toBe("laptop");
205 const detected = h.read();
206 h.write({ ...config, settings: { ...config.settings, environmentIcon: "laptop" } });
207 expect(h.read()).toBe(detected);
208 } finally {
209 h.registry.dispose();
210 }
211 });
212
213 it("removes forgotten environments and repopulates them after reconnect", () => {
214 const h = harness();
215 try {
216 h.registry.set(h.configs, new Map());
217 expect(h.read().providersByEnvironmentId.size).toBe(0);
218 expect(h.read().machineByEnvironmentId.size).toBe(0);
219 h.write(config);
220 expect([...h.read().providersByEnvironmentId.keys()]).toEqual([ID, OTHER_ID]);
221 expect(h.notifications().list).toBe(2);
222 } finally {
223 h.registry.dispose();
224 }
225 });
226});
227
228it("shows organization affordances on a new client without a source server configuration", () => {
229 const registry = AtomRegistry.make();
230 try {
231 const configs = Atom.make<ReadonlyMap<EnvironmentId, ServerConfig>>(new Map());
232 const capabilities = Atom.make(
233 new Map([
234 [
235 ID,
236 {
237 ...config.environment.capabilities,
238 threadSettlement: true,
239 threadSnooze: true,
240 threadPinning: true,
241 threadPinReorder: true,
242 threadActiveReorder: true,
243 threadAutoSettleOptOut: true,
244 },
245 ],
246 ]),
247 );
248 const list = createThreadListEnvironmentsAtom(configs, capabilities);
249 const result = registry.get(list);
250 expect([...result.settlementEnvironmentIds]).toEqual([ID]);
251 expect([...result.snoozeEnvironmentIds]).toEqual([ID]);
252 expect([...result.pinningEnvironmentIds]).toEqual([ID]);
253 expect([...result.pinReorderEnvironmentIds]).toEqual([ID]);
254 expect([...result.activeReorderEnvironmentIds]).toEqual([ID]);
255 expect(result.titleRegenerationEnvironmentIds.size).toBe(0);
256 expect(result.providersByEnvironmentId.get(ID)).toEqual([]);
257 expect(registry.get(configs).size).toBe(0);
258 } finally {
259 registry.dispose();
260 }
261});