1import {
2 DEFAULT_SERVER_SETTINGS,
3 ServerSettings,
4 type ServerConfig,
5 type ServerConfigStreamEvent,
6} from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { describe, expect, it } from "vite-plus/test";
10
11import { applyServerConfigProjection, reuseUnchanged } from "./serverConfigProjection.ts";
12
13const encodeSettings = Schema.encodeSync(ServerSettings);
14const decodeSettings = Schema.decodeSync(ServerSettings);
15
16describe("reuseUnchanged", () => {
17 const provider = (checkedAt: string, skills: ReadonlyArray<string>) => ({
18 instanceId: "claude",
19 checkedAt,
20 models: [{ slug: "opus", name: "Opus" }],
21 skills: skills.map((name) => ({ name })),
22 });
23
24 it("takes the new value when keys or lengths change", () => {
25 const previous = [provider("a", ["x"])];
26 const next = reuseUnchanged(previous, [provider("a", ["x", "z"])]);
27 expect(next[0]!.skills).toHaveLength(2);
28 expect(next[0]!.models).toBe(previous[0]!.models);
29 const dropped = reuseUnchanged({ a: 1, b: 2 }, { a: 1 });
30 expect(dropped).toEqual({ a: 1 });
31 });
32});
33
34describe("applyServerConfigProjection settingsUpdated", () => {
35 it("keeps unchanged settings sections when one field changes", () => {
36 const settings = {
37 favorites: [{ provider: "claude", model: "opus" }],
38 providerInstances: {
39 claude: { driver: "claudeAgent", config: { launchArgs: "" } },
40 codex: { driver: "codex" },
41 },
42 };
43 const current = Option.some({
44 config: { settings } as unknown as ServerConfig,
45 latestEvent: {} as ServerConfigStreamEvent,
46 source: "live" as const,
47 });
48 const next = applyServerConfigProjection(current, {
49 type: "settingsUpdated",
50 payload: {
51 settings: {
52 favorites: [{ provider: "claude", model: "opus" }],
53 providerInstances: {
54 claude: { driver: "claudeAgent", config: { launchArgs: "--verbose" } },
55 codex: { driver: "codex" },
56 },
57 },
58 },
59 } as unknown as ServerConfigStreamEvent);
60 const nextSettings = Option.getOrThrow(next).config.settings as unknown as typeof settings;
61 expect(nextSettings.favorites).toBe(settings.favorites);
62 expect(nextSettings.providerInstances.codex).toBe(settings.providerInstances.codex);
63 expect(nextSettings.providerInstances.claude.config.launchArgs).toBe("--verbose");
64 });
65
66 it("keeps the settings object when an update repeats the current settings", () => {
67 const wire = encodeSettings(DEFAULT_SERVER_SETTINGS);
68 const decode = () => decodeSettings(structuredClone(wire));
69 const settings = decode();
70 const current = Option.some({
71 config: { settings } as unknown as ServerConfig,
72 latestEvent: {} as ServerConfigStreamEvent,
73 source: "live" as const,
74 });
75 const next = applyServerConfigProjection(current, {
76 type: "settingsUpdated",
77 payload: { settings: decode() },
78 } as ServerConfigStreamEvent);
79 expect(Option.getOrThrow(next).config).toBe(Option.getOrThrow(current).config);
80 });
81});
82
83describe("applyServerConfigProjection provider catalogs", () => {
84 const provider = (fields: Record<string, unknown>) =>
85 ({
86 instanceId: "claude",
87 checkedAt: "a",
88 models: [],
89 ...fields,
90 }) as unknown as ServerConfig["providers"][number];
91 const projection = (providers: ServerConfig["providers"]) =>
92 Option.some({
93 config: { providers } as unknown as ServerConfig,
94 latestEvent: {} as ServerConfigStreamEvent,
95 source: "cache" as const,
96 });
97 const apply = (current: ReturnType<typeof projection>, event: unknown) =>
98 Option.getOrThrow(applyServerConfigProjection(current, event as ServerConfigStreamEvent)).config
99 .providers[0]!;
100
101 it("keeps held slash commands and skills when the server sends a digest in their place", () => {
102 const slashCommands = [{ name: "compact" }];
103 const skills = [{ name: "review", path: "/skills/review", enabled: true }];
104 const held = provider({
105 slashCommands,
106 skills,
107 catalogDigest: "d1",
108 loadedCatalogDigest: "d1",
109 });
110 const sent = (fields: Record<string, unknown>) =>
111 provider({ slashCommands: [], skills: [], catalogDigest: "d1", ...fields });
112
113 const reconnected = apply(projection([held]), {
114 type: "snapshot",
115 config: { providers: [sent({ checkedAt: "b" })] },
116 });
117 expect(reconnected).toMatchObject({ checkedAt: "b", loadedCatalogDigest: "d1" });
118 expect(reconnected.slashCommands).toBe(slashCommands);
119 expect(reconnected.skills).toBe(skills);
120
121 // A changed catalog keeps the stale lists; the old marker says they need a reload.
122 const { slashCommands: _c, skills: _s, ...delta } = sent({ catalogDigest: "d2" });
123 const changed = apply(projection([held]), {
124 type: "providerStatusDeltas",
125 payload: { providers: [delta] },
126 });
127 expect(changed).toMatchObject({ catalogDigest: "d2", loadedCatalogDigest: "d1" });
128 expect(changed.skills).toBe(skills);
129
130 // Lists cached before digests existed carry over without a marker.
131 const legacy = apply(projection([provider({ slashCommands, skills })]), {
132 type: "providerStatuses",
133 payload: { providers: [sent({})] },
134 });
135 expect(legacy.slashCommands).toBe(slashCommands);
136 expect(legacy.loadedCatalogDigest).toBeUndefined();
137 });
138});