Skip to content
Source
Sign inSign up

thread-provider-instance.test.ts

302 lines10.8 KB

1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 EnvironmentId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 type ServerConfig,
8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";
10import { Atom, AtomRegistry } from "effect/reactivity";
11​
12import { computeThreadMoveAvailability, createThreadMovePlanner } from "./threadOrder";
13import { makeThreadShellFixture } from "../../test-fixtures";
14import { createThreadListEnvironmentsAtom } from "../../state/thread-list-environments";
15​
16import {
17 createThreadRowProviderInstanceResolver,
18 resolveThreadProviderInstance,
19} from "./thread-provider-instance";
20​
21function makeConfig(
22 providers: ReadonlyArray<{
23 readonly instanceId: string;
24 readonly driver: string;
25 readonly displayName?: string;
26 readonly accentColor?: string;
27 }>,
28): ServerConfig {
29 return {
30 providers: providers.map((provider) => ({ enabled: true, ...provider })),
31 environment: { capabilities: {}, platform: { machine: "laptop" } },
32 settings: {},
33 } as unknown as ServerConfig;
35​
36function makeThread(environmentId: EnvironmentId, instanceId: string): EnvironmentThreadShell {
37 return {
38 environmentId,
39 id: ThreadId.make("thread-1"),
40 projectId: ProjectId.make("project-1"),
41 title: "Thread",
42 modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model: "gpt-5.4" },
43 runtimeMode: "full-access",
44 interactionMode: "default",
45 branch: null,
46 worktreePath: null,
47 latestRun: null,
48 createdAt: "2026-06-01T00:00:00.000Z",
49 updatedAt: "2026-06-01T00:00:00.000Z",
50 archivedAt: null,
51 settledOverride: null,
52 settledAt: null,
53 runtime: null,
54 latestUserMessageAt: null,
55 hasPendingApprovals: false,
56 hasPendingUserInput: false,
57 hasActionableProposedPlan: false,
58 } as unknown as EnvironmentThreadShell;
60​
61describe("resolveThreadProviderInstance", () => {
62 it("resolves two environments with the same default instance id independently", () => {
63 const environmentA = EnvironmentId.make("environment-a");
64 const environmentB = EnvironmentId.make("environment-b");
65 const serverConfigs = new Map<EnvironmentId, ServerConfig>([
66 [
67 environmentA,
68 makeConfig([{ instanceId: "codex", driver: "codex", accentColor: "#ff8800" }]),
69 ],
70 [environmentB, makeConfig([{ instanceId: "codex", driver: "codex" }])],
71 ]);
72​
73 const threadA = makeThread(environmentA, "codex");
74 const threadB = makeThread(environmentB, "codex");
75​
76 expect(
77 resolveThreadProviderInstance(serverConfigs.get(environmentA)?.providers, threadA)
78 ?.accentColor,
79 ).toBe("#ff8800");
80 expect(
81 resolveThreadProviderInstance(serverConfigs.get(environmentB)?.providers, threadB)
82 ?.accentColor,
83 ).toBeUndefined();
84 });
85​
86 it("uses the current runtime owner after a provider handoff", () => {
87 const environmentId = EnvironmentId.make("environment-a");
88 const serverConfigs = new Map<EnvironmentId, ServerConfig>([
89 [
90 environmentId,
91 makeConfig([
92 { instanceId: "claudeAgent", driver: "claudeAgent" },
93 { instanceId: "codex", driver: "codex", displayName: "Codex" },
94 { instanceId: "codex_work", driver: "codex", displayName: "Codex" },
95 ]),
96 ],
97 ]);
98 const thread = {
99 ...makeThread(environmentId, "claudeAgent"),
100 runtime: {
101 status: "running" as const,
102 activeRunId: null,
103 providerInstanceId: ProviderInstanceId.make("codex_work"),
104 providerName: "Codex",
105 lastError: null,
106 updatedAt: "2026-06-01T00:01:00.000Z",
107 },
108 };
109​
110 expect(
111 resolveThreadProviderInstance(serverConfigs.get(environmentId)?.providers, thread),
112 ).toMatchObject({
113 driverKind: "codex",
114 displayName: "Codex Work",
115 showBadge: true,
116 });
117 });
118});
119​
120describe("createThreadRowProviderInstanceResolver", () => {
121 it("updates thread badges through the environment projection when a sibling is disabled and re-enabled", () => {
122 const environmentId = EnvironmentId.make("environment-a");
123 const personal = makeThread(environmentId, "codex");
124 const work = makeThread(environmentId, "codex_work");
125 const config = makeConfig([
126 { instanceId: "codex", driver: "codex" },
127 { instanceId: "codex_work", driver: "codex" },
128 ]);
129 const configs = Atom.make<ReadonlyMap<EnvironmentId, ServerConfig>>(
130 new Map([[environmentId, config]]),
131 );
132 const environments = createThreadListEnvironmentsAtom(configs, Atom.make([environmentId]));
133 const registry = AtomRegistry.make();
134 registry.mount(environments);
135 try {
136 const before = createThreadRowProviderInstanceResolver(
137 registry.get(environments).providersByEnvironmentId,
138 );
139 expect(before(personal)?.showBadge).toBe(true);
140 expect(before(work)?.showBadge).toBe(true);
141​
142 registry.set(
143 configs,
144 new Map([
145 [
146 environmentId,
147 {
148 ...config,
149 providers: config.providers.map((provider) =>
150 provider.instanceId === "codex_work" ? { ...provider, enabled: false } : provider,
151 ),
152 },
153 ],
154 ]),
155 );
156 const disabled = createThreadRowProviderInstanceResolver(
157 registry.get(environments).providersByEnvironmentId,
158 );
159 expect(disabled(personal)?.showBadge).toBe(false);
160 expect(disabled(work)?.showBadge).toBe(true);
161​
162 registry.set(configs, new Map([[environmentId, config]]));
163 const restored = createThreadRowProviderInstanceResolver(
164 registry.get(environments).providersByEnvironmentId,
165 );
166 expect(restored(personal)?.showBadge).toBe(true);
167 expect(restored(work)?.showBadge).toBe(true);
168 } finally {
169 registry.dispose();
170 }
171 });
172});
173​
174describe("thread list reorder availability", () => {
175 it.each(["active", "pinned"] as const)(
176 "updates %s moves on disconnect and reconnect without changing cached config",
177 (section) => {
178 const onlineId = EnvironmentId.make("online");
179 const offlineId = EnvironmentId.make("offline");
180 const config = makeConfig([{ instanceId: "codex", driver: "codex" }]);
181 const configs = Atom.make<ReadonlyMap<EnvironmentId, ServerConfig>>(
182 new Map([
183 [onlineId, config],
184 [offlineId, config],
185 ]),
186 );
187 const connected = Atom.make<ReadonlyArray<EnvironmentId>>([onlineId, offlineId]);
188 const environments = createThreadListEnvironmentsAtom(configs, connected);
189 const registry = AtomRegistry.make();
190 registry.mount(environments);
191 try {
192 const ordered = [onlineId, offlineId].map((environmentId, index) =>
193 makeThreadShellFixture({
194 environmentId,
195 id: ThreadId.make(`thread-${index}`),
196 pinnedAt: section === "pinned" ? "2026-09-02T00:00:00.000Z" : null,
197 }),
198 );
199 const movedId = `${onlineId}:thread-0`;
200 const plannerInput = () => {
201 const current = registry.get(environments);
202 return {
203 ordered,
204 section,
205 reorderableEnvironmentIds:
206 section === "pinned"
207 ? current.pinReorderEnvironmentIds
208 : current.activeReorderEnvironmentIds,
209 };
210 };
211 expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toHaveLength(2);
212 const before = registry.get(environments);
213​
214 registry.set(connected, [onlineId]);
215​
216 expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull();
217 expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(false);
218 expect(registry.get(environments).pinningEnvironmentIds.has(offlineId)).toBe(true);
219 expect(registry.get(environments).settlementEnvironmentIds.has(offlineId)).toBe(true);
220 expect(registry.get(environments).providersByEnvironmentId.get(offlineId)).toBe(
221 before.providersByEnvironmentId.get(offlineId),
222 );
223​
224 const keyedInput = {
225 ...plannerInput(),
226 ordered: ordered.map((row, index) => ({
227 ...row,
228 pinOrderKey: section === "pinned" ? (index === 0 ? "bb" : "dd") : null,
229 activeOrderKey: section === "active" ? (index === 0 ? "bb" : "dd") : null,
230 })),
231 };
232 expect(createThreadMovePlanner(keyedInput)(movedId, "down")).toEqual([
233 expect.objectContaining({ id: movedId }),
234 ]);
235 expect(createThreadMovePlanner(keyedInput)(`${offlineId}:thread-1`, "up")).toBeNull();
236 expect(computeThreadMoveAvailability(keyedInput).get(movedId)?.canMoveDown).toBe(true);
237 expect(computeThreadMoveAvailability(keyedInput).get(`${offlineId}:thread-1`)).toEqual({
238 canMoveUp: false,
239 canMoveDown: false,
240 });
241​
242 const updatedConfig = {
243 ...config,
244 settings: {
245 ...config.settings,
246 snoozePresets: [{ kind: "after" as const, minutes: 90 }],
247 },
248 };
249 registry.set(
250 configs,
251 new Map([
252 [onlineId, config],
253 [offlineId, updatedConfig],
254 ]),
255 );
256​
257 const updated = registry.get(environments);
258 expect(updated.snoozePresetsByEnvironmentId.get(offlineId)).toEqual(
259 updatedConfig.settings.snoozePresets,
260 );
261 expect(updated.pinReorderEnvironmentIds.has(offlineId)).toBe(false);
262 expect(updated.activeReorderEnvironmentIds.has(offlineId)).toBe(false);
263 expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull();
264​
265 registry.set(
266 configs,
267 new Map([
268 [onlineId, config],
269 [
270 offlineId,
271 {
272 ...updatedConfig,
273 settings: {
274 ...updatedConfig.settings,
275 snoozePresets: updatedConfig.settings.snoozePresets.map((preset) => ({
276 ...preset,
277 })),
278 },
279 },
280 ],
281 ]),
282 );
283 expect(registry.get(environments)).toBe(updated);
284​
285 registry.set(connected, [onlineId, offlineId]);
286​
287 expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toHaveLength(2);
288 expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(true);
289 expect(registry.get(environments).snoozePresetsByEnvironmentId.get(offlineId)).toBe(
290 updated.snoozePresetsByEnvironmentId.get(offlineId),
291 );
292​
293 registry.set(configs, new Map([[onlineId, config]]));
294​
295 expect(createThreadMovePlanner(plannerInput())(movedId, "down")).toBeNull();
296 expect(computeThreadMoveAvailability(plannerInput()).get(movedId)?.canMoveDown).toBe(false);
297 } finally {
298 registry.dispose();
299 }
300 },
301 );
302});