| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import type { NotificationHandler } from "@muse-code/sdk"; |
| 4 | import { |
| 5 | MUSE_DEFAULT_MODEL, |
| 6 | ProviderInstanceId, |
| 7 | type ServerProviderModel, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { MuseSettings } from "../settings.ts"; |
| 10 | import { createModelSelection } from "@t3tools/shared/model"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Fiber from "effect/Fiber"; |
| 13 | import * as Schema from "effect/Schema"; |
| 14 | import { vi } from "vite-plus/test"; |
| 15 | |
| 16 | import type { MuseSdkHost } from "./sdk.ts"; |
| 17 | import { museModelCapabilities } from "./modelCatalog.ts"; |
| 18 | import { makeMuseTextGeneration } from "./textGeneration.ts"; |
| 19 | |
| 20 | const settings = Schema.decodeSync(MuseSettings)({ enabled: true }); |
| 21 | const modelSelection = createModelSelection( |
| 22 | ProviderInstanceId.make("muse"), |
| 23 | "muse-spark-1.3-contributor", |
| 24 | [{ id: "reasoningEffort", value: "high" }], |
| 25 | ); |
| 26 | const titleInput = { |
| 27 | cwd: "/project/should-not-be-used", |
| 28 | message: "Fix the login form", |
| 29 | modelSelection, |
| 30 | }; |
| 31 | |
| 32 | function 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 | |
| 107 | const 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 | |
| 116 | it.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 | }); |