1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import type { NotificationHandler } from "@muse-code/sdk";
4import {
5 MUSE_DEFAULT_MODEL,
6 ProviderInstanceId,
7 type ServerProviderModel,
8} from "@t3tools/contracts";
9import { MuseSettings } from "../settings.ts";
10import { createModelSelection } from "@t3tools/shared/model";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Schema from "effect/Schema";
14import { vi } from "vite-plus/test";
15
16import type { MuseSdkHost } from "./sdk.ts";
17import { museModelCapabilities } from "./modelCatalog.ts";
18import { makeMuseTextGeneration } from "./textGeneration.ts";
19
20const settings = Schema.decodeSync(MuseSettings)({ enabled: true });
21const modelSelection = createModelSelection(
22 ProviderInstanceId.make("muse"),
23 "muse-spark-1.3-contributor",
24 [{ id: "reasoningEffort", value: "high" }],
25);
26const titleInput = {
27 cwd: "/project/should-not-be-used",
28 message: "Fix the login form",
29 modelSelection,
30};
31
32function fixture(
33 onTurn?: (emit: (method: string, params: Record<string, unknown>) => void) => void,
34 acknowledgeTurn = true,
35 modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>,
36) {
37 let handler: NotificationHandler = () => {};
38 let commandSequence = 0;
39 let activeTurnId: string | undefined;
40 const mintCommandId = () => `turn-${++commandSequence}`;
41 const started = Promise.withResolvers<void>();
42 const closed = Promise.withResolvers<void>();
43 const emit = (method: string, params: Record<string, unknown>) =>
44 handler({
45 jsonrpc: "2.0",
46 method,
47 params: {
48 sessionId: "session-1",
49 turnId: activeTurnId,
50 ...params,
51 ...(params.item
52 ? {
53 item: {
54 revision: 1,
55 turnId: activeTurnId,
56 ...(params.item as Record<string, unknown>),
57 },
58 }
59 : {}),
60 },
61 });
62 const host: MuseSdkHost = {
63 initializeResult: { grantedCapabilities: [] },
64 connection: {
65 command: vi.fn(async (method, _params, options) => {
66 // Connection.command overwrites params.commandId unless supplied in options.
67 const commandId = options?.commandId ?? mintCommandId();
68 if (method === "session/start") return { session: { sessionId: "session-1" } };
69 if (method === "turn/start") {
70 activeTurnId = commandId;
71 started.resolve();
72 onTurn?.(emit);
73 if (!acknowledgeTurn) return await new Promise<Record<string, unknown>>(() => {});
74 return { turnId: commandId };
75 }
76 return {};
77 }),
78 request: vi.fn(async () => ({})),
79 mintCommandId,
80 onNotification: (nextHandler) => {
81 handler = nextHandler;
82 },
83 onProtocolError: () => {},
84 onServerRequest: () => {},
85 closed: closed.promise,
86 },
87 close: vi.fn(async () => {
88 closed.resolve();
89 }),
90 exited: new Promise(() => {}),
91 };
92 const createHost = vi.fn(async () => host);
93 const make = makeMuseTextGeneration(settings, {
94 createHost,
95 ...(modelCatalog ? { modelCatalog } : {}),
96 });
97 return {
98 host,
99 createHost,
100 started: started.promise,
101 closeConnection: closed.resolve,
102 emit,
103 make,
104 };
105}
106
107const finish =
108 (text: string) => (emit: (method: string, params: Record<string, unknown>) => void) => {
109 emit("item/updated", { item: { itemId: "answer", kind: "agentMessage", text: "partial" } });
110 emit("item/completed", {
111 item: { itemId: "answer", kind: "agentMessage", revision: 2, text },
112 });
113 emit("turn/completed", { terminal: "completed" });
114 };
115
116it.layer(NodeServices.layer)("Muse text generation", (it) => {
117 it.effect("validates the final item once and closes an isolated restrictive host", () =>
118 Effect.gen(function* () {
119 const test = fixture(finish('{"title":"Fix login form"}'));
120 const service = yield* test.make;
121 expect(yield* service.generateThreadTitle(titleInput)).toEqual({
122 title: "Fix login form",
123 });
124 expect(test.createHost).toHaveBeenCalledWith(
125 expect.objectContaining({ readOnly: true, sessionLogging: true }),
126 );
127 expect(test.createHost).not.toHaveBeenCalledWith(
128 expect.objectContaining({ cwd: titleInput.cwd }),
129 );
130 expect(test.host.connection.command).toHaveBeenCalledWith(
131 "session/start",
132 expect.objectContaining({
133 providerId: "meta",
134 modelId: modelSelection.model,
135 approvalMode: "denyUnmatched",
136 }),
137 );
138 expect(test.host.connection.command).toHaveBeenCalledWith(
139 "turn/start",
140 expect.objectContaining({
141 reasoningEffort: "high",
142 }),
143 { commandId: "turn-1" },
144 );
145 expect(test.host.close).toHaveBeenCalledOnce();
146 }),
147 );
148
149 it.effect("uses low effort by default and normalizes stale efforts against the model", () =>
150 Effect.gen(function* () {
151 for (const { saved, variants, expected } of [
152 { saved: undefined, variants: undefined, expected: "low" },
153 { saved: undefined, variants: ["medium", "xhigh"], expected: "medium" },
154 { saved: "max", variants: ["minimal", "max"], expected: "max" },
155 { saved: "ultra", variants: ["medium", "xhigh"], expected: "medium" },
156 { saved: "high", variants: [], expected: undefined },
157 ]) {
158 const test = fixture(
159 finish('{"title":"Fix login form"}'),
160 true,
161 Effect.succeed([
162 {
163 slug: modelSelection.model,
164 name: modelSelection.model,
165 isCustom: false,
166 capabilities: museModelCapabilities(variants ? { variants } : undefined),
167 },
168 ]),
169 );
170 const service = yield* test.make;
171 yield* service.generateThreadTitle({
172 ...titleInput,
173 modelSelection: createModelSelection(
174 ProviderInstanceId.make("muse"),
175 modelSelection.model,
176 saved !== undefined ? [{ id: "reasoningEffort", value: saved }] : undefined,
177 ),
178 });
179 const turn = vi
180 .mocked(test.host.connection.command)
181 .mock.calls.find(([method]) => method === "turn/start");
182 expect(turn?.[1].reasoningEffort).toBe(expected);
183 expect(Object.hasOwn(turn?.[1] ?? {}, "reasoningEffort")).toBe(expected !== undefined);
184 }
185 }),
186 );
187
188 it.effect("lets Muse choose the model when none is selected", () =>
189 Effect.gen(function* () {
190 for (const model of [MUSE_DEFAULT_MODEL, " "]) {
191 const test = fixture(finish('{"title":"Fix login form"}'));
192 const service = yield* test.make;
193 yield* service.generateThreadTitle({
194 ...titleInput,
195 modelSelection: createModelSelection(ProviderInstanceId.make("muse"), model),
196 });
197 const start = vi
198 .mocked(test.host.connection.command)
199 .mock.calls.find(([method]) => method === "session/start");
200 expect(start?.[1]).not.toHaveProperty("modelId");
201 }
202 }),
203 );
204
205 it.effect("rejects invalid structured output and releases the host", () =>
206 Effect.gen(function* () {
207 const test = fixture(finish('{"title":42}'));
208 const service = yield* test.make;
209 expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({
210 _tag: "TextGenerationError",
211 detail: "Muse returned invalid structured output.",
212 });
213 expect(test.host.close).toHaveBeenCalledOnce();
214 }),
215 );
216
217 it.effect("ignores output and terminal notifications for another turn", () =>
218 Effect.gen(function* () {
219 const test = fixture((emit) => {
220 emit("item/completed", {
221 item: {
222 itemId: "other-answer",
223 kind: "agentMessage",
224 turnId: "another-turn",
225 text: '{"title":"Wrong title"}',
226 },
227 });
228 emit("turn/completed", { turnId: "another-turn", terminal: "completed" });
229 finish('{"title":"Fix login form"}')(emit);
230 });
231 const service = yield* test.make;
232 expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" });
233 expect(test.host.connection.command).toHaveBeenCalledWith(
234 "turn/start",
235 expect.objectContaining({ sessionId: "session-1" }),
236 { commandId: "turn-1" },
237 );
238 }),
239 );
240
241 it.effect("rejects a truncated response even when its prefix contains valid JSON", () =>
242 Effect.gen(function* () {
243 const test = fixture((emit) => {
244 emit("item/completed", {
245 item: {
246 itemId: "answer",
247 kind: "agentMessage",
248 text: '{"title":"Incomplete answer"}',
249 truncated: true,
250 },
251 });
252 emit("turn/completed", { terminal: "completed" });
253 });
254 const service = yield* test.make;
255 expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({
256 _tag: "TextGenerationError",
257 });
258 expect(test.host.close).toHaveBeenCalledOnce();
259 }),
260 );
261
262 it.effect("settles a completed turn even when its start acknowledgement never arrives", () =>
263 Effect.gen(function* () {
264 const test = fixture(finish('{"title":"Fix login form"}'), false);
265 const service = yield* test.make;
266 expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" });
267 expect(test.host.close).toHaveBeenCalledOnce();
268 }),
269 );
270
271 it.effect("reports a failed turn without trying another provider", () =>
272 Effect.gen(function* () {
273 const test = fixture((emit) =>
274 emit("turn/completed", {
275 terminal: "failed",
276 error: { message: "Authentication required. Run muse login." },
277 }),
278 );
279 const service = yield* test.make;
280 expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({
281 _tag: "TextGenerationError",
282 });
283 expect(test.createHost).toHaveBeenCalledOnce();
284 expect(test.host.close).toHaveBeenCalledOnce();
285 }),
286 );
287
288 it.effect("settles when the transport closes before completion", () =>
289 Effect.gen(function* () {
290 const test = fixture();
291 const service = yield* test.make;
292 const pending = yield* service
293 .generateThreadTitle(titleInput)
294 .pipe(Effect.flip, Effect.forkChild);
295 yield* Effect.promise(() => test.started);
296 test.closeConnection();
297 expect(yield* Fiber.join(pending)).toMatchObject({ _tag: "TextGenerationError" });
298 expect(test.host.close).toHaveBeenCalledOnce();
299 }),
300 );
301
302 it.effect("releases the host when interrupted after the turn starts", () =>
303 Effect.gen(function* () {
304 const test = fixture();
305 const service = yield* test.make;
306 const fiber = yield* service.generateThreadTitle(titleInput).pipe(Effect.forkChild);
307 yield* Effect.promise(() => test.started);
308 yield* Fiber.interrupt(fiber);
309 expect(test.host.close).toHaveBeenCalledOnce();
310 }),
311 );
312
313 it.effect("denies approval requests and fails the noninteractive request", () =>
314 Effect.gen(function* () {
315 const test = fixture((emit) =>
316 emit("approval/requested", {
317 approvalId: "approval-1",
318 currentRequirementId: "requirement-1",
319 availableChoices: [{ choiceId: "deny-1", decision: "denied" }],
320 }),
321 );
322 const service = yield* test.make;
323 expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({
324 _tag: "TextGenerationError",
325 });
326 expect(test.host.connection.command).toHaveBeenCalledWith("approval/decide", {
327 sessionId: "session-1",
328 approvalId: "approval-1",
329 requirementId: "requirement-1",
330 choiceId: "deny-1",
331 });
332 expect(test.host.close).toHaveBeenCalledOnce();
333 }),
334 );
335
336 it.effect("cancels user input requests", () =>
337 Effect.gen(function* () {
338 const test = fixture((emit) => emit("userInput/requested", { userInputId: "input-1" }));
339 const service = yield* test.make;
340 expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({
341 _tag: "TextGenerationError",
342 });
343 expect(test.host.connection.command).toHaveBeenCalledWith(
344 "userInput/cancel",
345 expect.objectContaining({
346 userInputId: "input-1",
347 }),
348 );
349 expect(test.host.close).toHaveBeenCalledOnce();
350 }),
351 );
352
353 it.effect("generates and sanitizes commit content through Muse", () =>
354 Effect.gen(function* () {
355 const test = fixture(
356 finish('{"subject":"Fix login.","body":" Details ","branch":"fix-login"}'),
357 );
358 const service = yield* test.make;
359 const generated = yield* service.generateCommitMessage({
360 cwd: titleInput.cwd,
361 branch: "main",
362 stagedSummary: "login.ts",
363 stagedPatch: "login change",
364 includeBranch: true,
365 modelSelection,
366 });
367 expect(generated.subject).toBe("Fix login");
368 expect(generated.body).toBe("Details");
369 expect(generated.branch).toContain("fix-login");
370 }),
371 );
372});