authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 16:41:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 16:41:20-07:00
log5b78b34bba8645a268900f946858ea1470621c41
treeee273e850a0c217620f5b984dcc259195e54a127
parente0c80c198cfbb2afe04528cd6a7ec1d2f21606dd
signature Signed by PGP key B5690EEEBB952194

refactor(provider-muse): move Muse Code into its own provider package (#17331)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

62 files changed, 6181 insertions(+), 6051 deletions(-)

apps/mobile/package.json+1
......@@ -70,6 +70,7 @@
7070 "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
7171 "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
7272 "@t3tools/provider-core": "workspace:*",
73 "@t3tools/provider-muse": "workspace:*",
7374 "@t3tools/provider-pi": "workspace:*",
7475 "@t3tools/shared": "workspace:*",
7576 "@tabler/icons-react-native": "^3.44.0",
apps/mobile/src/components/ProviderIcon.tsx-12
......@@ -120,18 +120,6 @@ export function ProviderIcon(props: ProviderIconProps) {
120120 );
121121 }
122122
123 if (props.provider === "muse") {
124 return (
125 <Svg width={size} height={size} viewBox="0 0 150.4828 100" fill="none">
126 <Path
127 fill={mono}
128 fillRule="evenodd"
129 d="M107.9999847,0.0000013c-12.3391113,0-21.984787,9.2935991-30.7164001,21.0993118 C65.2847366,5.821527,55.2499847,0,43.2413788,0C18.7586193,0,0,31.8620682,0,65.5862122 C0,86.6896515,10.2095623,100,27.3103466,100c12.3080502,0,21.1600361-5.8025894,36.8965454-33.3103409 c0,0,6.5597382-11.5841255,11.0725021-19.563858c1.5813522,2.5531845,3.2431107,5.3001404,4.9964676,8.2535095 l7.379303,12.4137955C102.0298843,91.8478851,110.039032,100,124.5517273,100 c16.6595764,0,25.9310303-13.4923325,25.9310303-35.0344849 C150.4827576,29.6551723,131.301178,0.0000013,107.9999847,0.0000013z M52.2068977,59.2413788 c-12.7586212,20-17.1724167,24.4827576-24.2758656,24.4827576c-7.3103428,0-11.6551704-6.4178314-11.6551704-17.8620682 c0-24.4827576,12.2068977-49.5172424,26.7586231-49.5172424c7.880127,0,14.4653816,4.5510426,24.5522308,18.9913158 C58.0087967,50.0272293,52.2068977,59.2413788,52.2068977,59.2413788z M100.3597336,56.7235756L91.5367355,42.00877 c-2.3876724-3.8832092-4.6757278-7.451458-6.8838348-10.7230263 c7.951973-12.273531,14.5113449-18.3891907,22.3126144-18.3891907c16.2069016,0,29.1724243,23.8620682,29.1724243,53.1724091 c0,11.1724167-3.6593475,17.6551743-11.2413864,17.6551743 C117.6294937,83.7241364,114.1578674,78.924736,100.3597336,56.7235756z"
130 />
131 </Svg>
132 );
133 }
134
135123 const packageIcon = getProviderClient(props.provider)?.icon;
136124 if (packageIcon) {
137125 const themeFill = (fill: { readonly light: string; readonly dark: string }) =>
apps/mobile/src/lib/providerClients.ts+2-1
......@@ -1,9 +1,10 @@
11import { isProviderDriverKind } from "@t3tools/contracts";
22import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
3import { museClient } from "@t3tools/provider-muse/client";
34import { piClient } from "@t3tools/provider-pi/client";
45
56/** The provider client definitions this mobile build ships. */
6const providerClients = makeProviderClientRegistry([piClient]);
7const providerClients = makeProviderClientRegistry([museClient, piClient]);
78
89/** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */
910export function getProviderClient(driver: string | null | undefined) {
apps/server/package.json+1
......@@ -42,6 +42,7 @@
4242 "@opencode/protocol": "2.0.23",
4343 "@opencode/schema": "2.0.23",
4444 "@t3tools/provider-core": "workspace:*",
45 "@t3tools/provider-muse": "workspace:*",
4546 "@t3tools/provider-pi": "workspace:*",
4647 "diff": "8.0.3",
4748 "effect": "catalog:",
apps/server/scripts/record-muse-msp-replay-fixture.ts+5-2
......@@ -33,8 +33,11 @@ import {
3333 MuseOrchestratorReplayHarness,
3434} from "../src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts";
3535import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
36import { parseMuseVersion } from "../src/provider/museMaintenance.ts";
37import { makeMuseEnvironment, museServeArgs } from "../src/provider/museSdk.ts";
36import {
37 makeMuseEnvironment,
38 museServeArgs,
39 parseMuseVersion,
40} from "@t3tools/provider-muse/testing";
3841import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts";
3942import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";
4043import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts deleted-1108
......@@ -1,1108 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { MspError } from "@muse-code/sdk";
4import {
5 MUSE_DEFAULT_MODEL,
6 EnvironmentId,
7 MessageId,
8 MuseSettings,
9 NodeId,
10 ProjectId,
11 ProviderInstanceId,
12 ProviderDriverKind,
13 ProviderSessionId,
14 ProviderThreadId,
15 ProviderTurnId,
16 RunAttemptId,
17 RunId,
18 ThreadId,
19 type OrchestrationV2AppThread,
20 type OrchestrationV2ProviderThread,
21} from "@t3tools/contracts";
22import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";
24import * as Effect from "effect/Effect";
25import * as Option from "effect/Option";
26import type * as Exit from "effect/Exit";
27import * as FileSystem from "effect/FileSystem";
28import * as Fiber from "effect/Fiber";
29import * as Layer from "effect/Layer";
30import * as Queue from "effect/Queue";
31import * as Schema from "effect/Schema";
32import * as Stream from "effect/Stream";
33
34import * as ServerConfig from "../../config.ts";
35import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
36import type { MuseItem } from "../../provider/museProtocol.ts";
37import type { MuseSdkHost } from "../../provider/museSdk.ts";
38import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
39import {
40 ProviderAdapterV2RuntimePolicy,
41 type ProviderAdapterV2Event,
42 type ProviderAdapterV2TurnInput,
43} from "@t3tools/provider-core/server/ProviderAdapter";
44import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests";
45import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts";
46
47const testLayer = Layer.mergeAll(
48 NodeServices.layer,
49 IdAllocator.layer,
50 ServerConfig.layerTest(process.cwd(), { prefix: "t3-muse-v2-adapter-" }).pipe(
51 Layer.provide(NodeServices.layer),
52 ),
53);
54const MUSE_PROVIDER = ProviderDriverKind.make("muse");
55const INSTANCE_ID = ProviderInstanceId.make("muse_work");
56const THREAD_ID = ThreadId.make("thread-muse-test");
57const MODEL = "muse-spark-1.3-contributor";
58const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true });
59const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
60 runtimeMode: "full-access",
61 interactionMode: "default",
62 cwd: null,
63});
64const modelSelection = (instanceId = INSTANCE_ID) => ({
65 instanceId,
66 model: MODEL,
67 options: [{ id: "reasoningEffort", value: "max" }],
68});
69
70interface NativeCall {
71 readonly method: string;
72 readonly params: Record<string, unknown>;
73 readonly commandId?: string;
74}
75
76function pendingPromise<A>() {
77 let resolve!: (value: A) => void;
78 let reject!: (reason: unknown) => void;
79 const promise = new Promise<A>((done, fail) => {
80 resolve = done;
81 reject = fail;
82 });
83 return { promise, resolve, reject };
84}
85
86/** SDK-shaped transport fake: registration deliberately returns void, as the SDK does. */
87const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") {
88 const requests = yield* Queue.unbounded<NativeCall>();
89 const calls: NativeCall[] = [];
90 const closed = pendingPromise<void>();
91 const exited = pendingPromise<Awaited<MuseSdkHost["exited"]>>();
92 let notify: Parameters<MuseSdkHost["connection"]["onNotification"]>[0] = () => {};
93 let sequence = 0;
94 let sessionId = "";
95 let closeCount = 0;
96 let closeGate: Promise<void> | undefined;
97 let history: MuseItem[] = [];
98 const responses = new Map<
99 string,
100 Array<Promise<Record<string, unknown>> | Record<string, unknown> | Error>
101 >();
102 const respond = async (call: NativeCall): Promise<Record<string, unknown>> => {
103 calls.push(call);
104 Queue.offerUnsafe(requests, call);
105 if (["session/start", "session/resume", "session/read"].includes(call.method)) {
106 sessionId = String(call.params.sessionId);
107 }
108 const queued = responses.get(call.method)?.shift();
109 if (queued instanceof Error) throw queued;
110 if (queued) return queued;
111 switch (call.method) {
112 case "session/start":
113 case "session/resume":
114 case "session/read":
115 sessionId = String(call.params.sessionId);
116 return {
117 session: { sessionId, modelId: MODEL, activeTurnId: null },
118 history: { items: history },
119 };
120 case "turn/start":
121 return { turnId: call.commandId };
122 case "turn/steer":
123 return { turnId: call.params.expectedTurnId };
124 case "session/compact":
125 return { status: "accepted" };
126 default:
127 return {};
128 }
129 };
130 const host: MuseSdkHost = {
131 connection: {
132 mintCommandId: () => `${idPrefix}-${++sequence}`,
133 command: (method, params, options) =>
134 respond({
135 method,
136 params,
137 ...(options?.commandId ? { commandId: options.commandId } : {}),
138 }),
139 request: (method, params = {}) => respond({ method, params }),
140 onNotification: (handler) => {
141 notify = handler;
142 },
143 onProtocolError: () => {},
144 onServerRequest: () => {},
145 closed: closed.promise,
146 },
147 initializeResult: { grantedCapabilities: [] },
148 exited: exited.promise,
149 close: async () => {
150 closeCount += 1;
151 await closeGate;
152 closed.resolve();
153 exited.resolve({ code: 0, signal: null });
154 },
155 };
156 return {
157 host,
158 calls,
159 closeCount: () => closeCount,
160 holdClose: (gate: Promise<void>) => {
161 closeGate = gate;
162 },
163 disconnect: () =>
164 Effect.sync(() => {
165 closed.resolve();
166 exited.resolve({ code: 1, signal: null });
167 }),
168 setHistory: (items: MuseItem[]) => {
169 history = items;
170 },
171 queueResponse: (
172 method: string,
173 result: Record<string, unknown> | Promise<Record<string, unknown>> | Error,
174 ) => {
175 responses.set(method, [...(responses.get(method) ?? []), result]);
176 },
177 emit: (method: string, params: Record<string, unknown>) =>
178 Effect.sync(() => {
179 notify({ jsonrpc: "2.0", method, params: { sessionId, ...params } });
180 }),
181 takeCall: Effect.fnUntraced(function* (method: string) {
182 while (true) {
183 const call = yield* Queue.take(requests);
184 if (call.method === method) return call;
185 }
186 }),
187 };
188});
189
190const makeHarness = Effect.fnUntraced(function* (
191 fake: Effect.Success<ReturnType<typeof makeFakeMuse>>,
192 instanceId = INSTANCE_ID,
193 initialNativeThreadId?: string,
194 replacement?: Effect.Success<ReturnType<typeof makeFakeMuse>>,
195 existingProviderThread?: OrchestrationV2ProviderThread,
196 policy = runtimePolicy,
197 overrides: Pick<
198 MuseAdapterV2Options,
199 "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests"
200 > = {},
201) {
202 let hostCount = 0;
203 const adapter = makeMuseAdapterV2({
204 instanceId,
205 settings: museSettings,
206 environment: { PATH: "/fake/bin" },
207 idAllocator: yield* IdAllocator.IdAllocatorV2,
208 serverConfig: yield* ServerConfig.ServerConfig,
209 fileSystem: yield* FileSystem.FileSystem,
210 createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host),
211 ...overrides,
212 });
213 const runtime = yield* adapter.openSession({
214 threadId: THREAD_ID,
215 providerSessionId: ProviderSessionId.make(`session-${instanceId}`),
216 modelSelection: modelSelection(instanceId),
217 runtimePolicy: policy,
218 ...(initialNativeThreadId ? { initialNativeThreadId } : {}),
219 });
220 const emitted = yield* Queue.unbounded<ProviderAdapterV2Event>();
221 const allEvents: ProviderAdapterV2Event[] = [];
222 const eventsEnded = yield* Deferred.make<Exit.Exit<void, Stream.Error<typeof runtime.events>>>();
223 yield* runtime.events.pipe(
224 Stream.runForEach((event) =>
225 Effect.gen(function* () {
226 allEvents.push(event);
227 yield* Queue.offer(emitted, event);
228 }),
229 ),
230 Effect.exit,
231 Effect.flatMap((exit) => Deferred.succeed(eventsEnded, exit)),
232 Effect.forkScoped,
233 );
234 const providerThread = yield* runtime.ensureThread({
235 threadId: THREAD_ID,
236 modelSelection: modelSelection(instanceId),
237 runtimePolicy: policy,
238 ...(existingProviderThread ? { existingProviderThread } : {}),
239 });
240 const takeEvent = Effect.fnUntraced(function* <T extends ProviderAdapterV2Event["type"]>(
241 type: T,
242 predicate: (event: Extract<ProviderAdapterV2Event, { type: T }>) => boolean = () => true,
243 ) {
244 while (true) {
245 const event = yield* Queue.take(emitted);
246 if (event.type === type && predicate(event as Extract<ProviderAdapterV2Event, { type: T }>)) {
247 return event as Extract<ProviderAdapterV2Event, { type: T }>;
248 }
249 }
250 });
251 return { adapter, runtime, providerThread, takeEvent, allEvents, policy, eventsEnded };
252});
253
254const preallocatedProviderThread = Effect.fnUntraced(function* () {
255 const now = yield* DateTime.now;
256 return {
257 id: ProviderThreadId.make("preallocated-muse-thread"),
258 driver: MUSE_PROVIDER,
259 providerInstanceId: INSTANCE_ID,
260 providerSessionId: ProviderSessionId.make(`session-${INSTANCE_ID}`),
261 appThreadId: THREAD_ID,
262 ownerNodeId: null,
263 nativeThreadRef: null,
264 nativeConversationHeadRef: null,
265 status: "idle",
266 firstRunOrdinal: null,
267 lastRunOrdinal: null,
268 handoffIds: [],
269 forkedFrom: null,
270 createdAt: now,
271 updatedAt: now,
272 } satisfies OrchestrationV2ProviderThread;
273});
274
275const turnInput = Effect.fnUntraced(function* (
276 providerThread: OrchestrationV2ProviderThread,
277 runOrdinal = 1,
278 policy = runtimePolicy,
279): Effect.fn.Return<ProviderAdapterV2TurnInput> {
280 const now = yield* DateTime.now;
281 const threadId = providerThread.appThreadId ?? THREAD_ID;
282 const selection = modelSelection(providerThread.providerInstanceId);
283 const appThread: OrchestrationV2AppThread = {
284 id: threadId,
285 projectId: ProjectId.make("project-muse-test"),
286 title: "Muse test",
287 createdBy: "user",
288 creationSource: "web",
289 providerInstanceId: providerThread.providerInstanceId,
290 modelSelection: selection,
291 runtimeMode: policy.runtimeMode,
292 interactionMode: policy.interactionMode,
293 branch: null,
294 worktreePath: null,
295 activeProviderThreadId: providerThread.id,
296 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
297 forkedFrom: null,
298 createdAt: now,
299 updatedAt: now,
300 archivedAt: null,
301 settledOverride: null,
302 settledAt: null,
303 lastVisitedAt: null,
304 deletedAt: null,
305 };
306 return {
307 appThread,
308 threadId,
309 runId: RunId.make(`run-${runOrdinal}`),
310 runOrdinal,
311 providerTurnOrdinal: runOrdinal,
312 attemptId: RunAttemptId.make(`attempt-${runOrdinal}`),
313 rootNodeId: NodeId.make(`root-${runOrdinal}`),
314 providerThread,
315 message: {
316 messageId: MessageId.make(`message-${runOrdinal}`),
317 text: "Hello Muse",
318 attachments: [],
319 createdBy: "user",
320 creationSource: "web",
321 },
322 modelSelection: selection,
323 runtimePolicy: policy,
324 };
325});
326
327const startConversation = Effect.fnUntraced(function* (
328 harness: Effect.Success<ReturnType<typeof makeHarness>>,
329 fake: Effect.Success<ReturnType<typeof makeFakeMuse>>,
330 ordinal = 1,
331) {
332 yield* harness.runtime.startTurn(
333 yield* turnInput(harness.providerThread, ordinal, harness.policy),
334 );
335 const call = yield* fake.takeCall("turn/start");
336 const event = yield* harness.takeEvent(
337 "provider_turn.updated",
338 (event) => event.providerTurn.status === "running",
339 );
340 return { nativeId: call.commandId!, providerTurn: event.providerTurn };
341});
342
343const approval = (turnId: string) => ({
344 approvalId: "approval-1",
345 turnId,
346 currentRequirementId: { approvalId: "approval-1", sourceIndex: 0 },
347 availableChoices: [
348 { choiceId: "once", label: "Allow once", decision: "approved", scope: "once" },
349 { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" },
350 ],
351 subject: { kind: "shell", command: "touch result.txt" },
352});
353
354describe("MuseAdapterV2", () => {
355 it.effect("connects the thread's MCP credential on native start and resume", () =>
356 Effect.gen(function* () {
357 yield* Effect.addFinalizer(() =>
358 Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)),
359 );
360 McpProviderSession.setMcpProviderSession({
361 environmentId: EnvironmentId.make("test-environment"),
362 threadId: THREAD_ID,
363 providerSessionId: "test-session",
364 providerInstanceId: INSTANCE_ID,
365 endpoint: "http://127.0.0.1:43210/mcp",
366 authorizationHeader: "Bearer thread-scoped-test-token",
367 browserToolsAvailable: true,
368 });
369 for (const nativeId of [undefined, "saved-native-session"]) {
370 const fake = yield* makeFakeMuse();
371 fake.host.initializeResult.grantedCapabilities.push("sessionMcp");
372 yield* makeHarness(fake, INSTANCE_ID, nativeId);
373 const call = yield* fake.takeCall(nativeId ? "session/resume" : "session/start");
374 assert.deepStrictEqual(call.params.config, {
375 mcpServers: {
376 "t3-code": {
377 transport: "streamableHttp",
378 mode: "optional",
379 url: "http://127.0.0.1:43210/mcp",
380 headers: { Authorization: "Bearer thread-scoped-test-token" },
381 },
382 },
383 });
384 }
385 }).pipe(Effect.scoped, Effect.provide(testLayer)),
386 );
387
388 it.effect("rejects a host without session MCP support before opening a native session", () =>
389 Effect.gen(function* () {
390 yield* Effect.addFinalizer(() =>
391 Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)),
392 );
393 McpProviderSession.setMcpProviderSession({
394 environmentId: EnvironmentId.make("test-environment"),
395 threadId: THREAD_ID,
396 providerSessionId: "test-session",
397 providerInstanceId: INSTANCE_ID,
398 endpoint: "http://127.0.0.1:43210/mcp",
399 authorizationHeader: "Bearer thread-scoped-test-token",
400 browserToolsAvailable: false,
401 });
402 const fake = yield* makeFakeMuse();
403 const outcome = yield* makeHarness(fake).pipe(Effect.result);
404 assert.strictEqual(outcome._tag, "Failure");
405 assert.isFalse(fake.calls.some((call) => call.method === "session/start"));
406 }).pipe(Effect.scoped, Effect.provide(testLayer)),
407 );
408
409 it.effect(
410 "starts fresh after recovery clears a native ref instead of reusing the startup hint",
411 () =>
412 Effect.gen(function* () {
413 const fake = yield* makeFakeMuse();
414 const cleared = yield* preallocatedProviderThread();
415 const harness = yield* makeHarness(
416 fake,
417 INSTANCE_ID,
418 "failed-old-session",
419 undefined,
420 cleared,
421 );
422 const started = yield* fake.takeCall("session/start");
423 assert.isFalse(fake.calls.some((call) => call.method === "session/resume"));
424 assert.notStrictEqual(started.params.sessionId, "failed-old-session");
425 assert.strictEqual(harness.providerThread.id, cleared.id);
426 yield* startConversation(harness, fake);
427 }).pipe(Effect.scoped, Effect.provide(testLayer)),
428 );
429
430 it.effect("retries a missing native session with a cleared ref on the same healthy host", () =>
431 Effect.gen(function* () {
432 for (const kind of ["sessionNotFound", "notFound"] as const) {
433 const fake = yield* makeFakeMuse();
434 const allocated = yield* preallocatedProviderThread();
435 const adapter = makeMuseAdapterV2({
436 instanceId: INSTANCE_ID,
437 settings: museSettings,
438 environment: { PATH: "/fake/bin" },
439 idAllocator: yield* IdAllocator.IdAllocatorV2,
440 serverConfig: yield* ServerConfig.ServerConfig,
441 fileSystem: yield* FileSystem.FileSystem,
442 createHost: async () => fake.host,
443 });
444 const runtime = yield* adapter.openSession({
445 threadId: THREAD_ID,
446 providerSessionId: ProviderSessionId.make(`missing-${kind}`),
447 modelSelection: modelSelection(),
448 runtimePolicy,
449 initialNativeThreadId: "missing-session",
450 });
451 fake.queueResponse(
452 "session/resume",
453 new MspError({ code: -32000, message: "Saved session is absent", data: { kind } }),
454 );
455 const failed = yield* runtime
456 .ensureThread({
457 threadId: THREAD_ID,
458 modelSelection: modelSelection(),
459 runtimePolicy,
460 existingProviderThread: {
461 ...allocated,
462 nativeThreadRef: {
463 driver: MUSE_PROVIDER,
464 nativeId: "missing-session",
465 strength: "strong",
466 },
467 },
468 })
469 .pipe(Effect.exit);
470 assert.strictEqual(failed._tag, "Failure");
471 assert.strictEqual(fake.closeCount(), 0);
472 const fresh = yield* runtime.ensureThread({
473 threadId: THREAD_ID,
474 modelSelection: modelSelection(),
475 runtimePolicy,
476 existingProviderThread: allocated,
477 });
478 assert.strictEqual(fresh.id, allocated.id);
479 assert.notStrictEqual(fresh.nativeThreadRef?.nativeId, "missing-session");
480 assert.strictEqual(fake.closeCount(), 0);
481 yield* runtime.startTurn(yield* turnInput(fresh));
482 const admitted = yield* fake.takeCall("turn/start");
483 assert.strictEqual(admitted.params.reasoningEffort, "max");
484 yield* fake.emit("turn/completed", { turnId: admitted.commandId, terminal: "completed" });
485 yield* Stream.runDrain(
486 runtime.events.pipe(Stream.takeUntil((event) => event.type === "turn.terminal")),
487 );
488 }
489 }).pipe(Effect.scoped, Effect.provide(testLayer)),
490 );
491
492 it.effect("rejects cross-instance ensure requests without closing the current valid host", () =>
493 Effect.gen(function* () {
494 const fake = yield* makeFakeMuse();
495 const harness = yield* makeHarness(fake);
496 const rejected = yield* harness.runtime
497 .ensureThread({
498 threadId: THREAD_ID,
499 modelSelection: modelSelection(),
500 runtimePolicy,
501 existingProviderThread: {
502 ...harness.providerThread,
503 providerInstanceId: ProviderInstanceId.make("other-account"),
504 },
505 })
506 .pipe(Effect.exit);
507 assert.strictEqual(rejected._tag, "Failure");
508 assert.strictEqual(fake.closeCount(), 0);
509 yield* startConversation(harness, fake);
510 }).pipe(Effect.scoped, Effect.provide(testLayer)),
511 );
512
513 it.effect("skips an unreadable usage notification without ending the turn", () =>
514 Effect.gen(function* () {
515 const fake = yield* makeFakeMuse();
516 const harness = yield* makeHarness(fake);
517 const { nativeId } = yield* startConversation(harness, fake);
518 yield* fake.emit("session/tokenUsage", { turnId: nativeId, usage: "not usage" });
519 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
520 const terminal = yield* harness.takeEvent("turn.terminal");
521 assert.strictEqual(terminal.status, "completed");
522 assert.strictEqual(fake.closeCount(), 0);
523 }).pipe(Effect.scoped, Effect.provide(testLayer)),
524 );
525
526 it.effect("moves a resumed session on another model back to the catalog default", () =>
527 Effect.gen(function* () {
528 const fake = yield* makeFakeMuse();
529 // The saved session last ran MODEL; the catalog's default is another model.
530 const harness = yield* makeHarness(
531 fake,
532 INSTANCE_ID,
533 "saved-session",
534 undefined,
535 undefined,
536 runtimePolicy,
537 {
538 modelCatalog: Effect.succeed([
539 {
540 slug: "account-default",
541 name: "Default",
542 isCustom: false,
543 isDefault: true,
544 capabilities: null,
545 },
546 { slug: MODEL, name: "Other", isCustom: false, isDefault: false, capabilities: null },
547 ]),
548 },
549 );
550 const input = yield* turnInput(harness.providerThread, 1);
551 const selection = { ...input.modelSelection, model: MUSE_DEFAULT_MODEL };
552 yield* harness.runtime.startTurn({
553 ...input,
554 modelSelection: selection,
555 appThread: { ...input.appThread, modelSelection: selection },
556 });
557 const setModel = yield* fake.takeCall("session/setModel");
558 assert.deepStrictEqual(setModel.params.model, {
559 modelId: "account-default",
560 providerId: "meta",
561 });
562 yield* fake.takeCall("turn/start");
563 }).pipe(Effect.scoped, Effect.provide(testLayer)),
564 );
565
566 it.effect("settles partial messages and pending approvals before a provider failure", () =>
567 Effect.gen(function* () {
568 const fake = yield* makeFakeMuse();
569 const harness = yield* makeHarness(fake);
570 const { nativeId } = yield* startConversation(harness, fake);
571 yield* fake.emit("item/started", {
572 turnId: nativeId,
573 item: {
574 itemId: "partial",
575 turnId: nativeId,
576 kind: "agentMessage",
577 status: "inProgress",
578 revision: 1,
579 text: "Partial answer",
580 },
581 });
582 yield* fake.emit("approval/requested", approval(nativeId));
583 yield* harness.takeEvent(
584 "runtime_request.updated",
585 (event) => event.runtimeRequest.status === "pending",
586 );
587 yield* fake.emit("turn/completed", {
588 turnId: nativeId,
589 terminal: "failed",
590 error: { message: "Provider rejected this turn" },
591 });
592 const terminal = yield* harness.takeEvent("turn.terminal");
593 assert.strictEqual(terminal.status, "failed");
594 assert.strictEqual(terminal.threadDisposition, "reusable");
595 assert.isNotNull(terminal.failure);
596 assert.isTrue(
597 harness.allEvents.some(
598 (event) =>
599 event.type === "message.updated" &&
600 !event.message.streaming &&
601 event.message.text === "Partial answer",
602 ),
603 );
604 assert.isTrue(
605 harness.allEvents.some(
606 (event) =>
607 event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled",
608 ),
609 );
610 yield* startConversation(harness, fake, 2);
611 }).pipe(Effect.scoped, Effect.provide(testLayer)),
612 );
613
614 it.effect("closes its SDK host exactly once when its session scope ends", () =>
615 Effect.gen(function* () {
616 const fake = yield* makeFakeMuse();
617 yield* makeHarness(fake).pipe(Effect.scoped);
618 assert.strictEqual(fake.closeCount(), 1);
619 }).pipe(Effect.provide(testLayer)),
620 );
621
622 it.effect(
623 "closes a newly opened host when native session registration returns another identity",
624 () =>
625 Effect.gen(function* () {
626 const fake = yield* makeFakeMuse();
627 fake.queueResponse("session/start", {
628 session: { sessionId: "unexpected-session", modelId: MODEL, activeTurnId: null },
629 history: { items: [] },
630 });
631 const result = yield* makeHarness(fake).pipe(Effect.exit);
632 assert.strictEqual(result._tag, "Failure");
633 // A failed host closes in the background so the failure shows at once.
634 yield* Effect.promise(() => fake.host.exited);
635 assert.strictEqual(fake.closeCount(), 1);
636 assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 0);
637 }).pipe(Effect.scoped, Effect.provide(testLayer)),
638 );
639
640 it.effect("settles an active turn once when both the connection and host exit unexpectedly", () =>
641 Effect.gen(function* () {
642 const fake = yield* makeFakeMuse();
643 const harness = yield* makeHarness(fake);
644 const { nativeId } = yield* startConversation(harness, fake);
645 yield* fake.emit("approval/requested", approval(nativeId));
646 yield* harness.takeEvent(
647 "runtime_request.updated",
648 (event) => event.runtimeRequest.status === "pending",
649 );
650 yield* fake.disconnect();
651 const terminal = yield* harness.takeEvent("turn.terminal");
652 assert.strictEqual(terminal.status, "failed");
653 assert.strictEqual(terminal.threadDisposition, "broken");
654 assert.isTrue(
655 harness.allEvents.some(
656 (event) =>
657 event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled",
658 ),
659 );
660 const result = yield* harness.runtime
661 .startTurn(yield* turnInput(harness.providerThread, 2))
662 .pipe(Effect.exit);
663 assert.strictEqual(result._tag, "Failure");
664 assert.strictEqual(
665 harness.allEvents.filter((event) => event.type === "turn.terminal").length,
666 1,
667 );
668 assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1);
669 }).pipe(Effect.scoped, Effect.provide(testLayer)),
670 );
671
672 it.effect("turns a delivery gap into a broken terminal and rejects subsequent work", () =>
673 Effect.gen(function* () {
674 const fake = yield* makeFakeMuse();
675 const harness = yield* makeHarness(fake);
676 yield* startConversation(harness, fake);
677 yield* fake.emit("view/gap", {});
678 const terminal = yield* harness.takeEvent("turn.terminal");
679 assert.strictEqual(terminal.status, "failed");
680 assert.strictEqual(terminal.threadDisposition, "broken");
681 yield* Effect.promise(() => fake.host.exited);
682 assert.strictEqual(fake.closeCount(), 1);
683 yield* Deferred.await(harness.eventsEnded);
684 const result = yield* harness.runtime
685 .startTurn(yield* turnInput(harness.providerThread, 2))
686 .pipe(Effect.exit);
687 assert.strictEqual(result._tag, "Failure");
688 assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1);
689 }).pipe(Effect.scoped, Effect.provide(testLayer)),
690 );
691
692 it.effect("uses the latest native approval requirement and waits for its resolution event", () =>
693 Effect.gen(function* () {
694 const fake = yield* makeFakeMuse();
695 const harness = yield* makeHarness(fake);
696 const { nativeId } = yield* startConversation(harness, fake);
697 yield* fake.emit("approval/requested", approval(nativeId));
698 const first = yield* harness.takeEvent(
699 "runtime_request.updated",
700 (event) => event.runtimeRequest.status === "pending",
701 );
702 yield* fake.emit("approval/updated", {
703 ...approval(nativeId),
704 currentRequirementId: { approvalId: "approval-1", sourceIndex: 1 },
705 });
706 const refreshed = yield* harness.takeEvent(
707 "runtime_request.updated",
708 (event) => event.runtimeRequest.status === "pending",
709 );
710 assert.strictEqual(refreshed.runtimeRequest.id, first.runtimeRequest.id);
711 yield* harness.runtime.respondToRuntimeRequest({
712 requestId: first.runtimeRequest.id,
713 decision: "accept",
714 });
715 const decision = yield* fake.takeCall("approval/decide");
716 assert.deepStrictEqual(decision.params.requirementId, {
717 approvalId: "approval-1",
718 sourceIndex: 1,
719 });
720 assert.strictEqual(decision.params.choiceId, "once");
721 assert.isFalse(
722 harness.allEvents.some(
723 (event) =>
724 event.type === "runtime_request.updated" && event.runtimeRequest.status === "resolved",
725 ),
726 );
727 yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: nativeId });
728 const resolved = yield* harness.takeEvent(
729 "runtime_request.updated",
730 (event) => event.runtimeRequest.status === "resolved",
731 );
732 assert.strictEqual(resolved.runtimeRequest.id, first.runtimeRequest.id);
733 const repeated = yield* harness.runtime
734 .respondToRuntimeRequest({ requestId: first.runtimeRequest.id, decision: "accept" })
735 .pipe(Effect.exit);
736 assert.strictEqual(repeated._tag, "Failure");
737 }).pipe(Effect.scoped, Effect.provide(testLayer)),
738 );
739
740 it.effect("keeps the user's decision when Muse settles before acknowledging it", () =>
741 Effect.gen(function* () {
742 const fake = yield* makeFakeMuse();
743 const harness = yield* makeHarness(fake);
744 const { nativeId } = yield* startConversation(harness, fake);
745 yield* fake.emit("approval/requested", approval(nativeId));
746 const pending = yield* harness.takeEvent(
747 "runtime_request.updated",
748 (event) => event.runtimeRequest.status === "pending",
749 );
750 const acknowledgement = pendingPromise<Record<string, unknown>>();
751 fake.queueResponse("approval/decide", acknowledgement.promise);
752 const responding = yield* harness.runtime
753 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "decline" })
754 .pipe(Effect.forkScoped);
755 yield* fake.takeCall("approval/decide");
756 // Muse's native decision for this choice maps to decline too; "cancel" proves it was ours.
757 yield* fake.emit("approval/resolved", {
758 approvalId: "approval-1",
759 turnId: nativeId,
760 decision: "abort",
761 });
762 const resolved = yield* harness.takeEvent(
763 "runtime_request.updated",
764 (event) => event.runtimeRequest.status === "resolved",
765 );
766 assert.strictEqual(resolved.runtimeRequest.decision, "decline");
767 acknowledgement.resolve({});
768 yield* Fiber.join(responding);
769 }).pipe(Effect.scoped, Effect.provide(testLayer)),
770 );
771
772 it.effect("lets a user turn take a held Muse report turn, approvals included", () =>
773 Effect.gen(function* () {
774 const fake = yield* makeFakeMuse();
775 const offers: Array<ProviderContinuationRequest> = [];
776 const harness = yield* makeHarness(
777 fake,
778 INSTANCE_ID,
779 undefined,
780 undefined,
781 undefined,
782 runtimePolicy,
783 { continuationRequests: { offer: (request) => Effect.sync(() => offers.push(request)) } },
784 );
785 // A finished workflow's report turn starts while T3 runs nothing: it is held.
786 yield* fake.emit("turn/started", { turnId: "report-1" });
787 yield* Effect.yieldNow;
788 assert.lengthOf(offers, 1);
789 // The user sends a message first; Muse runs the report before it.
790 const { nativeId } = yield* startConversation(harness, fake);
791 yield* fake.emit("approval/requested", approval("report-1"));
792 const asked = yield* harness.takeEvent(
793 "runtime_request.updated",
794 (event) => event.runtimeRequest.status === "pending",
795 );
796 yield* harness.runtime.respondToRuntimeRequest({
797 requestId: asked.runtimeRequest.id,
798 decision: "accept",
799 });
800 yield* fake.takeCall("approval/decide");
801 yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: "report-1" });
802 yield* fake.emit("turn/completed", { turnId: "report-1", terminal: "completed" });
803 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
804 const terminal = yield* harness.takeEvent("turn.terminal");
805 assert.strictEqual(terminal.status, "completed");
806 // The user turn took it, so the continuation is not dispatched.
807 const dispatched = yield* offers[0]!.dispatchIfCurrent!(Effect.succeed("run"));
808 assert.isTrue(Option.isNone(dispatched));
809 }).pipe(Effect.scoped, Effect.provide(testLayer)),
810 );
811
812 it.effect("approves a workflow child's approval in full access after its turn ended", () =>
813 Effect.gen(function* () {
814 const fake = yield* makeFakeMuse();
815 const harness = yield* makeHarness(fake);
816 const { nativeId } = yield* startConversation(harness, fake);
817 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
818 yield* harness.takeEvent("turn.terminal");
819 // Muse runs workflow children without the session's allowAll mode.
820 const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } };
821 yield* fake.emit("approval/requested", childApproval);
822 const asked = yield* harness.takeEvent("runtime_request.updated");
823 assert.strictEqual(asked.runtimeRequest.status, "pending");
824 assert.isNull(asked.runtimeRequest.providerTurnId);
825 const decision = yield* fake.takeCall("approval/decide");
826 assert.strictEqual(decision.params.choiceId, "once");
827 yield* fake.emit("approval/resolved", childApproval);
828 const resolved = yield* harness.takeEvent("runtime_request.updated");
829 assert.strictEqual(resolved.runtimeRequest.status, "resolved");
830 assert.strictEqual(resolved.runtimeRequest.decision, "accept");
831 }).pipe(Effect.scoped, Effect.provide(testLayer)),
832 );
833
834 it.effect("keeps a workflow child's approval pending for the user past the turn's end", () =>
835 Effect.gen(function* () {
836 const fake = yield* makeFakeMuse();
837 const policy = ProviderAdapterV2RuntimePolicy.make({
838 ...runtimePolicy,
839 runtimeMode: "approval-required",
840 });
841 const harness = yield* makeHarness(
842 fake,
843 INSTANCE_ID,
844 undefined,
845 undefined,
846 undefined,
847 policy,
848 );
849 const { nativeId } = yield* startConversation(harness, fake);
850 const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } };
851 yield* fake.emit("approval/requested", childApproval);
852 const asked = yield* harness.takeEvent("runtime_request.updated");
853 assert.isNull(asked.runtimeRequest.providerTurnId);
854 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
855 yield* harness.takeEvent("turn.terminal");
856 assert.isFalse(
857 harness.allEvents.some(
858 (event) =>
859 event.type === "runtime_request.updated" && event.runtimeRequest.status !== "pending",
860 ),
861 );
862 yield* harness.runtime.respondToRuntimeRequest({
863 requestId: asked.runtimeRequest.id,
864 decision: "decline",
865 });
866 const decision = yield* fake.takeCall("approval/decide");
867 assert.strictEqual(decision.params.choiceId, "deny");
868 }).pipe(Effect.scoped, Effect.provide(testLayer)),
869 );
870
871 it.effect("validates structured answers and preserves selected labels with a custom note", () =>
872 Effect.gen(function* () {
873 const fake = yield* makeFakeMuse();
874 const harness = yield* makeHarness(fake);
875 const { nativeId } = yield* startConversation(harness, fake);
876 yield* fake.emit("userInput/requested", {
877 userInputId: "question-1",
878 turnId: nativeId,
879 questions: [
880 {
881 id: "approach",
882 header: "Approach",
883 question: "Which approach?",
884 options: [{ label: "Small" }, { label: "Large" }],
885 selection: { mode: "single" },
886 },
887 ],
888 });
889 const pending = yield* harness.takeEvent(
890 "runtime_request.updated",
891 (event) => event.runtimeRequest.kind === "user_input",
892 );
893 const invalid = yield* harness.runtime
894 .respondToRuntimeRequest({
895 requestId: pending.runtimeRequest.id,
896 answers: { approach: ["Small", "Large"] },
897 })
898 .pipe(Effect.exit);
899 assert.strictEqual(invalid._tag, "Failure");
900 assert.isFalse(fake.calls.some((call) => call.method === "userInput/answer"));
901 yield* harness.runtime.respondToRuntimeRequest({
902 requestId: pending.runtimeRequest.id,
903 answers: { approach: ["Small", "Keep tests focused"] },
904 });
905 const response = yield* fake.takeCall("userInput/answer");
906 assert.deepStrictEqual(response.params.answers, [
907 { questionId: "approach", selectedLabel: "Small", note: "Keep tests focused" },
908 ]);
909 yield* fake.emit("userInput/settled", {
910 userInputId: "question-1",
911 turnId: nativeId,
912 answers: response.params.answers,
913 });
914 yield* harness.takeEvent(
915 "runtime_request.updated",
916 (event) => event.runtimeRequest.status === "resolved",
917 );
918 }).pipe(Effect.scoped, Effect.provide(testLayer)),
919 );
920
921 it.effect("steers only the active native turn and waits for confirmed interruption", () =>
922 Effect.gen(function* () {
923 const fake = yield* makeFakeMuse();
924 const harness = yield* makeHarness(fake);
925 const turn = yield* startConversation(harness, fake);
926 const input = yield* turnInput(harness.providerThread);
927 const invalid = yield* harness.runtime.steerTurn!({
928 threadId: THREAD_ID,
929 runId: input.runId,
930 providerThread: harness.providerThread,
931 providerTurnId: ProviderTurnId.make("unrelated-turn"),
932 message: input.message,
933 }).pipe(Effect.exit);
934 assert.strictEqual(invalid._tag, "Failure");
935 assert.isFalse(fake.calls.some((call) => call.method === "turn/steer"));
936 yield* harness.runtime.steerTurn!({
937 threadId: THREAD_ID,
938 runId: input.runId,
939 providerThread: harness.providerThread,
940 providerTurnId: turn.providerTurn.id,
941 message: { ...input.message, text: "Focus on the parser" },
942 });
943 assert.strictEqual((yield* fake.takeCall("turn/steer")).params.expectedTurnId, turn.nativeId);
944 const interrupt = yield* harness.runtime
945 .interruptTurn({
946 providerThread: harness.providerThread,
947 providerTurnId: turn.providerTurn.id,
948 })
949 .pipe(Effect.forkScoped);
950 assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, turn.nativeId);
951 yield* fake.emit("turn/completed", { turnId: turn.nativeId, terminal: "cancelled" });
952 yield* Fiber.join(interrupt);
953 assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted");
954 }).pipe(Effect.scoped, Effect.provide(testLayer)),
955 );
956
957 it.effect("resumes the requested native session and reads the same session snapshot", () =>
958 Effect.gen(function* () {
959 const fake = yield* makeFakeMuse();
960 const nativeItem: MuseItem = {
961 itemId: "prior-message",
962 turnId: "prior-turn",
963 kind: "agentMessage",
964 status: "completed",
965 revision: 1,
966 text: "Previous answer",
967 };
968 fake.setHistory([nativeItem]);
969 const harness = yield* makeHarness(fake, INSTANCE_ID, "saved-session");
970 const resumed = yield* fake.takeCall("session/resume");
971 assert.strictEqual(resumed.params.sessionId, "saved-session");
972 assert.strictEqual(resumed.params.excludeItems, true);
973 assert.isFalse(fake.calls.some((call) => call.method === "session/start"));
974 assert.strictEqual(harness.providerThread.nativeThreadRef?.nativeId, "saved-session");
975 const snapshot = yield* harness.runtime.readThreadSnapshot!({
976 providerThread: harness.providerThread,
977 });
978 assert.strictEqual(snapshot.providerThread.nativeThreadRef?.nativeId, "saved-session");
979 assert.deepStrictEqual(snapshot.providerPayload, [nativeItem]);
980 assert.strictEqual(snapshot.messages[0]?.text, "Previous answer");
981 }).pipe(Effect.scoped, Effect.provide(testLayer)),
982 );
983
984 it.effect("serializes interruption behind pending turn admission without losing the target", () =>
985 Effect.gen(function* () {
986 const fake = yield* makeFakeMuse();
987 const harness = yield* makeHarness(fake);
988 const admission = pendingPromise<Record<string, unknown>>();
989 fake.queueResponse("turn/start", admission.promise);
990 const starting = yield* harness.runtime
991 .startTurn(yield* turnInput(harness.providerThread))
992 .pipe(Effect.forkScoped);
993 const call = yield* fake.takeCall("turn/start");
994 const running = yield* harness.takeEvent(
995 "provider_turn.updated",
996 (event) => event.providerTurn.status === "running",
997 );
998 const stopping = yield* harness.runtime
999 .interruptTurn({
1000 providerThread: harness.providerThread,
1001 providerTurnId: running.providerTurn.id,
1002 })
1003 .pipe(Effect.forkScoped);
1004 admission.resolve({ turnId: call.commandId });
1005 yield* Fiber.join(starting);
1006 assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, call.commandId);
1007 yield* fake.emit("turn/completed", { turnId: call.commandId, terminal: "cancelled" });
1008 yield* Fiber.join(stopping);
1009 assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted");
1010 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1011 );
1012
1013 it.effect("reports a no-op compaction as a visible failure and permits another turn", () =>
1014 Effect.gen(function* () {
1015 const fake = yield* makeFakeMuse();
1016 const harness = yield* makeHarness(fake);
1017 fake.queueResponse("session/compact", { status: "noop", reason: "no_compactable_history" });
1018 yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread));
1019 const terminal = yield* harness.takeEvent("turn.terminal");
1020 assert.strictEqual(terminal.status, "failed");
1021 assert.strictEqual(terminal.threadDisposition, "reusable");
1022 yield* startConversation(harness, fake, 2);
1023 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1024 );
1025
1026 it.effect("projects native todos with stable identity and removes cancelled steps", () =>
1027 Effect.gen(function* () {
1028 const fake = yield* makeFakeMuse();
1029 const harness = yield* makeHarness(fake);
1030 const { nativeId } = yield* startConversation(harness, fake);
1031 // Neither the tool call that wrote the list nor Muse's per-step reminder
1032 // child may show as a generic tool row.
1033 yield* fake.emit("item/completed", {
1034 turnId: nativeId,
1035 item: {
1036 itemId: "todo-tool",
1037 turnId: nativeId,
1038 kind: "toolCall",
1039 tool: "write_todos",
1040 revision: 1,
1041 status: "completed",
1042 },
1043 });
1044 yield* fake.emit("item/started", {
1045 turnId: nativeId,
1046 item: {
1047 itemId: "reminder",
1048 turnId: nativeId,
1049 kind: "reminderChild",
1050 revision: 1,
1051 status: "inProgress",
1052 fallbackText: "Reminder child session",
1053 },
1054 });
1055 yield* fake.emit("session/todoListChanged", {
1056 items: [
1057 { text: " Investigate ", status: "completed" },
1058 { text: "Fix", status: "inProgress" },
1059 { text: "Discarded", status: "cancelled" },
1060 { text: " ", status: "pending" },
1061 ],
1062 });
1063 const first = yield* harness.takeEvent(
1064 "turn_item.updated",
1065 (event) => event.turnItem.type === "todo_list",
1066 );
1067 assert.strictEqual(first.turnItem.type, "todo_list");
1068 if (first.turnItem.type !== "todo_list") return;
1069 assert.deepStrictEqual(
1070 first.turnItem.steps.map(({ text, status }) => ({ text, status })),
1071 [
1072 { text: "Investigate", status: "completed" },
1073 { text: "Fix", status: "running" },
1074 ],
1075 );
1076 const planId = first.turnItem.planId;
1077 yield* fake.emit("session/todoListChanged", {
1078 items: [
1079 { text: "Investigate", status: "completed" },
1080 { text: "Fix", status: "completed" },
1081 ],
1082 });
1083 const next = yield* harness.takeEvent(
1084 "turn_item.updated",
1085 (event) => event.turnItem.type === "todo_list",
1086 );
1087 assert.strictEqual(next.turnItem.id, first.turnItem.id);
1088 assert.strictEqual(next.turnItem.nodeId, first.turnItem.nodeId);
1089 if (next.turnItem.type !== "todo_list") return;
1090 assert.strictEqual(next.turnItem.planId, first.turnItem.planId);
1091 assert.isTrue(
1092 harness.allEvents.some(
1093 (event) =>
1094 event.type === "plan.updated" &&
1095 event.plan.id === planId &&
1096 event.plan.status === "completed",
1097 ),
1098 );
1099 assert.isFalse(
1100 harness.allEvents.some(
1101 (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool",
1102 ),
1103 );
1104 assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsPlanUpdated);
1105 assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsTodoList);
1106 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1107 );
1108});
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts+16-14
......@@ -16,33 +16,31 @@
1616import * as NodeServices from "@effect/platform-node/NodeServices";
1717import { Connection, type DuplexTransport } from "@muse-code/sdk";
1818import {
19 MuseSettings,
2019 ProviderDriverKind,
2120 ProviderInstanceId,
2221 ProviderReplayEntry,
2322 type ProviderReplayEntry as ProviderReplayEntryType,
2423 type ProviderReplayTranscript,
2524} from "@t3tools/contracts";
25import { MuseSettings } from "@t3tools/provider-muse/settings";
2626import * as Effect from "effect/Effect";
2727import * as FileSystem from "effect/FileSystem";
2828import * as Layer from "effect/Layer";
2929import * as Schema from "effect/Schema";
3030
31import * as ServerConfig from "../../config.ts";
31import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
32import { layerTestProviderHost } from "@t3tools/provider-testing/host";
3233import {
3334 museInitializeParams,
3435 museServeArgs,
3536 type MuseSdkHost,
3637 type MuseSdkHostOptions,
37} from "../../provider/museSdk.ts";
38} from "@t3tools/provider-muse/testing";
3839import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
3940import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
4041import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
41import {
42 makeReplayServerConfig,
43 type OrchestratorV2ProviderReplayHarness,
44} from "../testkit/ProviderReplayHarness.ts";
45import { makeMuseAdapterV2 } from "./MuseAdapterV2.ts";
42import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
43import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server";
4644
4745export const MUSE_PROVIDER_KIND = "muse";
4846export const MUSE_MSP_REPLAY_PROTOCOL = "muse.msp-jsonl";
......@@ -448,10 +446,6 @@ export function layer(input: {
448446 /** Replay needs none; a live recording passes the host environment. */
449447 readonly environment?: NodeJS.ProcessEnv;
450448}) {
451 const layerServerConfig = Layer.effect(
452 ServerConfig.ServerConfig,
453 makeReplayServerConfig(`muse-${input.scenario}`).pipe(Effect.orDie),
454 ).pipe(Layer.provide(NodeServices.layer));
455449 return ProviderAdapterRegistry.layerFromAdaptersEffect(
456450 Effect.gen(function* () {
457451 return [
......@@ -460,7 +454,7 @@ export function layer(input: {
460454 settings: DEFAULT_MUSE_SETTINGS,
461455 environment: input.environment ?? {},
462456 idAllocator: yield* IdAllocator.IdAllocatorV2,
463 serverConfig: yield* ServerConfig.ServerConfig,
457 host: yield* ProviderHost,
464458 fileSystem: yield* FileSystem.FileSystem,
465459 createHost: input.createHost,
466460 // Same queue the continuation worker drains when the fixture runs it.
......@@ -468,7 +462,15 @@ export function layer(input: {
468462 }),
469463 ];
470464 }),
471 ).pipe(Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer)));
465 ).pipe(
466 Layer.provide(
467 Layer.mergeAll(
468 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
469 NodeServices.layer,
470 IdAllocator.layer,
471 ),
472 ),
473 );
472474}
473475
474476export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts deleted-2058
......@@ -1,2058 +0,0 @@
1import { MspError, type SendUserTurnOptions } from "@muse-code/sdk";
2import {
3 MUSE_DEFAULT_MODEL,
4 type MuseSettings,
5 ProviderDriverKind,
6 type ModelSelection,
7 type OrchestrationV2ConversationMessage,
8 type OrchestrationV2ExecutionNode,
9 type OrchestrationV2ProviderCapabilities,
10 type OrchestrationV2ProviderSession,
11 type OrchestrationV2ProviderThread,
12 type OrchestrationV2ProviderTurn,
13 type OrchestrationV2RuntimeRequest,
14 type OrchestrationV2Subagent,
15 type OrchestrationV2TurnItemStatus,
16 type NodeId,
17 type OrchestrationV2TurnItem,
18 type ProviderInstanceId,
19 type PlanId,
20 type OrchestrationV2PlanStep,
21 type ProviderApprovalDecision,
22 type RuntimeRequestId,
23 type ServerProviderModel,
24} from "@t3tools/contracts";
25import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
26import * as Cause from "effect/Cause";
27import * as DateTime from "effect/DateTime";
28import * as Deferred from "effect/Deferred";
29import * as Effect from "effect/Effect";
30import * as Option from "effect/Option";
31import * as FileSystem from "effect/FileSystem";
32import * as Queue from "effect/Queue";
33import * as Schema from "effect/Schema";
34import * as Semaphore from "effect/Semaphore";
35import * as Scope from "effect/Scope";
36import * as Stream from "effect/Stream";
37
38import { resolveAttachmentPath } from "../../attachmentStore.ts";
39import * as ServerConfig from "../../config.ts";
40import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
41import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
42import {
43 museModelCapabilities,
44 resolveMuseReasoningEffort,
45} from "../../provider/museModelCatalog.ts";
46import {
47 MuseApproval,
48 MuseCompactResult,
49 MuseContextUsage,
50 MuseDelta,
51 MuseItemEvent,
52 MuseSessionResult,
53 MuseTokenUsageEvent,
54 MuseTodoList,
55 MuseTurnRetryScheduled,
56 MuseTurnCompleted,
57 MuseTurnStartResult,
58 MuseUserInput,
59 museApprovalChoices,
60 museApprovalDecision,
61 museApprovalOptions,
62 type MuseItem,
63} from "../../provider/museProtocol.ts";
64import {
65 createMuseSdkHostEffect,
66 museApprovalMode,
67 type createMuseSdkHost,
68 type MuseSdkHost,
69} from "../../provider/museSdk.ts";
70import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts";
71import {
72 providerMessageTextWithAttachmentPaths,
73 isProviderNativeImageAttachment,
74} from "@t3tools/provider-core/server/attachmentPrompt";
75import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
76import {
77 ProviderAdapterEnsureThreadError,
78 ProviderAdapterForkThreadError,
79 ProviderAdapterInterruptError,
80 ProviderAdapterOpenSessionError,
81 ProviderAdapterProtocolError,
82 ProviderAdapterReadThreadSnapshotError,
83 ProviderAdapterResumeThreadError,
84 ProviderAdapterRollbackThreadError,
85 ProviderAdapterRuntimeRequestResponseError,
86 ProviderAdapterSteerRunError,
87 ProviderAdapterTurnStartError,
88 ProviderAdapterV2,
89 type ProviderAdapterV2Error,
90 type ProviderAdapterV2Event,
91 type ProviderAdapterV2EnsureThreadInput,
92 type ProviderAdapterV2OpenSessionInput,
93 type ProviderAdapterV2SessionRuntime,
94 type ProviderAdapterV2Shape,
95 type ProviderAdapterV2ThreadSnapshot,
96 type ProviderAdapterV2TurnInput,
97 type ProviderAdapterV2TurnMessage,
98} from "@t3tools/provider-core/server/ProviderAdapter";
99import {
100 backgroundWorkNotification,
101 type BackgroundWorkReport,
102} from "@t3tools/provider-core/server/notification";
103import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
104import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
105import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
106import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts";
107
108const MUSE_PROVIDER = ProviderDriverKind.make("muse");
109const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError);
110const isProtocolError = Schema.is(ProviderAdapterProtocolError);
111
112const MuseProviderCapabilitiesV2 = {
113 runtimePolicy: { enforcement: "native" },
114 sessions: {
115 supportsMultipleProviderThreadsPerSession: false,
116 supportsModelSwitchInSession: true,
117 supportsProviderSwitchingViaHandoff: true,
118 supportsRuntimeModeSwitchInSession: false,
119 pendingRequestsSurviveRestart: false,
120 },
121 threads: {
122 canCreateEmptyThread: true,
123 canReadThreadSnapshot: true,
124 // Muse 1.2.1/1.3.0 reject tested completed-turn fork boundaries with InvalidCut.
125 // Public forks use portable context handoff until native boundaries are validated.
126 canRollbackThread: false,
127 canForkThread: false,
128 canForkFromTurn: false,
129 canForkFromSubagentThread: false,
130 exposesNativeThreadId: true,
131 },
132 turns: {
133 exposesNativeTurnId: true,
134 emitsTurnStarted: true,
135 emitsTurnCompleted: true,
136 supportsInterrupt: true,
137 supportsActiveSteering: true,
138 supportsSteeringByInterruptRestart: false,
139 supportsQueuedMessages: true,
140 terminalStatusQuality: "strong",
141 },
142 streaming: {
143 streamsAssistantText: true,
144 streamsReasoning: true,
145 streamsToolOutput: true,
146 streamsPlanText: false,
147 emitsMessageCompleted: true,
148 },
149 tools: {
150 exposesToolItemIds: true,
151 emitsToolStarted: true,
152 emitsToolCompleted: true,
153 emitsToolOutput: true,
154 supportsMcpTools: true,
155 supportsDynamicToolCallbacks: false,
156 },
157 approvals: {
158 supportsCommandApproval: true,
159 supportsFileReadApproval: true,
160 supportsFileChangeApproval: true,
161 supportsApplyPatchApproval: false,
162 approvalsHaveNativeRequestIds: true,
163 approvalCallbacksAreLiveOnly: true,
164 approvalsCanOriginateFromSubagents: true,
165 },
166 planning: {
167 emitsPlanUpdated: true,
168 emitsTodoList: true,
169 emitsProposedPlan: false,
170 supportsStructuredQuestions: true,
171 planDeltasHaveItemIds: false,
172 },
173 subagents: {
174 supportsSubagents: false,
175 exposesSubagentThreadIds: false,
176 emitsSubagentLifecycle: false,
177 canWaitForSubagents: false,
178 canCloseSubagents: false,
179 canForkSubagentThread: false,
180 },
181 context: {
182 acceptsSystemContext: false,
183 acceptsDeveloperContext: false,
184 acceptsSyntheticUserContext: true,
185 canGenerateSummaries: false,
186 canConsumeHandoffSummaries: true,
187 supportsDeltaHandoff: true,
188 supportsFullThreadHandoff: true,
189 maxRecommendedHandoffChars: null,
190 },
191 checkpointing: {
192 appCanCheckpointFilesystem: true,
193 supportsNestedCheckpointScopes: false,
194 providerCanRollbackConversation: false,
195 providerRollbackReturnsSnapshot: false,
196 providerCanReadConversationSnapshot: true,
197 },
198 identity: {
199 nativeThreadIds: "strong",
200 nativeTurnIds: "strong",
201 nativeItemIds: "strong",
202 nativeRequestIds: "strong",
203 },
204} satisfies OrchestrationV2ProviderCapabilities;
205
206export interface MuseAdapterV2Options {
207 readonly instanceId: ProviderInstanceId;
208 readonly settings: MuseSettings;
209 readonly environment: NodeJS.ProcessEnv;
210 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
211 readonly serverConfig: ServerConfig.ServerConfig["Service"];
212 readonly fileSystem: FileSystem.FileSystem;
213 readonly modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>;
214 readonly createHost?: typeof createMuseSdkHost;
215 readonly requestTimeoutMs?: number;
216 readonly nativeEventLogger?: EventNdjsonLogger;
217 /** Where a turn Muse starts on its own (a finished workflow's report) asks for its run. */
218 readonly continuationRequests?: {
219 readonly offer: (
220 request: ProviderContinuationRequests.ProviderContinuationRequest,
221 ) => Effect.Effect<void>;
222 };
223}
224
225interface ActiveTurn {
226 readonly input: ProviderAdapterV2TurnInput;
227 providerTurn: OrchestrationV2ProviderTurn;
228 readonly nativeId: string;
229 /** Turns Muse started on its own while this run was active, which this run shows. */
230 readonly joined: Set<string>;
231 readonly items: Map<string, MuseItem>;
232 readonly ordinals: Map<string, number>;
233 readonly started: Map<string, DateTime.Utc>;
234 readonly dirty: Set<string>;
235 readonly settledRequests: Set<string>;
236 readonly done: Deferred.Deferred<void>;
237 todoPlanId?: PlanId;
238 nextOrdinal: number;
239 flushScheduled: boolean;
240 interruptRequested: boolean;
241 compact: boolean;
242}
243
244interface PendingRequest {
245 readonly native:
246 | { type: "approval"; value: MuseApproval }
247 | { type: "question"; value: MuseUserInput };
248 request: OrchestrationV2RuntimeRequest;
249 node: OrchestrationV2ExecutionNode;
250 item: OrchestrationV2TurnItem;
251 /** What T3 answered, recorded on the request once Muse confirms it. */
252 response?: Pick<OrchestrationV2RuntimeRequest, "decision" | "answers">;
253}
254
255const nativeRef = (nativeId: string) => ({
256 driver: MUSE_PROVIDER,
257 nativeId,
258 strength: "strong" as const,
259});
260const recordSchema = Schema.Record(Schema.String, Schema.Unknown);
261// Notifications that only add detail. A malformed one is skipped instead of ending the session.
262const INFORMATIONAL_NOTIFICATIONS = new Set([
263 "session/contextUsage",
264 "session/tokenUsage",
265 "session/todoListChanged",
266 "turn/retryScheduled",
267]);
268// Tools whose results already show as their own rows: T3's todo list and question
269// rows, and the workflow item a `workflow` call launches.
270const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]);
271const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
272
273/** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */
274export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapterV2Shape {
275 const { idAllocator } = options;
276 const protocolError = (detail: string, payload?: unknown) =>
277 new ProviderAdapterProtocolError({
278 driver: MUSE_PROVIDER,
279 detail,
280 ...(payload === undefined ? {} : { payload }),
281 });
282 return ProviderAdapterV2.of({
283 instanceId: options.instanceId,
284 driver: MUSE_PROVIDER,
285 getCapabilities: () => Effect.succeed(MuseProviderCapabilitiesV2),
286 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
287 openSession: Effect.fn("MuseAdapterV2.openSession")(function* (
288 input: ProviderAdapterV2OpenSessionInput,
289 ) {
290 if (!options.settings.enabled) return yield* protocolError("Muse Code is disabled");
291 if (input.runtimePolicy.interactionMode === "plan")
292 return yield* protocolError("Muse Code does not support dedicated Plan mode");
293 const scope = yield* Effect.scope;
294 // Muse rejects non-canonical workspace roots (for example macOS /tmp) and
295 // reports canonical paths in approvals, so resolve symlinks once here.
296 const requestedCwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd;
297 const cwd = yield* options.fileSystem
298 .realPath(requestedCwd)
299 .pipe(Effect.orElseSucceed(() => requestedCwd));
300 const now = yield* DateTime.now;
301 let session: OrchestrationV2ProviderSession = {
302 id: input.providerSessionId,
303 driver: MUSE_PROVIDER,
304 providerInstanceId: options.instanceId,
305 status: "ready",
306 cwd,
307 model: input.modelSelection.model,
308 capabilities: MuseProviderCapabilitiesV2,
309 createdAt: now,
310 updatedAt: now,
311 lastError: null,
312 };
313 const events = yield* Queue.unbounded<
314 ProviderAdapterV2Event,
315 ProviderAdapterV2Error | Cause.Done
316 >();
317 type Inbox =
318 | { type: "notification"; method: string; params: unknown; epoch: number }
319 | { type: "failure"; cause: unknown; epoch: number }
320 | { type: "flush"; turn: ActiveTurn };
321 const inbox = yield* Queue.unbounded<Inbox>();
322 const commands = yield* Semaphore.make(1);
323 const eventPermit = yield* Semaphore.make(1);
324 let host: MuseSdkHost;
325 let hostEpoch = 0;
326 let closed = false;
327 let broken = false;
328 let nativeSessionId: string | undefined;
329 let thread: OrchestrationV2ProviderThread | undefined;
330 let active: ActiveTurn | undefined;
331 // The last finished turn, so context usage reported after it still lands on it.
332 let lastProviderTurn: OrchestrationV2ProviderTurn | undefined;
333 const agentEndedAt = new Map<string, DateTime.Utc>();
334 // "default" is the catalog's default model, from the provider snapshot. Without
335 // one, Muse keeps whatever model the session already runs.
336 const resolveModel = Effect.fnUntraced(function* (selection: ModelSelection) {
337 if (selection.model !== MUSE_DEFAULT_MODEL) return selection.model;
338 const catalog = options.modelCatalog ? yield* options.modelCatalog : [];
339 return catalog.find((model) => model.isDefault && !model.isCustom)?.slug;
340 });
341 const pending = new Map<RuntimeRequestId, PendingRequest>();
342 const seenTerminals = new Set<string>();
343 // Workflows and subagents that outlive their turn, by item id, with the turn that owns them.
344 const observedChildren = new Map<string, ActiveTurn>();
345 // Background work that finished since the last turn, reported to the turn Muse starts for it.
346 const finishedBackground: Array<BackgroundWorkReport> = [];
347 // A turn Muse started on its own, held until the continuation run T3 opens for it takes it.
348 let wake:
349 | { readonly nativeId: string; readonly events: Array<[string, unknown]> }
350 | undefined;
351 const emit = (event: ProviderAdapterV2Event) =>
352 Queue.offer(events, event).pipe(Effect.asVoid);
353 const decode = <A, I>(schema: Schema.Codec<A, I>, data: unknown) =>
354 Schema.decodeUnknownEffect(schema)(data).pipe(
355 Effect.mapError((cause) => protocolError("Invalid Muse protocol response", cause)),
356 );
357 const request = (
358 method: string,
359 params: Record<string, unknown>,
360 command = true,
361 commandId?: string,
362 ) =>
363 Effect.tryPromise({
364 try: () =>
365 command
366 ? host.connection.command(
367 method,
368 { ...(nativeSessionId ? { sessionId: nativeSessionId } : {}), ...params },
369 {
370 ...(method === "turn/start" ? {} : { maxAttempts: 1 }),
371 ...(commandId ? { commandId } : {}),
372 },
373 )
374 : host.connection.request(method, {
375 ...(nativeSessionId ? { sessionId: nativeSessionId } : {}),
376 ...params,
377 }),
378 catch: (cause) =>
379 protocolError(
380 cause instanceof Error && cause.message
381 ? `Muse ${method} failed: ${cause.message}`
382 : `Muse ${method} failed`,
383 cause,
384 ),
385 }).pipe(
386 Effect.timeout(options.requestTimeoutMs ?? 30_000),
387 Effect.catchTags({
388 TimeoutError: (cause) =>
389 Effect.gen(function* () {
390 yield* eventPermit.withPermits(1)(failHost(cause));
391 return yield* protocolError(`Muse ${method} timed out; its host was closed`, cause);
392 }),
393 }),
394 );
395 const updateSession = Effect.fnUntraced(function* (
396 status: OrchestrationV2ProviderSession["status"],
397 lastError: string | null = null,
398 ) {
399 session = { ...session, status, lastError, updatedAt: yield* DateTime.now };
400 yield* emit({
401 type: "provider_session.updated",
402 driver: MUSE_PROVIDER,
403 providerSession: session,
404 });
405 });
406 const updateThread = Effect.fnUntraced(function* (
407 patch: Partial<OrchestrationV2ProviderThread>,
408 ) {
409 if (!thread) return;
410 thread = { ...thread, ...patch, updatedAt: yield* DateTime.now };
411 yield* emit({
412 type: "provider_thread.updated",
413 driver: MUSE_PROVIDER,
414 providerThread: thread,
415 });
416 });
417 const itemIdentity = (turn: ActiveTurn, nativeId: string) =>
418 `${options.instanceId}:${turn.input.providerThread.id}:${turn.nativeId}:${nativeId}`;
419 const baseItem = (turn: ActiveTurn, nativeId: string, time: DateTime.Utc) => {
420 if (!turn.ordinals.has(nativeId)) turn.ordinals.set(nativeId, turn.nextOrdinal++);
421 if (!turn.started.has(nativeId)) turn.started.set(nativeId, time);
422 const identity = itemIdentity(turn, nativeId);
423 return {
424 id: idAllocator.derive.turnItemFromProviderItem({
425 driver: MUSE_PROVIDER,
426 nativeItemId: identity,
427 }),
428 nodeId: idAllocator.derive.nodeFromProviderItem({
429 driver: MUSE_PROVIDER,
430 nativeItemId: identity,
431 }),
432 threadId: turn.input.threadId,
433 runId: turn.input.runId,
434 providerThreadId: turn.input.providerThread.id,
435 providerTurnId: turn.providerTurn.id,
436 nativeItemRef: nativeRef(nativeId),
437 parentItemId: null,
438 ordinal: turn.ordinals.get(nativeId)!,
439 startedAt: turn.started.get(nativeId)!,
440 updatedAt: time,
441 };
442 };
443 /** Lists running workflows and subagents on the thread, so it shows it still waits on them. */
444 const syncBackground = Effect.fnUntraced(function* () {
445 yield* updateThread({
446 pendingBackgroundTasks: [...observedChildren].flatMap(([itemId, owner]) => {
447 const item = owner.items.get(itemId);
448 const description = item?.objective ?? item?.entryId ?? item?.fallbackText;
449 return [
450 {
451 taskId: itemId,
452 kind:
453 item?.kind === "subagent" ? ("subagent" as const) : ("background_task" as const),
454 ...(description ? { description } : {}),
455 },
456 ];
457 }),
458 });
459 });
460 const publishItem = Effect.fnUntraced(function* (
461 turn: ActiveTurn,
462 item: MuseItem,
463 terminal?: OrchestrationV2TurnItem["status"],
464 ) {
465 // Muse's skill-reminder child runs on every model step; it is housekeeping, not work.
466 if (item.kind === "userMessage" || item.kind === "reminderChild") return;
467 // A failed call keeps its row: it may have no native row to show its error.
468 if (
469 item.kind === "toolCall" &&
470 item.tool &&
471 TOOLS_WITH_NATIVE_ROWS.has(item.tool) &&
472 item.status !== "failed"
473 )
474 return;
475 const time = yield* DateTime.now;
476 const status = terminal ?? museItemStatus(item);
477 if (item.kind === "subagent" || item.kind === "workflow") {
478 const wasRunning = observedChildren.has(item.itemId);
479 if (status === "running") observedChildren.set(item.itemId, turn);
480 else if (observedChildren.delete(item.itemId) && active !== turn)
481 finishedBackground.push({
482 kind: item.kind === "subagent" ? "subagent" : "background_task",
483 label: item.objective ?? item.entryId ?? item.fallbackText,
484 outcome:
485 status === "completed"
486 ? "completed"
487 : status === "cancelled"
488 ? "cancelled"
489 : "failed",
490 });
491 if (wasRunning !== observedChildren.has(item.itemId)) yield* syncBackground();
492 }
493 const streaming = status === "running";
494 const base = {
495 ...baseItem(turn, item.itemId, time),
496 status,
497 completedAt: streaming ? null : time,
498 title: item.tool ?? null,
499 };
500 const text = item.text ?? item.summary?.join("\n") ?? item.fallbackText ?? "";
501 let turnItem: OrchestrationV2TurnItem;
502 if (item.kind === "agentMessage") {
503 const messageId = idAllocator.derive.messageFromProviderItem({
504 driver: MUSE_PROVIDER,
505 nativeItemId: itemIdentity(turn, item.itemId),
506 });
507 const message: OrchestrationV2ConversationMessage = {
508 id: messageId,
509 threadId: turn.input.threadId,
510 runId: turn.input.runId,
511 nodeId: base.nodeId,
512 role: "assistant",
513 text,
514 attachments: [],
515 streaming,
516 createdBy: "agent",
517 creationSource: "provider",
518 createdAt: base.startedAt,
519 updatedAt: time,
520 };
521 yield* emit({ type: "message.updated", driver: MUSE_PROVIDER, message });
522 turnItem = { ...base, type: "assistant_message", messageId, text, streaming };
523 } else if (item.kind === "reasoning") {
524 turnItem = { ...base, type: "reasoning", text, streaming };
525 } else if (item.kind === "compaction") {
526 turnItem = {
527 ...base,
528 type: "compaction",
529 driver: MUSE_PROVIDER,
530 summary: text || item.reason,
531 };
532 } else {
533 turnItem = { ...base, ...museToolPresentation(item, status) };
534 }
535 yield* emit({
536 type: "node.updated",
537 driver: MUSE_PROVIDER,
538 node: {
539 id: base.nodeId,
540 threadId: base.threadId,
541 runId: base.runId,
542 parentNodeId: turn.input.rootNodeId,
543 rootNodeId: turn.input.rootNodeId,
544 kind:
545 item.kind === "agentMessage"
546 ? "assistant_message"
547 : item.kind === "reasoning"
548 ? "reasoning"
549 : "tool_call",
550 status,
551 countsForRun: false,
552 providerThreadId: base.providerThreadId,
553 providerTurnId: base.providerTurnId,
554 nativeItemRef: base.nativeItemRef,
555 runtimeRequestId: null,
556 checkpointScopeId: null,
557 startedAt: base.startedAt,
558 completedAt: base.completedAt,
559 },
560 });
561 yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem });
562 if (item.kind === "workflow") yield* publishWorkflowAgents(turn, item, base, status);
563 });
564 /**
565 * Shows each workflow child as a native subagent under the workflow row. Muse
566 * reports only their status, label and outcome, not their conversations, so
567 * they get no child thread.
568 */
569 const publishWorkflowAgents = Effect.fnUntraced(function* (
570 turn: ActiveTurn,
571 item: MuseItem,
572 workflow: { readonly id: OrchestrationV2TurnItem["id"]; readonly nodeId: NodeId },
573 workflowStatus: OrchestrationV2TurnItemStatus,
574 ) {
575 const latest = new Map((item.children ?? []).map((child) => [child.childId, child]));
576 for (const [index, child] of [...latest.values()].entries()) {
577 const time = yield* DateTime.now;
578 const base = baseItem(turn, `${item.itemId}:agent:${child.childId}`, time);
579 const status: OrchestrationV2TurnItemStatus =
580 child.terminal === "completed"
581 ? "completed"
582 : child.terminal === "cancelled"
583 ? "cancelled"
584 : child.terminal !== undefined
585 ? "failed"
586 : workflowStatus !== "running"
587 ? workflowStatus
588 : child.status === "scheduled"
589 ? "pending"
590 : "running";
591 const settled = status !== "running" && status !== "pending";
592 // Children are re-sent on every workflow change; keep each one's first end time.
593 const endKey = `${item.itemId}:${child.childId}:${child.attempt}`;
594 if (settled && !agentEndedAt.has(endKey)) agentEndedAt.set(endKey, time);
595 const title = child.label?.trim() || `Agent ${index + 1}`;
596 const result = child.failureReason ?? null;
597 const subagent: OrchestrationV2Subagent = {
598 id: base.nodeId,
599 threadId: base.threadId,
600 runId: base.runId,
601 parentNodeId: workflow.nodeId,
602 origin: "provider_native",
603 createdBy: "agent",
604 driver: MUSE_PROVIDER,
605 providerInstanceId: options.instanceId,
606 providerThreadId: base.providerThreadId,
607 childThreadId: null,
608 nativeTaskRef: nativeRef(child.childId),
609 prompt: title,
610 title,
611 model: null,
612 status,
613 result,
614 startedAt: base.startedAt,
615 completedAt: settled ? (agentEndedAt.get(endKey) ?? time) : null,
616 updatedAt: time,
617 };
618 yield* emit({ type: "subagent.updated", driver: MUSE_PROVIDER, subagent });
619 yield* emit({
620 type: "node.updated",
621 driver: MUSE_PROVIDER,
622 node: {
623 id: base.nodeId,
624 threadId: base.threadId,
625 runId: base.runId,
626 parentNodeId: workflow.nodeId,
627 rootNodeId: turn.input.rootNodeId,
628 kind: "subagent",
629 status,
630 countsForRun: false,
631 providerThreadId: base.providerThreadId,
632 providerTurnId: base.providerTurnId,
633 nativeItemRef: subagent.nativeTaskRef,
634 runtimeRequestId: null,
635 checkpointScopeId: null,
636 startedAt: base.startedAt,
637 completedAt: subagent.completedAt,
638 },
639 });
640 yield* emit({
641 type: "turn_item.updated",
642 driver: MUSE_PROVIDER,
643 turnItem: {
644 ...base,
645 nativeItemRef: subagent.nativeTaskRef,
646 parentItemId: workflow.id,
647 type: "subagent",
648 status,
649 title,
650 completedAt: subagent.completedAt,
651 subagentId: subagent.id,
652 origin: "provider_native",
653 driver: MUSE_PROVIDER,
654 providerInstanceId: options.instanceId,
655 childThreadId: null,
656 prompt: title,
657 result,
658 },
659 });
660 }
661 });
662 const settleObservedChildren = Effect.fnUntraced(function* (
663 status: "failed" | "cancelled" | "interrupted",
664 ) {
665 for (const [itemId, owner] of observedChildren) {
666 const item = owner.items.get(itemId);
667 if (item) yield* publishItem(owner, item, status);
668 }
669 observedChildren.clear();
670 if (thread?.pendingBackgroundTasks?.length) yield* syncBackground();
671 // Only workflow children's requests outlive a turn; they end with the workflow.
672 for (const entry of pending.values()) yield* resolvePending(entry, "cancelled");
673 });
674 const resolvePending = Effect.fnUntraced(function* (
675 entry: PendingRequest,
676 status: "resolved" | "cancelled",
677 ) {
678 const time = yield* DateTime.now;
679 entry.request = {
680 ...entry.request,
681 ...(status === "resolved" ? entry.response : undefined),
682 status,
683 resolvedAt: time,
684 responseCapability: { type: "not_resumable", reason: "This Muse request has ended." },
685 };
686 entry.node = {
687 ...entry.node,
688 status: status === "resolved" ? "completed" : "cancelled",
689 completedAt: time,
690 };
691 entry.item = {
692 ...entry.item,
693 status: status === "resolved" ? "completed" : "cancelled",
694 completedAt: time,
695 updatedAt: time,
696 };
697 pending.delete(entry.request.id);
698 yield* emit({
699 type: "runtime_request.updated",
700 driver: MUSE_PROVIDER,
701 threadId: input.threadId,
702 runtimeRequest: entry.request,
703 });
704 yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node: entry.node });
705 yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: entry.item });
706 });
707 const finish = Effect.fnUntraced(function* (
708 turn: ActiveTurn,
709 status: "completed" | "cancelled" | "interrupted" | "failed",
710 detail?: string,
711 disposition: "reusable" | "broken" = "reusable",
712 ) {
713 if (active !== turn || seenTerminals.has(turn.nativeId)) return;
714 seenTerminals.add(turn.nativeId);
715 const completedAt = yield* DateTime.now;
716 for (const item of turn.items.values()) {
717 if (
718 (item.kind === "subagent" || item.kind === "workflow") &&
719 item.status === "inProgress" &&
720 disposition === "reusable"
721 )
722 continue;
723 if (turn.dirty.has(item.itemId) || item.status === "inProgress")
724 yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined);
725 }
726 turn.dirty.clear();
727 // A workflow child's request is runless and keeps waiting after the turn ends.
728 for (const entry of pending.values())
729 if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled");
730 turn.providerTurn = { ...turn.providerTurn, status, completedAt };
731 lastProviderTurn = turn.providerTurn;
732 yield* emit({
733 type: "provider_turn.updated",
734 driver: MUSE_PROVIDER,
735 threadId: turn.input.threadId,
736 providerTurn: turn.providerTurn,
737 });
738 yield* updateThread({
739 status: disposition === "broken" ? "error" : "idle",
740 ...(!turn.compact && status === "completed"
741 ? { nativeConversationHeadRef: nativeRef(turn.nativeId) }
742 : {}),
743 });
744 yield* updateSession(disposition === "broken" ? "error" : "ready", detail ?? null);
745 active = undefined;
746 if (status === "failed") {
747 const failure = makeProviderFailure({
748 class: disposition === "broken" ? "transport_error" : "provider_error",
749 message: detail ?? "Muse turn failed.",
750 });
751 const base = baseItem(turn, `failure:${turn.nativeId}`, completedAt);
752 yield* emit({
753 type: "turn_item.updated",
754 driver: MUSE_PROVIDER,
755 turnItem: {
756 ...base,
757 type: "error",
758 status: "failed",
759 title: null,
760 completedAt,
761 failure,
762 },
763 });
764 yield* emit({
765 type: "turn.terminal",
766 driver: MUSE_PROVIDER,
767 providerThreadId: turn.providerTurn.providerThreadId,
768 providerTurnId: turn.providerTurn.id,
769 runOrdinal: turn.input.runOrdinal,
770 status,
771 failure,
772 failureItemOrdinal: base.ordinal,
773 threadDisposition: disposition,
774 });
775 } else {
776 yield* emit({
777 type: "turn.terminal",
778 driver: MUSE_PROVIDER,
779 providerThreadId: turn.providerTurn.providerThreadId,
780 providerTurnId: turn.providerTurn.id,
781 runOrdinal: turn.input.runOrdinal,
782 status,
783 failure: null,
784 threadDisposition: disposition,
785 });
786 }
787 yield* Deferred.succeed(turn.done, undefined);
788 });
789 const failHost = Effect.fnUntraced(function* (cause: unknown) {
790 if (closed || broken) return;
791 broken = true;
792 const rootCause = Cause.isCause(cause) ? Cause.squash(cause) : cause;
793 const failure = isOpenSessionError(rootCause) ? rootCause.cause : rootCause;
794 const detail = isProtocolError(failure)
795 ? failure.detail
796 : failure instanceof Error
797 ? failure.message
798 : "Muse transport failed.";
799 if (active) yield* finish(active, "failed", detail, "broken");
800 else {
801 yield* updateThread({ status: "error" });
802 yield* updateSession("error", detail);
803 }
804 yield* settleObservedChildren("failed");
805 yield* Queue.fail(events, protocolError(detail, cause));
806 // Report the failure first; a hung host can take a while to close.
807 const failed = host;
808 yield* Effect.tryPromise(() => failed.close()).pipe(Effect.ignore, Effect.forkIn(scope));
809 });
810 const owns = (turn: ActiveTurn, turnId: string) =>
811 turnId === turn.nativeId || turn.joined.has(turnId);
812 /** Sends T3's decision for a pending approval to Muse. */
813 const decideApproval = Effect.fnUntraced(function* (
814 entry: PendingRequest,
815 decision: ProviderApprovalDecision | undefined,
816 ) {
817 if (entry.native.type !== "approval") return;
818 const approval = entry.native.value;
819 const choice = decision && museApprovalChoices(approval).get(decision);
820 if (!choice) return yield* protocolError("Muse did not offer this approval decision");
821 // Recorded first: Muse can settle the approval before it acknowledges the command.
822 entry.response = { decision };
823 yield* request("approval/decide", {
824 approvalId: approval.approvalId,
825 requirementId: approval.currentRequirementId,
826 choiceId: choice.choiceId,
827 }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response)));
828 });
829 /**
830 * Shows a Muse approval or question as a T3 request. A workflow child's approval
831 * is runless: the turn that started the workflow may end long before the child
832 * asks, and T3 keeps a runless request on the thread until Muse settles it.
833 */
834 const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) {
835 const child = native.type === "approval" && native.value.subagentOrigin !== undefined;
836 const turn = child ? undefined : active;
837 if (
838 !thread ||
839 (!child && !turn) ||
840 native.value.sessionId !== nativeSessionId ||
841 (turn && native.value.turnId && !owns(turn, native.value.turnId))
842 )
843 return;
844 const nativeId =
845 native.type === "approval" ? native.value.approvalId : native.value.userInputId;
846 if (turn?.settledRequests.has(`${native.type}:${nativeId}`)) return;
847 const previous = [...pending.values()].find(
848 (entry) =>
849 entry.native.type === native.type &&
850 entry.request.nativeRequestRef?.nativeId === nativeId,
851 );
852 const providerTurnId = turn?.providerTurn.id ?? null;
853 const threadId = turn?.input.threadId ?? thread.appThreadId ?? input.threadId;
854 const requestId =
855 previous?.request.id ??
856 (yield* idAllocator.allocate
857 .runtimeRequest({
858 driver: MUSE_PROVIDER,
859 ...(providerTurnId ? { providerTurnId } : {}),
860 nativeRequestId: nativeId,
861 })
862 .pipe(
863 Effect.mapError((cause) => protocolError("Cannot allocate Muse request", cause)),
864 ));
865 const time = yield* DateTime.now;
866 const nodeId = idAllocator.derive.approvalNode({ requestId });
867 const subject = native.type === "approval" ? native.value.subject : undefined;
868 const kind = !subject
869 ? "user_input"
870 : subject.kind === "shell"
871 ? "command"
872 : subject.kind !== "fileAccess"
873 ? "permission"
874 : subject.access === "read"
875 ? "file-read"
876 : "file-change";
877 const runtimeRequest: OrchestrationV2RuntimeRequest = {
878 id: requestId,
879 nodeId,
880 providerTurnId,
881 nativeRequestRef: nativeRef(nativeId),
882 kind,
883 status: "pending",
884 responseCapability: { type: "live", providerSessionId: input.providerSessionId },
885 createdAt: previous?.request.createdAt ?? time,
886 resolvedAt: null,
887 };
888 const node: OrchestrationV2ExecutionNode = {
889 id: nodeId,
890 threadId,
891 runId: turn?.input.runId ?? null,
892 parentNodeId: turn?.input.rootNodeId ?? null,
893 rootNodeId: turn?.input.rootNodeId ?? nodeId,
894 kind: native.type === "approval" ? "approval_request" : "user_input_request",
895 status: "waiting",
896 countsForRun: false,
897 providerThreadId: thread.id,
898 providerTurnId,
899 nativeItemRef: nativeRef(nativeId),
900 runtimeRequestId: requestId,
901 checkpointScopeId: null,
902 startedAt: runtimeRequest.createdAt,
903 completedAt: null,
904 };
905 const latestTurn = active?.providerTurn ?? lastProviderTurn;
906 const base = {
907 ...(turn
908 ? baseItem(turn, `request:${nativeId}`, time)
909 : {
910 threadId,
911 runId: null,
912 providerThreadId: thread.id,
913 providerTurnId: null,
914 nativeItemRef: nativeRef(nativeId),
915 parentItemId: null,
916 // Sorts after the latest turn's items.
917 ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100,
918 startedAt: runtimeRequest.createdAt,
919 updatedAt: time,
920 }),
921 id: idAllocator.derive.approvalTurnItem({ requestId }),
922 nodeId,
923 status: "waiting" as const,
924 title: null,
925 completedAt: null,
926 };
927 const item: OrchestrationV2TurnItem =
928 native.type === "approval"
929 ? {
930 ...base,
931 type: "approval_request",
932 requestId,
933 requestKind: kind === "user_input" ? "command" : kind,
934 prompt:
935 native.value.subject.command ??
936 native.value.subject.path ??
937 native.value.subject.host ??
938 native.value.subject.target ??
939 native.value.toolName ??
940 "Muse requests permission",
941 options: museApprovalOptions(native.value),
942 }
943 : {
944 ...base,
945 type: "user_input_request",
946 requestId,
947 questions: native.value.questions.map((question) => ({
948 id: question.id,
949 header: question.header || "Question",
950 question: question.question,
951 options: question.options.map((option) => ({
952 label: option.label,
953 description: option.description || option.label,
954 })),
955 multiSelect: question.selection.mode === "multiple",
956 allowCustomAnswer: true,
957 required: true,
958 })),
959 };
960 const entry: PendingRequest = { native, request: runtimeRequest, node, item };
961 pending.set(requestId, entry);
962 yield* emit({
963 type: "runtime_request.updated",
964 driver: MUSE_PROVIDER,
965 threadId,
966 runtimeRequest,
967 });
968 yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node });
969 yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item });
970 yield* updateSession("waiting");
971 // Muse does not give workflow children the session's allowAll mode, so in full
972 // access T3 approves for them. If Muse rejects that, the request waits for the user.
973 if (child && input.runtimePolicy.runtimeMode === "full-access")
974 yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope));
975 });
976 /** Approval and question events; `turn` is undefined for a workflow child's approval. */
977 const handleRequestEvent = Effect.fnUntraced(function* (
978 method: string,
979 params: Record<string, unknown>,
980 turn: ActiveTurn | undefined,
981 ) {
982 switch (method) {
983 case "approval/requested":
984 case "approval/updated":
985 if (
986 method === "approval/updated" &&
987 ![...pending.values()].some(
988 (entry) =>
989 entry.native.type === "approval" &&
990 entry.native.value.approvalId === params.approvalId,
991 )
992 )
993 return;
994 return yield* publishRequest({
995 type: "approval",
996 value: yield* decode(MuseApproval, params),
997 });
998 case "userInput/requested":
999 return yield* publishRequest({
1000 type: "question",
1001 value: yield* decode(MuseUserInput, params),
1002 });
1003 case "approval/resolved":
1004 case "userInput/settled": {
1005 const id = method === "approval/resolved" ? params.approvalId : params.userInputId;
1006 if (typeof id === "string")
1007 turn?.settledRequests.add(
1008 `${method === "approval/resolved" ? "approval" : "question"}:${id}`,
1009 );
1010 const entry = [...pending.values()].find(
1011 (candidate) => candidate.request.nativeRequestRef?.nativeId === id,
1012 );
1013 if (entry) {
1014 // An approval settled outside T3 still shows Muse's own decision.
1015 const native =
1016 method === "approval/resolved" && typeof params.decision === "string"
1017 ? museApprovalDecision({ decision: params.decision, scope: "once" })
1018 : undefined;
1019 if (!entry.response && native) entry.response = { decision: native };
1020 yield* resolvePending(entry, "resolved");
1021 }
1022 if (!pending.size) yield* updateSession(active ? "running" : "ready");
1023 }
1024 }
1025 });
1026 const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) {
1027 const params = yield* decode(recordSchema, data);
1028 if (params.sessionId !== nativeSessionId) return;
1029 if (method === "view/gap")
1030 return yield* protocolError("Muse delivery gap requires session recovery");
1031 if (method === "session/contextUsage") {
1032 const usage = yield* decode(MuseContextUsage, params);
1033 const currentTurn = active?.providerTurn ?? lastProviderTurn;
1034 const updatedAt = DateTime.formatIso(yield* DateTime.now);
1035 const tokenUsage = {
1036 ...currentTurn?.tokenUsage,
1037 usedTokens: usage.usedTokens,
1038 maxTokens: usage.windowTokens ?? currentTurn?.tokenUsage?.maxTokens ?? undefined,
1039 updatedAt,
1040 };
1041 yield* updateThread({ contextUsage: tokenUsage });
1042 if (currentTurn) {
1043 const updated = { ...currentTurn, tokenUsage };
1044 if (active) active.providerTurn = updated;
1045 else lastProviderTurn = updated;
1046 yield* emit({
1047 type: "provider_turn.updated",
1048 driver: MUSE_PROVIDER,
1049 providerTurn: updated,
1050 });
1051 }
1052 return;
1053 }
1054 const itemEvent = ["item/started", "item/updated", "item/completed"].includes(method)
1055 ? (yield* decode(MuseItemEvent, params)).item
1056 : undefined;
1057 if (itemEvent) {
1058 const owner = observedChildren.get(itemEvent.itemId);
1059 if (owner && owner !== active && itemEvent.turnId === owner.nativeId) {
1060 const previous = owner.items.get(itemEvent.itemId);
1061 if (!previous || itemEvent.revision > previous.revision) {
1062 owner.items.set(itemEvent.itemId, itemEvent);
1063 yield* publishItem(owner, itemEvent);
1064 }
1065 return;
1066 }
1067 }
1068 // A workflow child's approval comes under the child's own run id, during a turn
1069 // or after it, so it skips turn routing.
1070 if (params.subagentOrigin !== undefined && method.startsWith("approval/"))
1071 return yield* handleRequestEvent(method, params, undefined);
1072 // Muse starts a turn on its own when a workflow finishes, to report its result.
1073 // During a run, that run shows it. Otherwise hold it and ask the orchestrator for
1074 // a run; that run takes the held events, unless a user turn takes them first.
1075 if (
1076 method === "turn/started" &&
1077 typeof params.turnId === "string" &&
1078 params.turnId !== active?.nativeId &&
1079 params.turnId !== wake?.nativeId
1080 ) {
1081 if (active && !active.compact) active.joined.add(params.turnId);
1082 else if (!active && !wake) {
1083 const held = { nativeId: params.turnId, events: [] };
1084 wake = held;
1085 const notification = backgroundWorkNotification(finishedBackground.splice(0));
1086 if (thread?.appThreadId && options.continuationRequests)
1087 yield* options.continuationRequests.offer({
1088 threadId: thread.appThreadId,
1089 providerThreadId: thread.id,
1090 driver: MUSE_PROVIDER,
1091 detail: null,
1092 ...(notification ? { notification } : {}),
1093 dispatchIfCurrent: (dispatch) =>
1094 wake === held ? Effect.map(dispatch, Option.some) : Effect.succeed(Option.none()),
1095 clearIfCurrent: () =>
1096 Effect.sync(() => {
1097 if (wake === held) wake = undefined;
1098 }),
1099 });
1100 }
1101 }
1102 if (wake && active?.nativeId !== wake.nativeId) {
1103 const turnId = typeof params.turnId === "string" ? params.turnId : itemEvent?.turnId;
1104 if (turnId === wake.nativeId) {
1105 wake.events.push([method, data]);
1106 return;
1107 }
1108 }
1109 const turn = active;
1110 if (!turn) return;
1111 if (typeof params.turnId === "string" && !owns(turn, params.turnId) && !turn.compact)
1112 return;
1113 switch (method) {
1114 case "item/started":
1115 case "item/updated":
1116 case "item/completed": {
1117 const item = itemEvent!;
1118 if (
1119 item.turnId &&
1120 !owns(turn, item.turnId) &&
1121 !(turn.compact && item.kind === "compaction")
1122 )
1123 return;
1124 const previous = turn.items.get(item.itemId);
1125 if (previous && previous.revision >= item.revision) return;
1126 turn.items.set(item.itemId, item);
1127 turn.dirty.delete(item.itemId);
1128 yield* publishItem(turn, item);
1129 if (turn.compact && item.kind === "compaction" && item.status !== "inProgress") {
1130 yield* finish(
1131 turn,
1132 item.status === "completed" && (!item.outcome || item.outcome === "compacted")
1133 ? "completed"
1134 : item.status === "cancelled"
1135 ? "cancelled"
1136 : "failed",
1137 item.failureReason ?? item.reason,
1138 );
1139 }
1140 break;
1141 }
1142 case "item/delta": {
1143 const delta = yield* decode(MuseDelta, params);
1144 const previous = turn.items.get(delta.itemId);
1145 if (!previous || previous.status !== "inProgress") return;
1146 const field = delta.field ?? "text";
1147 let item = previous;
1148 if (field === "text") item = { ...previous, text: (previous.text ?? "") + delta.delta };
1149 else if (field === "output" || field === "visibleOutput")
1150 item = { ...previous, visibleOutput: (previous.visibleOutput ?? "") + delta.delta };
1151 else if (field.startsWith("summary.")) {
1152 const index = Number(field.slice(8));
1153 if (Number.isSafeInteger(index) && index >= 0 && index < 100) {
1154 const summary = [...(previous.summary ?? [])];
1155 summary[index] = (summary[index] ?? "") + delta.delta;
1156 item = { ...previous, summary };
1157 }
1158 }
1159 if (item === previous) return;
1160 turn.items.set(item.itemId, item);
1161 turn.dirty.add(item.itemId);
1162 if (!turn.flushScheduled) {
1163 turn.flushScheduled = true;
1164 yield* Effect.sleep(50).pipe(
1165 Effect.andThen(Queue.offer(inbox, { type: "flush", turn })),
1166 Effect.forkIn(scope),
1167 );
1168 }
1169 break;
1170 }
1171 case "turn/completed": {
1172 if (turn.compact) return;
1173 const result = yield* decode(MuseTurnCompleted, params);
1174 // A joined Muse turn ending does not end this run; its own turn does.
1175 if (result.turnId !== turn.nativeId) break;
1176 if (result.usage) {
1177 const usage = result.usage;
1178 turn.providerTurn = {
1179 ...turn.providerTurn,
1180 turnTokenUsage: {
1181 usageScope: "main_agent",
1182 usageStatus: "complete",
1183 hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"),
1184 inputTokens: turn.providerTurn.turnTokenUsage?.inputTokens ?? usage.inputTokens,
1185 outputTokens: usage.outputTokens,
1186 cachedInputTokens: usage.cacheReadTokens ?? usage.cachedTokens,
1187 reasoningTokens: usage.reasoningTokens,
1188 ...(usage.cacheWriteTokens === undefined
1189 ? {}
1190 : { cacheCreationTokens: usage.cacheWriteTokens }),
1191 },
1192 };
1193 }
1194 const status =
1195 result.terminal === "completed"
1196 ? "completed"
1197 : result.terminal === "cancelled"
1198 ? turn.interruptRequested
1199 ? "interrupted"
1200 : "cancelled"
1201 : "failed";
1202 yield* finish(turn, status, result.error?.message ?? result.reason);
1203 break;
1204 }
1205 case "approval/requested":
1206 case "approval/updated":
1207 case "approval/resolved":
1208 case "userInput/requested":
1209 case "userInput/settled":
1210 yield* handleRequestEvent(method, params, turn);
1211 break;
1212 case "session/tokenUsage": {
1213 const usage = yield* decode(MuseTokenUsageEvent, params);
1214 const previous = turn.providerTurn.turnTokenUsage;
1215 const contextUsage = thread?.contextUsage;
1216 turn.providerTurn = {
1217 ...turn.providerTurn,
1218 ...(contextUsage
1219 ? {
1220 tokenUsage: {
1221 ...turn.providerTurn.tokenUsage,
1222 usedTokens: contextUsage.usedTokens,
1223 inputTokens: usage.usage.inputTokens,
1224 outputTokens: usage.usage.outputTokens,
1225 cachedInputTokens: usage.usage.cacheReadTokens ?? usage.usage.cachedTokens,
1226 reasoningOutputTokens: usage.usage.reasoningTokens,
1227 updatedAt: DateTime.formatIso(yield* DateTime.now),
1228 },
1229 }
1230 : {}),
1231 turnTokenUsage: {
1232 usageScope: "main_agent",
1233 usageStatus: "partial",
1234 hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"),
1235 inputTokens: (previous?.inputTokens ?? 0) + usage.promptTokens,
1236 outputTokens: (previous?.outputTokens ?? 0) + usage.usage.outputTokens,
1237 cachedInputTokens:
1238 (previous?.cachedInputTokens ?? 0) +
1239 (usage.usage.cacheReadTokens ?? usage.usage.cachedTokens),
1240 ...(usage.usage.cacheWriteTokens === undefined &&
1241 previous?.cacheCreationTokens === undefined
1242 ? {}
1243 : {
1244 cacheCreationTokens:
1245 (previous?.cacheCreationTokens ?? 0) + (usage.usage.cacheWriteTokens ?? 0),
1246 }),
1247 reasoningTokens: (previous?.reasoningTokens ?? 0) + usage.usage.reasoningTokens,
1248 },
1249 };
1250 yield* emit({
1251 type: "provider_turn.updated",
1252 driver: MUSE_PROVIDER,
1253 providerTurn: turn.providerTurn,
1254 });
1255 break;
1256 }
1257 case "session/todoListChanged": {
1258 const todo = yield* decode(MuseTodoList, params);
1259 const steps: ReadonlyArray<OrchestrationV2PlanStep> = todo.items
1260 .filter((item) => item.text.trim() && item.status !== "cancelled")
1261 .map((item, index) => ({
1262 id: `${turn.nativeId}:todo:${index}`,
1263 text: item.text.trim(),
1264 status:
1265 item.status === "completed"
1266 ? "completed"
1267 : item.status === "inProgress"
1268 ? "running"
1269 : "pending",
1270 }));
1271 const time = yield* DateTime.now;
1272 const base = baseItem(turn, "todo", time);
1273 const planId =
1274 turn.todoPlanId ??
1275 (yield* idAllocator.allocate
1276 .plan({
1277 threadId: turn.input.threadId,
1278 runId: turn.input.runId,
1279 driver: MUSE_PROVIDER,
1280 })
1281 .pipe(
1282 Effect.mapError((cause) =>
1283 protocolError("Cannot allocate Muse todo list", cause),
1284 ),
1285 ));
1286 turn.todoPlanId = planId;
1287 yield* emit({
1288 type: "plan.updated",
1289 driver: MUSE_PROVIDER,
1290 plan: {
1291 id: planId,
1292 threadId: turn.input.threadId,
1293 runId: turn.input.runId,
1294 nodeId: base.nodeId,
1295 kind: "todo_list",
1296 status: steps.every((step) => step.status === "completed") ? "completed" : "active",
1297 steps,
1298 },
1299 });
1300 yield* emit({
1301 type: "node.updated",
1302 driver: MUSE_PROVIDER,
1303 node: {
1304 id: base.nodeId,
1305 threadId: base.threadId,
1306 runId: base.runId,
1307 parentNodeId: turn.input.rootNodeId,
1308 rootNodeId: turn.input.rootNodeId,
1309 kind: "todo_list",
1310 status: "completed",
1311 countsForRun: false,
1312 providerThreadId: base.providerThreadId,
1313 providerTurnId: base.providerTurnId,
1314 nativeItemRef: null,
1315 runtimeRequestId: null,
1316 checkpointScopeId: null,
1317 startedAt: base.startedAt,
1318 completedAt: time,
1319 },
1320 });
1321 yield* emit({
1322 type: "turn_item.updated",
1323 driver: MUSE_PROVIDER,
1324 turnItem: {
1325 ...base,
1326 nativeItemRef: null,
1327 type: "todo_list",
1328 status: "completed",
1329 title: null,
1330 completedAt: time,
1331 planId,
1332 steps,
1333 },
1334 });
1335 break;
1336 }
1337 case "turn/retryScheduled": {
1338 const retry = yield* decode(MuseTurnRetryScheduled, params);
1339 const time = yield* DateTime.now;
1340 yield* emit({
1341 type: "turn_item.updated",
1342 driver: MUSE_PROVIDER,
1343 turnItem: {
1344 ...baseItem(turn, `retry:${retry.nextAttempt}`, time),
1345 type: "system_notice",
1346 status: "completed",
1347 title: "Muse retry",
1348 completedAt: time,
1349 message: `Muse is retrying this turn (attempt ${retry.nextAttempt} of ${retry.maxAttempts}): ${retry.reason}`,
1350 },
1351 });
1352 break;
1353 }
1354 }
1355 });
1356 yield* Stream.fromQueue(inbox).pipe(
1357 Stream.runForEach((entry) =>
1358 Effect.gen(function* () {
1359 if (entry.type === "flush") {
1360 const turn = entry.turn;
1361 turn.flushScheduled = false;
1362 if (active !== turn) return;
1363 for (const id of turn.dirty) {
1364 const item = turn.items.get(id);
1365 if (item) yield* publishItem(turn, item);
1366 }
1367 turn.dirty.clear();
1368 } else if (entry.epoch === hostEpoch) {
1369 if (entry.type === "failure") yield* failHost(entry.cause);
1370 else {
1371 if (options.nativeEventLogger && entry.method !== "item/delta")
1372 yield* options.nativeEventLogger.write(
1373 { provider: "muse", method: entry.method, params: entry.params },
1374 input.threadId,
1375 );
1376 yield* handleNotification(entry.method, entry.params).pipe(
1377 Effect.catch((cause) =>
1378 INFORMATIONAL_NOTIFICATIONS.has(entry.method)
1379 ? Effect.logWarning(`Skipped an unreadable Muse ${entry.method}`, cause)
1380 : failHost(cause),
1381 ),
1382 );
1383 }
1384 }
1385 }).pipe(eventPermit.withPermits(1)),
1386 ),
1387 Effect.forkIn(scope),
1388 );
1389 const launchHost = Effect.fnUntraced(function* () {
1390 const epoch = ++hostEpoch;
1391 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
1392 const created = yield* Effect.acquireRelease(
1393 createMuseSdkHostEffect(
1394 {
1395 binaryPath: options.settings.binaryPath || "muse",
1396 cwd,
1397 environment: McpProviderSession.withAgentDeviceEnvironment(
1398 options.environment,
1399 mcpSession,
1400 ),
1401 runtimeMode: input.runtimePolicy.runtimeMode,
1402 },
1403 options.createHost,
1404 ).pipe(
1405 Effect.mapError(
1406 (error) =>
1407 new ProviderAdapterOpenSessionError({
1408 driver: MUSE_PROVIDER,
1409 providerSessionId: input.providerSessionId,
1410 cause: error.cause,
1411 }),
1412 ),
1413 ),
1414 (created) =>
1415 Effect.gen(function* () {
1416 if (host === created) {
1417 closed = true;
1418 hostEpoch++;
1419 }
1420 yield* Effect.tryPromise(() => created.close()).pipe(Effect.ignore);
1421 }),
1422 { interruptible: true },
1423 ).pipe(Effect.provideService(Scope.Scope, scope));
1424 host = created;
1425 created.connection.onNotification((notification) => {
1426 Queue.offerUnsafe(inbox, {
1427 type: "notification",
1428 method: notification.method,
1429 params: notification.params,
1430 epoch,
1431 });
1432 });
1433 created.connection.onProtocolError((cause) => {
1434 Queue.offerUnsafe(inbox, { type: "failure", cause, epoch });
1435 });
1436 // These are presentation receipts only. The matching notifications drive the UI,
1437 // and decisions travel as `approval/decide` and `userInput/answer` commands.
1438 created.connection.onServerRequest(async (request) => {
1439 if (request.method === "approval/request" || request.method === "userInput/request")
1440 return {};
1441 throw new Error(`Unsupported Muse server request: ${request.method}`);
1442 });
1443 void created.connection.closed.then(() => {
1444 if (!closed && epoch === hostEpoch)
1445 Queue.offerUnsafe(inbox, {
1446 type: "failure",
1447 cause: new Error("Muse connection closed unexpectedly"),
1448 epoch,
1449 });
1450 });
1451 void created.exited.then((exit) => {
1452 if (!closed && epoch === hostEpoch) {
1453 const reason = created
1454 .stderrTail?.()
1455 .findLast((line) => line.trim())
1456 ?.trim();
1457 Queue.offerUnsafe(inbox, {
1458 type: "failure",
1459 cause: new Error(
1460 `Muse exited unexpectedly (${exit.code ?? exit.signal})${reason ? `: ${reason}` : ""}`,
1461 ),
1462 epoch,
1463 });
1464 }
1465 });
1466 });
1467 yield* Effect.addFinalizer(() =>
1468 Effect.gen(function* () {
1469 closed = true;
1470 hostEpoch++;
1471 if (active) yield* finish(active, "cancelled", "Muse session closed", "broken");
1472 yield* settleObservedChildren("cancelled");
1473 yield* Queue.shutdown(inbox);
1474 yield* Queue.shutdown(events);
1475 }).pipe(eventPermit.withPermits(1)),
1476 );
1477 yield* launchHost();
1478
1479 const register = Effect.fnUntraced(function* (args: ProviderAdapterV2EnsureThreadInput) {
1480 if (broken || closed)
1481 return yield* protocolError("Muse host is unavailable; reopen the session");
1482 if (active)
1483 return yield* protocolError("Cannot change Muse sessions during an active turn");
1484 const existing = args.existingProviderThread;
1485 if (
1486 existing &&
1487 (existing.driver !== MUSE_PROVIDER || existing.providerInstanceId !== options.instanceId)
1488 )
1489 return yield* protocolError("Muse thread belongs to another provider instance");
1490 const requestedId = existing
1491 ? (existing.nativeThreadRef?.nativeId ?? undefined)
1492 : input.initialNativeThreadId;
1493 if (
1494 thread &&
1495 (!requestedId || requestedId === nativeSessionId) &&
1496 thread.appThreadId === args.threadId
1497 )
1498 return thread;
1499 if (thread) return yield* protocolError("Open a new host to attach another Muse session");
1500 let missingNativeSession = false;
1501 return yield* Effect.gen(function* () {
1502 nativeSessionId = requestedId ?? host.connection.mintCommandId();
1503 const mcpSession = McpProviderSession.readMcpProviderSession(args.threadId);
1504 if (mcpSession && !host.initializeResult.grantedCapabilities.includes("sessionMcp"))
1505 return yield* protocolError(
1506 "Update Muse Code to a version that supports session MCP servers",
1507 );
1508 const config = mcpSession
1509 ? {
1510 mcpServers: {
1511 // Muse defaults to "required", which fails the whole run when T3's
1512 // tools cannot be reached. The agent should still work without them.
1513 "t3-code": {
1514 transport: "streamableHttp",
1515 mode: "optional",
1516 url: mcpSession.endpoint,
1517 headers: { Authorization: mcpSession.authorizationHeader },
1518 },
1519 },
1520 }
1521 : undefined;
1522 const startModel = yield* resolveModel(args.modelSelection);
1523 const result = yield* request(
1524 requestedId ? "session/resume" : "session/start",
1525 requestedId
1526 ? // T3 already has the transcript; skip Muse's history payload.
1527 { excludeItems: true, ...(config ? { config } : {}) }
1528 : {
1529 workspaceRoot: cwd,
1530 ...(config ? { config } : {}),
1531 ...(startModel ? { modelId: startModel } : {}),
1532 providerId: "meta",
1533 approvalMode: museApprovalMode(args.runtimePolicy.runtimeMode),
1534 },
1535 ).pipe(
1536 Effect.catch((error) => {
1537 missingNativeSession =
1538 requestedId !== undefined &&
1539 error.payload instanceof MspError &&
1540 (error.payload.kind === "sessionNotFound" || error.payload.kind === "notFound");
1541 return Effect.fail(error);
1542 }),
1543 Effect.flatMap((value) => decode(MuseSessionResult, value)),
1544 );
1545 if (result.session.sessionId !== nativeSessionId)
1546 return yield* protocolError("Muse returned a different session identity");
1547 if (requestedId)
1548 yield* request("session/setApprovalMode", {
1549 mode: museApprovalMode(args.runtimePolicy.runtimeMode),
1550 });
1551 // A resumed session keeps the model it last ran with; the next turn
1552 // switches it when the selection differs.
1553 const createdAt = yield* DateTime.now;
1554 thread = existing
1555 ? {
1556 ...existing,
1557 providerSessionId: input.providerSessionId,
1558 nativeThreadRef: nativeRef(result.session.sessionId),
1559 status: "idle",
1560 updatedAt: createdAt,
1561 }
1562 : {
1563 id: idAllocator.derive.providerThread({
1564 driver: MUSE_PROVIDER,
1565 providerInstanceId: options.instanceId,
1566 nativeThreadId: nativeSessionId,
1567 }),
1568 driver: MUSE_PROVIDER,
1569 providerInstanceId: options.instanceId,
1570 providerSessionId: input.providerSessionId,
1571 appThreadId: args.threadId,
1572 ownerNodeId: null,
1573 nativeThreadRef: nativeRef(nativeSessionId),
1574 nativeConversationHeadRef: null,
1575 status: "idle",
1576 firstRunOrdinal: null,
1577 lastRunOrdinal: null,
1578 handoffIds: [],
1579 forkedFrom: null,
1580 createdAt,
1581 updatedAt: createdAt,
1582 };
1583 session = {
1584 ...session,
1585 model: result.session.modelId ?? args.modelSelection.model,
1586 };
1587 yield* emit({
1588 type: "provider_thread.updated",
1589 driver: MUSE_PROVIDER,
1590 providerThread: thread,
1591 });
1592 return thread;
1593 }).pipe(
1594 Effect.onError((cause) =>
1595 missingNativeSession ? Effect.void : eventPermit.withPermits(1)(failHost(cause)),
1596 ),
1597 );
1598 });
1599 const prompt = Effect.fnUntraced(function* (message: ProviderAdapterV2TurnMessage) {
1600 const parts: SendUserTurnOptions<unknown>["input"] = [];
1601 const text = providerMessageTextWithAttachmentPaths({
1602 text: message.text,
1603 attachments: message.attachments,
1604 resolveAttachmentPath: (attachment) =>
1605 resolveAttachmentPath({
1606 attachmentsDir: options.serverConfig.attachmentsDir,
1607 attachment,
1608 }),
1609 });
1610 if (text) parts.push({ type: "text", text });
1611 for (const attachment of message.attachments)
1612 if (isProviderNativeImageAttachment(attachment)) {
1613 const path = resolveAttachmentPath({
1614 attachmentsDir: options.serverConfig.attachmentsDir,
1615 attachment,
1616 });
1617 if (!path) return yield* protocolError("Muse image attachment is missing");
1618 const bytes = yield* options.fileSystem
1619 .readFile(path)
1620 .pipe(Effect.mapError((cause) => protocolError("Cannot read Muse image", cause)));
1621 parts.push({
1622 type: "image",
1623 base64Data: Buffer.from(bytes).toString("base64"),
1624 mediaType: attachment.mimeType,
1625 });
1626 }
1627 if (!parts.length) return yield* protocolError("Muse needs text or an image");
1628 return parts;
1629 });
1630 const validateThread = (candidate: OrchestrationV2ProviderThread) =>
1631 thread !== undefined &&
1632 candidate.driver === MUSE_PROVIDER &&
1633 candidate.providerInstanceId === options.instanceId &&
1634 candidate.appThreadId === thread.appThreadId &&
1635 candidate.nativeThreadRef?.nativeId === nativeSessionId;
1636 const selectionEffort = Effect.fnUntraced(function* (selection: ModelSelection) {
1637 if (selection.instanceId !== options.instanceId)
1638 return yield* protocolError("Model selection belongs to another Muse instance");
1639 const catalog = options.modelCatalog ? yield* options.modelCatalog : [];
1640 const model = yield* resolveModel(selection);
1641 const selected = getModelSelectionStringOptionValue(selection, "reasoningEffort");
1642 // With no saved choice, the model's own default applies.
1643 return resolveMuseReasoningEffort(
1644 catalog.find((entry) => entry.slug === model)?.capabilities ?? museModelCapabilities(),
1645 selected,
1646 );
1647 });
1648 const start = Effect.fnUntraced(function* (
1649 turnInput: ProviderAdapterV2TurnInput,
1650 compact = false,
1651 ) {
1652 if (!validateThread(turnInput.providerThread) || broken || closed)
1653 return yield* protocolError("Muse thread is not attached to this host");
1654 if (active)
1655 return yield* protocolError(
1656 "Muse already has an active turn; queue through the orchestrator",
1657 );
1658 if (turnInput.runtimePolicy.interactionMode === "plan")
1659 return yield* protocolError("Muse does not support dedicated Plan mode");
1660 if (turnInput.runtimePolicy.runtimeMode !== input.runtimePolicy.runtimeMode)
1661 return yield* protocolError("Runtime policy changes require a fresh Muse host");
1662 // A continuation run takes the turn Muse already started; it sends no prompt.
1663 const continuation =
1664 turnInput.message.createdBy === "agent" &&
1665 turnInput.message.creationSource === "provider";
1666 const adopted = continuation ? wake : undefined;
1667 const effort = continuation ? undefined : yield* selectionEffort(turnInput.modelSelection);
1668 const parts = compact || continuation ? [] : yield* prompt(turnInput.message);
1669 const targetModel = continuation
1670 ? undefined
1671 : yield* resolveModel(turnInput.modelSelection);
1672 if (targetModel && session.model !== targetModel) {
1673 yield* request("session/setModel", {
1674 model: { modelId: targetModel, providerId: "meta" },
1675 });
1676 session = { ...session, model: targetModel };
1677 }
1678 const nativeId = adopted?.nativeId ?? host.connection.mintCommandId();
1679 const startedAt = yield* DateTime.now;
1680 const providerTurn: OrchestrationV2ProviderTurn = {
1681 id: idAllocator.derive.providerTurn({
1682 driver: MUSE_PROVIDER,
1683 nativeTurnId: `${options.instanceId}:${nativeSessionId}:${nativeId}`,
1684 }),
1685 providerThreadId: turnInput.providerThread.id,
1686 nodeId: turnInput.rootNodeId,
1687 runAttemptId: turnInput.attemptId,
1688 nativeTurnRef: compact
1689 ? { ...nativeRef(nativeId), strength: "weak" }
1690 : nativeRef(nativeId),
1691 ordinal: turnInput.providerTurnOrdinal,
1692 status: "running",
1693 startedAt,
1694 completedAt: null,
1695 };
1696 const turn: ActiveTurn = {
1697 input: turnInput,
1698 providerTurn,
1699 nativeId,
1700 joined: new Set(),
1701 items: new Map(),
1702 ordinals: new Map(),
1703 started: new Map(),
1704 dirty: new Set(),
1705 settledRequests: new Set(),
1706 done: yield* Deferred.make<void>(),
1707 nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1,
1708 flushScheduled: false,
1709 interruptRequested: false,
1710 compact,
1711 };
1712 yield* Effect.gen(function* () {
1713 thread = turnInput.providerThread;
1714 active = turn;
1715 yield* emit({
1716 type: "provider_turn.updated",
1717 driver: MUSE_PROVIDER,
1718 threadId: turnInput.threadId,
1719 providerTurn,
1720 });
1721 yield* updateThread({
1722 status: "active",
1723 firstRunOrdinal: thread.firstRunOrdinal ?? turnInput.runOrdinal,
1724 lastRunOrdinal: turnInput.runOrdinal,
1725 });
1726 yield* updateSession("running");
1727 // A user turn takes a held Muse turn: Muse runs it first, so this run shows it
1728 // (and its approvals), and the continuation asked for it is no longer needed.
1729 const held = wake;
1730 wake = undefined;
1731 if (continuation && !held) return yield* finish(turn, "completed");
1732 if (held && !continuation) turn.joined.add(held.nativeId);
1733 for (const [method, data] of held?.events ?? [])
1734 yield* handleNotification(method, data).pipe(Effect.catch(failHost));
1735 }).pipe(eventPermit.withPermits(1));
1736 if (continuation) return;
1737 yield* Effect.gen(function* () {
1738 if (compact) {
1739 const result = yield* request("session/compact", {}, true, nativeId).pipe(
1740 Effect.flatMap((value) => decode(MuseCompactResult, value)),
1741 );
1742 if (result.status === "noop")
1743 yield* eventPermit.withPermits(1)(
1744 finish(turn, "failed", result.reason ?? "Muse has no context to compact."),
1745 );
1746 else if (result.status !== "accepted")
1747 return yield* protocolError(
1748 `Muse returned an unsupported compaction status: ${result.status}`,
1749 );
1750 } else {
1751 const result = yield* request(
1752 "turn/start",
1753 {
1754 input: [
1755 {
1756 type: "text",
1757 text: buildRuntimeInstructions({
1758 harness: "Muse Code",
1759 model: session.model ?? undefined,
1760 reasoningEffort: effort,
1761 }),
1762 },
1763 ...parts,
1764 ],
1765 displayText: turnInput.message.text || "Image attachment",
1766 // A resumed session keeps the root it was created with; pin it to
1767 // this thread's current checkout so edits land where T3 tracks them.
1768 workspaceRoots: [cwd],
1769 ifBusy: "queue",
1770 ...(effort ? { reasoningEffort: effort } : {}),
1771 },
1772 true,
1773 nativeId,
1774 ).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value)));
1775 if (result.turnId !== nativeId) {
1776 yield* eventPermit.withPermits(1)(
1777 failHost(new Error("Muse admitted an unowned turn")),
1778 );
1779 return yield* protocolError("Muse admitted the turn under an unexpected identity");
1780 }
1781 }
1782 }).pipe(
1783 Effect.onError((cause) => {
1784 const error = Cause.squash(cause);
1785 const detail = isProtocolError(error)
1786 ? error.detail
1787 : error instanceof Error
1788 ? error.message
1789 : "Muse could not start the turn.";
1790 return eventPermit.withPermits(1)(finish(turn, "failed", detail));
1791 }),
1792 );
1793 });
1794 // Only messages are read back; T3 owns turn and item history.
1795 const snapshot = Effect.fnUntraced(function* (
1796 current: OrchestrationV2ProviderThread,
1797 ): Effect.fn.Return<ProviderAdapterV2ThreadSnapshot, ProviderAdapterV2Error> {
1798 const result = yield* request("session/read", { excludeItems: false }, false).pipe(
1799 Effect.flatMap((value) => decode(MuseSessionResult, value)),
1800 );
1801 if (result.session.sessionId !== nativeSessionId)
1802 return yield* protocolError("Muse snapshot belongs to a different session");
1803 const items = result.history?.items ?? result.history?.snapshot?.state.items ?? [];
1804 const now = yield* DateTime.now;
1805 return {
1806 providerThread: current,
1807 providerTurns: [],
1808 messages: items.flatMap((item): OrchestrationV2ConversationMessage[] =>
1809 item.kind === "agentMessage" || item.kind === "userMessage"
1810 ? [
1811 {
1812 id: idAllocator.derive.messageFromProviderItem({
1813 driver: MUSE_PROVIDER,
1814 nativeItemId: `${options.instanceId}:${current.id}:${item.turnId ?? "history"}:${item.itemId}`,
1815 }),
1816 threadId: current.appThreadId ?? input.threadId,
1817 runId: null,
1818 nodeId: null,
1819 role: item.kind === "agentMessage" ? "assistant" : "user",
1820 text: item.text ?? "",
1821 attachments: [],
1822 streaming: item.status === "inProgress",
1823 createdBy: item.kind === "agentMessage" ? "agent" : "user",
1824 creationSource: "provider",
1825 createdAt: now,
1826 updatedAt: now,
1827 },
1828 ]
1829 : [],
1830 ),
1831 runtimeRequests: [...pending.values()].map((entry) => entry.request),
1832 providerPayload: items,
1833 };
1834 });
1835 const runtime: ProviderAdapterV2SessionRuntime = {
1836 instanceId: options.instanceId,
1837 driver: MUSE_PROVIDER,
1838 providerSessionId: input.providerSessionId,
1839 providerSession: session,
1840 events: Stream.fromQueue(events),
1841 // A running workflow or a held Muse turn keeps the host: idle release must wait.
1842 hasPendingBackgroundWork: Effect.sync(
1843 () => observedChildren.size > 0 || wake !== undefined,
1844 ),
1845 ensureThread: (args) =>
1846 register(args).pipe(
1847 commands.withPermits(1),
1848 Effect.mapError(
1849 (cause) =>
1850 new ProviderAdapterEnsureThreadError({
1851 driver: MUSE_PROVIDER,
1852 threadId: args.threadId,
1853 cause,
1854 }),
1855 ),
1856 ),
1857 resumeThread: (args) =>
1858 register({
1859 threadId: args.threadId ?? args.providerThread.appThreadId ?? input.threadId,
1860 modelSelection: args.modelSelection ?? input.modelSelection,
1861 runtimePolicy: args.runtimePolicy ?? input.runtimePolicy,
1862 existingProviderThread: args.providerThread,
1863 }).pipe(
1864 commands.withPermits(1),
1865 Effect.mapError(
1866 (cause) =>
1867 new ProviderAdapterResumeThreadError({
1868 driver: MUSE_PROVIDER,
1869 providerSessionId: input.providerSessionId,
1870 providerThreadId: args.providerThread.id,
1871 cause,
1872 }),
1873 ),
1874 ),
1875 startTurn: (args) =>
1876 start(args).pipe(
1877 commands.withPermits(1),
1878 Effect.mapError(
1879 (cause) =>
1880 new ProviderAdapterTurnStartError({
1881 driver: MUSE_PROVIDER,
1882 threadId: args.threadId,
1883 providerThreadId: args.providerThread.id,
1884 runId: args.runId,
1885 cause,
1886 }),
1887 ),
1888 ),
1889 compactThread: (args) => start(args, true).pipe(commands.withPermits(1)),
1890 steerTurn: (args) =>
1891 Effect.gen(function* () {
1892 const turn = active;
1893 if (
1894 !turn ||
1895 turn.providerTurn.id !== args.providerTurnId ||
1896 !validateThread(args.providerThread) ||
1897 turn.compact
1898 )
1899 return yield* protocolError("This Muse turn cannot be steered");
1900 const parts = yield* prompt(args.message);
1901 const result = yield* request("turn/steer", {
1902 expectedTurnId: turn.nativeId,
1903 input: parts,
1904 }).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value)));
1905 if (result.turnId !== turn.nativeId)
1906 return yield* protocolError("Muse steering targeted a different turn");
1907 }).pipe(
1908 commands.withPermits(1),
1909 Effect.mapError(
1910 (cause) =>
1911 new ProviderAdapterSteerRunError({
1912 driver: MUSE_PROVIDER,
1913 providerThreadId: args.providerThread.id,
1914 providerTurnId: args.providerTurnId,
1915 cause,
1916 }),
1917 ),
1918 ),
1919 interruptTurn: (args) =>
1920 Effect.gen(function* () {
1921 const turn = active;
1922 if (
1923 !turn ||
1924 turn.providerTurn.id !== args.providerTurnId ||
1925 !validateThread(args.providerThread)
1926 )
1927 return yield* protocolError("This Muse turn is no longer active");
1928 turn.interruptRequested = true;
1929 if (turn.compact) {
1930 yield* eventPermit.withPermits(1)(
1931 Effect.gen(function* () {
1932 if (active !== turn) return;
1933 hostEpoch++;
1934 yield* Effect.tryPromise(() => host.close()).pipe(
1935 Effect.mapError((cause) => protocolError("Cannot stop Muse compaction", cause)),
1936 Effect.onError(failHost),
1937 );
1938 broken = true;
1939 yield* finish(turn, "interrupted", undefined, "broken");
1940 yield* settleObservedChildren("interrupted");
1941 yield* updateSession("stopped");
1942 yield* Queue.end(events);
1943 }),
1944 );
1945 return;
1946 }
1947 // Muse runs a joined turn first, so Stop must end it too.
1948 for (const joined of turn.joined)
1949 yield* request("turn/interrupt", { turnId: joined }).pipe(Effect.ignore);
1950 yield* request("turn/interrupt", { turnId: turn.nativeId });
1951 yield* Deferred.await(turn.done).pipe(
1952 Effect.timeout(options.requestTimeoutMs ?? 30_000),
1953 Effect.mapError((cause) => protocolError("Muse did not confirm interruption", cause)),
1954 Effect.onError((cause) => eventPermit.withPermits(1)(failHost(cause))),
1955 );
1956 }).pipe(
1957 commands.withPermits(1),
1958 Effect.mapError(
1959 (cause) =>
1960 new ProviderAdapterInterruptError({
1961 driver: MUSE_PROVIDER,
1962 providerThreadId: args.providerThread.id,
1963 providerTurnId: args.providerTurnId,
1964 cause,
1965 }),
1966 ),
1967 ),
1968 respondToRuntimeRequest: (args) =>
1969 Effect.gen(function* () {
1970 const entry = pending.get(args.requestId);
1971 if (!entry) return yield* protocolError("This Muse request is no longer pending");
1972 if (entry.native.type === "approval") yield* decideApproval(entry, args.decision);
1973 else {
1974 const questionRequest = entry.native.value;
1975 const answers = [];
1976 for (const question of questionRequest.questions) {
1977 const raw = yield* decode(responseAnswerSchema, args.answers?.[question.id]);
1978 const values = typeof raw === "string" ? [raw] : [...new Set(raw)];
1979 const selected = values.filter((value) =>
1980 question.options.some((option) => option.label === value),
1981 );
1982 const freeText = values.filter((value) => !selected.includes(value)).join("\n");
1983 if (freeText.length > 500 || (!freeText && !selected.length))
1984 return yield* protocolError(
1985 "Muse needs a nonempty answer with at most 500 custom characters",
1986 );
1987 if (
1988 (question.selection.mode === "single" && selected.length > 1) ||
1989 (selected.length > 0 &&
1990 (selected.length < (question.selection.minSelections ?? 0) ||
1991 selected.length > (question.selection.maxSelections ?? Infinity)))
1992 )
1993 return yield* protocolError("Muse question selection count is invalid");
1994 answers.push({
1995 questionId: question.id,
1996 ...(selected.length
1997 ? question.selection.mode === "single"
1998 ? { selectedLabel: selected[0]! }
1999 : { selectedLabels: selected }
2000 : {}),
2001 ...(freeText ? (selected.length ? { note: freeText } : { freeText }) : {}),
2002 });
2003 }
2004 if (args.answers) entry.response = { answers: args.answers };
2005 yield* request("userInput/answer", {
2006 userInputId: questionRequest.userInputId,
2007 answers,
2008 }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response)));
2009 }
2010 }).pipe(
2011 Effect.mapError(
2012 (cause) =>
2013 new ProviderAdapterRuntimeRequestResponseError({
2014 driver: MUSE_PROVIDER,
2015 requestId: args.requestId,
2016 cause,
2017 }),
2018 ),
2019 ),
2020 readThreadSnapshot: (args) =>
2021 (!validateThread(args.providerThread)
2022 ? protocolError("Snapshot requested for a different Muse thread")
2023 : snapshot(args.providerThread)
2024 ).pipe(
2025 commands.withPermits(1),
2026 Effect.mapError(
2027 (cause) =>
2028 new ProviderAdapterReadThreadSnapshotError({
2029 driver: MUSE_PROVIDER,
2030 providerThreadId: args.providerThread.id,
2031 cause,
2032 }),
2033 ),
2034 ),
2035 // Muse rejects forks at completed-turn boundaries (InvalidCut), so T3 uses
2036 // portable context handoff for forks and does not rewind Muse conversations.
2037 rollbackThread: (args) =>
2038 Effect.fail(
2039 new ProviderAdapterRollbackThreadError({
2040 driver: MUSE_PROVIDER,
2041 providerThreadId: args.providerThread.id,
2042 checkpointId: args.target.checkpointId,
2043 cause: "Muse Code does not support conversation rollback in T3 Code.",
2044 }),
2045 ),
2046 forkThread: (args) =>
2047 Effect.fail(
2048 new ProviderAdapterForkThreadError({
2049 driver: MUSE_PROVIDER,
2050 providerThreadId: args.sourceProviderThread.id,
2051 cause: "Muse Code does not support native forks in T3 Code.",
2052 }),
2053 ),
2054 };
2055 return runtime;
2056 }),
2057 });
2058}
apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts deleted-82
......@@ -1,82 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import type { MuseItem } from "../../provider/museProtocol.ts";
4import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts";
5
6const item = (fields: Partial<MuseItem>): MuseItem => ({
7 itemId: "native-item",
8 kind: "toolCall",
9 status: "completed",
10 revision: 1,
11 ...fields,
12});
13
14describe("Muse item presentation", () => {
15 it("preserves an edit's file and exact old/new text for the shared diff UI", () => {
16 expect(
17 museToolPresentation(
18 item({
19 tool: "edit_file",
20 args: JSON.stringify({ file_path: "src/index.ts", old_string: "old\n", new_string: "" }),
21 }),
22 ),
23 ).toMatchObject({
24 type: "file_change",
25 status: "completed",
26 fileName: "src/index.ts",
27 oldStr: "old\n",
28 newStr: "",
29 });
30 });
31
32 it("preserves write content and provider-supplied patches", () => {
33 expect(
34 museToolPresentation(
35 item({
36 tool: "write_file",
37 args: JSON.stringify({ path: "README.md", content: "Hello\n" }),
38 }),
39 ),
40 ).toMatchObject({ type: "file_change", newStr: "Hello\n" });
41 expect(
42 museToolPresentation(
43 item({
44 tool: "apply_patch",
45 args: JSON.stringify({ path: "README.md", patch: "@@ -1 +1 @@\n-old\n+new" }),
46 }),
47 ),
48 ).toMatchObject({ type: "file_change", diffStr: "@@ -1 +1 @@\n-old\n+new" });
49 });
50
51 it.each(["rejected", "timedOut", "handedOff"])(
52 "does not report native %s activity as successful",
53 (status) => {
54 const native = item({ tool: "search_files", status, fallbackText: "Native explanation" });
55 expect(museItemStatus(native)).toBe("failed");
56 expect(museToolPresentation(native)).toMatchObject({
57 status: "failed",
58 output: `Muse reported ${status}.\nNative explanation`,
59 });
60 },
61 );
62
63 it("preserves failure details and a known cancelled status", () => {
64 expect(
65 museToolPresentation(
66 item({
67 status: "failed",
68 failureReason: "Permission denied",
69 visibleOutput: "Partial output",
70 }),
71 ),
72 ).toMatchObject({ status: "failed", output: "Permission denied\nPartial output" });
73 expect(museItemStatus(item({ status: "cancelled" }))).toBe("cancelled");
74 });
75
76 it.each(["failed", "noop"])(
77 "keeps %s compaction outcomes from appearing completed",
78 (outcome) => {
79 expect(museItemStatus(item({ kind: "compaction", outcome }))).toBe("failed");
80 },
81 );
82});
apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts deleted-117
......@@ -1,117 +0,0 @@
1import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4import type { MuseItem } from "../../provider/museProtocol.ts";
5
6const decodeToolArgs = Schema.decodeUnknownSync(
7 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
8);
9
10type Fields<
11 T extends OrchestrationV2TurnItem["type"],
12 K extends keyof Extract<OrchestrationV2TurnItem, { type: T }>,
13> = Pick<Extract<OrchestrationV2TurnItem, { type: T }>, K>;
14type MuseToolPresentation = {
15 readonly status: OrchestrationV2TurnItemStatus;
16 readonly title: string | null;
17} & (
18 | Fields<"command_execution", "type" | "input" | "output" | "exitCode">
19 | Fields<"file_change", "type" | "fileName" | "oldStr" | "newStr" | "diffStr">
20 | Fields<"web_search", "type" | "patterns" | "results">
21 | Fields<"dynamic_tool", "type" | "toolName" | "input" | "output">
22);
23
24/** Unrecognized native terminal states must not appear as successful work. */
25export function museItemStatus(item: MuseItem): OrchestrationV2TurnItemStatus {
26 if (item.status === "inProgress") return "running";
27 if (item.status === "cancelled") return "cancelled";
28 if (
29 item.status === "completed" &&
30 !(item.kind === "compaction" && item.outcome && item.outcome !== "compacted")
31 )
32 return "completed";
33 return "failed";
34}
35
36/** Converts native tool details into the shared timeline fields used by every client. */
37export function museToolPresentation(
38 item: MuseItem,
39 status = museItemStatus(item),
40): MuseToolPresentation {
41 let args: Record<string, unknown> | undefined;
42 if (item.args) {
43 try {
44 args = decodeToolArgs(item.args);
45 } catch {
46 // Streaming model-authored arguments may not be valid JSON yet.
47 }
48 }
49 const stringArg = (...keys: string[]) => {
50 for (const key of keys) {
51 const value = args?.[key];
52 if (typeof value === "string") return value;
53 }
54 return undefined;
55 };
56 const toolName = item.tool ?? item.kind;
57 const title =
58 item.kind === "subagent"
59 ? item.objective || item.role || "Muse agent"
60 : item.kind === "workflow"
61 ? item.entryId || "Workflow"
62 : toolName;
63 const detail =
64 status === "failed" ? item.failureReason || `Muse reported ${item.status}.` : undefined;
65 const shared = { status, title: detail ? `${title}: ${detail}` : title };
66 const text =
67 item.visibleOutput ??
68 item.result?.summary ??
69 item.text ??
70 item.summary?.join("\n") ??
71 item.fallbackText ??
72 item.objective ??
73 "";
74 const output = detail ? [detail, text].filter(Boolean).join("\n") : text;
75
76 if (item.kind === "userShell" || /shell|bash|exec_command/i.test(item.tool ?? "")) {
77 return {
78 ...shared,
79 type: "command_execution",
80 input: item.commandText ?? stringArg("command", "cmd") ?? item.args ?? "",
81 output,
82 ...(item.exitCode === undefined ? {} : { exitCode: item.exitCode }),
83 };
84 }
85 if (item.kind === "toolCall" && /write|edit|patch/i.test(toolName)) {
86 const fileName = stringArg("file_path", "path", "filePath")?.trim();
87 if (fileName) {
88 const oldStr = stringArg("old_string", "old_str", "oldStr", "oldText");
89 const newStr = stringArg("new_string", "new_str", "newStr", "newText", "content");
90 const diffStr = stringArg("diff", "patch", "diffStr");
91 return {
92 ...shared,
93 type: "file_change",
94 fileName,
95 ...(oldStr === undefined ? {} : { oldStr }),
96 ...(newStr === undefined ? {} : { newStr }),
97 ...(diffStr === undefined ? {} : { diffStr }),
98 };
99 }
100 }
101 if (item.kind === "toolCall" && /web|browse|fetch/i.test(toolName)) {
102 const query = stringArg("query", "url", "pattern")?.trim();
103 return {
104 ...shared,
105 type: "web_search",
106 ...(query ? { patterns: [query] } : {}),
107 ...(output ? { results: [{ snippet: output }] } : {}),
108 };
109 }
110 return {
111 ...shared,
112 type: "dynamic_tool",
113 toolName,
114 input: args ?? item.args ?? "",
115 output,
116 };
117}
apps/server/src/provider/Drivers/MuseDriver.ts deleted-180
......@@ -1,180 +0,0 @@
1import { MuseSettings, ProviderDriverKind } from "@t3tools/contracts";
2import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";
7import { HttpClient } from "effect/http";
8import { ChildProcessSpawner } from "effect/process";
9
10import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
11import * as ServerConfig from "../../config.ts";
12import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
13import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration.ts";
14import { ProviderDriverError } from "../Errors.ts";
15import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts";
16import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
17import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
18import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts";
19import * as ProviderEventLoggers from "../ProviderEventLoggers.ts";
20import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
21import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "../museMaintenance.ts";
22import { makeMuseEnvironment } from "../museSdk.ts";
23import {
24 defaultProviderContinuationIdentity,
25 type ProviderDriver,
26 type ProviderInstance,
27} from "@t3tools/provider-core/server/driver";
28import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
29import {
30 makeCachedProviderMaintenanceResolution,
31 resolveProviderMaintenanceCapabilitiesEffect,
32} from "@t3tools/provider-core/server/maintenanceResolver";
33import {
34 haveProviderSnapshotSettingsChanged,
35 makeProviderSnapshotSettingsSource,
36 type ProviderSnapshotSettings,
37} from "@t3tools/provider-core/server/snapshotSettings";
38import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
39
40const DRIVER_KIND = ProviderDriverKind.make("muse");
41const decodeMuseSettings = Schema.decodeSync(MuseSettings);
42
43export type MuseDriverEnv =
44 | IdAllocator.IdAllocatorV2
45 | ProviderHost
46 | ChildProcessSpawner.ChildProcessSpawner
47 | FileSystem.FileSystem
48 | HttpClient.HttpClient
49 | Path.Path
50 | ProviderEventLoggers.ProviderEventLoggers
51 | ServerConfig.ServerConfig;
52
53export const MuseDriver: ProviderDriver<MuseSettings, MuseDriverEnv> = {
54 driverKind: DRIVER_KIND,
55 metadata: { displayName: "Muse Code", supportsMultipleInstances: true },
56 configSchema: MuseSettings,
57 defaultConfig: () => decodeMuseSettings({}),
58 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
59 Effect.gen(function* () {
60 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
61 const fileSystem = yield* FileSystem.FileSystem;
62 const path = yield* Path.Path;
63 const httpClient = yield* HttpClient.HttpClient;
64 const host = yield* ProviderHost;
65 const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
66 const serverConfig = yield* ServerConfig.ServerConfig;
67 const { cwd } = serverConfig;
68 const idAllocator = yield* IdAllocator.IdAllocatorV2;
69 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
70 const hostEnvironment = yield* HostProcessEnvironment;
71 // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins.
72 const processEnvironment = mergeProviderInstanceEnvironment(
73 environment,
74 makeMuseEnvironment(hostEnvironment),
75 );
76 const effectiveConfig = {
77 ...config,
78 enabled,
79 binaryPath: expandHomePath(config.binaryPath),
80 } satisfies MuseSettings;
81 const continuationIdentity = defaultProviderContinuationIdentity({
82 driverKind: DRIVER_KIND,
83 instanceId,
84 });
85 const stampIdentity = withInstanceIdentity({
86 instanceId,
87 driverKind: DRIVER_KIND,
88 displayName,
89 accentColor,
90 continuationGroupKey: continuationIdentity.continuationKey,
91 });
92 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
93 const resolveInstallation = yield* makeCachedProviderMaintenanceResolution(
94 resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, {
95 binaryPath: effectiveConfig.binaryPath,
96 env: processEnvironment,
97 }).pipe(
98 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
99 Effect.provideService(FileSystem.FileSystem, fileSystem),
100 Effect.provideService(Path.Path, path),
101 ),
102 );
103 const resolveMaintenance = (options?: { readonly fresh?: boolean }) =>
104 Effect.gen(function* () {
105 const capabilities = yield* resolveInstallation(options);
106 // The maintenance runner requests fresh capabilities around an explicit update
107 // and needs the native target version to verify that the command actually upgraded.
108 const latestVersion = options?.fresh
109 ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe(
110 Effect.provideService(HttpClient.HttpClient, httpClient),
111 )
112 : undefined;
113 return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities;
114 });
115 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<MuseSettings>>({
116 resolveMaintenance,
117 getSettings: snapshotSettings.getSettings,
118 streamSettings: snapshotSettings.streamSettings,
119 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
120 initialSnapshot: (settings) =>
121 makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)),
122 checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe(
123 Effect.map(stampIdentity),
124 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
125 Effect.provideService(FileSystem.FileSystem, fileSystem),
126 Effect.provideService(Path.Path, path),
127 ),
128 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
129 resolveMaintenance().pipe(
130 Effect.flatMap((maintenanceCapabilities) =>
131 enrichMuseSnapshot({
132 snapshot: currentSnapshot,
133 maintenanceCapabilities,
134 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
135 environment: processEnvironment,
136 }),
137 ),
138 Effect.provideService(HttpClient.HttpClient, httpClient),
139 Effect.flatMap(publishSnapshot),
140 ),
141 }).pipe(
142 Effect.mapError(
143 (cause) =>
144 new ProviderDriverError({
145 driver: DRIVER_KIND,
146 instanceId,
147 detail: "Failed to build Muse Code snapshot.",
148 cause,
149 }),
150 ),
151 );
152 const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models));
153 const orchestrationAdapter = makeMuseAdapterV2({
154 instanceId,
155 settings: effectiveConfig,
156 environment: processEnvironment,
157 idAllocator,
158 serverConfig,
159 fileSystem,
160 modelCatalog,
161 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
162 continuationRequests,
163 });
164 const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, {
165 environment: processEnvironment,
166 modelCatalog,
167 });
168 return {
169 instanceId,
170 driverKind: DRIVER_KIND,
171 continuationIdentity,
172 displayName,
173 accentColor,
174 enabled,
175 snapshot,
176 orchestrationAdapter,
177 textGeneration,
178 } satisfies ProviderInstance;
179 }),
180};
apps/server/src/provider/GrokProvider.test.ts+1-1
......@@ -18,7 +18,7 @@ import {
1818 grokSlashCommandsFromInitialize,
1919 parseGrokModelsCliOutput,
2020} from "./GrokProvider.ts";
21import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
21import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";
2222import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts";
2323
2424const decodeGrokSettings = Schema.decodeSync(GrokSettings);
apps/server/src/provider/MuseProvider.test.ts deleted-167
......@@ -1,167 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { MuseSettings } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Schema from "effect/Schema";
7import { vi } from "vite-plus/test";
8
9import { writeFakeCli } from "../testUtils/fakeCli.ts";
10import type { MuseSdkHost } from "./museSdk.ts";
11import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe";
12import { checkMuseProviderStatus } from "./MuseProvider.ts";
13
14const settings = Schema.decodeSync(MuseSettings);
15const makeHost = (catalog: Record<string, unknown>) => {
16 const host: MuseSdkHost = {
17 initializeResult: { grantedCapabilities: [] },
18 connection: {
19 request: vi.fn(async () => catalog),
20 command: vi.fn(async () => ({})),
21 mintCommandId: () => "test-command",
22 onNotification: () => {},
23 onServerRequest: () => {},
24 onProtocolError: () => {},
25 closed: new Promise(() => {}),
26 },
27 exited: new Promise(() => {}),
28 close: vi.fn(async () => {}),
29 };
30 return host;
31};
32const metaCatalog = {
33 providerId: "meta",
34 models: [
35 {
36 modelId: "muse-discovered",
37 displayLabel: "Muse Discovered",
38 providerId: "meta",
39 isDefault: true,
40 },
41 {
42 modelId: "foreign-model",
43 displayLabel: "Another provider",
44 providerId: "another",
45 isDefault: false,
46 },
47 ],
48};
49
50const fakeCli = (source = 'process.stdout.write("muse 1.0.3-R2198.1\\n");') =>
51 Effect.gen(function* () {
52 const fs = yield* FileSystem.FileSystem;
53 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-status-test-" });
54 return writeFakeCli({ directory, name: "muse", source });
55 });
56
57it.layer(NodeServices.layer)("Muse status", (it) => {
58 it.effect("does not start a host when disabled", () =>
59 Effect.gen(function* () {
60 const createHost = vi.fn(async () => makeHost(metaCatalog));
61 const snapshot = yield* checkMuseProviderStatus(settings({}), {}, undefined, createHost);
62 expect(snapshot.status).toBe("disabled");
63 expect(createHost).not.toHaveBeenCalled();
64 }),
65 );
66
67 it.effect("gives host-local install and login guidance for a missing binary", () =>
68 Effect.gen(function* () {
69 const snapshot = yield* checkMuseProviderStatus(
70 settings({ enabled: true, binaryPath: "/definitely-missing/muse" }),
71 );
72 expect(snapshot.installed).toBe(false);
73 expect(snapshot.status).toBe("error");
74 expect(snapshot.message).toContain("muse login");
75 expect(snapshot.message).toContain("this T3 server host");
76 }),
77 );
78
79 it.effect("does not expose CLI stderr or start an SDK host after a failed version check", () =>
80 Effect.scoped(
81 Effect.gen(function* () {
82 const binaryPath = yield* fakeCli(
83 'process.stderr.write("secret-test-value");process.exit(2);',
84 );
85 const createHost = vi.fn(async () => makeHost(metaCatalog));
86 const snapshot = yield* checkMuseProviderStatus(
87 settings({ enabled: true, binaryPath }),
88 undefined,
89 undefined,
90 createHost,
91 );
92 expect(snapshot.installed).toBe(true);
93 expect(snapshot.status).toBe("error");
94 expect(snapshot.message).not.toContain("secret-test-value");
95 expect(createHost).not.toHaveBeenCalled();
96 }),
97 ),
98 );
99
100 it.effect("discovers the Meta catalog without treating it as authentication proof", () =>
101 Effect.scoped(
102 Effect.gen(function* () {
103 const binaryPath = yield* fakeCli();
104 const host = makeHost(metaCatalog);
105 const snapshot = yield* checkMuseProviderStatus(
106 settings({ enabled: true, binaryPath, customModels: ["muse-custom"] }),
107 undefined,
108 undefined,
109 async () => host,
110 );
111 expect(snapshot.status).toBe("ready");
112 expect(snapshot.message).toBeUndefined();
113 expect(snapshot.slashCommands).toEqual([COMPACT_SLASH_COMMAND]);
114 expect(snapshot.auth).toEqual({ status: "unknown" });
115 expect(snapshot.version).toBe("1.0.3-R2198.1");
116 expect(snapshot.models.map((model) => model.slug)).toEqual([
117 "muse-discovered",
118 "muse-custom",
119 ]);
120 const descriptor = snapshot.models[0]?.capabilities?.optionDescriptors?.[0];
121 expect(
122 descriptor?.type === "select" && descriptor.options.map((option) => option.id),
123 ).toEqual(["low", "medium", "high", "xhigh", "max"]);
124 expect(host.connection.request).toHaveBeenCalledWith("model/list", {});
125 expect(host.connection.command).not.toHaveBeenCalled();
126 expect(host.close).toHaveBeenCalledOnce();
127 }),
128 ),
129 );
130
131 it.effect("closes the host after malformed metadata and reports unavailable", () =>
132 Effect.scoped(
133 Effect.gen(function* () {
134 const binaryPath = yield* fakeCli();
135 const host = makeHost({ providerId: "meta", models: [{ modelId: 1 }] });
136 const snapshot = yield* checkMuseProviderStatus(
137 settings({ enabled: true, binaryPath }),
138 undefined,
139 undefined,
140 async () => host,
141 );
142 expect(snapshot.status).toBe("error");
143 expect(snapshot.auth.status).toBe("unknown");
144 expect(snapshot.models).toEqual([]);
145 expect(host.close).toHaveBeenCalledOnce();
146 }),
147 ),
148 );
149
150 it.effect("keeps an empty discovered catalog empty instead of advertising a fallback model", () =>
151 Effect.scoped(
152 Effect.gen(function* () {
153 const binaryPath = yield* fakeCli();
154 const host = makeHost({ providerId: "meta", models: [] });
155 const snapshot = yield* checkMuseProviderStatus(
156 settings({ enabled: true, binaryPath }),
157 undefined,
158 undefined,
159 async () => host,
160 );
161 expect(snapshot.status).toBe("warning");
162 expect(snapshot.models).toEqual([]);
163 expect(host.close).toHaveBeenCalledOnce();
164 }),
165 ),
166 );
167});
apps/server/src/provider/MuseProvider.ts deleted-153
......@@ -1,153 +0,0 @@
1import type { MuseSettings, ServerProviderModel } from "@t3tools/contracts";
2import { resolveSpawnCommand } from "@t3tools/shared/shell";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import { ChildProcess } from "effect/process";
8
9import { createMuseSdkHost, makeMuseEnvironment } from "./museSdk.ts";
10import { parseMuseVersion } from "./museMaintenance.ts";
11import { discoverMuseModels, museModelCapabilities } from "./museModelCatalog.ts";
12import {
13 buildServerProvider,
14 COMPACT_SLASH_COMMAND,
15 DEFAULT_TIMEOUT_MS,
16 isCommandMissingCause,
17 providerModelsFromSettings,
18 spawnAndCollect,
19 type ProviderProbeResult,
20} from "@t3tools/provider-core/server/snapshotProbe";
21
22const MUSE_PRESENTATION = {
23 displayName: "Muse Code",
24 showInteractionModeToggle: false,
25 reportsContextWindow: true,
26 // Muse has no native "accept edits" or reviewer-backed mode, so T3 offers only
27 // the two it maps directly: promptUnmatched and allowAll.
28 supportedRuntimeModes: ["approval-required", "full-access"],
29} as const;
30
31const FALLBACK_CAPABILITIES = museModelCapabilities();
32
33export const makePendingMuseProvider = Effect.fn("makePendingMuseProvider")(function* (
34 settings: MuseSettings,
35) {
36 return buildServerProvider({
37 presentation: MUSE_PRESENTATION,
38 enabled: settings.enabled,
39 checkedAt: DateTime.formatIso(yield* DateTime.now),
40 models: providerModelsFromSettings([], settings.customModels, FALLBACK_CAPABILITIES),
41 slashCommands: settings.enabled ? [COMPACT_SLASH_COMMAND] : [],
42 probe: {
43 installed: false,
44 version: null,
45 status: "warning",
46 auth: { status: "unknown" },
47 message: settings.enabled
48 ? "Checking Muse Code CLI availability..."
49 : "Muse Code is disabled in T3 Code settings.",
50 },
51 });
52});
53
54export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(function* (
55 settings: MuseSettings,
56 environment?: NodeJS.ProcessEnv,
57 cwd?: string,
58 createHost: typeof createMuseSdkHost = createMuseSdkHost,
59) {
60 if (!settings.enabled) return yield* makePendingMuseProvider(settings);
61 const checkedAt = DateTime.formatIso(yield* DateTime.now);
62 // The driver passes a prepared environment; direct callers get the host's without META_API_KEY.
63 const museEnvironment = environment ?? makeMuseEnvironment();
64 const snapshot = (probe: ProviderProbeResult, models: ReadonlyArray<ServerProviderModel> = []) =>
65 buildServerProvider({
66 presentation: MUSE_PRESENTATION,
67 enabled: true,
68 checkedAt,
69 models: providerModelsFromSettings(models, settings.customModels, FALLBACK_CAPABILITIES),
70 slashCommands: [COMPACT_SLASH_COMMAND],
71 probe,
72 });
73 const versionResult = yield* Effect.gen(function* () {
74 const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], {
75 env: museEnvironment,
76 });
77 return yield* spawnAndCollect(
78 settings.binaryPath,
79 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
80 env: museEnvironment,
81 shell: spawnCommand.shell,
82 ...(cwd ? { cwd } : {}),
83 }),
84 );
85 }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result);
86
87 if (Result.isFailure(versionResult)) {
88 const missing = isCommandMissingCause(versionResult.failure);
89 return snapshot({
90 installed: !missing,
91 version: null,
92 status: "error",
93 auth: { status: "unknown" },
94 message: missing
95 ? "Muse Code CLI (`muse`) was not found. Install Muse Code and run `muse login` on this T3 server host."
96 : "Failed to execute Muse Code CLI. Check its binary path on this T3 server host.",
97 });
98 }
99 if (Option.isNone(versionResult.success)) {
100 return snapshot({
101 installed: true,
102 version: null,
103 status: "error",
104 auth: { status: "unknown" },
105 message: "Muse Code CLI version check timed out.",
106 });
107 }
108 const versionOutput = versionResult.success.value;
109 const version = parseMuseVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
110 if (versionOutput.code !== 0) {
111 return snapshot({
112 installed: true,
113 version,
114 status: "error",
115 auth: { status: "unknown" },
116 message: "Muse Code CLI is installed but failed to run.",
117 });
118 }
119
120 const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe(
121 Effect.scoped,
122 Effect.timeoutOption(12_000),
123 Effect.result,
124 );
125 if (Result.isFailure(catalog) || Option.isNone(catalog.success)) {
126 return snapshot({
127 installed: true,
128 version,
129 status: "error",
130 auth: { status: "unknown" },
131 message:
132 "Muse Code SDK could not read the model catalog. Check your Muse installation and run `muse login` on this T3 server host.",
133 });
134 }
135 const models = catalog.success.value;
136 // MSP has no auth signal, and model/list answers from Muse's cache even when logged out.
137 // Auth failures surface on the first turn instead.
138 return snapshot(
139 {
140 installed: true,
141 version,
142 auth: { status: "unknown" },
143 ...(models.length > 0
144 ? { status: "ready" }
145 : {
146 status: "warning",
147 message:
148 "Muse Code returned no models. Run `muse login` on this T3 server host and refresh its status.",
149 }),
150 },
151 models,
152 );
153});
apps/server/src/provider/builtInDrivers.ts+1-1
......@@ -27,7 +27,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
2727import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
2828import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
2929import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
30import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts";
30import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server";
3131import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server";
3232import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver";
3333
apps/server/src/provider/museMaintenance.test.ts deleted-232
......@@ -1,232 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import { HttpClient, HttpClientResponse } from "effect/http";
7
8import {
9 compareMuseVersions,
10 enrichMuseSnapshot,
11 museMaintenance,
12 latestMuseVersion,
13 parseMuseVersion,
14} from "./museMaintenance.ts";
15import {
16 ProviderVersionCache,
17 makeManualOnlyProviderMaintenanceCapabilities,
18} from "@t3tools/provider-core/server/maintenanceResolver";
19
20const maintenanceCapabilities = {
21 ...makeManualOnlyProviderMaintenanceCapabilities({
22 provider: ProviderDriverKind.make("muse"),
23 packageName: null,
24 }),
25 compareVersions: compareMuseVersions,
26};
27const snapshot: ServerProvider = {
28 instanceId: ProviderInstanceId.make("muse"),
29 driver: ProviderDriverKind.make("muse"),
30 enabled: true,
31 installed: true,
32 version: "1.0.3",
33 status: "ready",
34 auth: { status: "unknown" },
35 checkedAt: "2026-09-10T00:00:00.000Z",
36 models: [],
37 skills: [],
38 slashCommands: [],
39};
40
41it.layer(NodeServices.layer)("Muse maintenance", (it) => {
42 it.effect(
43 "refreshes a cached channel failure when an explicit update requests verification",
44 () =>
45 Effect.gen(function* () {
46 let requests = 0;
47 const check = Effect.gen(function* () {
48 expect(yield* latestMuseVersion({})).toBeNull();
49 expect(yield* latestMuseVersion({})).toBeNull();
50 expect(yield* latestMuseVersion({}, { fresh: true })).toBe("1.1.1-R10.1");
51 });
52 yield* check.pipe(
53 Effect.provideService(ProviderVersionCache, new Map()),
54 Effect.provideService(
55 HttpClient.HttpClient,
56 HttpClient.make((request) => {
57 requests += 1;
58 return Effect.succeed(
59 HttpClientResponse.fromWeb(
60 request,
61 requests === 1
62 ? new Response("unavailable", { status: 503 })
63 : Response.json({ version: "1.1.1-R10.1" }),
64 ),
65 );
66 }),
67 ),
68 );
69 expect(requests).toBe(2);
70 }),
71 );
72 it.effect(
73 "compares numeric native release revisions and preserves the CLI release identity",
74 () =>
75 Effect.sync(() => {
76 expect(parseMuseVersion("Muse Code 1.1.1 (1.1.1-R2514.1)")).toBe("1.1.1-R2514.1");
77 expect(compareMuseVersions("1.1.1-R9.1", "1.1.1-R10.1")).toBeLessThan(0);
78 expect(compareMuseVersions("1.1.1-R2514.1", "1.1.1-R2514.2")).toBeLessThan(0);
79 expect(compareMuseVersions("1.2.0-R1.1", "1.1.1-R2514.2")).toBeGreaterThan(0);
80 // A CLI that prints no revision compares by release, never as an older prerelease.
81 expect(compareMuseVersions("1.4.3", "1.4.3-R5018.1")).toBe(0);
82 expect(compareMuseVersions("1.4.2", "1.4.3-R5018.1")).toBeLessThan(0);
83 }),
84 );
85 it.effect(
86 "finds releases from the instance's channel and caches the native version, not the SDK version",
87 () =>
88 Effect.gen(function* () {
89 const requests: string[] = [];
90 const httpClient = HttpClient.make((request) => {
91 requests.push(request.url);
92 return Effect.succeed(
93 HttpClientResponse.fromWeb(request, Response.json({ version: "1.1.1-R2514.1" })),
94 );
95 });
96 const check = enrichMuseSnapshot({
97 snapshot,
98 maintenanceCapabilities,
99 enableProviderUpdateChecks: true,
100 environment: { MUSE_CHANNEL: "muse-canary" },
101 });
102 const [first, second] = yield* Effect.all([check, check], { concurrency: 1 }).pipe(
103 Effect.provideService(HttpClient.HttpClient, httpClient),
104 Effect.provideService(ProviderVersionCache, new Map()),
105 );
106 expect(first.versionAdvisory).toMatchObject({
107 status: "behind_latest",
108 currentVersion: "1.0.3",
109 latestVersion: "1.1.1-R2514.1",
110 canUpdate: false,
111 });
112 expect(second.versionAdvisory?.latestVersion).toBe("1.1.1-R2514.1");
113 expect(requests).toEqual(["https://api.meta.ai/muse-code/channels/muse-canary"]);
114 }),
115 );
116
117 it.effect("reports native release R9 behind R10 and R10 current against R9", () =>
118 Effect.gen(function* () {
119 for (const [current, latest, status] of [
120 ["1.4.2-R9.1", "1.4.2-R10.1", "behind_latest"],
121 ["1.4.2-R10.1", "1.4.2-R9.1", "current"],
122 ] as const) {
123 const result = yield* enrichMuseSnapshot({
124 snapshot: { ...snapshot, version: current },
125 maintenanceCapabilities,
126 enableProviderUpdateChecks: true,
127 environment: {},
128 }).pipe(
129 Effect.provideService(
130 HttpClient.HttpClient,
131 HttpClient.make((request) =>
132 Effect.succeed(
133 HttpClientResponse.fromWeb(request, Response.json({ version: latest })),
134 ),
135 ),
136 ),
137 Effect.provideService(ProviderVersionCache, new Map()),
138 );
139 expect(result.versionAdvisory?.status).toBe(status);
140 }
141 }),
142 );
143
144 it.effect("skips network checks when disabled, uninstalled or disabled in settings", () =>
145 Effect.gen(function* () {
146 for (const input of [
147 { snapshot, enableProviderUpdateChecks: false },
148 { snapshot: { ...snapshot, enabled: false }, enableProviderUpdateChecks: true },
149 { snapshot: { ...snapshot, installed: false }, enableProviderUpdateChecks: true },
150 ]) {
151 const result = yield* enrichMuseSnapshot({
152 ...input,
153 maintenanceCapabilities,
154 environment: {},
155 }).pipe(
156 Effect.provideService(
157 HttpClient.HttpClient,
158 HttpClient.make(() => Effect.die("Unexpected release request")),
159 ),
160 );
161 expect(result.versionAdvisory?.status).toBe("unknown");
162 }
163 }),
164 );
165
166 it.effect("tolerates invalid release manifests and unavailable channels", () =>
167 Effect.gen(function* () {
168 for (const response of [
169 Response.json({ version: 12 }),
170 Response.json({ version: "1.1.1" }),
171 new Response("unavailable", { status: 503 }),
172 ]) {
173 const result = yield* enrichMuseSnapshot({
174 snapshot,
175 maintenanceCapabilities,
176 enableProviderUpdateChecks: true,
177 environment: {},
178 }).pipe(
179 Effect.provideService(
180 HttpClient.HttpClient,
181 HttpClient.make((request) =>
182 Effect.succeed(HttpClientResponse.fromWeb(request, response)),
183 ),
184 ),
185 Effect.provideService(ProviderVersionCache, new Map()),
186 );
187 expect(result.status).toBe("ready");
188 expect(result.versionAdvisory?.status).toBe("unknown");
189 }
190 }),
191 );
192
193 it.effect("updates only a recognized launcher at the resolved instance path", () =>
194 Effect.scoped(
195 Effect.gen(function* () {
196 const fs = yield* FileSystem.FileSystem;
197 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" });
198 const binaryPath = `${directory}/custom muse`;
199 const context = {
200 binaryPath,
201 resolvedCommandPath: binaryPath,
202 realCommandPath: binaryPath,
203 platform: "linux" as const,
204 env: { MUSE_CHANNEL: "muse-canary" },
205 };
206 yield* fs.writeFileString(
207 binaryPath,
208 "#!/bin/bash\n# muse-code/launcher-2\n# MUSE_SYNC_UPDATE\n",
209 );
210 const result = yield* museMaintenance.resolve(context);
211 expect(result.update).toMatchObject({
212 executable: binaryPath,
213 args: ["--version"],
214 lockKey: `muse:${binaryPath}`,
215 env: {
216 MUSE_CHANNEL: "muse-canary",
217 MUSE_NO_AUTO_UPDATE: "0",
218 MUSE_SYNC_UPDATE: "1",
219 MUSE_UPDATE_INTERVAL_SECONDS: "0",
220 },
221 });
222 expect(result.update?.command).toBe(
223 `MUSE_CHANNEL=muse-canary MUSE_NO_AUTO_UPDATE=0 MUSE_SYNC_UPDATE=1 MUSE_UPDATE_INTERVAL_SECONDS=0 '${binaryPath}' --version`,
224 );
225 expect(result.compareVersions).toBe(compareMuseVersions);
226 yield* fs.writeFileString(binaryPath, "standalone binary");
227 expect((yield* museMaintenance.resolve(context)).update).toBeNull();
228 expect((yield* museMaintenance.resolve(null)).update).toBeNull();
229 }),
230 ),
231 );
232});
apps/server/src/provider/museMaintenance.ts deleted-139
......@@ -1,139 +0,0 @@
1import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2import { compareSemverVersions } from "@t3tools/shared/semver";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Schema from "effect/Schema";
7import { HttpClient, HttpClientRequest } from "effect/http";
8
9import {
10 enrichProviderSnapshotWithVersionAdvisory,
11 makeManualOnlyProviderMaintenanceCapabilities,
12 makeProviderMaintenanceCapabilities,
13 ProviderVersionCache,
14 type ProviderMaintenanceCapabilities,
15 type ProviderMaintenanceCapabilitiesResolver,
16} from "@t3tools/provider-core/server/maintenanceResolver";
17import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe";
18
19const DRIVER = ProviderDriverKind.make("muse");
20/** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */
21const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]);
22const ChannelManifest = Schema.Struct({
23 version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)),
24});
25
26/** Muse prints both a display version and a release revision; prefer the latter. */
27export function parseMuseVersion(output: string): string | null {
28 return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output);
29}
30
31function comparableMuseVersion(version: string): string {
32 // Make the release sequence numeric for semver comparison (R9 precedes R10).
33 return version.replace(/-R(\d+)(?:\.(\d+))?$/, "-release.$1.$2").replace(/\.$/, ".0");
34}
35
36const MUSE_REVISION = /-R\d+(?:\.\d+)?$/;
37
38/** A version printed without a revision is compared by its release alone. */
39export const compareMuseVersions = (current: string, latest: string) =>
40 MUSE_REVISION.test(current) === MUSE_REVISION.test(latest)
41 ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest))
42 : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, ""));
43
44/** Only the official launcher owns an update channel; a standalone binary cannot update itself. */
45export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = {
46 resolve: (context) =>
47 Effect.gen(function* () {
48 const manual = {
49 ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }),
50 compareVersions: compareMuseVersions,
51 };
52 if (!context || context.platform === "win32") return manual;
53 const fs = yield* FileSystem.FileSystem;
54 const isLauncher = yield* Effect.gen(function* () {
55 const stat = yield* fs.stat(context.realCommandPath);
56 if (Number(stat.size) > 128 * 1_024) return false;
57 const source = yield* fs.readFileString(context.realCommandPath);
58 return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE");
59 }).pipe(Effect.orElseSucceed(() => false));
60 if (!isLauncher) return manual;
61 // The launcher has no `update` subcommand. Its documented launch environment
62 // forces a synchronous refresh of this executable's own installation.
63 const updateEnv = {
64 ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}),
65 MUSE_NO_AUTO_UPDATE: "0",
66 MUSE_SYNC_UPDATE: "1",
67 MUSE_UPDATE_INTERVAL_SECONDS: "0",
68 };
69 return {
70 ...makeProviderMaintenanceCapabilities({
71 provider: DRIVER,
72 packageName: null,
73 updateExecutable: context.resolvedCommandPath,
74 updateArgs: ["--version"],
75 updateCommand: [
76 ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`),
77 shellQuote(context.resolvedCommandPath),
78 "--version",
79 ].join(" "),
80 updateLockKey: `muse:${context.realCommandPath}`,
81 platform: context.platform,
82 env: { ...context.env, ...updateEnv },
83 }),
84 compareVersions: compareMuseVersions,
85 };
86 }),
87};
88
89function shellQuote(word: string) {
90 return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
91}
92
93export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* (
94 environment: NodeJS.ProcessEnv,
95 options?: { readonly fresh?: boolean },
96) {
97 const channel = environment.MUSE_CHANNEL || "muse-stable";
98 if (!MUSE_CHANNELS.has(channel)) return null;
99 const channelUrl = `https://api.meta.ai/muse-code/channels/${channel}`;
100 const cache = yield* ProviderVersionCache;
101 const key = `muse:${channelUrl}`;
102 const now = DateTime.toEpochMillis(yield* DateTime.now);
103 const cached = cache.get(key);
104 if (!options?.fresh && cached && cached.expiresAt > now) return cached.version;
105 const client = yield* HttpClient.HttpClient;
106 const version = yield* client.execute(HttpClientRequest.get(channelUrl)).pipe(
107 Effect.flatMap((response) =>
108 response.status >= 200 && response.status < 300
109 ? response.json.pipe(
110 Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)),
111 Effect.map((manifest) => parseMuseVersion(manifest.version)),
112 )
113 : Effect.succeed(null),
114 ),
115 Effect.timeout(4_000),
116 Effect.orElseSucceed(() => null),
117 );
118 cache.set(key, { version, expiresAt: now + (version === null ? 60_000 : 60 * 60 * 1_000) });
119 return version;
120});
121
122/** Feed Muse's release channel into the same advisory and notification flow as other drivers. */
123export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (input: {
124 readonly snapshot: ServerProvider;
125 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
126 readonly enableProviderUpdateChecks: boolean;
127 readonly environment: NodeJS.ProcessEnv;
128}) {
129 const { snapshot } = input;
130 const latestVersion =
131 input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version
132 ? yield* latestMuseVersion(input.environment)
133 : null;
134 return yield* enrichProviderSnapshotWithVersionAdvisory(
135 snapshot,
136 { ...input.maintenanceCapabilities, latestVersion },
137 { enableProviderUpdateChecks: input.enableProviderUpdateChecks },
138 );
139});
apps/server/src/provider/museModelCatalog.test.ts deleted-98
......@@ -1,98 +0,0 @@
1import { describe, expect, it } from "@effect/vitest";
2import { MuseSettings } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5
6import type { MuseSdkHost } from "./museSdk.ts";
7import {
8 discoverMuseModels,
9 museModelCapabilities,
10 resolveMuseReasoningEffort,
11} from "./museModelCatalog.ts";
12
13const settings = Schema.decodeSync(MuseSettings)({});
14const host = (catalog: Record<string, unknown>): MuseSdkHost => ({
15 connection: {
16 request: async () => catalog,
17 command: async () => ({}),
18 mintCommandId: () => "test",
19 onNotification: () => {},
20 onServerRequest: () => {},
21 onProtocolError: () => {},
22 closed: new Promise<void>(() => {}),
23 },
24 initializeResult: { grantedCapabilities: [] },
25 exited: new Promise(() => {}),
26 close: async () => {},
27});
28
29const row = (modelId: string, extra: Record<string, unknown> = {}) => ({
30 modelId,
31 displayLabel: modelId,
32 providerId: "meta",
33 profileId: "tbh",
34 isDefault: false,
35 contextLimit: 1_000_000,
36 ...extra,
37});
38
39describe("Muse model catalog", () => {
40 it.effect("uses the labels, efforts and defaults that model/list sends", () =>
41 Effect.gen(function* () {
42 const models = yield* discoverMuseModels(settings, {}, undefined, async () =>
43 host({
44 providerId: "meta",
45 profileId: "tbh",
46 source: "providerCatalog",
47 models: [
48 row("restful-walrus", {
49 displayLabel: " Restful Walrus ",
50 isDefault: true,
51 variants: ["minimal", "low", "high", "future-tier"],
52 reasoningEffortVariants: [{ tier: "low", description: "Quick" }],
53 defaultReasoningEffort: "high",
54 }),
55 row("older-host"),
56 row("unknown-efforts", { variants: "unknown" }),
57 row("foreign", { providerId: "another" }),
58 ],
59 }),
60 ).pipe(Effect.scoped);
61 expect(models.map(({ slug, name, isDefault }) => [slug, name, isDefault])).toEqual([
62 ["restful-walrus", "Restful Walrus", true],
63 ["older-host", "older-host", false],
64 ["unknown-efforts", "unknown-efforts", false],
65 ]);
66 expect(models[0]?.capabilities?.optionDescriptors).toEqual([
67 {
68 id: "reasoningEffort",
69 label: "Reasoning",
70 type: "select",
71 currentValue: "high",
72 options: [
73 { id: "minimal", label: "Minimal" },
74 { id: "low", label: "Low", description: "Quick" },
75 { id: "high", label: "High", isDefault: true },
76 ],
77 },
78 ]);
79 // Older hosts and "unknown" fall back to Muse's documented tiers.
80 expect(models[1]?.capabilities).toEqual(museModelCapabilities());
81 expect(models[2]?.capabilities).toEqual(museModelCapabilities());
82 }),
83 );
84
85 it("normalizes saved and implicit efforts against the selected model before dispatch", () => {
86 const fallback = museModelCapabilities();
87 expect(resolveMuseReasoningEffort(fallback, undefined)).toBe("high");
88 expect(resolveMuseReasoningEffort(fallback, "max")).toBe("max");
89 expect(resolveMuseReasoningEffort(fallback, "ultra")).toBe("high");
90 const restricted = museModelCapabilities({ variants: ["xhigh"] });
91 expect(resolveMuseReasoningEffort(restricted, undefined)).toBe("xhigh");
92 expect(resolveMuseReasoningEffort(restricted, "low")).toBe("xhigh");
93 expect(
94 resolveMuseReasoningEffort(museModelCapabilities({ variants: [] }), "max"),
95 ).toBeUndefined();
96 expect(resolveMuseReasoningEffort(undefined, "max")).toBe("max");
97 });
98});
apps/server/src/provider/museModelCatalog.ts deleted-138
......@@ -1,138 +0,0 @@
1import type { SendUserTurnOptions } from "@muse-code/sdk";
2import type { ModelCapabilities, MuseSettings, ServerProviderModel } from "@t3tools/contracts";
3import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./museSdk.ts";
7
8type MuseReasoningEffort = NonNullable<SendUserTurnOptions<never>["reasoningEffort"]>;
9
10const REASONING_EFFORT_LABELS = {
11 none: "None",
12 minimal: "Minimal",
13 low: "Low",
14 medium: "Medium",
15 high: "High",
16 xhigh: "Extra High",
17 max: "Max",
18 ultra: "Ultra",
19} satisfies Record<MuseReasoningEffort, string>;
20
21const isMuseReasoningEffort = (tier: string): tier is MuseReasoningEffort =>
22 Object.hasOwn(REASONING_EFFORT_LABELS, tier);
23
24/** Used when Muse does not report a model's efforts (custom slugs, older hosts, "unknown"). */
25const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
26/** Muse's own CLI default. */
27const FALLBACK_DEFAULT_EFFORT = "high";
28
29const ModelCatalogEntry = Schema.Struct({
30 modelId: Schema.NonEmptyString,
31 displayLabel: Schema.String,
32 providerId: Schema.String,
33 isDefault: Schema.Boolean,
34 variants: Schema.optional(Schema.Union([Schema.Array(Schema.String), Schema.Literal("unknown")])),
35 defaultReasoningEffort: Schema.optional(Schema.String),
36 reasoningEffortVariants: Schema.optional(
37 Schema.Array(
38 Schema.Struct({ tier: Schema.String, description: Schema.optional(Schema.String) }),
39 ),
40 ),
41});
42const decodeModelList = Schema.decodeUnknownEffect(
43 Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }),
44);
45
46/** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */
47export function museModelCapabilities(
48 model?: Pick<
49 typeof ModelCatalogEntry.Type,
50 "variants" | "defaultReasoningEffort" | "reasoningEffortVariants"
51 >,
52): ModelCapabilities {
53 const reported = model?.variants !== undefined && model.variants !== "unknown";
54 const efforts = (reported ? model.variants : FALLBACK_EFFORTS).filter(isMuseReasoningEffort);
55 const preferredDefault = reported ? model?.defaultReasoningEffort : FALLBACK_DEFAULT_EFFORT;
56 const defaultValue = efforts.find((tier) => tier === preferredDefault) ?? efforts[0];
57 if (!defaultValue) return createModelCapabilities({ optionDescriptors: [] });
58 return createModelCapabilities({
59 optionDescriptors: [
60 {
61 id: "reasoningEffort",
62 label: "Reasoning",
63 type: "select",
64 currentValue: defaultValue,
65 options: efforts.map((id) => {
66 const description = model?.reasoningEffortVariants?.find(
67 (variant) => variant.tier === id,
68 )?.description;
69 return {
70 id,
71 label: REASONING_EFFORT_LABELS[id],
72 ...(id === defaultValue ? { isDefault: true } : {}),
73 ...(description ? { description } : {}),
74 };
75 }),
76 },
77 ],
78 });
79}
80
81/** Apply the advertised model choices to saved, remembered and implicit efforts at dispatch. */
82export function resolveMuseReasoningEffort(
83 capabilities: ModelCapabilities | null | undefined,
84 effort: string | undefined,
85): string | undefined {
86 if (!capabilities) return effort;
87 const descriptor = getProviderOptionDescriptors({
88 caps: capabilities,
89 selections: effort ? [{ id: "reasoningEffort", value: effort }] : undefined,
90 }).find((descriptor) => descriptor.id === "reasoningEffort");
91 if (descriptor?.type !== "select") return undefined;
92 return descriptor.options.find((option) => option.id === descriptor.currentValue)?.id;
93}
94
95class MuseCatalogError extends Schema.TaggedError<MuseCatalogError>()("MuseCatalogError", {
96 detail: Schema.String,
97}) {}
98
99export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* (
100 settings: MuseSettings,
101 environment: NodeJS.ProcessEnv | undefined,
102 cwd?: string,
103 createHost: typeof createMuseSdkHost = createMuseSdkHost,
104) {
105 const host = yield* Effect.acquireRelease(
106 createMuseSdkHostEffect(
107 {
108 binaryPath: settings.binaryPath,
109 ...(environment ? { environment } : {}),
110 ...(cwd ? { cwd } : {}),
111 readOnly: true,
112 startupTimeoutMs: 8_000,
113 },
114 createHost,
115 ),
116 (host: MuseSdkHost) => Effect.promise(() => host.close()),
117 { interruptible: true },
118 );
119 const result = yield* Effect.tryPromise(() => host.connection.request("model/list", {}));
120 const catalog = yield* decodeModelList(result);
121 if (catalog.providerId !== "meta") {
122 return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." });
123 }
124 const seen = new Set<string>();
125 return catalog.models.flatMap((model): ServerProviderModel[] => {
126 if (model.providerId !== "meta" || seen.has(model.modelId)) return [];
127 seen.add(model.modelId);
128 return [
129 {
130 slug: model.modelId,
131 name: model.displayLabel.trim() || model.modelId,
132 isCustom: false,
133 isDefault: model.isDefault,
134 capabilities: museModelCapabilities(model),
135 },
136 ];
137 });
138});
apps/server/src/provider/museProtocol.test.ts deleted-17
......@@ -1,17 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { museApprovalChoices } from "./museProtocol.ts";
4
5// Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case.
6describe("Muse approval choices", () => {
7 it("keeps denied and abort separate when Muse offers both", () => {
8 const choices = museApprovalChoices({
9 availableChoices: [
10 { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" },
11 { choiceId: "abort", label: "Stop", decision: "abort", scope: "once" },
12 ],
13 });
14 expect(choices.get("decline")?.choiceId).toBe("deny");
15 expect(choices.get("cancel")?.choiceId).toBe("abort");
16 });
17});
apps/server/src/provider/museProtocol.ts deleted-228
......@@ -1,228 +0,0 @@
1import type { ProviderApprovalDecision } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4const NonEmptyString = Schema.String.check(Schema.isMinLength(1));
5
6const MuseUsage = Schema.Struct({
7 inputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
8 outputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
9 cachedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
10 reasoningTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
11 cacheReadTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
12 cacheWriteTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
13});
14
15export const MuseItem = Schema.Struct({
16 /** `workflow`: the launched entry's name. */
17 entryId: Schema.optional(Schema.String),
18 /** `workflow`: each child agent's latest attempt, re-sent whole on every change. */
19 children: Schema.optional(
20 Schema.Array(
21 Schema.Struct({
22 childId: NonEmptyString,
23 attempt: Schema.Int,
24 status: Schema.String,
25 label: Schema.optional(Schema.String),
26 terminal: Schema.optional(Schema.String),
27 durationMs: Schema.optional(Schema.Finite),
28 failureReason: Schema.optional(Schema.String),
29 }),
30 ),
31 ),
32 itemId: NonEmptyString,
33 kind: NonEmptyString,
34 revision: Schema.Int.check(Schema.isGreaterThan(0)),
35 status: NonEmptyString,
36 turnId: Schema.optional(Schema.NullOr(Schema.String)),
37 text: Schema.optional(Schema.String),
38 summary: Schema.optional(Schema.Array(Schema.String)),
39 tool: Schema.optional(Schema.String),
40 args: Schema.optional(Schema.String),
41 visibleOutput: Schema.optional(Schema.String),
42 truncated: Schema.optional(Schema.Boolean),
43 failureReason: Schema.optional(Schema.String),
44 fallbackText: Schema.optional(Schema.String),
45 commandText: Schema.optional(Schema.String),
46 exitCode: Schema.optional(Schema.Int),
47 durationMs: Schema.optional(Schema.Finite),
48 subagentId: Schema.optional(Schema.String),
49 childSessionId: Schema.optional(Schema.String),
50 objective: Schema.optional(Schema.String),
51 role: Schema.optional(Schema.String),
52 controlStatus: Schema.optional(Schema.String),
53 result: Schema.optional(
54 Schema.Struct({ summary: Schema.String, text: Schema.optional(Schema.String) }),
55 ),
56 usage: Schema.optional(MuseUsage),
57 outcome: Schema.optional(Schema.String),
58 trigger: Schema.optional(Schema.String),
59 reason: Schema.optional(Schema.String),
60});
61export type MuseItem = typeof MuseItem.Type;
62
63export const MuseSessionResult = Schema.Struct({
64 session: Schema.Struct({
65 sessionId: NonEmptyString,
66 modelId: Schema.optional(Schema.NullOr(Schema.String)),
67 activeTurnId: Schema.optional(Schema.NullOr(Schema.String)),
68 }),
69 history: Schema.optional(
70 Schema.Struct({
71 items: Schema.NullOr(Schema.Array(MuseItem)),
72 snapshot: Schema.optional(
73 Schema.NullOr(
74 Schema.Struct({
75 schemaVersion: Schema.optional(Schema.Int),
76 state: Schema.Struct({ items: Schema.Array(MuseItem) }),
77 }),
78 ),
79 ),
80 }),
81 ),
82});
83export const MuseTurnStartResult = Schema.Struct({ turnId: NonEmptyString });
84
85export const MuseApproval = Schema.Struct({
86 approvalId: NonEmptyString,
87 protectedWrite: Schema.optional(Schema.Boolean),
88 judgeEscalated: Schema.optional(Schema.Boolean),
89 sessionId: NonEmptyString,
90 turnId: Schema.optional(Schema.String),
91 itemId: Schema.optional(Schema.String),
92 toolName: Schema.optional(Schema.String),
93 rawArgs: Schema.optional(Schema.String),
94 /** Set when a workflow child asks; `turnId` is then the child's run, not a session turn. */
95 subagentOrigin: Schema.optional(Schema.Struct({ subagentId: NonEmptyString })),
96 currentRequirementId: Schema.Struct({ approvalId: NonEmptyString, sourceIndex: Schema.Int }),
97 availableChoices: Schema.Array(
98 Schema.Struct({
99 choiceId: NonEmptyString,
100 label: Schema.String,
101 decision: NonEmptyString,
102 scope: Schema.String,
103 }),
104 ),
105 subject: Schema.Struct({
106 kind: Schema.String,
107 access: Schema.optional(Schema.String),
108 command: Schema.optional(Schema.String),
109 path: Schema.optional(Schema.String),
110 host: Schema.optional(Schema.String),
111 target: Schema.optional(Schema.String),
112 }),
113});
114export type MuseApproval = typeof MuseApproval.Type;
115
116export const MuseUserInput = Schema.Struct({
117 userInputId: NonEmptyString,
118 sessionId: NonEmptyString,
119 turnId: NonEmptyString,
120 questions: Schema.Array(
121 Schema.Struct({
122 id: NonEmptyString,
123 header: Schema.String,
124 question: Schema.String,
125 options: Schema.Array(
126 Schema.Struct({ label: Schema.String, description: Schema.optional(Schema.String) }),
127 ),
128 selection: Schema.Struct({
129 mode: Schema.Literals(["single", "multiple"]),
130 minSelections: Schema.optional(Schema.Int),
131 maxSelections: Schema.optional(Schema.Int),
132 }),
133 }),
134 ),
135});
136export type MuseUserInput = typeof MuseUserInput.Type;
137
138export const MuseTokenUsageEvent = Schema.Struct({
139 turnId: NonEmptyString,
140 promptTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
141 usage: MuseUsage,
142});
143export const MuseContextUsage = Schema.Struct({
144 usedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
145 windowTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
146});
147export const MuseTodoList = Schema.Struct({
148 items: Schema.Array(Schema.Struct({ text: Schema.String, status: Schema.String })),
149});
150export const MuseTurnCompleted = Schema.Struct({
151 turnId: NonEmptyString,
152 terminal: NonEmptyString,
153 reason: Schema.optional(Schema.String),
154 error: Schema.optional(Schema.Struct({ message: Schema.String })),
155 usage: Schema.optional(MuseUsage),
156});
157export const MuseTurnRetryScheduled = Schema.Struct({
158 turnId: NonEmptyString,
159 nextAttempt: Schema.Int.check(Schema.isGreaterThan(0)),
160 maxAttempts: Schema.Int.check(Schema.isGreaterThan(0)),
161 reason: NonEmptyString,
162 retryDelayMs: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
163});
164export const MuseCompactResult = Schema.Struct({
165 status: Schema.String,
166 reason: Schema.optional(Schema.String),
167});
168
169export const MuseItemEvent = Schema.Struct({ item: MuseItem });
170export const MuseDelta = Schema.Struct({
171 itemId: NonEmptyString,
172 delta: Schema.String,
173 field: Schema.optional(Schema.String),
174});
175
176/** The T3 decision for one native Muse choice, or none when T3 has no equivalent. */
177export function museApprovalDecision(
178 choice: Pick<MuseApproval["availableChoices"][number], "decision" | "scope">,
179): ProviderApprovalDecision | undefined {
180 switch (choice.decision) {
181 case "approved":
182 return "accept";
183 case "approvedForSession":
184 return "acceptForSession";
185 case "approvedPolicyAmendment":
186 return choice.scope === "session"
187 ? "acceptForSession"
188 : choice.scope === "localPersistent"
189 ? "acceptAlways"
190 : undefined;
191 case "denied":
192 case "deniedPolicyAmendment":
193 return "decline";
194 case "abort":
195 return "cancel";
196 default:
197 return undefined;
198 }
199}
200
201/**
202 * Maps each canonical decision to one native choice. Muse shell approvals offer
203 * no `denied` choice, only `abort` ("Reject"), so `decline` falls back to it.
204 */
205export function museApprovalChoices(approval: Pick<MuseApproval, "availableChoices">) {
206 const choices = new Map<ProviderApprovalDecision, MuseApproval["availableChoices"][number]>();
207 const rank = (choice: MuseApproval["availableChoices"][number]) =>
208 (choice.scope === "once" ? 0 : choice.scope === "session" ? 2 : 4) +
209 (choice.decision.endsWith("PolicyAmendment") ? 1 : 0);
210 for (const choice of approval.availableChoices) {
211 const decision = museApprovalDecision(choice);
212 if (!decision) continue;
213 const previous = choices.get(decision);
214 if (!previous || rank(choice) < rank(previous)) choices.set(decision, choice);
215 }
216 const abort = choices.get("cancel");
217 if (!choices.has("decline") && abort) choices.set("decline", abort);
218 return choices;
219}
220
221/** The approval buttons to show: one per native choice, preferring `decline` over `cancel`. */
222export function museApprovalOptions(approval: Pick<MuseApproval, "availableChoices">) {
223 const choices = museApprovalChoices(approval);
224 const declineChoiceId = choices.get("decline")?.choiceId;
225 return [...choices]
226 .filter(([decision, choice]) => decision !== "cancel" || choice.choiceId !== declineChoiceId)
227 .map(([decision, choice]) => ({ decision, label: choice.label }));
228}
apps/server/src/provider/museSdk.test.ts deleted-266
......@@ -1,266 +0,0 @@
1import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk";
2import { it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import { afterEach, describe, expect, vi } from "vite-plus/test";
6import {
7 createMuseSdkHost,
8 createMuseSdkHostEffect,
9 makeMuseEnvironment,
10 museApprovalMode,
11 type MuseSdkHost,
12} from "./museSdk.ts";
13
14vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() }));
15
16function pending<A>() {
17 let resolve = (_value: A) => {};
18 let reject = (_error: unknown) => {};
19 const promise = new Promise<A>((yes, no) => {
20 resolve = yes;
21 reject = no;
22 });
23 return { promise, resolve, reject };
24}
25
26function mockSpawn() {
27 const startup = pending<SpawnedMspConnection>();
28 const shutdown = pending<{ code: number; signal: null }>();
29 const closing = pending<void>();
30 const initializing = pending<void>();
31 const initialize = vi.fn(() => {
32 initializing.resolve();
33 return startup.promise;
34 });
35 const close = vi.fn(() => {
36 closing.resolve();
37 return shutdown.promise;
38 });
39 const handshake = { initialize, close } as unknown as MspHandshake;
40 vi.mocked(spawnMspConnection).mockReturnValue(handshake);
41 const ready = {
42 connection: {},
43 initializeResult: { grantedCapabilities: [], schema: { version: 1 } },
44 exited: shutdown.promise,
45 fingerprintWarning: { warning: "additive optional" },
46 } as unknown as SpawnedMspConnection;
47 return { startup, shutdown, closing, initializing, initialize, close, ready };
48}
49
50afterEach(() => {
51 vi.useRealTimers();
52 vi.clearAllMocks();
53});
54
55describe("Muse SDK host", () => {
56 it.each(["META_API_KEY", "meta_api_key", "Meta_Api_Key"])(
57 "excludes %s without reading its value or mutating the supplied environment",
58 (key) => {
59 const source: NodeJS.ProcessEnv = { PATH: "/bin", MUSE_TEST: "yes" };
60 const read = vi.fn(() => {
61 throw new Error("The API override must not be read.");
62 });
63 Object.defineProperty(source, key, { enumerable: true, get: read });
64 expect(makeMuseEnvironment(source)).toEqual({
65 PATH: "/bin",
66 MUSE_TEST: "yes",
67 MUSE_NO_AUTO_UPDATE: "1",
68 });
69 expect(read).not.toHaveBeenCalled();
70 expect(Object.keys(source)).toContain(key);
71 },
72 );
73
74 it("selects full access explicitly and keeps other runtime modes sandboxed", async () => {
75 for (const mode of ["approval-required", "auto-accept-edits", "auto", "full-access"] as const) {
76 const fake = mockSpawn();
77 const running = createMuseSdkHost({
78 binaryPath: "/custom/muse",
79 cwd: "/workspace",
80 runtimeMode: mode,
81 environment: { PATH: "/bin" },
82 });
83 expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0]).toMatchObject({
84 command: "/custom/muse",
85 cwd: "/workspace",
86 args:
87 mode === "full-access"
88 ? ["serve", "--trust-workspace", "--disable-sandbox"]
89 : ["serve", "--trust-workspace"],
90 });
91 expect(museApprovalMode(mode)).toBe(mode === "full-access" ? "allowAll" : "promptUnmatched");
92 fake.startup.resolve(fake.ready);
93 const host = await running;
94 fake.shutdown.resolve({ code: 0, signal: null });
95 await host.close();
96 await host.close();
97 expect(fake.close).toHaveBeenCalledOnce();
98 }
99 });
100
101 it("bounds startup and waits for native shutdown after timeout", async () => {
102 vi.useFakeTimers();
103 const fake = mockSpawn();
104 const startup = createMuseSdkHost({ binaryPath: "muse", startupTimeoutMs: 30 });
105 let settled = false;
106 const outcome = startup.then(
107 () => {
108 settled = true;
109 },
110 (error: unknown) => {
111 settled = true;
112 return error;
113 },
114 );
115 await vi.advanceTimersByTimeAsync(31);
116 expect(fake.close).toHaveBeenCalledOnce();
117 expect(settled).toBe(false);
118 fake.shutdown.resolve({ code: 0, signal: null });
119 expect(await outcome).toMatchObject({ message: "Muse SDK initialization timed out." });
120 });
121
122 it("closes the pre-handshake host on abort and rejects only after shutdown", async () => {
123 const fake = mockSpawn();
124 const abort = new AbortController();
125 const started = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal });
126 const reason = new Error("Cancelled by caller");
127 const outcome = started.catch((error: unknown) => error);
128 abort.abort(reason);
129 expect(fake.close).toHaveBeenCalledOnce();
130 fake.shutdown.resolve({ code: 0, signal: null });
131 expect(await outcome).toBe(reason);
132 });
133
134 it("closes rejected initialization and keeps successful schema advisories usable", async () => {
135 const failed = mockSpawn();
136 const started = createMuseSdkHost({ binaryPath: "muse" });
137 const outcome = started.catch((error: unknown) => error);
138 failed.startup.reject(new Error("Handshake failed"));
139 failed.shutdown.resolve({ code: 0, signal: null });
140 expect(await outcome).toMatchObject({ message: "Handshake failed" });
141 expect(failed.close).toHaveBeenCalledOnce();
142 const success = mockSpawn();
143 const ready = createMuseSdkHost({ binaryPath: "muse", readOnly: true });
144 success.startup.resolve(success.ready);
145 const host = await ready;
146 expect(host.initializeResult.grantedCapabilities).toEqual([]);
147 expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0].args).toEqual([
148 "serve",
149 "--disable-shell",
150 "--disable-write",
151 "--no-session-log",
152 ]);
153 success.shutdown.resolve({ code: 0, signal: null });
154 await host.close();
155 });
156
157 it.each([undefined, 2])(
158 "rejects unsupported envelope version %s and awaits shutdown",
159 async (version) => {
160 const fake = mockSpawn();
161 const started = createMuseSdkHost({ binaryPath: "muse" });
162 let settled = false;
163 const outcome = started.catch((error: unknown) => {
164 settled = true;
165 return error;
166 });
167 fake.startup.resolve({
168 ...fake.ready,
169 initializeResult: {
170 ...fake.ready.initializeResult,
171 schema: { fingerprint: "test", version },
172 },
173 } as unknown as SpawnedMspConnection);
174 await fake.closing.promise;
175 expect(fake.close).toHaveBeenCalledOnce();
176 expect(settled).toBe(false);
177 fake.shutdown.resolve({ code: 0, signal: null });
178 expect(await outcome).toMatchObject({
179 message: "Muse SDK returned an unsupported protocol envelope version.",
180 });
181 },
182 );
183
184 it("does not spawn when already aborted and closes a ready host on later abort", async () => {
185 const aborted = new AbortController();
186 aborted.abort();
187 await expect(
188 createMuseSdkHost({ binaryPath: "muse", signal: aborted.signal }),
189 ).rejects.toBeDefined();
190 expect(spawnMspConnection).not.toHaveBeenCalled();
191 const fake = mockSpawn();
192 const abort = new AbortController();
193 const running = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal });
194 fake.startup.resolve(fake.ready);
195 const host = await running;
196 abort.abort();
197 fake.shutdown.resolve({ code: 0, signal: null });
198 await host.close();
199 expect(fake.close).toHaveBeenCalledOnce();
200 });
201
202 it.effect(
203 "keeps Effect startup resources alive until interrupted initialization shuts down",
204 () =>
205 Effect.gen(function* () {
206 const fake = mockSpawn();
207 let released = false;
208 const fiber = yield* Effect.forkChild(
209 createMuseSdkHostEffect({ binaryPath: "muse" }).pipe(
210 Effect.ensuring(
211 Effect.sync(() => {
212 released = true;
213 }),
214 ),
215 ),
216 );
217 yield* Effect.promise(() => fake.initializing.promise);
218 const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild);
219 yield* Effect.promise(() => fake.closing.promise);
220 expect(released).toBe(false);
221 fake.shutdown.resolve({ code: 0, signal: null });
222 yield* Fiber.join(interrupted);
223 expect(released).toBe(true);
224 expect(fake.close).toHaveBeenCalledOnce();
225 }),
226 );
227
228 it.effect("closes a fulfilled host when interruption wins before acquisition is delivered", () =>
229 Effect.gen(function* () {
230 const started = pending<void>();
231 const startup = pending<MuseSdkHost>();
232 const closing = pending<void>();
233 const shutdown = pending<void>();
234 const host = {
235 close: vi.fn(() => {
236 closing.resolve();
237 return shutdown.promise;
238 }),
239 } as unknown as MuseSdkHost;
240 let released = false;
241 const fiber = yield* Effect.forkChild(
242 createMuseSdkHostEffect({ binaryPath: "muse" }, () => {
243 started.resolve();
244 return startup.promise;
245 }).pipe(
246 Effect.ensuring(
247 Effect.sync(() => {
248 released = true;
249 }),
250 ),
251 ),
252 );
253 yield* Effect.promise(() => started.promise);
254 startup.resolve(host);
255 const interrupted = yield* Fiber.interrupt(fiber).pipe(
256 Effect.forkChild({ startImmediately: true }),
257 );
258 yield* Effect.promise(() => closing.promise);
259 expect(released).toBe(false);
260 shutdown.resolve();
261 yield* Fiber.join(interrupted);
262 expect(released).toBe(true);
263 expect(host.close).toHaveBeenCalledOnce();
264 }),
265 );
266});
apps/server/src/provider/museSdk.ts deleted-163
......@@ -1,163 +0,0 @@
1// @effect-diagnostics globalTimers:off
2// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists.
3import {
4 spawnMspConnection,
5 type Connection,
6 type ProcessExit,
7 type SpawnedMspConnection,
8} from "@muse-code/sdk";
9import type { RuntimeMode } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11
12export interface MuseSdkHost {
13 readonly connection: Pick<
14 Connection,
15 | "command"
16 | "request"
17 | "mintCommandId"
18 | "onNotification"
19 | "onServerRequest"
20 | "onProtocolError"
21 | "closed"
22 >;
23 readonly initializeResult: Pick<SpawnedMspConnection["initializeResult"], "grantedCapabilities">;
24 readonly exited: Promise<ProcessExit>;
25 /** The last lines Muse wrote to stderr, for explaining an unexpected exit. */
26 readonly stderrTail?: () => ReadonlyArray<string>;
27 readonly close: () => Promise<void>;
28}
29
30export interface MuseSdkHostOptions {
31 readonly binaryPath: string;
32 readonly cwd?: string;
33 readonly environment?: NodeJS.ProcessEnv;
34 readonly runtimeMode?: RuntimeMode;
35 /** Disables shell and filesystem writes and workspace trust. */
36 readonly readOnly?: boolean;
37 /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */
38 readonly sessionLogging?: boolean;
39 readonly signal?: AbortSignal;
40 readonly startupTimeoutMs?: number;
41}
42
43/** Preserve the host's CLI login while excluding the API-key override before copying values. */
44export function makeMuseEnvironment(
45 environment: NodeJS.ProcessEnv = process.env,
46): NodeJS.ProcessEnv {
47 return {
48 ...Object.fromEntries(
49 Object.keys(environment)
50 .filter((key) => key.toUpperCase() !== "META_API_KEY")
51 .map((key) => [key, environment[key]]),
52 ),
53 MUSE_NO_AUTO_UPDATE: "1",
54 };
55}
56
57export function museApprovalMode(runtimeMode: RuntimeMode) {
58 return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched";
59}
60
61/** The `muse serve` arguments for a host: read-only hosts get no shell, writes, or workspace trust. */
62export function museServeArgs(
63 options: Pick<MuseSdkHostOptions, "readOnly" | "sessionLogging" | "runtimeMode">,
64) {
65 const args = ["serve"];
66 if (options.readOnly) {
67 args.push("--disable-shell", "--disable-write");
68 if (options.sessionLogging !== true) args.push("--no-session-log");
69 } else {
70 args.push("--trust-workspace");
71 if (options.runtimeMode === "full-access") args.push("--disable-sandbox");
72 }
73 return args;
74}
75
76/** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */
77export function museInitializeParams(readOnly = false) {
78 return {
79 clientInfo: { name: "t3_code", title: "T3 Code", version: "1" },
80 capabilities: { requestedCapabilities: readOnly ? [] : ["sessionMcp"] },
81 };
82}
83
84/** One SDK-owned process. The pre-handshake handle owns cleanup even when initialize never replies. */
85export async function createMuseSdkHost(
86 options: MuseSdkHostOptions,
87 spawn: typeof spawnMspConnection = spawnMspConnection,
88): Promise<MuseSdkHost> {
89 options.signal?.throwIfAborted();
90 const handshake = spawn({
91 command: options.binaryPath,
92 args: museServeArgs(options),
93 ...(options.cwd ? { cwd: options.cwd } : {}),
94 // Callers pass an environment already built with makeMuseEnvironment.
95 env: options.environment ?? makeMuseEnvironment(),
96 // A healthy host exits at once; this only bounds a hung one on close.
97 shutdownTimeoutMs: 10_000,
98 });
99 let closePromise: Promise<void> | undefined;
100 const close = () => {
101 options.signal?.removeEventListener("abort", onAbort);
102 return (closePromise ??= handshake.close().then(() => undefined));
103 };
104 let rejectStartup: (reason: unknown) => void = () => {};
105 const onAbort = () => {
106 rejectStartup(options.signal?.reason ?? new Error("Muse SDK startup aborted."));
107 void close().catch(() => {});
108 };
109 const interrupted = new Promise<never>((_resolve, reject) => {
110 rejectStartup = reject;
111 });
112 options.signal?.addEventListener("abort", onAbort, { once: true });
113 if (options.signal?.aborted) onAbort();
114 const timer = setTimeout(
115 () => rejectStartup(new Error("Muse SDK initialization timed out.")),
116 options.startupTimeoutMs ?? 20_000,
117 );
118 timer.unref();
119 try {
120 const host = await Promise.race([
121 handshake.initialize(museInitializeParams(options.readOnly)),
122 interrupted,
123 ]);
124 if (host.initializeResult.schema?.version !== 1) {
125 throw new Error("Muse SDK returned an unsupported protocol envelope version.");
126 }
127 // The SDK's fingerprint warning permits additive optional schema changes.
128 // Successful initialization, not fingerprint identity, determines readiness.
129 return {
130 connection: host.connection,
131 initializeResult: host.initializeResult,
132 exited: host.exited,
133 stderrTail: () => handshake.child.stderrTail,
134 close,
135 };
136 } catch (error) {
137 await close();
138 throw error;
139 } finally {
140 clearTimeout(timer);
141 }
142}
143
144/** Cancellation waits for native shutdown before callers release startup resources. */
145export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(function* (
146 options: Omit<MuseSdkHostOptions, "signal">,
147 createHost: typeof createMuseSdkHost = createMuseSdkHost,
148) {
149 let startup: Promise<MuseSdkHost> | undefined;
150 return yield* Effect.tryPromise((signal) => {
151 startup = createHost({ ...options, signal });
152 return startup;
153 }).pipe(
154 Effect.onInterrupt(() =>
155 Effect.promise(async () => {
156 await startup?.then(
157 (host) => host.close(),
158 () => {},
159 );
160 }),
161 ),
162 );
163});
apps/server/src/testUtils/fakeCli.ts deleted-106
......@@ -1,106 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6
7export interface FakeCliOptions {
8 /** Directory the launcher and its stub are written into. */
9 readonly directory: string;
10 /** Command name as callers spawn it, e.g. `codex`. */
11 readonly name: string;
12 /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */
13 readonly source: string;
14 /** Environment set for the stub before it runs, on top of the inherited environment. */
15 readonly env?: Readonly<Record<string, string>>;
16 /**
17 * Platform the launcher is shaped for. Effect callers should pass the value
18 * they read from `HostProcessPlatform` so an injected override is honoured;
19 * defaults to the real host for plain tests.
20 */
21 readonly platform?: NodeJS.Platform;
22}
23
24/**
25 * Writes a fake CLI whose behaviour lives in a Node stub. On posix the
26 * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a
27 * shebang file is not executable there and `resolveSpawnCommand` routes
28 * `.cmd` through a shell. Returns the launcher path to hand to the code under
29 * test, which on Windows carries the `.cmd` extension.
30 */
31export function writeFakeCli(options: FakeCliOptions): string {
32 NodeFS.mkdirSync(options.directory, { recursive: true });
33 const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`);
34 // The environment rides in a JSON sidecar the stub applies to itself, since
35 // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely.
36 const envPath = NodePath.join(options.directory, `${options.name}-env.json`);
37 NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8");
38 NodeFS.writeFileSync(
39 stubPath,
40 [
41 'import { readFileSync as readFakeCliEnv } from "node:fs";',
42 `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`,
43 options.source,
44 ].join("\n"),
45 "utf8",
46 );
47
48 if ((options.platform ?? HostProcessPlatform.defaultValue()) === "win32") {
49 const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`);
50 NodeFS.writeFileSync(
51 launcherPath,
52 ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join(
53 "\r\n",
54 ),
55 "utf8",
56 );
57 return launcherPath;
58 }
59
60 const launcherPath = NodePath.join(options.directory, options.name);
61 NodeFS.writeFileSync(
62 launcherPath,
63 ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"),
64 "utf8",
65 );
66 NodeFS.chmodSync(launcherPath, 0o755);
67 return launcherPath;
68}
69
70/**
71 * Stub source that becomes `scriptPath`: after optionally requiring a leading
72 * argv prefix, it imports the script into its own process so the stub is the
73 * agent. Nothing sits between the code under test and the mock, so a kill or
74 * a closed stdin reaches it directly and its exit log is faithful on every
75 * host. The script must not read `process.argv`; the mock agents are driven
76 * by environment and stdin.
77 */
78export function execScriptSource(options: {
79 readonly scriptPath: string;
80 readonly expectedArgs?: ReadonlyArray<string>;
81 /** Tab-separated argv appended here per invocation, for launch-flag assertions. */
82 readonly argvLogPath?: string;
83 /** Wait before handing over, for tests that race a slow startup. */
84 readonly delayMs?: number;
85}): string {
86 return [
87 'import { appendFileSync } from "node:fs";',
88 'import { pathToFileURL } from "node:url";',
89 "const args = process.argv.slice(2);",
90 ...(options.argvLogPath === undefined
91 ? []
92 : [
93 `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`,
94 ]),
95 ...(options.delayMs === undefined
96 ? []
97 : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]),
98 `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`,
99 "if (expected.some((value, index) => args[index] !== value)) {",
100 ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);',
101 " process.exit(11);",
102 "}",
103 `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`,
104 "",
105 ].join("\n");
106}
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+1-1
......@@ -21,7 +21,7 @@ import {
2121import * as TextGeneration from "./TextGeneration.ts";
2222import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils";
2323import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
24import { writeFakeCli } from "../testUtils/fakeCli.ts";
24import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
2525const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
2626
2727const layerClaudeTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), {
apps/server/src/textGeneration/CodexTextGeneration.test.ts+1-1
......@@ -16,7 +16,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools
1616import * as ServerConfig from "../config.ts";
1717import * as TextGeneration from "./TextGeneration.ts";
1818import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
19import { writeFakeCli } from "../testUtils/fakeCli.ts";
19import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
2020const decodeCodexSettings = Schema.decodeSync(CodexSettings);
2121
2222const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
apps/server/src/textGeneration/GrokTextGeneration.test.ts+1-1
......@@ -16,7 +16,7 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts";
1616import * as ServerConfig from "../config.ts";
1717import * as TextGeneration from "./TextGeneration.ts";
1818import { makeGrokTextGeneration } from "./GrokTextGeneration.ts";
19import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts";
19import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli";
2020const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2121
2222const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
apps/server/src/textGeneration/MuseTextGeneration.test.ts deleted-372
......@@ -1,372 +0,0 @@
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 MuseSettings,
7 ProviderInstanceId,
8 type ServerProviderModel,
9} from "@t3tools/contracts";
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 "../provider/museSdk.ts";
17import { museModelCapabilities } from "../provider/museModelCatalog.ts";
18import { makeMuseTextGeneration } from "./MuseTextGeneration.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});
apps/server/src/textGeneration/MuseTextGeneration.ts deleted-350
......@@ -1,350 +0,0 @@
1import {
2 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
3 MUSE_DEFAULT_MODEL,
4 type ModelSelection,
5 type MuseSettings,
6 type ServerProviderModel,
7 TextGenerationError,
8} from "@t3tools/contracts";
9import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
11import { extractJsonObject } from "@t3tools/shared/schemaJson";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16
17import {
18 createMuseSdkHost,
19 createMuseSdkHostEffect,
20 type MuseSdkHost,
21} from "../provider/museSdk.ts";
22import { museModelCapabilities, resolveMuseReasoningEffort } from "../provider/museModelCatalog.ts";
23import type * as TextGeneration from "./TextGeneration.ts";
24import {
25 buildBranchNamePrompt,
26 buildCommitMessagePrompt,
27 buildPrContentPrompt,
28 buildThreadTitlePrompt,
29} from "@t3tools/provider-core/server/textGenerationPrompts";
30import {
31 sanitizeCommitSubject,
32 sanitizePrTitle,
33 sanitizeThreadTitle,
34 toJsonSchemaObject,
35} from "@t3tools/provider-core/server/textGenerationUtils";
36
37const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) });
38const ItemNotification = Schema.Struct({
39 sessionId: Schema.String,
40 item: Schema.Struct({
41 itemId: Schema.String,
42 kind: Schema.String,
43 revision: Schema.Int,
44 turnId: Schema.optionalKey(Schema.String),
45 text: Schema.optionalKey(Schema.String),
46 retracted: Schema.optionalKey(Schema.Boolean),
47 truncated: Schema.optionalKey(Schema.Boolean),
48 }),
49});
50const TurnCompleted = Schema.Struct({
51 sessionId: Schema.String,
52 turnId: Schema.String,
53 terminal: Schema.String,
54 error: Schema.optionalKey(Schema.Struct({ message: Schema.String })),
55});
56const ApprovalRequested = Schema.Struct({
57 sessionId: Schema.String,
58 approvalId: Schema.String,
59 currentRequirementId: Schema.Unknown,
60 availableChoices: Schema.Array(
61 Schema.Struct({ choiceId: Schema.String, decision: Schema.String }),
62 ),
63});
64const UserInputRequested = Schema.Struct({
65 sessionId: Schema.String,
66 userInputId: Schema.String,
67});
68const decodeSession = Schema.decodeUnknownSync(SessionStarted);
69const decodeItem = Schema.decodeUnknownSync(ItemNotification);
70const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted);
71const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested);
72const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested);
73const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
74const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
75
76type Operation = keyof TextGeneration.TextGeneration["Service"];
77
78async function generateMuseText(
79 host: MuseSdkHost,
80 workspaceRoot: string,
81 prompt: string,
82 modelId: string | undefined,
83 reasoningEffort: string | undefined,
84 signal: AbortSignal,
85) {
86 let sessionId: string | undefined;
87 const turnId = host.connection.mintCommandId();
88 const messages = new Map<string, (typeof ItemNotification.Type)["item"]>();
89 let resolveCompletion: (text: string) => void = () => {};
90 let rejectCompletion: (error: unknown) => void = () => {};
91 const completion = new Promise<string>((resolve, reject) => {
92 resolveCompletion = resolve;
93 rejectCompletion = reject;
94 });
95 // Notifications may settle the turn before the turn/start acknowledgement arrives.
96 void completion.catch(() => {});
97 const rejectInteractive = async (method: string, params: Record<string, unknown>) => {
98 if (method === "approval/requested") {
99 const approval = decodeApproval(params);
100 if (approval.sessionId !== sessionId) return;
101 rejectCompletion(new Error("Muse requested an interactive action during text generation."));
102 const deny = approval.availableChoices.find((choice) => choice.decision === "denied");
103 if (deny) {
104 await host.connection.command("approval/decide", {
105 sessionId,
106 approvalId: approval.approvalId,
107 requirementId: approval.currentRequirementId,
108 choiceId: deny.choiceId,
109 });
110 }
111 } else {
112 const request = decodeUserInput(params);
113 if (request.sessionId !== sessionId) return;
114 rejectCompletion(new Error("Muse requested an interactive action during text generation."));
115 await host.connection.command("userInput/cancel", {
116 sessionId,
117 userInputId: request.userInputId,
118 reason: "T3 Code text generation cannot request user input.",
119 });
120 }
121 };
122 host.connection.onNotification((notification) => {
123 try {
124 if (notification.method === "item/completed" || notification.method === "item/updated") {
125 const { item, sessionId: itemSessionId } = decodeItem(notification.params);
126 if (itemSessionId !== sessionId || item.turnId !== turnId || item.kind !== "agentMessage")
127 return;
128 const previous = messages.get(item.itemId);
129 if (!previous || item.revision > previous.revision) messages.set(item.itemId, item);
130 } else if (notification.method === "turn/completed") {
131 const completed = decodeCompleted(notification.params);
132 if (completed.sessionId !== sessionId || completed.turnId !== turnId) return;
133 if (completed.terminal === "completed") {
134 // A turn may include commentary before its final structured answer.
135 const answer = [...messages.values()].findLast((item) => !item.retracted);
136 if (answer?.truncated) {
137 rejectCompletion(new Error("Muse text generation returned a truncated response."));
138 } else {
139 resolveCompletion(answer?.text ?? "");
140 }
141 } else {
142 rejectCompletion(
143 new Error(completed.error?.message ?? `Muse turn ${completed.terminal}.`),
144 );
145 }
146 } else if (
147 notification.method === "approval/requested" ||
148 notification.method === "userInput/requested"
149 ) {
150 void rejectInteractive(notification.method, notification.params ?? {}).catch(
151 rejectCompletion,
152 );
153 } else if (notification.method === "view/gap") {
154 rejectCompletion(new Error("Muse text generation lost part of its response."));
155 }
156 } catch (error) {
157 rejectCompletion(error);
158 }
159 });
160 host.connection.onProtocolError(rejectCompletion);
161 host.connection.onServerRequest(async () => {
162 const error = new Error("T3 Code text generation cannot approve interactive requests.");
163 rejectCompletion(error);
164 throw error;
165 });
166 void host.connection.closed.then(() => {
167 rejectCompletion(new Error("Muse connection closed before text generation completed."));
168 });
169 void host.exited.then(() => {
170 rejectCompletion(new Error("Muse exited before text generation completed."));
171 });
172 const onAbort = () => rejectCompletion(signal.reason);
173 signal.addEventListener("abort", onAbort, { once: true });
174 try {
175 signal.throwIfAborted();
176 const started = decodeSession(
177 await host.connection.command("session/start", {
178 workspaceRoot,
179 providerId: "meta",
180 ...(modelId ? { modelId } : {}),
181 approvalMode: "denyUnmatched",
182 }),
183 );
184 sessionId = started.session.sessionId;
185 const acknowledged = host.connection.command(
186 "turn/start",
187 {
188 sessionId,
189 input: [{ type: "text", text: prompt }],
190 ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
191 },
192 { commandId: turnId },
193 );
194 return await Promise.race([acknowledged.then(() => completion), completion]);
195 } finally {
196 signal.removeEventListener("abort", onAbort);
197 }
198}
199
200export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(function* (
201 settings: MuseSettings,
202 options: {
203 readonly environment?: NodeJS.ProcessEnv;
204 readonly modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>;
205 /** Test hook. */
206 readonly createHost?: typeof createMuseSdkHost;
207 } = {},
208) {
209 const { environment, modelCatalog = Effect.succeed([]), createHost } = options;
210 const fileSystem = yield* FileSystem.FileSystem;
211 const runMuseJson = Effect.fn("runMuseJson")(function* <S extends Schema.Top>(input: {
212 operation: Operation;
213 prompt: string;
214 outputSchema: S;
215 modelSelection: ModelSelection;
216 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
217 const { operation } = input;
218 if (!settings.enabled) {
219 return yield* new TextGenerationError({ operation, detail: "Muse Code is disabled." });
220 }
221 const selectedModel = input.modelSelection.model.trim();
222 const models = yield* modelCatalog;
223 // "default" is the catalog's default model, as in chat. Without one, Muse picks.
224 const catalogModel =
225 selectedModel && selectedModel !== MUSE_DEFAULT_MODEL
226 ? models.find((model) => model.slug === selectedModel)
227 : models.find((model) => model.isDefault && !model.isCustom);
228 const modelId =
229 selectedModel && selectedModel !== MUSE_DEFAULT_MODEL ? selectedModel : catalogModel?.slug;
230 const reasoningEffort = resolveMuseReasoningEffort(
231 catalogModel?.capabilities ?? museModelCapabilities(),
232 getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") ??
233 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
234 );
235 const jsonSchema = yield* encodeJson(toJsonSchemaObject(input.outputSchema)).pipe(
236 Effect.mapError(
237 (cause) => new TextGenerationError({ operation, detail: "Invalid output schema.", cause }),
238 ),
239 );
240 const response = yield* Effect.gen(function* () {
241 // Metadata requests already include their context and do not need checkout configuration.
242 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-muse-text-" });
243 const host = yield* Effect.acquireRelease(
244 createMuseSdkHostEffect(
245 {
246 binaryPath: settings.binaryPath,
247 cwd,
248 ...(environment ? { environment } : {}),
249 readOnly: true,
250 // Muse derives SDK turn events from its session log, including completion.
251 sessionLogging: true,
252 },
253 createHost,
254 ),
255 (host: MuseSdkHost) => Effect.promise(() => host.close()),
256 { interruptible: true },
257 );
258 return yield* Effect.tryPromise((signal) =>
259 generateMuseText(
260 host,
261 cwd,
262 `${input.prompt}\n\nReturn only a JSON object matching this schema, with no markdown fences. Do not use tools or ask questions.\n${jsonSchema}`,
263 modelId,
264 reasoningEffort,
265 signal,
266 ),
267 );
268 }).pipe(
269 Effect.scoped,
270 Effect.mapError(
271 (cause) =>
272 new TextGenerationError({
273 operation,
274 detail:
275 "Muse Code text generation failed. Check Muse login and availability on this T3 server host.",
276 cause,
277 }),
278 ),
279 Effect.timeoutOption(180_000),
280 Effect.flatMap(
281 Option.match({
282 onNone: () =>
283 Effect.fail(new TextGenerationError({ operation, detail: "Muse request timed out." })),
284 onSome: Effect.succeed,
285 }),
286 ),
287 );
288 const decodeOutput = Schema.decodeEffect(input.outputSchema);
289 return yield* decodeJson(extractJsonObject(response)).pipe(
290 Effect.flatMap(decodeOutput),
291 Effect.mapError(
292 (cause) =>
293 new TextGenerationError({
294 operation,
295 detail: "Muse returned invalid structured output.",
296 cause,
297 }),
298 ),
299 );
300 });
301
302 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
303 Effect.fn("MuseTextGeneration.generateCommitMessage")(function* (input) {
304 const generated = yield* runMuseJson({
305 operation: "generateCommitMessage",
306 ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }),
307 modelSelection: input.modelSelection,
308 });
309 return {
310 subject: sanitizeCommitSubject(generated.subject),
311 body: generated.body.trim(),
312 ...("branch" in generated && typeof generated.branch === "string"
313 ? { branch: sanitizeFeatureBranchName(generated.branch) }
314 : {}),
315 };
316 });
317 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
318 Effect.fn("MuseTextGeneration.generatePrContent")(function* (input) {
319 const generated = yield* runMuseJson({
320 operation: "generatePrContent",
321 ...buildPrContentPrompt(input),
322 modelSelection: input.modelSelection,
323 });
324 return { title: sanitizePrTitle(generated.title), body: generated.body.trim() };
325 });
326 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
327 Effect.fn("MuseTextGeneration.generateBranchName")(function* (input) {
328 const generated = yield* runMuseJson({
329 operation: "generateBranchName",
330 ...buildBranchNamePrompt(input),
331 modelSelection: input.modelSelection,
332 });
333 return { branch: sanitizeBranchFragment(generated.branch) };
334 });
335 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
336 Effect.fn("MuseTextGeneration.generateThreadTitle")(function* (input) {
337 const generated = yield* runMuseJson({
338 operation: "generateThreadTitle",
339 ...buildThreadTitlePrompt(input),
340 modelSelection: input.modelSelection,
341 });
342 return { title: sanitizeThreadTitle(generated.title) };
343 });
344 return {
345 generateCommitMessage,
346 generatePrContent,
347 generateBranchName,
348 generateThreadTitle,
349 } satisfies TextGeneration.TextGeneration["Service"];
350});
apps/web/package.json+1
......@@ -29,6 +29,7 @@
2929 "@t3tools/client-runtime": "workspace:*",
3030 "@t3tools/contracts": "workspace:*",
3131 "@t3tools/provider-core": "workspace:*",
32 "@t3tools/provider-muse": "workspace:*",
3233 "@t3tools/provider-pi": "workspace:*",
3334 "@t3tools/shared": "workspace:*",
3435 "@tanstack/react-pacer": "^0.19.4",
apps/web/src/components/Icons.tsx-10
......@@ -815,13 +815,3 @@ export const ComputerUseAppIcon: Icon = (props) => {
815815 </svg>
816816 );
817817};
818
819export const MuseIcon: Icon = (props) => (
820 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150.4828 100" fill="none" {...props}>
821 <path
822 d="M107.9999847,0.0000013c-12.3391113,0-21.984787,9.2935991-30.7164001,21.0993118 C65.2847366,5.821527,55.2499847,0,43.2413788,0C18.7586193,0,0,31.8620682,0,65.5862122 C0,86.6896515,10.2095623,100,27.3103466,100c12.3080502,0,21.1600361-5.8025894,36.8965454-33.3103409 c0,0,6.5597382-11.5841255,11.0725021-19.563858c1.5813522,2.5531845,3.2431107,5.3001404,4.9964676,8.2535095 l7.379303,12.4137955C102.0298843,91.8478851,110.039032,100,124.5517273,100 c16.6595764,0,25.9310303-13.4923325,25.9310303-35.0344849 C150.4827576,29.6551723,131.301178,0.0000013,107.9999847,0.0000013z M52.2068977,59.2413788 c-12.7586212,20-17.1724167,24.4827576-24.2758656,24.4827576c-7.3103428,0-11.6551704-6.4178314-11.6551704-17.8620682 c0-24.4827576,12.2068977-49.5172424,26.7586231-49.5172424c7.880127,0,14.4653816,4.5510426,24.5522308,18.9913158 C58.0087967,50.0272293,52.2068977,59.2413788,52.2068977,59.2413788z M100.3597336,56.7235756L91.5367355,42.00877 c-2.3876724-3.8832092-4.6757278-7.451458-6.8838348-10.7230263 c7.951973-12.273531,14.5113449-18.3891907,22.3126144-18.3891907c16.2069016,0,29.1724243,23.8620682,29.1724243,53.1724091 c0,11.1724167-3.6593475,17.6551743-11.2413864,17.6551743 C117.6294937,83.7241364,114.1578674,78.924736,100.3597336,56.7235756z"
823 fill="currentColor"
824 fillRule="evenodd"
825 />
826 </svg>
827);
apps/web/src/components/chat/ProviderInstanceIcon.tsx-2
......@@ -8,7 +8,6 @@ import {
88 ClaudeAI,
99 CursorIcon,
1010 GrokIcon,
11 MuseIcon,
1211 Icon,
1312 OpenAI,
1413 OpenCodeIcon,
......@@ -29,7 +28,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
2928 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
3029 [ProviderDriverKind.make("cursor")]: CursorIcon,
3130 [ProviderDriverKind.make("grok")]: GrokIcon,
32 [ProviderDriverKind.make("muse")]: MuseIcon,
3331 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
3432};
3533
apps/web/src/components/settings/providerDriverMeta.ts+2-7
......@@ -6,10 +6,10 @@ import {
66 CursorSettings,
77 GrokSettings,
88 OpenCodeSettings,
9 MuseSettings,
109 ProviderDriverKind,
1110} from "@t3tools/contracts";
1211import { makeProviderClientRegistry } from "@t3tools/provider-core/client";
12import { museClient } from "@t3tools/provider-muse/client";
1313import { piClient } from "@t3tools/provider-pi/client";
1414
1515/** The provider client definitions this web build ships, in presentation order. */
......@@ -53,12 +53,7 @@ export const providerClients = makeProviderClientRegistry([
5353 label: "Antigravity",
5454 settingsSchema: AntigravitySettings,
5555 },
56 {
57 driverKind: ProviderDriverKind.make("muse"),
58 label: "Muse Code",
59 settingsSchema: MuseSettings,
60 badgeLabel: "Beta",
61 },
56 museClient,
6257 piClient,
6358 {
6459 driverKind: ProviderDriverKind.make("acpRegistry"),
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
31 "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "test": "vp run -r test",
packages/contracts/src/settings.ts-23
......@@ -840,29 +840,6 @@ export const AntigravitySettings = makeProviderSettingsSchema(
840840);
841841export type AntigravitySettings = typeof AntigravitySettings.Type;
842842
843export const MuseSettings = makeProviderSettingsSchema(
844 {
845 enabled: Schema.Boolean.pipe(
846 Schema.withDecodingDefault(Effect.succeed(false)),
847 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
848 ),
849 binaryPath: makeBinaryPathSetting("muse").pipe(
850 Schema.annotateKey({
851 title: "Binary path",
852 description:
853 "Path to the Muse Code binary on this environment. Sign in with muse login on that host.",
854 providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" },
855 }),
856 ),
857 customModels: Schema.Array(CustomModelSetting).pipe(
858 Schema.withDecodingDefault(Effect.succeed([])),
859 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
860 ),
861 },
862 { order: ["binaryPath"] },
863);
864export type MuseSettings = typeof MuseSettings.Type;
865
866843export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]);
867844export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type;
868845
packages/provider-muse/package.json created+41
......@@ -0,0 +1,41 @@
1{
2 "name": "@t3tools/provider-muse",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./client": {
7 "types": "./src/client.ts",
8 "import": "./src/client.ts"
9 },
10 "./settings": {
11 "types": "./src/settings.ts",
12 "import": "./src/settings.ts"
13 },
14 "./server": {
15 "types": "./src/server.ts",
16 "import": "./src/server.ts"
17 },
18 "./testing": {
19 "types": "./src/testing.ts",
20 "import": "./src/testing.ts"
21 }
22 },
23 "scripts": {
24 "typecheck": "tsc --noEmit",
25 "test": "vp test run --config ../../vite.config.ts --dir ."
26 },
27 "dependencies": {
28 "@muse-code/sdk": "1.4.2",
29 "@t3tools/contracts": "workspace:*",
30 "@t3tools/provider-core": "workspace:*",
31 "@t3tools/shared": "workspace:*",
32 "effect": "catalog:"
33 },
34 "devDependencies": {
35 "@effect/platform-node": "catalog:",
36 "@effect/vitest": "catalog:",
37 "@t3tools/provider-testing": "workspace:*",
38 "@types/node": "catalog:",
39 "vite-plus": "catalog:"
40 }
41}
packages/provider-muse/src/client.ts created+27
......@@ -0,0 +1,27 @@
1/**
2 * Muse Code's client definition: settings form, label, badge, and glyph.
3 * Browser- and React Native-safe.
4 *
5 * @module provider-muse/client
6 */
7import { ProviderDriverKind } from "@t3tools/contracts";
8import { defineProviderClient } from "@t3tools/provider-core/client";
9
10import { MuseSettings } from "./settings.ts";
11
12export const museClient = defineProviderClient({
13 driverKind: ProviderDriverKind.make("muse"),
14 label: "Muse Code",
15 settingsSchema: MuseSettings,
16 badgeLabel: "Beta",
17 icon: {
18 viewBox: "0 0 150.4828 100",
19 fill: { light: "#171717", dark: "#e5e5e5" },
20 paths: [
21 {
22 d: "M107.9999847,0.0000013c-12.3391113,0-21.984787,9.2935991-30.7164001,21.0993118 C65.2847366,5.821527,55.2499847,0,43.2413788,0C18.7586193,0,0,31.8620682,0,65.5862122 C0,86.6896515,10.2095623,100,27.3103466,100c12.3080502,0,21.1600361-5.8025894,36.8965454-33.3103409 c0,0,6.5597382-11.5841255,11.0725021-19.563858c1.5813522,2.5531845,3.2431107,5.3001404,4.9964676,8.2535095 l7.379303,12.4137955C102.0298843,91.8478851,110.039032,100,124.5517273,100 c16.6595764,0,25.9310303-13.4923325,25.9310303-35.0344849 C150.4827576,29.6551723,131.301178,0.0000013,107.9999847,0.0000013z M52.2068977,59.2413788 c-12.7586212,20-17.1724167,24.4827576-24.2758656,24.4827576c-7.3103428,0-11.6551704-6.4178314-11.6551704-17.8620682 c0-24.4827576,12.2068977-49.5172424,26.7586231-49.5172424c7.880127,0,14.4653816,4.5510426,24.5522308,18.9913158 C58.0087967,50.0272293,52.2068977,59.2413788,52.2068977,59.2413788z M100.3597336,56.7235756L91.5367355,42.00877 c-2.3876724-3.8832092-4.6757278-7.451458-6.8838348-10.7230263 c7.951973-12.273531,14.5113449-18.3891907,22.3126144-18.3891907c16.2069016,0,29.1724243,23.8620682,29.1724243,53.1724091 c0,11.1724167-3.6593475,17.6551743-11.2413864,17.6551743 C117.6294937,83.7241364,114.1578674,78.924736,100.3597336,56.7235756z",
23 fillRule: "evenodd",
24 },
25 ],
26 },
27});
packages/provider-muse/src/server.ts created+8
......@@ -0,0 +1,8 @@
1/**
2 * Muse Code's server entry: the driver the server registers, plus the
3 * adapter factory the replay testkit and fixture recorder build on.
4 *
5 * @module provider-muse/server
6 */
7export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts";
8export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts";
packages/provider-muse/src/server/adapter.test.ts created+1107
......@@ -0,0 +1,1107 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { MspError } from "@muse-code/sdk";
4import {
5 MUSE_DEFAULT_MODEL,
6 EnvironmentId,
7 MessageId,
8 NodeId,
9 ProjectId,
10 ProviderInstanceId,
11 ProviderDriverKind,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 RunAttemptId,
16 RunId,
17 ThreadId,
18 type OrchestrationV2AppThread,
19 type OrchestrationV2ProviderThread,
20} from "@t3tools/contracts";
21import { MuseSettings } from "../settings.ts";
22import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";
24import * as Effect from "effect/Effect";
25import * as Option from "effect/Option";
26import type * as Exit from "effect/Exit";
27import * as FileSystem from "effect/FileSystem";
28import * as Fiber from "effect/Fiber";
29import * as Layer from "effect/Layer";
30import * as Queue from "effect/Queue";
31import * as Schema from "effect/Schema";
32import * as Stream from "effect/Stream";
33
34import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
35import { layerTestProviderHost } from "@t3tools/provider-testing/host";
36import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
37import type { MuseItem } from "./protocol.ts";
38import type { MuseSdkHost } from "./sdk.ts";
39import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
40import {
41 ProviderAdapterV2RuntimePolicy,
42 type ProviderAdapterV2Event,
43 type ProviderAdapterV2TurnInput,
44} from "@t3tools/provider-core/server/ProviderAdapter";
45import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests";
46import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts";
47
48const testLayer = Layer.mergeAll(
49 NodeServices.layer,
50 IdAllocator.layer,
51 layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)),
52);
53const MUSE_PROVIDER = ProviderDriverKind.make("muse");
54const INSTANCE_ID = ProviderInstanceId.make("muse_work");
55const THREAD_ID = ThreadId.make("thread-muse-test");
56const MODEL = "muse-spark-1.3-contributor";
57const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true });
58const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
59 runtimeMode: "full-access",
60 interactionMode: "default",
61 cwd: null,
62});
63const modelSelection = (instanceId = INSTANCE_ID) => ({
64 instanceId,
65 model: MODEL,
66 options: [{ id: "reasoningEffort", value: "max" }],
67});
68
69interface NativeCall {
70 readonly method: string;
71 readonly params: Record<string, unknown>;
72 readonly commandId?: string;
73}
74
75function pendingPromise<A>() {
76 let resolve!: (value: A) => void;
77 let reject!: (reason: unknown) => void;
78 const promise = new Promise<A>((done, fail) => {
79 resolve = done;
80 reject = fail;
81 });
82 return { promise, resolve, reject };
83}
84
85/** SDK-shaped transport fake: registration deliberately returns void, as the SDK does. */
86const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") {
87 const requests = yield* Queue.unbounded<NativeCall>();
88 const calls: NativeCall[] = [];
89 const closed = pendingPromise<void>();
90 const exited = pendingPromise<Awaited<MuseSdkHost["exited"]>>();
91 let notify: Parameters<MuseSdkHost["connection"]["onNotification"]>[0] = () => {};
92 let sequence = 0;
93 let sessionId = "";
94 let closeCount = 0;
95 let closeGate: Promise<void> | undefined;
96 let history: MuseItem[] = [];
97 const responses = new Map<
98 string,
99 Array<Promise<Record<string, unknown>> | Record<string, unknown> | Error>
100 >();
101 const respond = async (call: NativeCall): Promise<Record<string, unknown>> => {
102 calls.push(call);
103 Queue.offerUnsafe(requests, call);
104 if (["session/start", "session/resume", "session/read"].includes(call.method)) {
105 sessionId = String(call.params.sessionId);
106 }
107 const queued = responses.get(call.method)?.shift();
108 if (queued instanceof Error) throw queued;
109 if (queued) return queued;
110 switch (call.method) {
111 case "session/start":
112 case "session/resume":
113 case "session/read":
114 sessionId = String(call.params.sessionId);
115 return {
116 session: { sessionId, modelId: MODEL, activeTurnId: null },
117 history: { items: history },
118 };
119 case "turn/start":
120 return { turnId: call.commandId };
121 case "turn/steer":
122 return { turnId: call.params.expectedTurnId };
123 case "session/compact":
124 return { status: "accepted" };
125 default:
126 return {};
127 }
128 };
129 const host: MuseSdkHost = {
130 connection: {
131 mintCommandId: () => `${idPrefix}-${++sequence}`,
132 command: (method, params, options) =>
133 respond({
134 method,
135 params,
136 ...(options?.commandId ? { commandId: options.commandId } : {}),
137 }),
138 request: (method, params = {}) => respond({ method, params }),
139 onNotification: (handler) => {
140 notify = handler;
141 },
142 onProtocolError: () => {},
143 onServerRequest: () => {},
144 closed: closed.promise,
145 },
146 initializeResult: { grantedCapabilities: [] },
147 exited: exited.promise,
148 close: async () => {
149 closeCount += 1;
150 await closeGate;
151 closed.resolve();
152 exited.resolve({ code: 0, signal: null });
153 },
154 };
155 return {
156 host,
157 calls,
158 closeCount: () => closeCount,
159 holdClose: (gate: Promise<void>) => {
160 closeGate = gate;
161 },
162 disconnect: () =>
163 Effect.sync(() => {
164 closed.resolve();
165 exited.resolve({ code: 1, signal: null });
166 }),
167 setHistory: (items: MuseItem[]) => {
168 history = items;
169 },
170 queueResponse: (
171 method: string,
172 result: Record<string, unknown> | Promise<Record<string, unknown>> | Error,
173 ) => {
174 responses.set(method, [...(responses.get(method) ?? []), result]);
175 },
176 emit: (method: string, params: Record<string, unknown>) =>
177 Effect.sync(() => {
178 notify({ jsonrpc: "2.0", method, params: { sessionId, ...params } });
179 }),
180 takeCall: Effect.fnUntraced(function* (method: string) {
181 while (true) {
182 const call = yield* Queue.take(requests);
183 if (call.method === method) return call;
184 }
185 }),
186 };
187});
188
189const makeHarness = Effect.fnUntraced(function* (
190 fake: Effect.Success<ReturnType<typeof makeFakeMuse>>,
191 instanceId = INSTANCE_ID,
192 initialNativeThreadId?: string,
193 replacement?: Effect.Success<ReturnType<typeof makeFakeMuse>>,
194 existingProviderThread?: OrchestrationV2ProviderThread,
195 policy = runtimePolicy,
196 overrides: Pick<
197 MuseAdapterV2Options,
198 "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests"
199 > = {},
200) {
201 let hostCount = 0;
202 const adapter = makeMuseAdapterV2({
203 instanceId,
204 settings: museSettings,
205 environment: { PATH: "/fake/bin" },
206 idAllocator: yield* IdAllocator.IdAllocatorV2,
207 host: yield* ProviderHost,
208 fileSystem: yield* FileSystem.FileSystem,
209 createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host),
210 ...overrides,
211 });
212 const runtime = yield* adapter.openSession({
213 threadId: THREAD_ID,
214 providerSessionId: ProviderSessionId.make(`session-${instanceId}`),
215 modelSelection: modelSelection(instanceId),
216 runtimePolicy: policy,
217 ...(initialNativeThreadId ? { initialNativeThreadId } : {}),
218 });
219 const emitted = yield* Queue.unbounded<ProviderAdapterV2Event>();
220 const allEvents: ProviderAdapterV2Event[] = [];
221 const eventsEnded = yield* Deferred.make<Exit.Exit<void, Stream.Error<typeof runtime.events>>>();
222 yield* runtime.events.pipe(
223 Stream.runForEach((event) =>
224 Effect.gen(function* () {
225 allEvents.push(event);
226 yield* Queue.offer(emitted, event);
227 }),
228 ),
229 Effect.exit,
230 Effect.flatMap((exit) => Deferred.succeed(eventsEnded, exit)),
231 Effect.forkScoped,
232 );
233 const providerThread = yield* runtime.ensureThread({
234 threadId: THREAD_ID,
235 modelSelection: modelSelection(instanceId),
236 runtimePolicy: policy,
237 ...(existingProviderThread ? { existingProviderThread } : {}),
238 });
239 const takeEvent = Effect.fnUntraced(function* <T extends ProviderAdapterV2Event["type"]>(
240 type: T,
241 predicate: (event: Extract<ProviderAdapterV2Event, { type: T }>) => boolean = () => true,
242 ) {
243 while (true) {
244 const event = yield* Queue.take(emitted);
245 if (event.type === type && predicate(event as Extract<ProviderAdapterV2Event, { type: T }>)) {
246 return event as Extract<ProviderAdapterV2Event, { type: T }>;
247 }
248 }
249 });
250 return { adapter, runtime, providerThread, takeEvent, allEvents, policy, eventsEnded };
251});
252
253const preallocatedProviderThread = Effect.fnUntraced(function* () {
254 const now = yield* DateTime.now;
255 return {
256 id: ProviderThreadId.make("preallocated-muse-thread"),
257 driver: MUSE_PROVIDER,
258 providerInstanceId: INSTANCE_ID,
259 providerSessionId: ProviderSessionId.make(`session-${INSTANCE_ID}`),
260 appThreadId: THREAD_ID,
261 ownerNodeId: null,
262 nativeThreadRef: null,
263 nativeConversationHeadRef: null,
264 status: "idle",
265 firstRunOrdinal: null,
266 lastRunOrdinal: null,
267 handoffIds: [],
268 forkedFrom: null,
269 createdAt: now,
270 updatedAt: now,
271 } satisfies OrchestrationV2ProviderThread;
272});
273
274const turnInput = Effect.fnUntraced(function* (
275 providerThread: OrchestrationV2ProviderThread,
276 runOrdinal = 1,
277 policy = runtimePolicy,
278): Effect.fn.Return<ProviderAdapterV2TurnInput> {
279 const now = yield* DateTime.now;
280 const threadId = providerThread.appThreadId ?? THREAD_ID;
281 const selection = modelSelection(providerThread.providerInstanceId);
282 const appThread: OrchestrationV2AppThread = {
283 id: threadId,
284 projectId: ProjectId.make("project-muse-test"),
285 title: "Muse test",
286 createdBy: "user",
287 creationSource: "web",
288 providerInstanceId: providerThread.providerInstanceId,
289 modelSelection: selection,
290 runtimeMode: policy.runtimeMode,
291 interactionMode: policy.interactionMode,
292 branch: null,
293 worktreePath: null,
294 activeProviderThreadId: providerThread.id,
295 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
296 forkedFrom: null,
297 createdAt: now,
298 updatedAt: now,
299 archivedAt: null,
300 settledOverride: null,
301 settledAt: null,
302 lastVisitedAt: null,
303 deletedAt: null,
304 };
305 return {
306 appThread,
307 threadId,
308 runId: RunId.make(`run-${runOrdinal}`),
309 runOrdinal,
310 providerTurnOrdinal: runOrdinal,
311 attemptId: RunAttemptId.make(`attempt-${runOrdinal}`),
312 rootNodeId: NodeId.make(`root-${runOrdinal}`),
313 providerThread,
314 message: {
315 messageId: MessageId.make(`message-${runOrdinal}`),
316 text: "Hello Muse",
317 attachments: [],
318 createdBy: "user",
319 creationSource: "web",
320 },
321 modelSelection: selection,
322 runtimePolicy: policy,
323 };
324});
325
326const startConversation = Effect.fnUntraced(function* (
327 harness: Effect.Success<ReturnType<typeof makeHarness>>,
328 fake: Effect.Success<ReturnType<typeof makeFakeMuse>>,
329 ordinal = 1,
330) {
331 yield* harness.runtime.startTurn(
332 yield* turnInput(harness.providerThread, ordinal, harness.policy),
333 );
334 const call = yield* fake.takeCall("turn/start");
335 const event = yield* harness.takeEvent(
336 "provider_turn.updated",
337 (event) => event.providerTurn.status === "running",
338 );
339 return { nativeId: call.commandId!, providerTurn: event.providerTurn };
340});
341
342const approval = (turnId: string) => ({
343 approvalId: "approval-1",
344 turnId,
345 currentRequirementId: { approvalId: "approval-1", sourceIndex: 0 },
346 availableChoices: [
347 { choiceId: "once", label: "Allow once", decision: "approved", scope: "once" },
348 { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" },
349 ],
350 subject: { kind: "shell", command: "touch result.txt" },
351});
352
353describe("MuseAdapterV2", () => {
354 it.effect("connects the thread's MCP credential on native start and resume", () =>
355 Effect.gen(function* () {
356 yield* Effect.addFinalizer(() =>
357 Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)),
358 );
359 McpProviderSession.setMcpProviderSession({
360 environmentId: EnvironmentId.make("test-environment"),
361 threadId: THREAD_ID,
362 providerSessionId: "test-session",
363 providerInstanceId: INSTANCE_ID,
364 endpoint: "http://127.0.0.1:43210/mcp",
365 authorizationHeader: "Bearer thread-scoped-test-token",
366 browserToolsAvailable: true,
367 });
368 for (const nativeId of [undefined, "saved-native-session"]) {
369 const fake = yield* makeFakeMuse();
370 fake.host.initializeResult.grantedCapabilities.push("sessionMcp");
371 yield* makeHarness(fake, INSTANCE_ID, nativeId);
372 const call = yield* fake.takeCall(nativeId ? "session/resume" : "session/start");
373 assert.deepStrictEqual(call.params.config, {
374 mcpServers: {
375 "t3-code": {
376 transport: "streamableHttp",
377 mode: "optional",
378 url: "http://127.0.0.1:43210/mcp",
379 headers: { Authorization: "Bearer thread-scoped-test-token" },
380 },
381 },
382 });
383 }
384 }).pipe(Effect.scoped, Effect.provide(testLayer)),
385 );
386
387 it.effect("rejects a host without session MCP support before opening a native session", () =>
388 Effect.gen(function* () {
389 yield* Effect.addFinalizer(() =>
390 Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)),
391 );
392 McpProviderSession.setMcpProviderSession({
393 environmentId: EnvironmentId.make("test-environment"),
394 threadId: THREAD_ID,
395 providerSessionId: "test-session",
396 providerInstanceId: INSTANCE_ID,
397 endpoint: "http://127.0.0.1:43210/mcp",
398 authorizationHeader: "Bearer thread-scoped-test-token",
399 browserToolsAvailable: false,
400 });
401 const fake = yield* makeFakeMuse();
402 const outcome = yield* makeHarness(fake).pipe(Effect.result);
403 assert.strictEqual(outcome._tag, "Failure");
404 assert.isFalse(fake.calls.some((call) => call.method === "session/start"));
405 }).pipe(Effect.scoped, Effect.provide(testLayer)),
406 );
407
408 it.effect(
409 "starts fresh after recovery clears a native ref instead of reusing the startup hint",
410 () =>
411 Effect.gen(function* () {
412 const fake = yield* makeFakeMuse();
413 const cleared = yield* preallocatedProviderThread();
414 const harness = yield* makeHarness(
415 fake,
416 INSTANCE_ID,
417 "failed-old-session",
418 undefined,
419 cleared,
420 );
421 const started = yield* fake.takeCall("session/start");
422 assert.isFalse(fake.calls.some((call) => call.method === "session/resume"));
423 assert.notStrictEqual(started.params.sessionId, "failed-old-session");
424 assert.strictEqual(harness.providerThread.id, cleared.id);
425 yield* startConversation(harness, fake);
426 }).pipe(Effect.scoped, Effect.provide(testLayer)),
427 );
428
429 it.effect("retries a missing native session with a cleared ref on the same healthy host", () =>
430 Effect.gen(function* () {
431 for (const kind of ["sessionNotFound", "notFound"] as const) {
432 const fake = yield* makeFakeMuse();
433 const allocated = yield* preallocatedProviderThread();
434 const adapter = makeMuseAdapterV2({
435 instanceId: INSTANCE_ID,
436 settings: museSettings,
437 environment: { PATH: "/fake/bin" },
438 idAllocator: yield* IdAllocator.IdAllocatorV2,
439 host: yield* ProviderHost,
440 fileSystem: yield* FileSystem.FileSystem,
441 createHost: async () => fake.host,
442 });
443 const runtime = yield* adapter.openSession({
444 threadId: THREAD_ID,
445 providerSessionId: ProviderSessionId.make(`missing-${kind}`),
446 modelSelection: modelSelection(),
447 runtimePolicy,
448 initialNativeThreadId: "missing-session",
449 });
450 fake.queueResponse(
451 "session/resume",
452 new MspError({ code: -32000, message: "Saved session is absent", data: { kind } }),
453 );
454 const failed = yield* runtime
455 .ensureThread({
456 threadId: THREAD_ID,
457 modelSelection: modelSelection(),
458 runtimePolicy,
459 existingProviderThread: {
460 ...allocated,
461 nativeThreadRef: {
462 driver: MUSE_PROVIDER,
463 nativeId: "missing-session",
464 strength: "strong",
465 },
466 },
467 })
468 .pipe(Effect.exit);
469 assert.strictEqual(failed._tag, "Failure");
470 assert.strictEqual(fake.closeCount(), 0);
471 const fresh = yield* runtime.ensureThread({
472 threadId: THREAD_ID,
473 modelSelection: modelSelection(),
474 runtimePolicy,
475 existingProviderThread: allocated,
476 });
477 assert.strictEqual(fresh.id, allocated.id);
478 assert.notStrictEqual(fresh.nativeThreadRef?.nativeId, "missing-session");
479 assert.strictEqual(fake.closeCount(), 0);
480 yield* runtime.startTurn(yield* turnInput(fresh));
481 const admitted = yield* fake.takeCall("turn/start");
482 assert.strictEqual(admitted.params.reasoningEffort, "max");
483 yield* fake.emit("turn/completed", { turnId: admitted.commandId, terminal: "completed" });
484 yield* Stream.runDrain(
485 runtime.events.pipe(Stream.takeUntil((event) => event.type === "turn.terminal")),
486 );
487 }
488 }).pipe(Effect.scoped, Effect.provide(testLayer)),
489 );
490
491 it.effect("rejects cross-instance ensure requests without closing the current valid host", () =>
492 Effect.gen(function* () {
493 const fake = yield* makeFakeMuse();
494 const harness = yield* makeHarness(fake);
495 const rejected = yield* harness.runtime
496 .ensureThread({
497 threadId: THREAD_ID,
498 modelSelection: modelSelection(),
499 runtimePolicy,
500 existingProviderThread: {
501 ...harness.providerThread,
502 providerInstanceId: ProviderInstanceId.make("other-account"),
503 },
504 })
505 .pipe(Effect.exit);
506 assert.strictEqual(rejected._tag, "Failure");
507 assert.strictEqual(fake.closeCount(), 0);
508 yield* startConversation(harness, fake);
509 }).pipe(Effect.scoped, Effect.provide(testLayer)),
510 );
511
512 it.effect("skips an unreadable usage notification without ending the turn", () =>
513 Effect.gen(function* () {
514 const fake = yield* makeFakeMuse();
515 const harness = yield* makeHarness(fake);
516 const { nativeId } = yield* startConversation(harness, fake);
517 yield* fake.emit("session/tokenUsage", { turnId: nativeId, usage: "not usage" });
518 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
519 const terminal = yield* harness.takeEvent("turn.terminal");
520 assert.strictEqual(terminal.status, "completed");
521 assert.strictEqual(fake.closeCount(), 0);
522 }).pipe(Effect.scoped, Effect.provide(testLayer)),
523 );
524
525 it.effect("moves a resumed session on another model back to the catalog default", () =>
526 Effect.gen(function* () {
527 const fake = yield* makeFakeMuse();
528 // The saved session last ran MODEL; the catalog's default is another model.
529 const harness = yield* makeHarness(
530 fake,
531 INSTANCE_ID,
532 "saved-session",
533 undefined,
534 undefined,
535 runtimePolicy,
536 {
537 modelCatalog: Effect.succeed([
538 {
539 slug: "account-default",
540 name: "Default",
541 isCustom: false,
542 isDefault: true,
543 capabilities: null,
544 },
545 { slug: MODEL, name: "Other", isCustom: false, isDefault: false, capabilities: null },
546 ]),
547 },
548 );
549 const input = yield* turnInput(harness.providerThread, 1);
550 const selection = { ...input.modelSelection, model: MUSE_DEFAULT_MODEL };
551 yield* harness.runtime.startTurn({
552 ...input,
553 modelSelection: selection,
554 appThread: { ...input.appThread, modelSelection: selection },
555 });
556 const setModel = yield* fake.takeCall("session/setModel");
557 assert.deepStrictEqual(setModel.params.model, {
558 modelId: "account-default",
559 providerId: "meta",
560 });
561 yield* fake.takeCall("turn/start");
562 }).pipe(Effect.scoped, Effect.provide(testLayer)),
563 );
564
565 it.effect("settles partial messages and pending approvals before a provider failure", () =>
566 Effect.gen(function* () {
567 const fake = yield* makeFakeMuse();
568 const harness = yield* makeHarness(fake);
569 const { nativeId } = yield* startConversation(harness, fake);
570 yield* fake.emit("item/started", {
571 turnId: nativeId,
572 item: {
573 itemId: "partial",
574 turnId: nativeId,
575 kind: "agentMessage",
576 status: "inProgress",
577 revision: 1,
578 text: "Partial answer",
579 },
580 });
581 yield* fake.emit("approval/requested", approval(nativeId));
582 yield* harness.takeEvent(
583 "runtime_request.updated",
584 (event) => event.runtimeRequest.status === "pending",
585 );
586 yield* fake.emit("turn/completed", {
587 turnId: nativeId,
588 terminal: "failed",
589 error: { message: "Provider rejected this turn" },
590 });
591 const terminal = yield* harness.takeEvent("turn.terminal");
592 assert.strictEqual(terminal.status, "failed");
593 assert.strictEqual(terminal.threadDisposition, "reusable");
594 assert.isNotNull(terminal.failure);
595 assert.isTrue(
596 harness.allEvents.some(
597 (event) =>
598 event.type === "message.updated" &&
599 !event.message.streaming &&
600 event.message.text === "Partial answer",
601 ),
602 );
603 assert.isTrue(
604 harness.allEvents.some(
605 (event) =>
606 event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled",
607 ),
608 );
609 yield* startConversation(harness, fake, 2);
610 }).pipe(Effect.scoped, Effect.provide(testLayer)),
611 );
612
613 it.effect("closes its SDK host exactly once when its session scope ends", () =>
614 Effect.gen(function* () {
615 const fake = yield* makeFakeMuse();
616 yield* makeHarness(fake).pipe(Effect.scoped);
617 assert.strictEqual(fake.closeCount(), 1);
618 }).pipe(Effect.provide(testLayer)),
619 );
620
621 it.effect(
622 "closes a newly opened host when native session registration returns another identity",
623 () =>
624 Effect.gen(function* () {
625 const fake = yield* makeFakeMuse();
626 fake.queueResponse("session/start", {
627 session: { sessionId: "unexpected-session", modelId: MODEL, activeTurnId: null },
628 history: { items: [] },
629 });
630 const result = yield* makeHarness(fake).pipe(Effect.exit);
631 assert.strictEqual(result._tag, "Failure");
632 // A failed host closes in the background so the failure shows at once.
633 yield* Effect.promise(() => fake.host.exited);
634 assert.strictEqual(fake.closeCount(), 1);
635 assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 0);
636 }).pipe(Effect.scoped, Effect.provide(testLayer)),
637 );
638
639 it.effect("settles an active turn once when both the connection and host exit unexpectedly", () =>
640 Effect.gen(function* () {
641 const fake = yield* makeFakeMuse();
642 const harness = yield* makeHarness(fake);
643 const { nativeId } = yield* startConversation(harness, fake);
644 yield* fake.emit("approval/requested", approval(nativeId));
645 yield* harness.takeEvent(
646 "runtime_request.updated",
647 (event) => event.runtimeRequest.status === "pending",
648 );
649 yield* fake.disconnect();
650 const terminal = yield* harness.takeEvent("turn.terminal");
651 assert.strictEqual(terminal.status, "failed");
652 assert.strictEqual(terminal.threadDisposition, "broken");
653 assert.isTrue(
654 harness.allEvents.some(
655 (event) =>
656 event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled",
657 ),
658 );
659 const result = yield* harness.runtime
660 .startTurn(yield* turnInput(harness.providerThread, 2))
661 .pipe(Effect.exit);
662 assert.strictEqual(result._tag, "Failure");
663 assert.strictEqual(
664 harness.allEvents.filter((event) => event.type === "turn.terminal").length,
665 1,
666 );
667 assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1);
668 }).pipe(Effect.scoped, Effect.provide(testLayer)),
669 );
670
671 it.effect("turns a delivery gap into a broken terminal and rejects subsequent work", () =>
672 Effect.gen(function* () {
673 const fake = yield* makeFakeMuse();
674 const harness = yield* makeHarness(fake);
675 yield* startConversation(harness, fake);
676 yield* fake.emit("view/gap", {});
677 const terminal = yield* harness.takeEvent("turn.terminal");
678 assert.strictEqual(terminal.status, "failed");
679 assert.strictEqual(terminal.threadDisposition, "broken");
680 yield* Effect.promise(() => fake.host.exited);
681 assert.strictEqual(fake.closeCount(), 1);
682 yield* Deferred.await(harness.eventsEnded);
683 const result = yield* harness.runtime
684 .startTurn(yield* turnInput(harness.providerThread, 2))
685 .pipe(Effect.exit);
686 assert.strictEqual(result._tag, "Failure");
687 assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1);
688 }).pipe(Effect.scoped, Effect.provide(testLayer)),
689 );
690
691 it.effect("uses the latest native approval requirement and waits for its resolution event", () =>
692 Effect.gen(function* () {
693 const fake = yield* makeFakeMuse();
694 const harness = yield* makeHarness(fake);
695 const { nativeId } = yield* startConversation(harness, fake);
696 yield* fake.emit("approval/requested", approval(nativeId));
697 const first = yield* harness.takeEvent(
698 "runtime_request.updated",
699 (event) => event.runtimeRequest.status === "pending",
700 );
701 yield* fake.emit("approval/updated", {
702 ...approval(nativeId),
703 currentRequirementId: { approvalId: "approval-1", sourceIndex: 1 },
704 });
705 const refreshed = yield* harness.takeEvent(
706 "runtime_request.updated",
707 (event) => event.runtimeRequest.status === "pending",
708 );
709 assert.strictEqual(refreshed.runtimeRequest.id, first.runtimeRequest.id);
710 yield* harness.runtime.respondToRuntimeRequest({
711 requestId: first.runtimeRequest.id,
712 decision: "accept",
713 });
714 const decision = yield* fake.takeCall("approval/decide");
715 assert.deepStrictEqual(decision.params.requirementId, {
716 approvalId: "approval-1",
717 sourceIndex: 1,
718 });
719 assert.strictEqual(decision.params.choiceId, "once");
720 assert.isFalse(
721 harness.allEvents.some(
722 (event) =>
723 event.type === "runtime_request.updated" && event.runtimeRequest.status === "resolved",
724 ),
725 );
726 yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: nativeId });
727 const resolved = yield* harness.takeEvent(
728 "runtime_request.updated",
729 (event) => event.runtimeRequest.status === "resolved",
730 );
731 assert.strictEqual(resolved.runtimeRequest.id, first.runtimeRequest.id);
732 const repeated = yield* harness.runtime
733 .respondToRuntimeRequest({ requestId: first.runtimeRequest.id, decision: "accept" })
734 .pipe(Effect.exit);
735 assert.strictEqual(repeated._tag, "Failure");
736 }).pipe(Effect.scoped, Effect.provide(testLayer)),
737 );
738
739 it.effect("keeps the user's decision when Muse settles before acknowledging it", () =>
740 Effect.gen(function* () {
741 const fake = yield* makeFakeMuse();
742 const harness = yield* makeHarness(fake);
743 const { nativeId } = yield* startConversation(harness, fake);
744 yield* fake.emit("approval/requested", approval(nativeId));
745 const pending = yield* harness.takeEvent(
746 "runtime_request.updated",
747 (event) => event.runtimeRequest.status === "pending",
748 );
749 const acknowledgement = pendingPromise<Record<string, unknown>>();
750 fake.queueResponse("approval/decide", acknowledgement.promise);
751 const responding = yield* harness.runtime
752 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "decline" })
753 .pipe(Effect.forkScoped);
754 yield* fake.takeCall("approval/decide");
755 // Muse's native decision for this choice maps to decline too; "cancel" proves it was ours.
756 yield* fake.emit("approval/resolved", {
757 approvalId: "approval-1",
758 turnId: nativeId,
759 decision: "abort",
760 });
761 const resolved = yield* harness.takeEvent(
762 "runtime_request.updated",
763 (event) => event.runtimeRequest.status === "resolved",
764 );
765 assert.strictEqual(resolved.runtimeRequest.decision, "decline");
766 acknowledgement.resolve({});
767 yield* Fiber.join(responding);
768 }).pipe(Effect.scoped, Effect.provide(testLayer)),
769 );
770
771 it.effect("lets a user turn take a held Muse report turn, approvals included", () =>
772 Effect.gen(function* () {
773 const fake = yield* makeFakeMuse();
774 const offers: Array<ProviderContinuationRequest> = [];
775 const harness = yield* makeHarness(
776 fake,
777 INSTANCE_ID,
778 undefined,
779 undefined,
780 undefined,
781 runtimePolicy,
782 { continuationRequests: { offer: (request) => Effect.sync(() => offers.push(request)) } },
783 );
784 // A finished workflow's report turn starts while T3 runs nothing: it is held.
785 yield* fake.emit("turn/started", { turnId: "report-1" });
786 yield* Effect.yieldNow;
787 assert.lengthOf(offers, 1);
788 // The user sends a message first; Muse runs the report before it.
789 const { nativeId } = yield* startConversation(harness, fake);
790 yield* fake.emit("approval/requested", approval("report-1"));
791 const asked = yield* harness.takeEvent(
792 "runtime_request.updated",
793 (event) => event.runtimeRequest.status === "pending",
794 );
795 yield* harness.runtime.respondToRuntimeRequest({
796 requestId: asked.runtimeRequest.id,
797 decision: "accept",
798 });
799 yield* fake.takeCall("approval/decide");
800 yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: "report-1" });
801 yield* fake.emit("turn/completed", { turnId: "report-1", terminal: "completed" });
802 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
803 const terminal = yield* harness.takeEvent("turn.terminal");
804 assert.strictEqual(terminal.status, "completed");
805 // The user turn took it, so the continuation is not dispatched.
806 const dispatched = yield* offers[0]!.dispatchIfCurrent!(Effect.succeed("run"));
807 assert.isTrue(Option.isNone(dispatched));
808 }).pipe(Effect.scoped, Effect.provide(testLayer)),
809 );
810
811 it.effect("approves a workflow child's approval in full access after its turn ended", () =>
812 Effect.gen(function* () {
813 const fake = yield* makeFakeMuse();
814 const harness = yield* makeHarness(fake);
815 const { nativeId } = yield* startConversation(harness, fake);
816 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
817 yield* harness.takeEvent("turn.terminal");
818 // Muse runs workflow children without the session's allowAll mode.
819 const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } };
820 yield* fake.emit("approval/requested", childApproval);
821 const asked = yield* harness.takeEvent("runtime_request.updated");
822 assert.strictEqual(asked.runtimeRequest.status, "pending");
823 assert.isNull(asked.runtimeRequest.providerTurnId);
824 const decision = yield* fake.takeCall("approval/decide");
825 assert.strictEqual(decision.params.choiceId, "once");
826 yield* fake.emit("approval/resolved", childApproval);
827 const resolved = yield* harness.takeEvent("runtime_request.updated");
828 assert.strictEqual(resolved.runtimeRequest.status, "resolved");
829 assert.strictEqual(resolved.runtimeRequest.decision, "accept");
830 }).pipe(Effect.scoped, Effect.provide(testLayer)),
831 );
832
833 it.effect("keeps a workflow child's approval pending for the user past the turn's end", () =>
834 Effect.gen(function* () {
835 const fake = yield* makeFakeMuse();
836 const policy = ProviderAdapterV2RuntimePolicy.make({
837 ...runtimePolicy,
838 runtimeMode: "approval-required",
839 });
840 const harness = yield* makeHarness(
841 fake,
842 INSTANCE_ID,
843 undefined,
844 undefined,
845 undefined,
846 policy,
847 );
848 const { nativeId } = yield* startConversation(harness, fake);
849 const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } };
850 yield* fake.emit("approval/requested", childApproval);
851 const asked = yield* harness.takeEvent("runtime_request.updated");
852 assert.isNull(asked.runtimeRequest.providerTurnId);
853 yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" });
854 yield* harness.takeEvent("turn.terminal");
855 assert.isFalse(
856 harness.allEvents.some(
857 (event) =>
858 event.type === "runtime_request.updated" && event.runtimeRequest.status !== "pending",
859 ),
860 );
861 yield* harness.runtime.respondToRuntimeRequest({
862 requestId: asked.runtimeRequest.id,
863 decision: "decline",
864 });
865 const decision = yield* fake.takeCall("approval/decide");
866 assert.strictEqual(decision.params.choiceId, "deny");
867 }).pipe(Effect.scoped, Effect.provide(testLayer)),
868 );
869
870 it.effect("validates structured answers and preserves selected labels with a custom note", () =>
871 Effect.gen(function* () {
872 const fake = yield* makeFakeMuse();
873 const harness = yield* makeHarness(fake);
874 const { nativeId } = yield* startConversation(harness, fake);
875 yield* fake.emit("userInput/requested", {
876 userInputId: "question-1",
877 turnId: nativeId,
878 questions: [
879 {
880 id: "approach",
881 header: "Approach",
882 question: "Which approach?",
883 options: [{ label: "Small" }, { label: "Large" }],
884 selection: { mode: "single" },
885 },
886 ],
887 });
888 const pending = yield* harness.takeEvent(
889 "runtime_request.updated",
890 (event) => event.runtimeRequest.kind === "user_input",
891 );
892 const invalid = yield* harness.runtime
893 .respondToRuntimeRequest({
894 requestId: pending.runtimeRequest.id,
895 answers: { approach: ["Small", "Large"] },
896 })
897 .pipe(Effect.exit);
898 assert.strictEqual(invalid._tag, "Failure");
899 assert.isFalse(fake.calls.some((call) => call.method === "userInput/answer"));
900 yield* harness.runtime.respondToRuntimeRequest({
901 requestId: pending.runtimeRequest.id,
902 answers: { approach: ["Small", "Keep tests focused"] },
903 });
904 const response = yield* fake.takeCall("userInput/answer");
905 assert.deepStrictEqual(response.params.answers, [
906 { questionId: "approach", selectedLabel: "Small", note: "Keep tests focused" },
907 ]);
908 yield* fake.emit("userInput/settled", {
909 userInputId: "question-1",
910 turnId: nativeId,
911 answers: response.params.answers,
912 });
913 yield* harness.takeEvent(
914 "runtime_request.updated",
915 (event) => event.runtimeRequest.status === "resolved",
916 );
917 }).pipe(Effect.scoped, Effect.provide(testLayer)),
918 );
919
920 it.effect("steers only the active native turn and waits for confirmed interruption", () =>
921 Effect.gen(function* () {
922 const fake = yield* makeFakeMuse();
923 const harness = yield* makeHarness(fake);
924 const turn = yield* startConversation(harness, fake);
925 const input = yield* turnInput(harness.providerThread);
926 const invalid = yield* harness.runtime.steerTurn!({
927 threadId: THREAD_ID,
928 runId: input.runId,
929 providerThread: harness.providerThread,
930 providerTurnId: ProviderTurnId.make("unrelated-turn"),
931 message: input.message,
932 }).pipe(Effect.exit);
933 assert.strictEqual(invalid._tag, "Failure");
934 assert.isFalse(fake.calls.some((call) => call.method === "turn/steer"));
935 yield* harness.runtime.steerTurn!({
936 threadId: THREAD_ID,
937 runId: input.runId,
938 providerThread: harness.providerThread,
939 providerTurnId: turn.providerTurn.id,
940 message: { ...input.message, text: "Focus on the parser" },
941 });
942 assert.strictEqual((yield* fake.takeCall("turn/steer")).params.expectedTurnId, turn.nativeId);
943 const interrupt = yield* harness.runtime
944 .interruptTurn({
945 providerThread: harness.providerThread,
946 providerTurnId: turn.providerTurn.id,
947 })
948 .pipe(Effect.forkScoped);
949 assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, turn.nativeId);
950 yield* fake.emit("turn/completed", { turnId: turn.nativeId, terminal: "cancelled" });
951 yield* Fiber.join(interrupt);
952 assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted");
953 }).pipe(Effect.scoped, Effect.provide(testLayer)),
954 );
955
956 it.effect("resumes the requested native session and reads the same session snapshot", () =>
957 Effect.gen(function* () {
958 const fake = yield* makeFakeMuse();
959 const nativeItem: MuseItem = {
960 itemId: "prior-message",
961 turnId: "prior-turn",
962 kind: "agentMessage",
963 status: "completed",
964 revision: 1,
965 text: "Previous answer",
966 };
967 fake.setHistory([nativeItem]);
968 const harness = yield* makeHarness(fake, INSTANCE_ID, "saved-session");
969 const resumed = yield* fake.takeCall("session/resume");
970 assert.strictEqual(resumed.params.sessionId, "saved-session");
971 assert.strictEqual(resumed.params.excludeItems, true);
972 assert.isFalse(fake.calls.some((call) => call.method === "session/start"));
973 assert.strictEqual(harness.providerThread.nativeThreadRef?.nativeId, "saved-session");
974 const snapshot = yield* harness.runtime.readThreadSnapshot!({
975 providerThread: harness.providerThread,
976 });
977 assert.strictEqual(snapshot.providerThread.nativeThreadRef?.nativeId, "saved-session");
978 assert.deepStrictEqual(snapshot.providerPayload, [nativeItem]);
979 assert.strictEqual(snapshot.messages[0]?.text, "Previous answer");
980 }).pipe(Effect.scoped, Effect.provide(testLayer)),
981 );
982
983 it.effect("serializes interruption behind pending turn admission without losing the target", () =>
984 Effect.gen(function* () {
985 const fake = yield* makeFakeMuse();
986 const harness = yield* makeHarness(fake);
987 const admission = pendingPromise<Record<string, unknown>>();
988 fake.queueResponse("turn/start", admission.promise);
989 const starting = yield* harness.runtime
990 .startTurn(yield* turnInput(harness.providerThread))
991 .pipe(Effect.forkScoped);
992 const call = yield* fake.takeCall("turn/start");
993 const running = yield* harness.takeEvent(
994 "provider_turn.updated",
995 (event) => event.providerTurn.status === "running",
996 );
997 const stopping = yield* harness.runtime
998 .interruptTurn({
999 providerThread: harness.providerThread,
1000 providerTurnId: running.providerTurn.id,
1001 })
1002 .pipe(Effect.forkScoped);
1003 admission.resolve({ turnId: call.commandId });
1004 yield* Fiber.join(starting);
1005 assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, call.commandId);
1006 yield* fake.emit("turn/completed", { turnId: call.commandId, terminal: "cancelled" });
1007 yield* Fiber.join(stopping);
1008 assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted");
1009 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1010 );
1011
1012 it.effect("reports a no-op compaction as a visible failure and permits another turn", () =>
1013 Effect.gen(function* () {
1014 const fake = yield* makeFakeMuse();
1015 const harness = yield* makeHarness(fake);
1016 fake.queueResponse("session/compact", { status: "noop", reason: "no_compactable_history" });
1017 yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread));
1018 const terminal = yield* harness.takeEvent("turn.terminal");
1019 assert.strictEqual(terminal.status, "failed");
1020 assert.strictEqual(terminal.threadDisposition, "reusable");
1021 yield* startConversation(harness, fake, 2);
1022 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1023 );
1024
1025 it.effect("projects native todos with stable identity and removes cancelled steps", () =>
1026 Effect.gen(function* () {
1027 const fake = yield* makeFakeMuse();
1028 const harness = yield* makeHarness(fake);
1029 const { nativeId } = yield* startConversation(harness, fake);
1030 // Neither the tool call that wrote the list nor Muse's per-step reminder
1031 // child may show as a generic tool row.
1032 yield* fake.emit("item/completed", {
1033 turnId: nativeId,
1034 item: {
1035 itemId: "todo-tool",
1036 turnId: nativeId,
1037 kind: "toolCall",
1038 tool: "write_todos",
1039 revision: 1,
1040 status: "completed",
1041 },
1042 });
1043 yield* fake.emit("item/started", {
1044 turnId: nativeId,
1045 item: {
1046 itemId: "reminder",
1047 turnId: nativeId,
1048 kind: "reminderChild",
1049 revision: 1,
1050 status: "inProgress",
1051 fallbackText: "Reminder child session",
1052 },
1053 });
1054 yield* fake.emit("session/todoListChanged", {
1055 items: [
1056 { text: " Investigate ", status: "completed" },
1057 { text: "Fix", status: "inProgress" },
1058 { text: "Discarded", status: "cancelled" },
1059 { text: " ", status: "pending" },
1060 ],
1061 });
1062 const first = yield* harness.takeEvent(
1063 "turn_item.updated",
1064 (event) => event.turnItem.type === "todo_list",
1065 );
1066 assert.strictEqual(first.turnItem.type, "todo_list");
1067 if (first.turnItem.type !== "todo_list") return;
1068 assert.deepStrictEqual(
1069 first.turnItem.steps.map(({ text, status }) => ({ text, status })),
1070 [
1071 { text: "Investigate", status: "completed" },
1072 { text: "Fix", status: "running" },
1073 ],
1074 );
1075 const planId = first.turnItem.planId;
1076 yield* fake.emit("session/todoListChanged", {
1077 items: [
1078 { text: "Investigate", status: "completed" },
1079 { text: "Fix", status: "completed" },
1080 ],
1081 });
1082 const next = yield* harness.takeEvent(
1083 "turn_item.updated",
1084 (event) => event.turnItem.type === "todo_list",
1085 );
1086 assert.strictEqual(next.turnItem.id, first.turnItem.id);
1087 assert.strictEqual(next.turnItem.nodeId, first.turnItem.nodeId);
1088 if (next.turnItem.type !== "todo_list") return;
1089 assert.strictEqual(next.turnItem.planId, first.turnItem.planId);
1090 assert.isTrue(
1091 harness.allEvents.some(
1092 (event) =>
1093 event.type === "plan.updated" &&
1094 event.plan.id === planId &&
1095 event.plan.status === "completed",
1096 ),
1097 );
1098 assert.isFalse(
1099 harness.allEvents.some(
1100 (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool",
1101 ),
1102 );
1103 assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsPlanUpdated);
1104 assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsTodoList);
1105 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1106 );
1107});
packages/provider-muse/src/server/adapter.ts created+2047
......@@ -0,0 +1,2047 @@
1import { MspError, type SendUserTurnOptions } from "@muse-code/sdk";
2import {
3 MUSE_DEFAULT_MODEL,
4 ProviderDriverKind,
5 type ModelSelection,
6 type OrchestrationV2ConversationMessage,
7 type OrchestrationV2ExecutionNode,
8 type OrchestrationV2ProviderCapabilities,
9 type OrchestrationV2ProviderSession,
10 type OrchestrationV2ProviderThread,
11 type OrchestrationV2ProviderTurn,
12 type OrchestrationV2RuntimeRequest,
13 type OrchestrationV2Subagent,
14 type OrchestrationV2TurnItemStatus,
15 type NodeId,
16 type OrchestrationV2TurnItem,
17 type ProviderInstanceId,
18 type PlanId,
19 type OrchestrationV2PlanStep,
20 type ProviderApprovalDecision,
21 type RuntimeRequestId,
22 type ServerProviderModel,
23} from "@t3tools/contracts";
24import type { MuseSettings } from "../settings.ts";
25import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
26import * as Cause from "effect/Cause";
27import * as DateTime from "effect/DateTime";
28import * as Deferred from "effect/Deferred";
29import * as Effect from "effect/Effect";
30import * as Option from "effect/Option";
31import * as FileSystem from "effect/FileSystem";
32import * as Queue from "effect/Queue";
33import * as Schema from "effect/Schema";
34import * as Semaphore from "effect/Semaphore";
35import * as Scope from "effect/Scope";
36import * as Stream from "effect/Stream";
37
38import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost";
39import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
40import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
41import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts";
42import {
43 MuseApproval,
44 MuseCompactResult,
45 MuseContextUsage,
46 MuseDelta,
47 MuseItemEvent,
48 MuseSessionResult,
49 MuseTokenUsageEvent,
50 MuseTodoList,
51 MuseTurnRetryScheduled,
52 MuseTurnCompleted,
53 MuseTurnStartResult,
54 MuseUserInput,
55 museApprovalChoices,
56 museApprovalDecision,
57 museApprovalOptions,
58 type MuseItem,
59} from "./protocol.ts";
60import {
61 createMuseSdkHostEffect,
62 museApprovalMode,
63 type createMuseSdkHost,
64 type MuseSdkHost,
65} from "./sdk.ts";
66import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers";
67import {
68 providerMessageTextWithAttachmentPaths,
69 isProviderNativeImageAttachment,
70} from "@t3tools/provider-core/server/attachmentPrompt";
71import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
72import {
73 ProviderAdapterEnsureThreadError,
74 ProviderAdapterForkThreadError,
75 ProviderAdapterInterruptError,
76 ProviderAdapterOpenSessionError,
77 ProviderAdapterProtocolError,
78 ProviderAdapterReadThreadSnapshotError,
79 ProviderAdapterResumeThreadError,
80 ProviderAdapterRollbackThreadError,
81 ProviderAdapterRuntimeRequestResponseError,
82 ProviderAdapterSteerRunError,
83 ProviderAdapterTurnStartError,
84 ProviderAdapterV2,
85 type ProviderAdapterV2Error,
86 type ProviderAdapterV2Event,
87 type ProviderAdapterV2EnsureThreadInput,
88 type ProviderAdapterV2OpenSessionInput,
89 type ProviderAdapterV2SessionRuntime,
90 type ProviderAdapterV2Shape,
91 type ProviderAdapterV2ThreadSnapshot,
92 type ProviderAdapterV2TurnInput,
93 type ProviderAdapterV2TurnMessage,
94} from "@t3tools/provider-core/server/ProviderAdapter";
95import {
96 backgroundWorkNotification,
97 type BackgroundWorkReport,
98} from "@t3tools/provider-core/server/notification";
99import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
100import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
101import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
102import { museItemStatus, museToolPresentation } from "./itemPresentation.ts";
103
104const MUSE_PROVIDER = ProviderDriverKind.make("muse");
105const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError);
106const isProtocolError = Schema.is(ProviderAdapterProtocolError);
107
108const MuseProviderCapabilitiesV2 = {
109 runtimePolicy: { enforcement: "native" },
110 sessions: {
111 supportsMultipleProviderThreadsPerSession: false,
112 supportsModelSwitchInSession: true,
113 supportsProviderSwitchingViaHandoff: true,
114 supportsRuntimeModeSwitchInSession: false,
115 pendingRequestsSurviveRestart: false,
116 },
117 threads: {
118 canCreateEmptyThread: true,
119 canReadThreadSnapshot: true,
120 // Muse 1.2.1/1.3.0 reject tested completed-turn fork boundaries with InvalidCut.
121 // Public forks use portable context handoff until native boundaries are validated.
122 canRollbackThread: false,
123 canForkThread: false,
124 canForkFromTurn: false,
125 canForkFromSubagentThread: false,
126 exposesNativeThreadId: true,
127 },
128 turns: {
129 exposesNativeTurnId: true,
130 emitsTurnStarted: true,
131 emitsTurnCompleted: true,
132 supportsInterrupt: true,
133 supportsActiveSteering: true,
134 supportsSteeringByInterruptRestart: false,
135 supportsQueuedMessages: true,
136 terminalStatusQuality: "strong",
137 },
138 streaming: {
139 streamsAssistantText: true,
140 streamsReasoning: true,
141 streamsToolOutput: true,
142 streamsPlanText: false,
143 emitsMessageCompleted: true,
144 },
145 tools: {
146 exposesToolItemIds: true,
147 emitsToolStarted: true,
148 emitsToolCompleted: true,
149 emitsToolOutput: true,
150 supportsMcpTools: true,
151 supportsDynamicToolCallbacks: false,
152 },
153 approvals: {
154 supportsCommandApproval: true,
155 supportsFileReadApproval: true,
156 supportsFileChangeApproval: true,
157 supportsApplyPatchApproval: false,
158 approvalsHaveNativeRequestIds: true,
159 approvalCallbacksAreLiveOnly: true,
160 approvalsCanOriginateFromSubagents: true,
161 },
162 planning: {
163 emitsPlanUpdated: true,
164 emitsTodoList: true,
165 emitsProposedPlan: false,
166 supportsStructuredQuestions: true,
167 planDeltasHaveItemIds: false,
168 },
169 subagents: {
170 supportsSubagents: false,
171 exposesSubagentThreadIds: false,
172 emitsSubagentLifecycle: false,
173 canWaitForSubagents: false,
174 canCloseSubagents: false,
175 canForkSubagentThread: false,
176 },
177 context: {
178 acceptsSystemContext: false,
179 acceptsDeveloperContext: false,
180 acceptsSyntheticUserContext: true,
181 canGenerateSummaries: false,
182 canConsumeHandoffSummaries: true,
183 supportsDeltaHandoff: true,
184 supportsFullThreadHandoff: true,
185 maxRecommendedHandoffChars: null,
186 },
187 checkpointing: {
188 appCanCheckpointFilesystem: true,
189 supportsNestedCheckpointScopes: false,
190 providerCanRollbackConversation: false,
191 providerRollbackReturnsSnapshot: false,
192 providerCanReadConversationSnapshot: true,
193 },
194 identity: {
195 nativeThreadIds: "strong",
196 nativeTurnIds: "strong",
197 nativeItemIds: "strong",
198 nativeRequestIds: "strong",
199 },
200} satisfies OrchestrationV2ProviderCapabilities;
201
202export interface MuseAdapterV2Options {
203 readonly instanceId: ProviderInstanceId;
204 readonly settings: MuseSettings;
205 readonly environment: NodeJS.ProcessEnv;
206 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
207 readonly host: ProviderHostShape;
208 readonly fileSystem: FileSystem.FileSystem;
209 readonly modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>;
210 readonly createHost?: typeof createMuseSdkHost;
211 readonly requestTimeoutMs?: number;
212 readonly nativeEventLogger?: EventNdjsonLogger;
213 /** Where a turn Muse starts on its own (a finished workflow's report) asks for its run. */
214 readonly continuationRequests?: {
215 readonly offer: (
216 request: ProviderContinuationRequests.ProviderContinuationRequest,
217 ) => Effect.Effect<void>;
218 };
219}
220
221interface ActiveTurn {
222 readonly input: ProviderAdapterV2TurnInput;
223 providerTurn: OrchestrationV2ProviderTurn;
224 readonly nativeId: string;
225 /** Turns Muse started on its own while this run was active, which this run shows. */
226 readonly joined: Set<string>;
227 readonly items: Map<string, MuseItem>;
228 readonly ordinals: Map<string, number>;
229 readonly started: Map<string, DateTime.Utc>;
230 readonly dirty: Set<string>;
231 readonly settledRequests: Set<string>;
232 readonly done: Deferred.Deferred<void>;
233 todoPlanId?: PlanId;
234 nextOrdinal: number;
235 flushScheduled: boolean;
236 interruptRequested: boolean;
237 compact: boolean;
238}
239
240interface PendingRequest {
241 readonly native:
242 | { type: "approval"; value: MuseApproval }
243 | { type: "question"; value: MuseUserInput };
244 request: OrchestrationV2RuntimeRequest;
245 node: OrchestrationV2ExecutionNode;
246 item: OrchestrationV2TurnItem;
247 /** What T3 answered, recorded on the request once Muse confirms it. */
248 response?: Pick<OrchestrationV2RuntimeRequest, "decision" | "answers">;
249}
250
251const nativeRef = (nativeId: string) => ({
252 driver: MUSE_PROVIDER,
253 nativeId,
254 strength: "strong" as const,
255});
256const recordSchema = Schema.Record(Schema.String, Schema.Unknown);
257// Notifications that only add detail. A malformed one is skipped instead of ending the session.
258const INFORMATIONAL_NOTIFICATIONS = new Set([
259 "session/contextUsage",
260 "session/tokenUsage",
261 "session/todoListChanged",
262 "turn/retryScheduled",
263]);
264// Tools whose results already show as their own rows: T3's todo list and question
265// rows, and the workflow item a `workflow` call launches.
266const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]);
267const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
268
269/** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */
270export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapterV2Shape {
271 const { idAllocator } = options;
272 const protocolError = (detail: string, payload?: unknown) =>
273 new ProviderAdapterProtocolError({
274 driver: MUSE_PROVIDER,
275 detail,
276 ...(payload === undefined ? {} : { payload }),
277 });
278 return ProviderAdapterV2.of({
279 instanceId: options.instanceId,
280 driver: MUSE_PROVIDER,
281 getCapabilities: () => Effect.succeed(MuseProviderCapabilitiesV2),
282 planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()),
283 openSession: Effect.fn("MuseAdapterV2.openSession")(function* (
284 input: ProviderAdapterV2OpenSessionInput,
285 ) {
286 if (!options.settings.enabled) return yield* protocolError("Muse Code is disabled");
287 if (input.runtimePolicy.interactionMode === "plan")
288 return yield* protocolError("Muse Code does not support dedicated Plan mode");
289 const scope = yield* Effect.scope;
290 // Muse rejects non-canonical workspace roots (for example macOS /tmp) and
291 // reports canonical paths in approvals, so resolve symlinks once here.
292 const requestedCwd = input.runtimePolicy.cwd ?? options.host.paths.cwd;
293 const cwd = yield* options.fileSystem
294 .realPath(requestedCwd)
295 .pipe(Effect.orElseSucceed(() => requestedCwd));
296 const now = yield* DateTime.now;
297 let session: OrchestrationV2ProviderSession = {
298 id: input.providerSessionId,
299 driver: MUSE_PROVIDER,
300 providerInstanceId: options.instanceId,
301 status: "ready",
302 cwd,
303 model: input.modelSelection.model,
304 capabilities: MuseProviderCapabilitiesV2,
305 createdAt: now,
306 updatedAt: now,
307 lastError: null,
308 };
309 const events = yield* Queue.unbounded<
310 ProviderAdapterV2Event,
311 ProviderAdapterV2Error | Cause.Done
312 >();
313 type Inbox =
314 | { type: "notification"; method: string; params: unknown; epoch: number }
315 | { type: "failure"; cause: unknown; epoch: number }
316 | { type: "flush"; turn: ActiveTurn };
317 const inbox = yield* Queue.unbounded<Inbox>();
318 const commands = yield* Semaphore.make(1);
319 const eventPermit = yield* Semaphore.make(1);
320 let host: MuseSdkHost;
321 let hostEpoch = 0;
322 let closed = false;
323 let broken = false;
324 let nativeSessionId: string | undefined;
325 let thread: OrchestrationV2ProviderThread | undefined;
326 let active: ActiveTurn | undefined;
327 // The last finished turn, so context usage reported after it still lands on it.
328 let lastProviderTurn: OrchestrationV2ProviderTurn | undefined;
329 const agentEndedAt = new Map<string, DateTime.Utc>();
330 // "default" is the catalog's default model, from the provider snapshot. Without
331 // one, Muse keeps whatever model the session already runs.
332 const resolveModel = Effect.fnUntraced(function* (selection: ModelSelection) {
333 if (selection.model !== MUSE_DEFAULT_MODEL) return selection.model;
334 const catalog = options.modelCatalog ? yield* options.modelCatalog : [];
335 return catalog.find((model) => model.isDefault && !model.isCustom)?.slug;
336 });
337 const pending = new Map<RuntimeRequestId, PendingRequest>();
338 const seenTerminals = new Set<string>();
339 // Workflows and subagents that outlive their turn, by item id, with the turn that owns them.
340 const observedChildren = new Map<string, ActiveTurn>();
341 // Background work that finished since the last turn, reported to the turn Muse starts for it.
342 const finishedBackground: Array<BackgroundWorkReport> = [];
343 // A turn Muse started on its own, held until the continuation run T3 opens for it takes it.
344 let wake:
345 | { readonly nativeId: string; readonly events: Array<[string, unknown]> }
346 | undefined;
347 const emit = (event: ProviderAdapterV2Event) =>
348 Queue.offer(events, event).pipe(Effect.asVoid);
349 const decode = <A, I>(schema: Schema.Codec<A, I>, data: unknown) =>
350 Schema.decodeUnknownEffect(schema)(data).pipe(
351 Effect.mapError((cause) => protocolError("Invalid Muse protocol response", cause)),
352 );
353 const request = (
354 method: string,
355 params: Record<string, unknown>,
356 command = true,
357 commandId?: string,
358 ) =>
359 Effect.tryPromise({
360 try: () =>
361 command
362 ? host.connection.command(
363 method,
364 { ...(nativeSessionId ? { sessionId: nativeSessionId } : {}), ...params },
365 {
366 ...(method === "turn/start" ? {} : { maxAttempts: 1 }),
367 ...(commandId ? { commandId } : {}),
368 },
369 )
370 : host.connection.request(method, {
371 ...(nativeSessionId ? { sessionId: nativeSessionId } : {}),
372 ...params,
373 }),
374 catch: (cause) =>
375 protocolError(
376 cause instanceof Error && cause.message
377 ? `Muse ${method} failed: ${cause.message}`
378 : `Muse ${method} failed`,
379 cause,
380 ),
381 }).pipe(
382 Effect.timeout(options.requestTimeoutMs ?? 30_000),
383 Effect.catchTags({
384 TimeoutError: (cause) =>
385 Effect.gen(function* () {
386 yield* eventPermit.withPermits(1)(failHost(cause));
387 return yield* protocolError(`Muse ${method} timed out; its host was closed`, cause);
388 }),
389 }),
390 );
391 const updateSession = Effect.fnUntraced(function* (
392 status: OrchestrationV2ProviderSession["status"],
393 lastError: string | null = null,
394 ) {
395 session = { ...session, status, lastError, updatedAt: yield* DateTime.now };
396 yield* emit({
397 type: "provider_session.updated",
398 driver: MUSE_PROVIDER,
399 providerSession: session,
400 });
401 });
402 const updateThread = Effect.fnUntraced(function* (
403 patch: Partial<OrchestrationV2ProviderThread>,
404 ) {
405 if (!thread) return;
406 thread = { ...thread, ...patch, updatedAt: yield* DateTime.now };
407 yield* emit({
408 type: "provider_thread.updated",
409 driver: MUSE_PROVIDER,
410 providerThread: thread,
411 });
412 });
413 const itemIdentity = (turn: ActiveTurn, nativeId: string) =>
414 `${options.instanceId}:${turn.input.providerThread.id}:${turn.nativeId}:${nativeId}`;
415 const baseItem = (turn: ActiveTurn, nativeId: string, time: DateTime.Utc) => {
416 if (!turn.ordinals.has(nativeId)) turn.ordinals.set(nativeId, turn.nextOrdinal++);
417 if (!turn.started.has(nativeId)) turn.started.set(nativeId, time);
418 const identity = itemIdentity(turn, nativeId);
419 return {
420 id: idAllocator.derive.turnItemFromProviderItem({
421 driver: MUSE_PROVIDER,
422 nativeItemId: identity,
423 }),
424 nodeId: idAllocator.derive.nodeFromProviderItem({
425 driver: MUSE_PROVIDER,
426 nativeItemId: identity,
427 }),
428 threadId: turn.input.threadId,
429 runId: turn.input.runId,
430 providerThreadId: turn.input.providerThread.id,
431 providerTurnId: turn.providerTurn.id,
432 nativeItemRef: nativeRef(nativeId),
433 parentItemId: null,
434 ordinal: turn.ordinals.get(nativeId)!,
435 startedAt: turn.started.get(nativeId)!,
436 updatedAt: time,
437 };
438 };
439 /** Lists running workflows and subagents on the thread, so it shows it still waits on them. */
440 const syncBackground = Effect.fnUntraced(function* () {
441 yield* updateThread({
442 pendingBackgroundTasks: [...observedChildren].flatMap(([itemId, owner]) => {
443 const item = owner.items.get(itemId);
444 const description = item?.objective ?? item?.entryId ?? item?.fallbackText;
445 return [
446 {
447 taskId: itemId,
448 kind:
449 item?.kind === "subagent" ? ("subagent" as const) : ("background_task" as const),
450 ...(description ? { description } : {}),
451 },
452 ];
453 }),
454 });
455 });
456 const publishItem = Effect.fnUntraced(function* (
457 turn: ActiveTurn,
458 item: MuseItem,
459 terminal?: OrchestrationV2TurnItem["status"],
460 ) {
461 // Muse's skill-reminder child runs on every model step; it is housekeeping, not work.
462 if (item.kind === "userMessage" || item.kind === "reminderChild") return;
463 // A failed call keeps its row: it may have no native row to show its error.
464 if (
465 item.kind === "toolCall" &&
466 item.tool &&
467 TOOLS_WITH_NATIVE_ROWS.has(item.tool) &&
468 item.status !== "failed"
469 )
470 return;
471 const time = yield* DateTime.now;
472 const status = terminal ?? museItemStatus(item);
473 if (item.kind === "subagent" || item.kind === "workflow") {
474 const wasRunning = observedChildren.has(item.itemId);
475 if (status === "running") observedChildren.set(item.itemId, turn);
476 else if (observedChildren.delete(item.itemId) && active !== turn)
477 finishedBackground.push({
478 kind: item.kind === "subagent" ? "subagent" : "background_task",
479 label: item.objective ?? item.entryId ?? item.fallbackText,
480 outcome:
481 status === "completed"
482 ? "completed"
483 : status === "cancelled"
484 ? "cancelled"
485 : "failed",
486 });
487 if (wasRunning !== observedChildren.has(item.itemId)) yield* syncBackground();
488 }
489 const streaming = status === "running";
490 const base = {
491 ...baseItem(turn, item.itemId, time),
492 status,
493 completedAt: streaming ? null : time,
494 title: item.tool ?? null,
495 };
496 const text = item.text ?? item.summary?.join("\n") ?? item.fallbackText ?? "";
497 let turnItem: OrchestrationV2TurnItem;
498 if (item.kind === "agentMessage") {
499 const messageId = idAllocator.derive.messageFromProviderItem({
500 driver: MUSE_PROVIDER,
501 nativeItemId: itemIdentity(turn, item.itemId),
502 });
503 const message: OrchestrationV2ConversationMessage = {
504 id: messageId,
505 threadId: turn.input.threadId,
506 runId: turn.input.runId,
507 nodeId: base.nodeId,
508 role: "assistant",
509 text,
510 attachments: [],
511 streaming,
512 createdBy: "agent",
513 creationSource: "provider",
514 createdAt: base.startedAt,
515 updatedAt: time,
516 };
517 yield* emit({ type: "message.updated", driver: MUSE_PROVIDER, message });
518 turnItem = { ...base, type: "assistant_message", messageId, text, streaming };
519 } else if (item.kind === "reasoning") {
520 turnItem = { ...base, type: "reasoning", text, streaming };
521 } else if (item.kind === "compaction") {
522 turnItem = {
523 ...base,
524 type: "compaction",
525 driver: MUSE_PROVIDER,
526 summary: text || item.reason,
527 };
528 } else {
529 turnItem = { ...base, ...museToolPresentation(item, status) };
530 }
531 yield* emit({
532 type: "node.updated",
533 driver: MUSE_PROVIDER,
534 node: {
535 id: base.nodeId,
536 threadId: base.threadId,
537 runId: base.runId,
538 parentNodeId: turn.input.rootNodeId,
539 rootNodeId: turn.input.rootNodeId,
540 kind:
541 item.kind === "agentMessage"
542 ? "assistant_message"
543 : item.kind === "reasoning"
544 ? "reasoning"
545 : "tool_call",
546 status,
547 countsForRun: false,
548 providerThreadId: base.providerThreadId,
549 providerTurnId: base.providerTurnId,
550 nativeItemRef: base.nativeItemRef,
551 runtimeRequestId: null,
552 checkpointScopeId: null,
553 startedAt: base.startedAt,
554 completedAt: base.completedAt,
555 },
556 });
557 yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem });
558 if (item.kind === "workflow") yield* publishWorkflowAgents(turn, item, base, status);
559 });
560 /**
561 * Shows each workflow child as a native subagent under the workflow row. Muse
562 * reports only their status, label and outcome, not their conversations, so
563 * they get no child thread.
564 */
565 const publishWorkflowAgents = Effect.fnUntraced(function* (
566 turn: ActiveTurn,
567 item: MuseItem,
568 workflow: { readonly id: OrchestrationV2TurnItem["id"]; readonly nodeId: NodeId },
569 workflowStatus: OrchestrationV2TurnItemStatus,
570 ) {
571 const latest = new Map((item.children ?? []).map((child) => [child.childId, child]));
572 for (const [index, child] of [...latest.values()].entries()) {
573 const time = yield* DateTime.now;
574 const base = baseItem(turn, `${item.itemId}:agent:${child.childId}`, time);
575 const status: OrchestrationV2TurnItemStatus =
576 child.terminal === "completed"
577 ? "completed"
578 : child.terminal === "cancelled"
579 ? "cancelled"
580 : child.terminal !== undefined
581 ? "failed"
582 : workflowStatus !== "running"
583 ? workflowStatus
584 : child.status === "scheduled"
585 ? "pending"
586 : "running";
587 const settled = status !== "running" && status !== "pending";
588 // Children are re-sent on every workflow change; keep each one's first end time.
589 const endKey = `${item.itemId}:${child.childId}:${child.attempt}`;
590 if (settled && !agentEndedAt.has(endKey)) agentEndedAt.set(endKey, time);
591 const title = child.label?.trim() || `Agent ${index + 1}`;
592 const result = child.failureReason ?? null;
593 const subagent: OrchestrationV2Subagent = {
594 id: base.nodeId,
595 threadId: base.threadId,
596 runId: base.runId,
597 parentNodeId: workflow.nodeId,
598 origin: "provider_native",
599 createdBy: "agent",
600 driver: MUSE_PROVIDER,
601 providerInstanceId: options.instanceId,
602 providerThreadId: base.providerThreadId,
603 childThreadId: null,
604 nativeTaskRef: nativeRef(child.childId),
605 prompt: title,
606 title,
607 model: null,
608 status,
609 result,
610 startedAt: base.startedAt,
611 completedAt: settled ? (agentEndedAt.get(endKey) ?? time) : null,
612 updatedAt: time,
613 };
614 yield* emit({ type: "subagent.updated", driver: MUSE_PROVIDER, subagent });
615 yield* emit({
616 type: "node.updated",
617 driver: MUSE_PROVIDER,
618 node: {
619 id: base.nodeId,
620 threadId: base.threadId,
621 runId: base.runId,
622 parentNodeId: workflow.nodeId,
623 rootNodeId: turn.input.rootNodeId,
624 kind: "subagent",
625 status,
626 countsForRun: false,
627 providerThreadId: base.providerThreadId,
628 providerTurnId: base.providerTurnId,
629 nativeItemRef: subagent.nativeTaskRef,
630 runtimeRequestId: null,
631 checkpointScopeId: null,
632 startedAt: base.startedAt,
633 completedAt: subagent.completedAt,
634 },
635 });
636 yield* emit({
637 type: "turn_item.updated",
638 driver: MUSE_PROVIDER,
639 turnItem: {
640 ...base,
641 nativeItemRef: subagent.nativeTaskRef,
642 parentItemId: workflow.id,
643 type: "subagent",
644 status,
645 title,
646 completedAt: subagent.completedAt,
647 subagentId: subagent.id,
648 origin: "provider_native",
649 driver: MUSE_PROVIDER,
650 providerInstanceId: options.instanceId,
651 childThreadId: null,
652 prompt: title,
653 result,
654 },
655 });
656 }
657 });
658 const settleObservedChildren = Effect.fnUntraced(function* (
659 status: "failed" | "cancelled" | "interrupted",
660 ) {
661 for (const [itemId, owner] of observedChildren) {
662 const item = owner.items.get(itemId);
663 if (item) yield* publishItem(owner, item, status);
664 }
665 observedChildren.clear();
666 if (thread?.pendingBackgroundTasks?.length) yield* syncBackground();
667 // Only workflow children's requests outlive a turn; they end with the workflow.
668 for (const entry of pending.values()) yield* resolvePending(entry, "cancelled");
669 });
670 const resolvePending = Effect.fnUntraced(function* (
671 entry: PendingRequest,
672 status: "resolved" | "cancelled",
673 ) {
674 const time = yield* DateTime.now;
675 entry.request = {
676 ...entry.request,
677 ...(status === "resolved" ? entry.response : undefined),
678 status,
679 resolvedAt: time,
680 responseCapability: { type: "not_resumable", reason: "This Muse request has ended." },
681 };
682 entry.node = {
683 ...entry.node,
684 status: status === "resolved" ? "completed" : "cancelled",
685 completedAt: time,
686 };
687 entry.item = {
688 ...entry.item,
689 status: status === "resolved" ? "completed" : "cancelled",
690 completedAt: time,
691 updatedAt: time,
692 };
693 pending.delete(entry.request.id);
694 yield* emit({
695 type: "runtime_request.updated",
696 driver: MUSE_PROVIDER,
697 threadId: input.threadId,
698 runtimeRequest: entry.request,
699 });
700 yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node: entry.node });
701 yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: entry.item });
702 });
703 const finish = Effect.fnUntraced(function* (
704 turn: ActiveTurn,
705 status: "completed" | "cancelled" | "interrupted" | "failed",
706 detail?: string,
707 disposition: "reusable" | "broken" = "reusable",
708 ) {
709 if (active !== turn || seenTerminals.has(turn.nativeId)) return;
710 seenTerminals.add(turn.nativeId);
711 const completedAt = yield* DateTime.now;
712 for (const item of turn.items.values()) {
713 if (
714 (item.kind === "subagent" || item.kind === "workflow") &&
715 item.status === "inProgress" &&
716 disposition === "reusable"
717 )
718 continue;
719 if (turn.dirty.has(item.itemId) || item.status === "inProgress")
720 yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined);
721 }
722 turn.dirty.clear();
723 // A workflow child's request is runless and keeps waiting after the turn ends.
724 for (const entry of pending.values())
725 if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled");
726 turn.providerTurn = { ...turn.providerTurn, status, completedAt };
727 lastProviderTurn = turn.providerTurn;
728 yield* emit({
729 type: "provider_turn.updated",
730 driver: MUSE_PROVIDER,
731 threadId: turn.input.threadId,
732 providerTurn: turn.providerTurn,
733 });
734 yield* updateThread({
735 status: disposition === "broken" ? "error" : "idle",
736 ...(!turn.compact && status === "completed"
737 ? { nativeConversationHeadRef: nativeRef(turn.nativeId) }
738 : {}),
739 });
740 yield* updateSession(disposition === "broken" ? "error" : "ready", detail ?? null);
741 active = undefined;
742 if (status === "failed") {
743 const failure = makeProviderFailure({
744 class: disposition === "broken" ? "transport_error" : "provider_error",
745 message: detail ?? "Muse turn failed.",
746 });
747 const base = baseItem(turn, `failure:${turn.nativeId}`, completedAt);
748 yield* emit({
749 type: "turn_item.updated",
750 driver: MUSE_PROVIDER,
751 turnItem: {
752 ...base,
753 type: "error",
754 status: "failed",
755 title: null,
756 completedAt,
757 failure,
758 },
759 });
760 yield* emit({
761 type: "turn.terminal",
762 driver: MUSE_PROVIDER,
763 providerThreadId: turn.providerTurn.providerThreadId,
764 providerTurnId: turn.providerTurn.id,
765 runOrdinal: turn.input.runOrdinal,
766 status,
767 failure,
768 failureItemOrdinal: base.ordinal,
769 threadDisposition: disposition,
770 });
771 } else {
772 yield* emit({
773 type: "turn.terminal",
774 driver: MUSE_PROVIDER,
775 providerThreadId: turn.providerTurn.providerThreadId,
776 providerTurnId: turn.providerTurn.id,
777 runOrdinal: turn.input.runOrdinal,
778 status,
779 failure: null,
780 threadDisposition: disposition,
781 });
782 }
783 yield* Deferred.succeed(turn.done, undefined);
784 });
785 const failHost = Effect.fnUntraced(function* (cause: unknown) {
786 if (closed || broken) return;
787 broken = true;
788 const rootCause = Cause.isCause(cause) ? Cause.squash(cause) : cause;
789 const failure = isOpenSessionError(rootCause) ? rootCause.cause : rootCause;
790 const detail = isProtocolError(failure)
791 ? failure.detail
792 : failure instanceof Error
793 ? failure.message
794 : "Muse transport failed.";
795 if (active) yield* finish(active, "failed", detail, "broken");
796 else {
797 yield* updateThread({ status: "error" });
798 yield* updateSession("error", detail);
799 }
800 yield* settleObservedChildren("failed");
801 yield* Queue.fail(events, protocolError(detail, cause));
802 // Report the failure first; a hung host can take a while to close.
803 const failed = host;
804 yield* Effect.tryPromise(() => failed.close()).pipe(Effect.ignore, Effect.forkIn(scope));
805 });
806 const owns = (turn: ActiveTurn, turnId: string) =>
807 turnId === turn.nativeId || turn.joined.has(turnId);
808 /** Sends T3's decision for a pending approval to Muse. */
809 const decideApproval = Effect.fnUntraced(function* (
810 entry: PendingRequest,
811 decision: ProviderApprovalDecision | undefined,
812 ) {
813 if (entry.native.type !== "approval") return;
814 const approval = entry.native.value;
815 const choice = decision && museApprovalChoices(approval).get(decision);
816 if (!choice) return yield* protocolError("Muse did not offer this approval decision");
817 // Recorded first: Muse can settle the approval before it acknowledges the command.
818 entry.response = { decision };
819 yield* request("approval/decide", {
820 approvalId: approval.approvalId,
821 requirementId: approval.currentRequirementId,
822 choiceId: choice.choiceId,
823 }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response)));
824 });
825 /**
826 * Shows a Muse approval or question as a T3 request. A workflow child's approval
827 * is runless: the turn that started the workflow may end long before the child
828 * asks, and T3 keeps a runless request on the thread until Muse settles it.
829 */
830 const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) {
831 const child = native.type === "approval" && native.value.subagentOrigin !== undefined;
832 const turn = child ? undefined : active;
833 if (
834 !thread ||
835 (!child && !turn) ||
836 native.value.sessionId !== nativeSessionId ||
837 (turn && native.value.turnId && !owns(turn, native.value.turnId))
838 )
839 return;
840 const nativeId =
841 native.type === "approval" ? native.value.approvalId : native.value.userInputId;
842 if (turn?.settledRequests.has(`${native.type}:${nativeId}`)) return;
843 const previous = [...pending.values()].find(
844 (entry) =>
845 entry.native.type === native.type &&
846 entry.request.nativeRequestRef?.nativeId === nativeId,
847 );
848 const providerTurnId = turn?.providerTurn.id ?? null;
849 const threadId = turn?.input.threadId ?? thread.appThreadId ?? input.threadId;
850 const requestId =
851 previous?.request.id ??
852 (yield* idAllocator.allocate
853 .runtimeRequest({
854 driver: MUSE_PROVIDER,
855 ...(providerTurnId ? { providerTurnId } : {}),
856 nativeRequestId: nativeId,
857 })
858 .pipe(
859 Effect.mapError((cause) => protocolError("Cannot allocate Muse request", cause)),
860 ));
861 const time = yield* DateTime.now;
862 const nodeId = idAllocator.derive.approvalNode({ requestId });
863 const subject = native.type === "approval" ? native.value.subject : undefined;
864 const kind = !subject
865 ? "user_input"
866 : subject.kind === "shell"
867 ? "command"
868 : subject.kind !== "fileAccess"
869 ? "permission"
870 : subject.access === "read"
871 ? "file-read"
872 : "file-change";
873 const runtimeRequest: OrchestrationV2RuntimeRequest = {
874 id: requestId,
875 nodeId,
876 providerTurnId,
877 nativeRequestRef: nativeRef(nativeId),
878 kind,
879 status: "pending",
880 responseCapability: { type: "live", providerSessionId: input.providerSessionId },
881 createdAt: previous?.request.createdAt ?? time,
882 resolvedAt: null,
883 };
884 const node: OrchestrationV2ExecutionNode = {
885 id: nodeId,
886 threadId,
887 runId: turn?.input.runId ?? null,
888 parentNodeId: turn?.input.rootNodeId ?? null,
889 rootNodeId: turn?.input.rootNodeId ?? nodeId,
890 kind: native.type === "approval" ? "approval_request" : "user_input_request",
891 status: "waiting",
892 countsForRun: false,
893 providerThreadId: thread.id,
894 providerTurnId,
895 nativeItemRef: nativeRef(nativeId),
896 runtimeRequestId: requestId,
897 checkpointScopeId: null,
898 startedAt: runtimeRequest.createdAt,
899 completedAt: null,
900 };
901 const latestTurn = active?.providerTurn ?? lastProviderTurn;
902 const base = {
903 ...(turn
904 ? baseItem(turn, `request:${nativeId}`, time)
905 : {
906 threadId,
907 runId: null,
908 providerThreadId: thread.id,
909 providerTurnId: null,
910 nativeItemRef: nativeRef(nativeId),
911 parentItemId: null,
912 // Sorts after the latest turn's items.
913 ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100,
914 startedAt: runtimeRequest.createdAt,
915 updatedAt: time,
916 }),
917 id: idAllocator.derive.approvalTurnItem({ requestId }),
918 nodeId,
919 status: "waiting" as const,
920 title: null,
921 completedAt: null,
922 };
923 const item: OrchestrationV2TurnItem =
924 native.type === "approval"
925 ? {
926 ...base,
927 type: "approval_request",
928 requestId,
929 requestKind: kind === "user_input" ? "command" : kind,
930 prompt:
931 native.value.subject.command ??
932 native.value.subject.path ??
933 native.value.subject.host ??
934 native.value.subject.target ??
935 native.value.toolName ??
936 "Muse requests permission",
937 options: museApprovalOptions(native.value),
938 }
939 : {
940 ...base,
941 type: "user_input_request",
942 requestId,
943 questions: native.value.questions.map((question) => ({
944 id: question.id,
945 header: question.header || "Question",
946 question: question.question,
947 options: question.options.map((option) => ({
948 label: option.label,
949 description: option.description || option.label,
950 })),
951 multiSelect: question.selection.mode === "multiple",
952 allowCustomAnswer: true,
953 required: true,
954 })),
955 };
956 const entry: PendingRequest = { native, request: runtimeRequest, node, item };
957 pending.set(requestId, entry);
958 yield* emit({
959 type: "runtime_request.updated",
960 driver: MUSE_PROVIDER,
961 threadId,
962 runtimeRequest,
963 });
964 yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node });
965 yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item });
966 yield* updateSession("waiting");
967 // Muse does not give workflow children the session's allowAll mode, so in full
968 // access T3 approves for them. If Muse rejects that, the request waits for the user.
969 if (child && input.runtimePolicy.runtimeMode === "full-access")
970 yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope));
971 });
972 /** Approval and question events; `turn` is undefined for a workflow child's approval. */
973 const handleRequestEvent = Effect.fnUntraced(function* (
974 method: string,
975 params: Record<string, unknown>,
976 turn: ActiveTurn | undefined,
977 ) {
978 switch (method) {
979 case "approval/requested":
980 case "approval/updated":
981 if (
982 method === "approval/updated" &&
983 ![...pending.values()].some(
984 (entry) =>
985 entry.native.type === "approval" &&
986 entry.native.value.approvalId === params.approvalId,
987 )
988 )
989 return;
990 return yield* publishRequest({
991 type: "approval",
992 value: yield* decode(MuseApproval, params),
993 });
994 case "userInput/requested":
995 return yield* publishRequest({
996 type: "question",
997 value: yield* decode(MuseUserInput, params),
998 });
999 case "approval/resolved":
1000 case "userInput/settled": {
1001 const id = method === "approval/resolved" ? params.approvalId : params.userInputId;
1002 if (typeof id === "string")
1003 turn?.settledRequests.add(
1004 `${method === "approval/resolved" ? "approval" : "question"}:${id}`,
1005 );
1006 const entry = [...pending.values()].find(
1007 (candidate) => candidate.request.nativeRequestRef?.nativeId === id,
1008 );
1009 if (entry) {
1010 // An approval settled outside T3 still shows Muse's own decision.
1011 const native =
1012 method === "approval/resolved" && typeof params.decision === "string"
1013 ? museApprovalDecision({ decision: params.decision, scope: "once" })
1014 : undefined;
1015 if (!entry.response && native) entry.response = { decision: native };
1016 yield* resolvePending(entry, "resolved");
1017 }
1018 if (!pending.size) yield* updateSession(active ? "running" : "ready");
1019 }
1020 }
1021 });
1022 const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) {
1023 const params = yield* decode(recordSchema, data);
1024 if (params.sessionId !== nativeSessionId) return;
1025 if (method === "view/gap")
1026 return yield* protocolError("Muse delivery gap requires session recovery");
1027 if (method === "session/contextUsage") {
1028 const usage = yield* decode(MuseContextUsage, params);
1029 const currentTurn = active?.providerTurn ?? lastProviderTurn;
1030 const updatedAt = DateTime.formatIso(yield* DateTime.now);
1031 const tokenUsage = {
1032 ...currentTurn?.tokenUsage,
1033 usedTokens: usage.usedTokens,
1034 maxTokens: usage.windowTokens ?? currentTurn?.tokenUsage?.maxTokens ?? undefined,
1035 updatedAt,
1036 };
1037 yield* updateThread({ contextUsage: tokenUsage });
1038 if (currentTurn) {
1039 const updated = { ...currentTurn, tokenUsage };
1040 if (active) active.providerTurn = updated;
1041 else lastProviderTurn = updated;
1042 yield* emit({
1043 type: "provider_turn.updated",
1044 driver: MUSE_PROVIDER,
1045 providerTurn: updated,
1046 });
1047 }
1048 return;
1049 }
1050 const itemEvent = ["item/started", "item/updated", "item/completed"].includes(method)
1051 ? (yield* decode(MuseItemEvent, params)).item
1052 : undefined;
1053 if (itemEvent) {
1054 const owner = observedChildren.get(itemEvent.itemId);
1055 if (owner && owner !== active && itemEvent.turnId === owner.nativeId) {
1056 const previous = owner.items.get(itemEvent.itemId);
1057 if (!previous || itemEvent.revision > previous.revision) {
1058 owner.items.set(itemEvent.itemId, itemEvent);
1059 yield* publishItem(owner, itemEvent);
1060 }
1061 return;
1062 }
1063 }
1064 // A workflow child's approval comes under the child's own run id, during a turn
1065 // or after it, so it skips turn routing.
1066 if (params.subagentOrigin !== undefined && method.startsWith("approval/"))
1067 return yield* handleRequestEvent(method, params, undefined);
1068 // Muse starts a turn on its own when a workflow finishes, to report its result.
1069 // During a run, that run shows it. Otherwise hold it and ask the orchestrator for
1070 // a run; that run takes the held events, unless a user turn takes them first.
1071 if (
1072 method === "turn/started" &&
1073 typeof params.turnId === "string" &&
1074 params.turnId !== active?.nativeId &&
1075 params.turnId !== wake?.nativeId
1076 ) {
1077 if (active && !active.compact) active.joined.add(params.turnId);
1078 else if (!active && !wake) {
1079 const held = { nativeId: params.turnId, events: [] };
1080 wake = held;
1081 const notification = backgroundWorkNotification(finishedBackground.splice(0));
1082 if (thread?.appThreadId && options.continuationRequests)
1083 yield* options.continuationRequests.offer({
1084 threadId: thread.appThreadId,
1085 providerThreadId: thread.id,
1086 driver: MUSE_PROVIDER,
1087 detail: null,
1088 ...(notification ? { notification } : {}),
1089 dispatchIfCurrent: (dispatch) =>
1090 wake === held ? Effect.map(dispatch, Option.some) : Effect.succeed(Option.none()),
1091 clearIfCurrent: () =>
1092 Effect.sync(() => {
1093 if (wake === held) wake = undefined;
1094 }),
1095 });
1096 }
1097 }
1098 if (wake && active?.nativeId !== wake.nativeId) {
1099 const turnId = typeof params.turnId === "string" ? params.turnId : itemEvent?.turnId;
1100 if (turnId === wake.nativeId) {
1101 wake.events.push([method, data]);
1102 return;
1103 }
1104 }
1105 const turn = active;
1106 if (!turn) return;
1107 if (typeof params.turnId === "string" && !owns(turn, params.turnId) && !turn.compact)
1108 return;
1109 switch (method) {
1110 case "item/started":
1111 case "item/updated":
1112 case "item/completed": {
1113 const item = itemEvent!;
1114 if (
1115 item.turnId &&
1116 !owns(turn, item.turnId) &&
1117 !(turn.compact && item.kind === "compaction")
1118 )
1119 return;
1120 const previous = turn.items.get(item.itemId);
1121 if (previous && previous.revision >= item.revision) return;
1122 turn.items.set(item.itemId, item);
1123 turn.dirty.delete(item.itemId);
1124 yield* publishItem(turn, item);
1125 if (turn.compact && item.kind === "compaction" && item.status !== "inProgress") {
1126 yield* finish(
1127 turn,
1128 item.status === "completed" && (!item.outcome || item.outcome === "compacted")
1129 ? "completed"
1130 : item.status === "cancelled"
1131 ? "cancelled"
1132 : "failed",
1133 item.failureReason ?? item.reason,
1134 );
1135 }
1136 break;
1137 }
1138 case "item/delta": {
1139 const delta = yield* decode(MuseDelta, params);
1140 const previous = turn.items.get(delta.itemId);
1141 if (!previous || previous.status !== "inProgress") return;
1142 const field = delta.field ?? "text";
1143 let item = previous;
1144 if (field === "text") item = { ...previous, text: (previous.text ?? "") + delta.delta };
1145 else if (field === "output" || field === "visibleOutput")
1146 item = { ...previous, visibleOutput: (previous.visibleOutput ?? "") + delta.delta };
1147 else if (field.startsWith("summary.")) {
1148 const index = Number(field.slice(8));
1149 if (Number.isSafeInteger(index) && index >= 0 && index < 100) {
1150 const summary = [...(previous.summary ?? [])];
1151 summary[index] = (summary[index] ?? "") + delta.delta;
1152 item = { ...previous, summary };
1153 }
1154 }
1155 if (item === previous) return;
1156 turn.items.set(item.itemId, item);
1157 turn.dirty.add(item.itemId);
1158 if (!turn.flushScheduled) {
1159 turn.flushScheduled = true;
1160 yield* Effect.sleep(50).pipe(
1161 Effect.andThen(Queue.offer(inbox, { type: "flush", turn })),
1162 Effect.forkIn(scope),
1163 );
1164 }
1165 break;
1166 }
1167 case "turn/completed": {
1168 if (turn.compact) return;
1169 const result = yield* decode(MuseTurnCompleted, params);
1170 // A joined Muse turn ending does not end this run; its own turn does.
1171 if (result.turnId !== turn.nativeId) break;
1172 if (result.usage) {
1173 const usage = result.usage;
1174 turn.providerTurn = {
1175 ...turn.providerTurn,
1176 turnTokenUsage: {
1177 usageScope: "main_agent",
1178 usageStatus: "complete",
1179 hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"),
1180 inputTokens: turn.providerTurn.turnTokenUsage?.inputTokens ?? usage.inputTokens,
1181 outputTokens: usage.outputTokens,
1182 cachedInputTokens: usage.cacheReadTokens ?? usage.cachedTokens,
1183 reasoningTokens: usage.reasoningTokens,
1184 ...(usage.cacheWriteTokens === undefined
1185 ? {}
1186 : { cacheCreationTokens: usage.cacheWriteTokens }),
1187 },
1188 };
1189 }
1190 const status =
1191 result.terminal === "completed"
1192 ? "completed"
1193 : result.terminal === "cancelled"
1194 ? turn.interruptRequested
1195 ? "interrupted"
1196 : "cancelled"
1197 : "failed";
1198 yield* finish(turn, status, result.error?.message ?? result.reason);
1199 break;
1200 }
1201 case "approval/requested":
1202 case "approval/updated":
1203 case "approval/resolved":
1204 case "userInput/requested":
1205 case "userInput/settled":
1206 yield* handleRequestEvent(method, params, turn);
1207 break;
1208 case "session/tokenUsage": {
1209 const usage = yield* decode(MuseTokenUsageEvent, params);
1210 const previous = turn.providerTurn.turnTokenUsage;
1211 const contextUsage = thread?.contextUsage;
1212 turn.providerTurn = {
1213 ...turn.providerTurn,
1214 ...(contextUsage
1215 ? {
1216 tokenUsage: {
1217 ...turn.providerTurn.tokenUsage,
1218 usedTokens: contextUsage.usedTokens,
1219 inputTokens: usage.usage.inputTokens,
1220 outputTokens: usage.usage.outputTokens,
1221 cachedInputTokens: usage.usage.cacheReadTokens ?? usage.usage.cachedTokens,
1222 reasoningOutputTokens: usage.usage.reasoningTokens,
1223 updatedAt: DateTime.formatIso(yield* DateTime.now),
1224 },
1225 }
1226 : {}),
1227 turnTokenUsage: {
1228 usageScope: "main_agent",
1229 usageStatus: "partial",
1230 hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"),
1231 inputTokens: (previous?.inputTokens ?? 0) + usage.promptTokens,
1232 outputTokens: (previous?.outputTokens ?? 0) + usage.usage.outputTokens,
1233 cachedInputTokens:
1234 (previous?.cachedInputTokens ?? 0) +
1235 (usage.usage.cacheReadTokens ?? usage.usage.cachedTokens),
1236 ...(usage.usage.cacheWriteTokens === undefined &&
1237 previous?.cacheCreationTokens === undefined
1238 ? {}
1239 : {
1240 cacheCreationTokens:
1241 (previous?.cacheCreationTokens ?? 0) + (usage.usage.cacheWriteTokens ?? 0),
1242 }),
1243 reasoningTokens: (previous?.reasoningTokens ?? 0) + usage.usage.reasoningTokens,
1244 },
1245 };
1246 yield* emit({
1247 type: "provider_turn.updated",
1248 driver: MUSE_PROVIDER,
1249 providerTurn: turn.providerTurn,
1250 });
1251 break;
1252 }
1253 case "session/todoListChanged": {
1254 const todo = yield* decode(MuseTodoList, params);
1255 const steps: ReadonlyArray<OrchestrationV2PlanStep> = todo.items
1256 .filter((item) => item.text.trim() && item.status !== "cancelled")
1257 .map((item, index) => ({
1258 id: `${turn.nativeId}:todo:${index}`,
1259 text: item.text.trim(),
1260 status:
1261 item.status === "completed"
1262 ? "completed"
1263 : item.status === "inProgress"
1264 ? "running"
1265 : "pending",
1266 }));
1267 const time = yield* DateTime.now;
1268 const base = baseItem(turn, "todo", time);
1269 const planId =
1270 turn.todoPlanId ??
1271 (yield* idAllocator.allocate
1272 .plan({
1273 threadId: turn.input.threadId,
1274 runId: turn.input.runId,
1275 driver: MUSE_PROVIDER,
1276 })
1277 .pipe(
1278 Effect.mapError((cause) =>
1279 protocolError("Cannot allocate Muse todo list", cause),
1280 ),
1281 ));
1282 turn.todoPlanId = planId;
1283 yield* emit({
1284 type: "plan.updated",
1285 driver: MUSE_PROVIDER,
1286 plan: {
1287 id: planId,
1288 threadId: turn.input.threadId,
1289 runId: turn.input.runId,
1290 nodeId: base.nodeId,
1291 kind: "todo_list",
1292 status: steps.every((step) => step.status === "completed") ? "completed" : "active",
1293 steps,
1294 },
1295 });
1296 yield* emit({
1297 type: "node.updated",
1298 driver: MUSE_PROVIDER,
1299 node: {
1300 id: base.nodeId,
1301 threadId: base.threadId,
1302 runId: base.runId,
1303 parentNodeId: turn.input.rootNodeId,
1304 rootNodeId: turn.input.rootNodeId,
1305 kind: "todo_list",
1306 status: "completed",
1307 countsForRun: false,
1308 providerThreadId: base.providerThreadId,
1309 providerTurnId: base.providerTurnId,
1310 nativeItemRef: null,
1311 runtimeRequestId: null,
1312 checkpointScopeId: null,
1313 startedAt: base.startedAt,
1314 completedAt: time,
1315 },
1316 });
1317 yield* emit({
1318 type: "turn_item.updated",
1319 driver: MUSE_PROVIDER,
1320 turnItem: {
1321 ...base,
1322 nativeItemRef: null,
1323 type: "todo_list",
1324 status: "completed",
1325 title: null,
1326 completedAt: time,
1327 planId,
1328 steps,
1329 },
1330 });
1331 break;
1332 }
1333 case "turn/retryScheduled": {
1334 const retry = yield* decode(MuseTurnRetryScheduled, params);
1335 const time = yield* DateTime.now;
1336 yield* emit({
1337 type: "turn_item.updated",
1338 driver: MUSE_PROVIDER,
1339 turnItem: {
1340 ...baseItem(turn, `retry:${retry.nextAttempt}`, time),
1341 type: "system_notice",
1342 status: "completed",
1343 title: "Muse retry",
1344 completedAt: time,
1345 message: `Muse is retrying this turn (attempt ${retry.nextAttempt} of ${retry.maxAttempts}): ${retry.reason}`,
1346 },
1347 });
1348 break;
1349 }
1350 }
1351 });
1352 yield* Stream.fromQueue(inbox).pipe(
1353 Stream.runForEach((entry) =>
1354 Effect.gen(function* () {
1355 if (entry.type === "flush") {
1356 const turn = entry.turn;
1357 turn.flushScheduled = false;
1358 if (active !== turn) return;
1359 for (const id of turn.dirty) {
1360 const item = turn.items.get(id);
1361 if (item) yield* publishItem(turn, item);
1362 }
1363 turn.dirty.clear();
1364 } else if (entry.epoch === hostEpoch) {
1365 if (entry.type === "failure") yield* failHost(entry.cause);
1366 else {
1367 if (options.nativeEventLogger && entry.method !== "item/delta")
1368 yield* options.nativeEventLogger.write(
1369 { provider: "muse", method: entry.method, params: entry.params },
1370 input.threadId,
1371 );
1372 yield* handleNotification(entry.method, entry.params).pipe(
1373 Effect.catch((cause) =>
1374 INFORMATIONAL_NOTIFICATIONS.has(entry.method)
1375 ? Effect.logWarning(`Skipped an unreadable Muse ${entry.method}`, cause)
1376 : failHost(cause),
1377 ),
1378 );
1379 }
1380 }
1381 }).pipe(eventPermit.withPermits(1)),
1382 ),
1383 Effect.forkIn(scope),
1384 );
1385 const launchHost = Effect.fnUntraced(function* () {
1386 const epoch = ++hostEpoch;
1387 const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId);
1388 const created = yield* Effect.acquireRelease(
1389 createMuseSdkHostEffect(
1390 {
1391 binaryPath: options.settings.binaryPath || "muse",
1392 cwd,
1393 environment: McpProviderSession.withAgentDeviceEnvironment(
1394 options.environment,
1395 mcpSession,
1396 ),
1397 runtimeMode: input.runtimePolicy.runtimeMode,
1398 },
1399 options.createHost,
1400 ).pipe(
1401 Effect.mapError(
1402 (error) =>
1403 new ProviderAdapterOpenSessionError({
1404 driver: MUSE_PROVIDER,
1405 providerSessionId: input.providerSessionId,
1406 cause: error.cause,
1407 }),
1408 ),
1409 ),
1410 (created) =>
1411 Effect.gen(function* () {
1412 if (host === created) {
1413 closed = true;
1414 hostEpoch++;
1415 }
1416 yield* Effect.tryPromise(() => created.close()).pipe(Effect.ignore);
1417 }),
1418 { interruptible: true },
1419 ).pipe(Effect.provideService(Scope.Scope, scope));
1420 host = created;
1421 created.connection.onNotification((notification) => {
1422 Queue.offerUnsafe(inbox, {
1423 type: "notification",
1424 method: notification.method,
1425 params: notification.params,
1426 epoch,
1427 });
1428 });
1429 created.connection.onProtocolError((cause) => {
1430 Queue.offerUnsafe(inbox, { type: "failure", cause, epoch });
1431 });
1432 // These are presentation receipts only. The matching notifications drive the UI,
1433 // and decisions travel as `approval/decide` and `userInput/answer` commands.
1434 created.connection.onServerRequest(async (request) => {
1435 if (request.method === "approval/request" || request.method === "userInput/request")
1436 return {};
1437 throw new Error(`Unsupported Muse server request: ${request.method}`);
1438 });
1439 void created.connection.closed.then(() => {
1440 if (!closed && epoch === hostEpoch)
1441 Queue.offerUnsafe(inbox, {
1442 type: "failure",
1443 cause: new Error("Muse connection closed unexpectedly"),
1444 epoch,
1445 });
1446 });
1447 void created.exited.then((exit) => {
1448 if (!closed && epoch === hostEpoch) {
1449 const reason = created
1450 .stderrTail?.()
1451 .findLast((line) => line.trim())
1452 ?.trim();
1453 Queue.offerUnsafe(inbox, {
1454 type: "failure",
1455 cause: new Error(
1456 `Muse exited unexpectedly (${exit.code ?? exit.signal})${reason ? `: ${reason}` : ""}`,
1457 ),
1458 epoch,
1459 });
1460 }
1461 });
1462 });
1463 yield* Effect.addFinalizer(() =>
1464 Effect.gen(function* () {
1465 closed = true;
1466 hostEpoch++;
1467 if (active) yield* finish(active, "cancelled", "Muse session closed", "broken");
1468 yield* settleObservedChildren("cancelled");
1469 yield* Queue.shutdown(inbox);
1470 yield* Queue.shutdown(events);
1471 }).pipe(eventPermit.withPermits(1)),
1472 );
1473 yield* launchHost();
1474
1475 const register = Effect.fnUntraced(function* (args: ProviderAdapterV2EnsureThreadInput) {
1476 if (broken || closed)
1477 return yield* protocolError("Muse host is unavailable; reopen the session");
1478 if (active)
1479 return yield* protocolError("Cannot change Muse sessions during an active turn");
1480 const existing = args.existingProviderThread;
1481 if (
1482 existing &&
1483 (existing.driver !== MUSE_PROVIDER || existing.providerInstanceId !== options.instanceId)
1484 )
1485 return yield* protocolError("Muse thread belongs to another provider instance");
1486 const requestedId = existing
1487 ? (existing.nativeThreadRef?.nativeId ?? undefined)
1488 : input.initialNativeThreadId;
1489 if (
1490 thread &&
1491 (!requestedId || requestedId === nativeSessionId) &&
1492 thread.appThreadId === args.threadId
1493 )
1494 return thread;
1495 if (thread) return yield* protocolError("Open a new host to attach another Muse session");
1496 let missingNativeSession = false;
1497 return yield* Effect.gen(function* () {
1498 nativeSessionId = requestedId ?? host.connection.mintCommandId();
1499 const mcpSession = McpProviderSession.readMcpProviderSession(args.threadId);
1500 if (mcpSession && !host.initializeResult.grantedCapabilities.includes("sessionMcp"))
1501 return yield* protocolError(
1502 "Update Muse Code to a version that supports session MCP servers",
1503 );
1504 const config = mcpSession
1505 ? {
1506 mcpServers: {
1507 // Muse defaults to "required", which fails the whole run when T3's
1508 // tools cannot be reached. The agent should still work without them.
1509 "t3-code": {
1510 transport: "streamableHttp",
1511 mode: "optional",
1512 url: mcpSession.endpoint,
1513 headers: { Authorization: mcpSession.authorizationHeader },
1514 },
1515 },
1516 }
1517 : undefined;
1518 const startModel = yield* resolveModel(args.modelSelection);
1519 const result = yield* request(
1520 requestedId ? "session/resume" : "session/start",
1521 requestedId
1522 ? // T3 already has the transcript; skip Muse's history payload.
1523 { excludeItems: true, ...(config ? { config } : {}) }
1524 : {
1525 workspaceRoot: cwd,
1526 ...(config ? { config } : {}),
1527 ...(startModel ? { modelId: startModel } : {}),
1528 providerId: "meta",
1529 approvalMode: museApprovalMode(args.runtimePolicy.runtimeMode),
1530 },
1531 ).pipe(
1532 Effect.catch((error) => {
1533 missingNativeSession =
1534 requestedId !== undefined &&
1535 error.payload instanceof MspError &&
1536 (error.payload.kind === "sessionNotFound" || error.payload.kind === "notFound");
1537 return Effect.fail(error);
1538 }),
1539 Effect.flatMap((value) => decode(MuseSessionResult, value)),
1540 );
1541 if (result.session.sessionId !== nativeSessionId)
1542 return yield* protocolError("Muse returned a different session identity");
1543 if (requestedId)
1544 yield* request("session/setApprovalMode", {
1545 mode: museApprovalMode(args.runtimePolicy.runtimeMode),
1546 });
1547 // A resumed session keeps the model it last ran with; the next turn
1548 // switches it when the selection differs.
1549 const createdAt = yield* DateTime.now;
1550 thread = existing
1551 ? {
1552 ...existing,
1553 providerSessionId: input.providerSessionId,
1554 nativeThreadRef: nativeRef(result.session.sessionId),
1555 status: "idle",
1556 updatedAt: createdAt,
1557 }
1558 : {
1559 id: idAllocator.derive.providerThread({
1560 driver: MUSE_PROVIDER,
1561 providerInstanceId: options.instanceId,
1562 nativeThreadId: nativeSessionId,
1563 }),
1564 driver: MUSE_PROVIDER,
1565 providerInstanceId: options.instanceId,
1566 providerSessionId: input.providerSessionId,
1567 appThreadId: args.threadId,
1568 ownerNodeId: null,
1569 nativeThreadRef: nativeRef(nativeSessionId),
1570 nativeConversationHeadRef: null,
1571 status: "idle",
1572 firstRunOrdinal: null,
1573 lastRunOrdinal: null,
1574 handoffIds: [],
1575 forkedFrom: null,
1576 createdAt,
1577 updatedAt: createdAt,
1578 };
1579 session = {
1580 ...session,
1581 model: result.session.modelId ?? args.modelSelection.model,
1582 };
1583 yield* emit({
1584 type: "provider_thread.updated",
1585 driver: MUSE_PROVIDER,
1586 providerThread: thread,
1587 });
1588 return thread;
1589 }).pipe(
1590 Effect.onError((cause) =>
1591 missingNativeSession ? Effect.void : eventPermit.withPermits(1)(failHost(cause)),
1592 ),
1593 );
1594 });
1595 const prompt = Effect.fnUntraced(function* (message: ProviderAdapterV2TurnMessage) {
1596 const parts: SendUserTurnOptions<unknown>["input"] = [];
1597 const text = providerMessageTextWithAttachmentPaths({
1598 text: message.text,
1599 attachments: message.attachments,
1600 resolveAttachmentPath: options.host.resolveAttachmentPath,
1601 });
1602 if (text) parts.push({ type: "text", text });
1603 for (const attachment of message.attachments)
1604 if (isProviderNativeImageAttachment(attachment)) {
1605 const path = options.host.resolveAttachmentPath(attachment);
1606 if (!path) return yield* protocolError("Muse image attachment is missing");
1607 const bytes = yield* options.fileSystem
1608 .readFile(path)
1609 .pipe(Effect.mapError((cause) => protocolError("Cannot read Muse image", cause)));
1610 parts.push({
1611 type: "image",
1612 base64Data: Buffer.from(bytes).toString("base64"),
1613 mediaType: attachment.mimeType,
1614 });
1615 }
1616 if (!parts.length) return yield* protocolError("Muse needs text or an image");
1617 return parts;
1618 });
1619 const validateThread = (candidate: OrchestrationV2ProviderThread) =>
1620 thread !== undefined &&
1621 candidate.driver === MUSE_PROVIDER &&
1622 candidate.providerInstanceId === options.instanceId &&
1623 candidate.appThreadId === thread.appThreadId &&
1624 candidate.nativeThreadRef?.nativeId === nativeSessionId;
1625 const selectionEffort = Effect.fnUntraced(function* (selection: ModelSelection) {
1626 if (selection.instanceId !== options.instanceId)
1627 return yield* protocolError("Model selection belongs to another Muse instance");
1628 const catalog = options.modelCatalog ? yield* options.modelCatalog : [];
1629 const model = yield* resolveModel(selection);
1630 const selected = getModelSelectionStringOptionValue(selection, "reasoningEffort");
1631 // With no saved choice, the model's own default applies.
1632 return resolveMuseReasoningEffort(
1633 catalog.find((entry) => entry.slug === model)?.capabilities ?? museModelCapabilities(),
1634 selected,
1635 );
1636 });
1637 const start = Effect.fnUntraced(function* (
1638 turnInput: ProviderAdapterV2TurnInput,
1639 compact = false,
1640 ) {
1641 if (!validateThread(turnInput.providerThread) || broken || closed)
1642 return yield* protocolError("Muse thread is not attached to this host");
1643 if (active)
1644 return yield* protocolError(
1645 "Muse already has an active turn; queue through the orchestrator",
1646 );
1647 if (turnInput.runtimePolicy.interactionMode === "plan")
1648 return yield* protocolError("Muse does not support dedicated Plan mode");
1649 if (turnInput.runtimePolicy.runtimeMode !== input.runtimePolicy.runtimeMode)
1650 return yield* protocolError("Runtime policy changes require a fresh Muse host");
1651 // A continuation run takes the turn Muse already started; it sends no prompt.
1652 const continuation =
1653 turnInput.message.createdBy === "agent" &&
1654 turnInput.message.creationSource === "provider";
1655 const adopted = continuation ? wake : undefined;
1656 const effort = continuation ? undefined : yield* selectionEffort(turnInput.modelSelection);
1657 const parts = compact || continuation ? [] : yield* prompt(turnInput.message);
1658 const targetModel = continuation
1659 ? undefined
1660 : yield* resolveModel(turnInput.modelSelection);
1661 if (targetModel && session.model !== targetModel) {
1662 yield* request("session/setModel", {
1663 model: { modelId: targetModel, providerId: "meta" },
1664 });
1665 session = { ...session, model: targetModel };
1666 }
1667 const nativeId = adopted?.nativeId ?? host.connection.mintCommandId();
1668 const startedAt = yield* DateTime.now;
1669 const providerTurn: OrchestrationV2ProviderTurn = {
1670 id: idAllocator.derive.providerTurn({
1671 driver: MUSE_PROVIDER,
1672 nativeTurnId: `${options.instanceId}:${nativeSessionId}:${nativeId}`,
1673 }),
1674 providerThreadId: turnInput.providerThread.id,
1675 nodeId: turnInput.rootNodeId,
1676 runAttemptId: turnInput.attemptId,
1677 nativeTurnRef: compact
1678 ? { ...nativeRef(nativeId), strength: "weak" }
1679 : nativeRef(nativeId),
1680 ordinal: turnInput.providerTurnOrdinal,
1681 status: "running",
1682 startedAt,
1683 completedAt: null,
1684 };
1685 const turn: ActiveTurn = {
1686 input: turnInput,
1687 providerTurn,
1688 nativeId,
1689 joined: new Set(),
1690 items: new Map(),
1691 ordinals: new Map(),
1692 started: new Map(),
1693 dirty: new Set(),
1694 settledRequests: new Set(),
1695 done: yield* Deferred.make<void>(),
1696 nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1,
1697 flushScheduled: false,
1698 interruptRequested: false,
1699 compact,
1700 };
1701 yield* Effect.gen(function* () {
1702 thread = turnInput.providerThread;
1703 active = turn;
1704 yield* emit({
1705 type: "provider_turn.updated",
1706 driver: MUSE_PROVIDER,
1707 threadId: turnInput.threadId,
1708 providerTurn,
1709 });
1710 yield* updateThread({
1711 status: "active",
1712 firstRunOrdinal: thread.firstRunOrdinal ?? turnInput.runOrdinal,
1713 lastRunOrdinal: turnInput.runOrdinal,
1714 });
1715 yield* updateSession("running");
1716 // A user turn takes a held Muse turn: Muse runs it first, so this run shows it
1717 // (and its approvals), and the continuation asked for it is no longer needed.
1718 const held = wake;
1719 wake = undefined;
1720 if (continuation && !held) return yield* finish(turn, "completed");
1721 if (held && !continuation) turn.joined.add(held.nativeId);
1722 for (const [method, data] of held?.events ?? [])
1723 yield* handleNotification(method, data).pipe(Effect.catch(failHost));
1724 }).pipe(eventPermit.withPermits(1));
1725 if (continuation) return;
1726 yield* Effect.gen(function* () {
1727 if (compact) {
1728 const result = yield* request("session/compact", {}, true, nativeId).pipe(
1729 Effect.flatMap((value) => decode(MuseCompactResult, value)),
1730 );
1731 if (result.status === "noop")
1732 yield* eventPermit.withPermits(1)(
1733 finish(turn, "failed", result.reason ?? "Muse has no context to compact."),
1734 );
1735 else if (result.status !== "accepted")
1736 return yield* protocolError(
1737 `Muse returned an unsupported compaction status: ${result.status}`,
1738 );
1739 } else {
1740 const result = yield* request(
1741 "turn/start",
1742 {
1743 input: [
1744 {
1745 type: "text",
1746 text: buildRuntimeInstructions({
1747 harness: "Muse Code",
1748 model: session.model ?? undefined,
1749 reasoningEffort: effort,
1750 }),
1751 },
1752 ...parts,
1753 ],
1754 displayText: turnInput.message.text || "Image attachment",
1755 // A resumed session keeps the root it was created with; pin it to
1756 // this thread's current checkout so edits land where T3 tracks them.
1757 workspaceRoots: [cwd],
1758 ifBusy: "queue",
1759 ...(effort ? { reasoningEffort: effort } : {}),
1760 },
1761 true,
1762 nativeId,
1763 ).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value)));
1764 if (result.turnId !== nativeId) {
1765 yield* eventPermit.withPermits(1)(
1766 failHost(new Error("Muse admitted an unowned turn")),
1767 );
1768 return yield* protocolError("Muse admitted the turn under an unexpected identity");
1769 }
1770 }
1771 }).pipe(
1772 Effect.onError((cause) => {
1773 const error = Cause.squash(cause);
1774 const detail = isProtocolError(error)
1775 ? error.detail
1776 : error instanceof Error
1777 ? error.message
1778 : "Muse could not start the turn.";
1779 return eventPermit.withPermits(1)(finish(turn, "failed", detail));
1780 }),
1781 );
1782 });
1783 // Only messages are read back; T3 owns turn and item history.
1784 const snapshot = Effect.fnUntraced(function* (
1785 current: OrchestrationV2ProviderThread,
1786 ): Effect.fn.Return<ProviderAdapterV2ThreadSnapshot, ProviderAdapterV2Error> {
1787 const result = yield* request("session/read", { excludeItems: false }, false).pipe(
1788 Effect.flatMap((value) => decode(MuseSessionResult, value)),
1789 );
1790 if (result.session.sessionId !== nativeSessionId)
1791 return yield* protocolError("Muse snapshot belongs to a different session");
1792 const items = result.history?.items ?? result.history?.snapshot?.state.items ?? [];
1793 const now = yield* DateTime.now;
1794 return {
1795 providerThread: current,
1796 providerTurns: [],
1797 messages: items.flatMap((item): OrchestrationV2ConversationMessage[] =>
1798 item.kind === "agentMessage" || item.kind === "userMessage"
1799 ? [
1800 {
1801 id: idAllocator.derive.messageFromProviderItem({
1802 driver: MUSE_PROVIDER,
1803 nativeItemId: `${options.instanceId}:${current.id}:${item.turnId ?? "history"}:${item.itemId}`,
1804 }),
1805 threadId: current.appThreadId ?? input.threadId,
1806 runId: null,
1807 nodeId: null,
1808 role: item.kind === "agentMessage" ? "assistant" : "user",
1809 text: item.text ?? "",
1810 attachments: [],
1811 streaming: item.status === "inProgress",
1812 createdBy: item.kind === "agentMessage" ? "agent" : "user",
1813 creationSource: "provider",
1814 createdAt: now,
1815 updatedAt: now,
1816 },
1817 ]
1818 : [],
1819 ),
1820 runtimeRequests: [...pending.values()].map((entry) => entry.request),
1821 providerPayload: items,
1822 };
1823 });
1824 const runtime: ProviderAdapterV2SessionRuntime = {
1825 instanceId: options.instanceId,
1826 driver: MUSE_PROVIDER,
1827 providerSessionId: input.providerSessionId,
1828 providerSession: session,
1829 events: Stream.fromQueue(events),
1830 // A running workflow or a held Muse turn keeps the host: idle release must wait.
1831 hasPendingBackgroundWork: Effect.sync(
1832 () => observedChildren.size > 0 || wake !== undefined,
1833 ),
1834 ensureThread: (args) =>
1835 register(args).pipe(
1836 commands.withPermits(1),
1837 Effect.mapError(
1838 (cause) =>
1839 new ProviderAdapterEnsureThreadError({
1840 driver: MUSE_PROVIDER,
1841 threadId: args.threadId,
1842 cause,
1843 }),
1844 ),
1845 ),
1846 resumeThread: (args) =>
1847 register({
1848 threadId: args.threadId ?? args.providerThread.appThreadId ?? input.threadId,
1849 modelSelection: args.modelSelection ?? input.modelSelection,
1850 runtimePolicy: args.runtimePolicy ?? input.runtimePolicy,
1851 existingProviderThread: args.providerThread,
1852 }).pipe(
1853 commands.withPermits(1),
1854 Effect.mapError(
1855 (cause) =>
1856 new ProviderAdapterResumeThreadError({
1857 driver: MUSE_PROVIDER,
1858 providerSessionId: input.providerSessionId,
1859 providerThreadId: args.providerThread.id,
1860 cause,
1861 }),
1862 ),
1863 ),
1864 startTurn: (args) =>
1865 start(args).pipe(
1866 commands.withPermits(1),
1867 Effect.mapError(
1868 (cause) =>
1869 new ProviderAdapterTurnStartError({
1870 driver: MUSE_PROVIDER,
1871 threadId: args.threadId,
1872 providerThreadId: args.providerThread.id,
1873 runId: args.runId,
1874 cause,
1875 }),
1876 ),
1877 ),
1878 compactThread: (args) => start(args, true).pipe(commands.withPermits(1)),
1879 steerTurn: (args) =>
1880 Effect.gen(function* () {
1881 const turn = active;
1882 if (
1883 !turn ||
1884 turn.providerTurn.id !== args.providerTurnId ||
1885 !validateThread(args.providerThread) ||
1886 turn.compact
1887 )
1888 return yield* protocolError("This Muse turn cannot be steered");
1889 const parts = yield* prompt(args.message);
1890 const result = yield* request("turn/steer", {
1891 expectedTurnId: turn.nativeId,
1892 input: parts,
1893 }).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value)));
1894 if (result.turnId !== turn.nativeId)
1895 return yield* protocolError("Muse steering targeted a different turn");
1896 }).pipe(
1897 commands.withPermits(1),
1898 Effect.mapError(
1899 (cause) =>
1900 new ProviderAdapterSteerRunError({
1901 driver: MUSE_PROVIDER,
1902 providerThreadId: args.providerThread.id,
1903 providerTurnId: args.providerTurnId,
1904 cause,
1905 }),
1906 ),
1907 ),
1908 interruptTurn: (args) =>
1909 Effect.gen(function* () {
1910 const turn = active;
1911 if (
1912 !turn ||
1913 turn.providerTurn.id !== args.providerTurnId ||
1914 !validateThread(args.providerThread)
1915 )
1916 return yield* protocolError("This Muse turn is no longer active");
1917 turn.interruptRequested = true;
1918 if (turn.compact) {
1919 yield* eventPermit.withPermits(1)(
1920 Effect.gen(function* () {
1921 if (active !== turn) return;
1922 hostEpoch++;
1923 yield* Effect.tryPromise(() => host.close()).pipe(
1924 Effect.mapError((cause) => protocolError("Cannot stop Muse compaction", cause)),
1925 Effect.onError(failHost),
1926 );
1927 broken = true;
1928 yield* finish(turn, "interrupted", undefined, "broken");
1929 yield* settleObservedChildren("interrupted");
1930 yield* updateSession("stopped");
1931 yield* Queue.end(events);
1932 }),
1933 );
1934 return;
1935 }
1936 // Muse runs a joined turn first, so Stop must end it too.
1937 for (const joined of turn.joined)
1938 yield* request("turn/interrupt", { turnId: joined }).pipe(Effect.ignore);
1939 yield* request("turn/interrupt", { turnId: turn.nativeId });
1940 yield* Deferred.await(turn.done).pipe(
1941 Effect.timeout(options.requestTimeoutMs ?? 30_000),
1942 Effect.mapError((cause) => protocolError("Muse did not confirm interruption", cause)),
1943 Effect.onError((cause) => eventPermit.withPermits(1)(failHost(cause))),
1944 );
1945 }).pipe(
1946 commands.withPermits(1),
1947 Effect.mapError(
1948 (cause) =>
1949 new ProviderAdapterInterruptError({
1950 driver: MUSE_PROVIDER,
1951 providerThreadId: args.providerThread.id,
1952 providerTurnId: args.providerTurnId,
1953 cause,
1954 }),
1955 ),
1956 ),
1957 respondToRuntimeRequest: (args) =>
1958 Effect.gen(function* () {
1959 const entry = pending.get(args.requestId);
1960 if (!entry) return yield* protocolError("This Muse request is no longer pending");
1961 if (entry.native.type === "approval") yield* decideApproval(entry, args.decision);
1962 else {
1963 const questionRequest = entry.native.value;
1964 const answers = [];
1965 for (const question of questionRequest.questions) {
1966 const raw = yield* decode(responseAnswerSchema, args.answers?.[question.id]);
1967 const values = typeof raw === "string" ? [raw] : [...new Set(raw)];
1968 const selected = values.filter((value) =>
1969 question.options.some((option) => option.label === value),
1970 );
1971 const freeText = values.filter((value) => !selected.includes(value)).join("\n");
1972 if (freeText.length > 500 || (!freeText && !selected.length))
1973 return yield* protocolError(
1974 "Muse needs a nonempty answer with at most 500 custom characters",
1975 );
1976 if (
1977 (question.selection.mode === "single" && selected.length > 1) ||
1978 (selected.length > 0 &&
1979 (selected.length < (question.selection.minSelections ?? 0) ||
1980 selected.length > (question.selection.maxSelections ?? Infinity)))
1981 )
1982 return yield* protocolError("Muse question selection count is invalid");
1983 answers.push({
1984 questionId: question.id,
1985 ...(selected.length
1986 ? question.selection.mode === "single"
1987 ? { selectedLabel: selected[0]! }
1988 : { selectedLabels: selected }
1989 : {}),
1990 ...(freeText ? (selected.length ? { note: freeText } : { freeText }) : {}),
1991 });
1992 }
1993 if (args.answers) entry.response = { answers: args.answers };
1994 yield* request("userInput/answer", {
1995 userInputId: questionRequest.userInputId,
1996 answers,
1997 }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response)));
1998 }
1999 }).pipe(
2000 Effect.mapError(
2001 (cause) =>
2002 new ProviderAdapterRuntimeRequestResponseError({
2003 driver: MUSE_PROVIDER,
2004 requestId: args.requestId,
2005 cause,
2006 }),
2007 ),
2008 ),
2009 readThreadSnapshot: (args) =>
2010 (!validateThread(args.providerThread)
2011 ? protocolError("Snapshot requested for a different Muse thread")
2012 : snapshot(args.providerThread)
2013 ).pipe(
2014 commands.withPermits(1),
2015 Effect.mapError(
2016 (cause) =>
2017 new ProviderAdapterReadThreadSnapshotError({
2018 driver: MUSE_PROVIDER,
2019 providerThreadId: args.providerThread.id,
2020 cause,
2021 }),
2022 ),
2023 ),
2024 // Muse rejects forks at completed-turn boundaries (InvalidCut), so T3 uses
2025 // portable context handoff for forks and does not rewind Muse conversations.
2026 rollbackThread: (args) =>
2027 Effect.fail(
2028 new ProviderAdapterRollbackThreadError({
2029 driver: MUSE_PROVIDER,
2030 providerThreadId: args.providerThread.id,
2031 checkpointId: args.target.checkpointId,
2032 cause: "Muse Code does not support conversation rollback in T3 Code.",
2033 }),
2034 ),
2035 forkThread: (args) =>
2036 Effect.fail(
2037 new ProviderAdapterForkThreadError({
2038 driver: MUSE_PROVIDER,
2039 providerThreadId: args.sourceProviderThread.id,
2040 cause: "Muse Code does not support native forks in T3 Code.",
2041 }),
2042 ),
2043 };
2044 return runtime;
2045 }),
2046 });
2047}
packages/provider-muse/src/server/driver.ts created+178
......@@ -0,0 +1,178 @@
1import { ProviderDriverKind } from "@t3tools/contracts";
2import { MuseSettings } from "../settings.ts";
3import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import { HttpClient } from "effect/http";
9import { ChildProcessSpawner } from "effect/process";
10
11import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost";
12import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
13import { makeMuseTextGeneration } from "./textGeneration.ts";
14import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
15import { makeMuseAdapterV2 } from "./adapter.ts";
16import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
17import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests";
18import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts";
19import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
20import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
21import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts";
22import { makeMuseEnvironment } from "./sdk.ts";
23import {
24 defaultProviderContinuationIdentity,
25 type ProviderDriver,
26 type ProviderInstance,
27} from "@t3tools/provider-core/server/driver";
28import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
29import {
30 makeCachedProviderMaintenanceResolution,
31 resolveProviderMaintenanceCapabilitiesEffect,
32} from "@t3tools/provider-core/server/maintenanceResolver";
33import {
34 haveProviderSnapshotSettingsChanged,
35 makeProviderSnapshotSettingsSource,
36 type ProviderSnapshotSettings,
37} from "@t3tools/provider-core/server/snapshotSettings";
38import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
39
40const DRIVER_KIND = ProviderDriverKind.make("muse");
41const decodeMuseSettings = Schema.decodeSync(MuseSettings);
42
43export type MuseDriverEnv =
44 | IdAllocator.IdAllocatorV2
45 | ProviderHost
46 | ChildProcessSpawner.ChildProcessSpawner
47 | FileSystem.FileSystem
48 | HttpClient.HttpClient
49 | Path.Path
50 | ProviderEventLoggers.ProviderEventLoggers;
51
52export const MuseDriver: ProviderDriver<MuseSettings, MuseDriverEnv> = {
53 driverKind: DRIVER_KIND,
54 metadata: { displayName: "Muse Code", supportsMultipleInstances: true },
55 configSchema: MuseSettings,
56 defaultConfig: () => decodeMuseSettings({}),
57 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
58 Effect.gen(function* () {
59 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
60 const fileSystem = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const httpClient = yield* HttpClient.HttpClient;
63 const host = yield* ProviderHost;
64 const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
65 const { cwd } = host.paths;
66 const idAllocator = yield* IdAllocator.IdAllocatorV2;
67 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
68 const hostEnvironment = yield* HostProcessEnvironment;
69 // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins.
70 const processEnvironment = mergeProviderInstanceEnvironment(
71 environment,
72 makeMuseEnvironment(hostEnvironment),
73 );
74 const effectiveConfig = {
75 ...config,
76 enabled,
77 binaryPath: expandHomePath(config.binaryPath),
78 } satisfies MuseSettings;
79 const continuationIdentity = defaultProviderContinuationIdentity({
80 driverKind: DRIVER_KIND,
81 instanceId,
82 });
83 const stampIdentity = withInstanceIdentity({
84 instanceId,
85 driverKind: DRIVER_KIND,
86 displayName,
87 accentColor,
88 continuationGroupKey: continuationIdentity.continuationKey,
89 });
90 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings);
91 const resolveInstallation = yield* makeCachedProviderMaintenanceResolution(
92 resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, {
93 binaryPath: effectiveConfig.binaryPath,
94 env: processEnvironment,
95 }).pipe(
96 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
97 Effect.provideService(FileSystem.FileSystem, fileSystem),
98 Effect.provideService(Path.Path, path),
99 ),
100 );
101 const resolveMaintenance = (options?: { readonly fresh?: boolean }) =>
102 Effect.gen(function* () {
103 const capabilities = yield* resolveInstallation(options);
104 // The maintenance runner requests fresh capabilities around an explicit update
105 // and needs the native target version to verify that the command actually upgraded.
106 const latestVersion = options?.fresh
107 ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe(
108 Effect.provideService(HttpClient.HttpClient, httpClient),
109 )
110 : undefined;
111 return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities;
112 });
113 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<MuseSettings>>({
114 resolveMaintenance,
115 getSettings: snapshotSettings.getSettings,
116 streamSettings: snapshotSettings.streamSettings,
117 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
118 initialSnapshot: (settings) =>
119 makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)),
120 checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe(
121 Effect.map(stampIdentity),
122 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
123 Effect.provideService(FileSystem.FileSystem, fileSystem),
124 Effect.provideService(Path.Path, path),
125 ),
126 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
127 resolveMaintenance().pipe(
128 Effect.flatMap((maintenanceCapabilities) =>
129 enrichMuseSnapshot({
130 snapshot: currentSnapshot,
131 maintenanceCapabilities,
132 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
133 environment: processEnvironment,
134 }),
135 ),
136 Effect.provideService(HttpClient.HttpClient, httpClient),
137 Effect.flatMap(publishSnapshot),
138 ),
139 }).pipe(
140 Effect.mapError(
141 (cause) =>
142 new ProviderDriverError({
143 driver: DRIVER_KIND,
144 instanceId,
145 detail: "Failed to build Muse Code snapshot.",
146 cause,
147 }),
148 ),
149 );
150 const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models));
151 const orchestrationAdapter = makeMuseAdapterV2({
152 instanceId,
153 settings: effectiveConfig,
154 environment: processEnvironment,
155 idAllocator,
156 host,
157 fileSystem,
158 modelCatalog,
159 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
160 continuationRequests,
161 });
162 const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, {
163 environment: processEnvironment,
164 modelCatalog,
165 });
166 return {
167 instanceId,
168 driverKind: DRIVER_KIND,
169 continuationIdentity,
170 displayName,
171 accentColor,
172 enabled,
173 snapshot,
174 orchestrationAdapter,
175 textGeneration,
176 } satisfies ProviderInstance;
177 }),
178};
packages/provider-muse/src/server/itemPresentation.test.ts created+82
......@@ -0,0 +1,82 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import type { MuseItem } from "./protocol.ts";
4import { museItemStatus, museToolPresentation } from "./itemPresentation.ts";
5
6const item = (fields: Partial<MuseItem>): MuseItem => ({
7 itemId: "native-item",
8 kind: "toolCall",
9 status: "completed",
10 revision: 1,
11 ...fields,
12});
13
14describe("Muse item presentation", () => {
15 it("preserves an edit's file and exact old/new text for the shared diff UI", () => {
16 expect(
17 museToolPresentation(
18 item({
19 tool: "edit_file",
20 args: JSON.stringify({ file_path: "src/index.ts", old_string: "old\n", new_string: "" }),
21 }),
22 ),
23 ).toMatchObject({
24 type: "file_change",
25 status: "completed",
26 fileName: "src/index.ts",
27 oldStr: "old\n",
28 newStr: "",
29 });
30 });
31
32 it("preserves write content and provider-supplied patches", () => {
33 expect(
34 museToolPresentation(
35 item({
36 tool: "write_file",
37 args: JSON.stringify({ path: "README.md", content: "Hello\n" }),
38 }),
39 ),
40 ).toMatchObject({ type: "file_change", newStr: "Hello\n" });
41 expect(
42 museToolPresentation(
43 item({
44 tool: "apply_patch",
45 args: JSON.stringify({ path: "README.md", patch: "@@ -1 +1 @@\n-old\n+new" }),
46 }),
47 ),
48 ).toMatchObject({ type: "file_change", diffStr: "@@ -1 +1 @@\n-old\n+new" });
49 });
50
51 it.each(["rejected", "timedOut", "handedOff"])(
52 "does not report native %s activity as successful",
53 (status) => {
54 const native = item({ tool: "search_files", status, fallbackText: "Native explanation" });
55 expect(museItemStatus(native)).toBe("failed");
56 expect(museToolPresentation(native)).toMatchObject({
57 status: "failed",
58 output: `Muse reported ${status}.\nNative explanation`,
59 });
60 },
61 );
62
63 it("preserves failure details and a known cancelled status", () => {
64 expect(
65 museToolPresentation(
66 item({
67 status: "failed",
68 failureReason: "Permission denied",
69 visibleOutput: "Partial output",
70 }),
71 ),
72 ).toMatchObject({ status: "failed", output: "Permission denied\nPartial output" });
73 expect(museItemStatus(item({ status: "cancelled" }))).toBe("cancelled");
74 });
75
76 it.each(["failed", "noop"])(
77 "keeps %s compaction outcomes from appearing completed",
78 (outcome) => {
79 expect(museItemStatus(item({ kind: "compaction", outcome }))).toBe("failed");
80 },
81 );
82});
packages/provider-muse/src/server/itemPresentation.ts created+117
......@@ -0,0 +1,117 @@
1import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4import type { MuseItem } from "./protocol.ts";
5
6const decodeToolArgs = Schema.decodeUnknownSync(
7 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
8);
9
10type Fields<
11 T extends OrchestrationV2TurnItem["type"],
12 K extends keyof Extract<OrchestrationV2TurnItem, { type: T }>,
13> = Pick<Extract<OrchestrationV2TurnItem, { type: T }>, K>;
14type MuseToolPresentation = {
15 readonly status: OrchestrationV2TurnItemStatus;
16 readonly title: string | null;
17} & (
18 | Fields<"command_execution", "type" | "input" | "output" | "exitCode">
19 | Fields<"file_change", "type" | "fileName" | "oldStr" | "newStr" | "diffStr">
20 | Fields<"web_search", "type" | "patterns" | "results">
21 | Fields<"dynamic_tool", "type" | "toolName" | "input" | "output">
22);
23
24/** Unrecognized native terminal states must not appear as successful work. */
25export function museItemStatus(item: MuseItem): OrchestrationV2TurnItemStatus {
26 if (item.status === "inProgress") return "running";
27 if (item.status === "cancelled") return "cancelled";
28 if (
29 item.status === "completed" &&
30 !(item.kind === "compaction" && item.outcome && item.outcome !== "compacted")
31 )
32 return "completed";
33 return "failed";
34}
35
36/** Converts native tool details into the shared timeline fields used by every client. */
37export function museToolPresentation(
38 item: MuseItem,
39 status = museItemStatus(item),
40): MuseToolPresentation {
41 let args: Record<string, unknown> | undefined;
42 if (item.args) {
43 try {
44 args = decodeToolArgs(item.args);
45 } catch {
46 // Streaming model-authored arguments may not be valid JSON yet.
47 }
48 }
49 const stringArg = (...keys: string[]) => {
50 for (const key of keys) {
51 const value = args?.[key];
52 if (typeof value === "string") return value;
53 }
54 return undefined;
55 };
56 const toolName = item.tool ?? item.kind;
57 const title =
58 item.kind === "subagent"
59 ? item.objective || item.role || "Muse agent"
60 : item.kind === "workflow"
61 ? item.entryId || "Workflow"
62 : toolName;
63 const detail =
64 status === "failed" ? item.failureReason || `Muse reported ${item.status}.` : undefined;
65 const shared = { status, title: detail ? `${title}: ${detail}` : title };
66 const text =
67 item.visibleOutput ??
68 item.result?.summary ??
69 item.text ??
70 item.summary?.join("\n") ??
71 item.fallbackText ??
72 item.objective ??
73 "";
74 const output = detail ? [detail, text].filter(Boolean).join("\n") : text;
75
76 if (item.kind === "userShell" || /shell|bash|exec_command/i.test(item.tool ?? "")) {
77 return {
78 ...shared,
79 type: "command_execution",
80 input: item.commandText ?? stringArg("command", "cmd") ?? item.args ?? "",
81 output,
82 ...(item.exitCode === undefined ? {} : { exitCode: item.exitCode }),
83 };
84 }
85 if (item.kind === "toolCall" && /write|edit|patch/i.test(toolName)) {
86 const fileName = stringArg("file_path", "path", "filePath")?.trim();
87 if (fileName) {
88 const oldStr = stringArg("old_string", "old_str", "oldStr", "oldText");
89 const newStr = stringArg("new_string", "new_str", "newStr", "newText", "content");
90 const diffStr = stringArg("diff", "patch", "diffStr");
91 return {
92 ...shared,
93 type: "file_change",
94 fileName,
95 ...(oldStr === undefined ? {} : { oldStr }),
96 ...(newStr === undefined ? {} : { newStr }),
97 ...(diffStr === undefined ? {} : { diffStr }),
98 };
99 }
100 }
101 if (item.kind === "toolCall" && /web|browse|fetch/i.test(toolName)) {
102 const query = stringArg("query", "url", "pattern")?.trim();
103 return {
104 ...shared,
105 type: "web_search",
106 ...(query ? { patterns: [query] } : {}),
107 ...(output ? { results: [{ snippet: output }] } : {}),
108 };
109 }
110 return {
111 ...shared,
112 type: "dynamic_tool",
113 toolName,
114 input: args ?? item.args ?? "",
115 output,
116 };
117}
packages/provider-muse/src/server/maintenance.test.ts created+232
......@@ -0,0 +1,232 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import { HttpClient, HttpClientResponse } from "effect/http";
7
8import {
9 compareMuseVersions,
10 enrichMuseSnapshot,
11 museMaintenance,
12 latestMuseVersion,
13 parseMuseVersion,
14} from "./maintenance.ts";
15import {
16 ProviderVersionCache,
17 makeManualOnlyProviderMaintenanceCapabilities,
18} from "@t3tools/provider-core/server/maintenanceResolver";
19
20const maintenanceCapabilities = {
21 ...makeManualOnlyProviderMaintenanceCapabilities({
22 provider: ProviderDriverKind.make("muse"),
23 packageName: null,
24 }),
25 compareVersions: compareMuseVersions,
26};
27const snapshot: ServerProvider = {
28 instanceId: ProviderInstanceId.make("muse"),
29 driver: ProviderDriverKind.make("muse"),
30 enabled: true,
31 installed: true,
32 version: "1.0.3",
33 status: "ready",
34 auth: { status: "unknown" },
35 checkedAt: "2026-09-10T00:00:00.000Z",
36 models: [],
37 skills: [],
38 slashCommands: [],
39};
40
41it.layer(NodeServices.layer)("Muse maintenance", (it) => {
42 it.effect(
43 "refreshes a cached channel failure when an explicit update requests verification",
44 () =>
45 Effect.gen(function* () {
46 let requests = 0;
47 const check = Effect.gen(function* () {
48 expect(yield* latestMuseVersion({})).toBeNull();
49 expect(yield* latestMuseVersion({})).toBeNull();
50 expect(yield* latestMuseVersion({}, { fresh: true })).toBe("1.1.1-R10.1");
51 });
52 yield* check.pipe(
53 Effect.provideService(ProviderVersionCache, new Map()),
54 Effect.provideService(
55 HttpClient.HttpClient,
56 HttpClient.make((request) => {
57 requests += 1;
58 return Effect.succeed(
59 HttpClientResponse.fromWeb(
60 request,
61 requests === 1
62 ? new Response("unavailable", { status: 503 })
63 : Response.json({ version: "1.1.1-R10.1" }),
64 ),
65 );
66 }),
67 ),
68 );
69 expect(requests).toBe(2);
70 }),
71 );
72 it.effect(
73 "compares numeric native release revisions and preserves the CLI release identity",
74 () =>
75 Effect.sync(() => {
76 expect(parseMuseVersion("Muse Code 1.1.1 (1.1.1-R2514.1)")).toBe("1.1.1-R2514.1");
77 expect(compareMuseVersions("1.1.1-R9.1", "1.1.1-R10.1")).toBeLessThan(0);
78 expect(compareMuseVersions("1.1.1-R2514.1", "1.1.1-R2514.2")).toBeLessThan(0);
79 expect(compareMuseVersions("1.2.0-R1.1", "1.1.1-R2514.2")).toBeGreaterThan(0);
80 // A CLI that prints no revision compares by release, never as an older prerelease.
81 expect(compareMuseVersions("1.4.3", "1.4.3-R5018.1")).toBe(0);
82 expect(compareMuseVersions("1.4.2", "1.4.3-R5018.1")).toBeLessThan(0);
83 }),
84 );
85 it.effect(
86 "finds releases from the instance's channel and caches the native version, not the SDK version",
87 () =>
88 Effect.gen(function* () {
89 const requests: string[] = [];
90 const httpClient = HttpClient.make((request) => {
91 requests.push(request.url);
92 return Effect.succeed(
93 HttpClientResponse.fromWeb(request, Response.json({ version: "1.1.1-R2514.1" })),
94 );
95 });
96 const check = enrichMuseSnapshot({
97 snapshot,
98 maintenanceCapabilities,
99 enableProviderUpdateChecks: true,
100 environment: { MUSE_CHANNEL: "muse-canary" },
101 });
102 const [first, second] = yield* Effect.all([check, check], { concurrency: 1 }).pipe(
103 Effect.provideService(HttpClient.HttpClient, httpClient),
104 Effect.provideService(ProviderVersionCache, new Map()),
105 );
106 expect(first.versionAdvisory).toMatchObject({
107 status: "behind_latest",
108 currentVersion: "1.0.3",
109 latestVersion: "1.1.1-R2514.1",
110 canUpdate: false,
111 });
112 expect(second.versionAdvisory?.latestVersion).toBe("1.1.1-R2514.1");
113 expect(requests).toEqual(["https://api.meta.ai/muse-code/channels/muse-canary"]);
114 }),
115 );
116
117 it.effect("reports native release R9 behind R10 and R10 current against R9", () =>
118 Effect.gen(function* () {
119 for (const [current, latest, status] of [
120 ["1.4.2-R9.1", "1.4.2-R10.1", "behind_latest"],
121 ["1.4.2-R10.1", "1.4.2-R9.1", "current"],
122 ] as const) {
123 const result = yield* enrichMuseSnapshot({
124 snapshot: { ...snapshot, version: current },
125 maintenanceCapabilities,
126 enableProviderUpdateChecks: true,
127 environment: {},
128 }).pipe(
129 Effect.provideService(
130 HttpClient.HttpClient,
131 HttpClient.make((request) =>
132 Effect.succeed(
133 HttpClientResponse.fromWeb(request, Response.json({ version: latest })),
134 ),
135 ),
136 ),
137 Effect.provideService(ProviderVersionCache, new Map()),
138 );
139 expect(result.versionAdvisory?.status).toBe(status);
140 }
141 }),
142 );
143
144 it.effect("skips network checks when disabled, uninstalled or disabled in settings", () =>
145 Effect.gen(function* () {
146 for (const input of [
147 { snapshot, enableProviderUpdateChecks: false },
148 { snapshot: { ...snapshot, enabled: false }, enableProviderUpdateChecks: true },
149 { snapshot: { ...snapshot, installed: false }, enableProviderUpdateChecks: true },
150 ]) {
151 const result = yield* enrichMuseSnapshot({
152 ...input,
153 maintenanceCapabilities,
154 environment: {},
155 }).pipe(
156 Effect.provideService(
157 HttpClient.HttpClient,
158 HttpClient.make(() => Effect.die("Unexpected release request")),
159 ),
160 );
161 expect(result.versionAdvisory?.status).toBe("unknown");
162 }
163 }),
164 );
165
166 it.effect("tolerates invalid release manifests and unavailable channels", () =>
167 Effect.gen(function* () {
168 for (const response of [
169 Response.json({ version: 12 }),
170 Response.json({ version: "1.1.1" }),
171 new Response("unavailable", { status: 503 }),
172 ]) {
173 const result = yield* enrichMuseSnapshot({
174 snapshot,
175 maintenanceCapabilities,
176 enableProviderUpdateChecks: true,
177 environment: {},
178 }).pipe(
179 Effect.provideService(
180 HttpClient.HttpClient,
181 HttpClient.make((request) =>
182 Effect.succeed(HttpClientResponse.fromWeb(request, response)),
183 ),
184 ),
185 Effect.provideService(ProviderVersionCache, new Map()),
186 );
187 expect(result.status).toBe("ready");
188 expect(result.versionAdvisory?.status).toBe("unknown");
189 }
190 }),
191 );
192
193 it.effect("updates only a recognized launcher at the resolved instance path", () =>
194 Effect.scoped(
195 Effect.gen(function* () {
196 const fs = yield* FileSystem.FileSystem;
197 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" });
198 const binaryPath = `${directory}/custom muse`;
199 const context = {
200 binaryPath,
201 resolvedCommandPath: binaryPath,
202 realCommandPath: binaryPath,
203 platform: "linux" as const,
204 env: { MUSE_CHANNEL: "muse-canary" },
205 };
206 yield* fs.writeFileString(
207 binaryPath,
208 "#!/bin/bash\n# muse-code/launcher-2\n# MUSE_SYNC_UPDATE\n",
209 );
210 const result = yield* museMaintenance.resolve(context);
211 expect(result.update).toMatchObject({
212 executable: binaryPath,
213 args: ["--version"],
214 lockKey: `muse:${binaryPath}`,
215 env: {
216 MUSE_CHANNEL: "muse-canary",
217 MUSE_NO_AUTO_UPDATE: "0",
218 MUSE_SYNC_UPDATE: "1",
219 MUSE_UPDATE_INTERVAL_SECONDS: "0",
220 },
221 });
222 expect(result.update?.command).toBe(
223 `MUSE_CHANNEL=muse-canary MUSE_NO_AUTO_UPDATE=0 MUSE_SYNC_UPDATE=1 MUSE_UPDATE_INTERVAL_SECONDS=0 '${binaryPath}' --version`,
224 );
225 expect(result.compareVersions).toBe(compareMuseVersions);
226 yield* fs.writeFileString(binaryPath, "standalone binary");
227 expect((yield* museMaintenance.resolve(context)).update).toBeNull();
228 expect((yield* museMaintenance.resolve(null)).update).toBeNull();
229 }),
230 ),
231 );
232});
packages/provider-muse/src/server/maintenance.ts created+139
......@@ -0,0 +1,139 @@
1import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
2import { compareSemverVersions } from "@t3tools/shared/semver";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Schema from "effect/Schema";
7import { HttpClient, HttpClientRequest } from "effect/http";
8
9import {
10 enrichProviderSnapshotWithVersionAdvisory,
11 makeManualOnlyProviderMaintenanceCapabilities,
12 makeProviderMaintenanceCapabilities,
13 ProviderVersionCache,
14 type ProviderMaintenanceCapabilities,
15 type ProviderMaintenanceCapabilitiesResolver,
16} from "@t3tools/provider-core/server/maintenanceResolver";
17import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe";
18
19const DRIVER = ProviderDriverKind.make("muse");
20/** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */
21const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]);
22const ChannelManifest = Schema.Struct({
23 version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)),
24});
25
26/** Muse prints both a display version and a release revision; prefer the latter. */
27export function parseMuseVersion(output: string): string | null {
28 return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output);
29}
30
31function comparableMuseVersion(version: string): string {
32 // Make the release sequence numeric for semver comparison (R9 precedes R10).
33 return version.replace(/-R(\d+)(?:\.(\d+))?$/, "-release.$1.$2").replace(/\.$/, ".0");
34}
35
36const MUSE_REVISION = /-R\d+(?:\.\d+)?$/;
37
38/** A version printed without a revision is compared by its release alone. */
39export const compareMuseVersions = (current: string, latest: string) =>
40 MUSE_REVISION.test(current) === MUSE_REVISION.test(latest)
41 ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest))
42 : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, ""));
43
44/** Only the official launcher owns an update channel; a standalone binary cannot update itself. */
45export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = {
46 resolve: (context) =>
47 Effect.gen(function* () {
48 const manual = {
49 ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }),
50 compareVersions: compareMuseVersions,
51 };
52 if (!context || context.platform === "win32") return manual;
53 const fs = yield* FileSystem.FileSystem;
54 const isLauncher = yield* Effect.gen(function* () {
55 const stat = yield* fs.stat(context.realCommandPath);
56 if (Number(stat.size) > 128 * 1_024) return false;
57 const source = yield* fs.readFileString(context.realCommandPath);
58 return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE");
59 }).pipe(Effect.orElseSucceed(() => false));
60 if (!isLauncher) return manual;
61 // The launcher has no `update` subcommand. Its documented launch environment
62 // forces a synchronous refresh of this executable's own installation.
63 const updateEnv = {
64 ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}),
65 MUSE_NO_AUTO_UPDATE: "0",
66 MUSE_SYNC_UPDATE: "1",
67 MUSE_UPDATE_INTERVAL_SECONDS: "0",
68 };
69 return {
70 ...makeProviderMaintenanceCapabilities({
71 provider: DRIVER,
72 packageName: null,
73 updateExecutable: context.resolvedCommandPath,
74 updateArgs: ["--version"],
75 updateCommand: [
76 ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`),
77 shellQuote(context.resolvedCommandPath),
78 "--version",
79 ].join(" "),
80 updateLockKey: `muse:${context.realCommandPath}`,
81 platform: context.platform,
82 env: { ...context.env, ...updateEnv },
83 }),
84 compareVersions: compareMuseVersions,
85 };
86 }),
87};
88
89function shellQuote(word: string) {
90 return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
91}
92
93export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* (
94 environment: NodeJS.ProcessEnv,
95 options?: { readonly fresh?: boolean },
96) {
97 const channel = environment.MUSE_CHANNEL || "muse-stable";
98 if (!MUSE_CHANNELS.has(channel)) return null;
99 const channelUrl = `https://api.meta.ai/muse-code/channels/${channel}`;
100 const cache = yield* ProviderVersionCache;
101 const key = `muse:${channelUrl}`;
102 const now = DateTime.toEpochMillis(yield* DateTime.now);
103 const cached = cache.get(key);
104 if (!options?.fresh && cached && cached.expiresAt > now) return cached.version;
105 const client = yield* HttpClient.HttpClient;
106 const version = yield* client.execute(HttpClientRequest.get(channelUrl)).pipe(
107 Effect.flatMap((response) =>
108 response.status >= 200 && response.status < 300
109 ? response.json.pipe(
110 Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)),
111 Effect.map((manifest) => parseMuseVersion(manifest.version)),
112 )
113 : Effect.succeed(null),
114 ),
115 Effect.timeout(4_000),
116 Effect.orElseSucceed(() => null),
117 );
118 cache.set(key, { version, expiresAt: now + (version === null ? 60_000 : 60 * 60 * 1_000) });
119 return version;
120});
121
122/** Feed Muse's release channel into the same advisory and notification flow as other drivers. */
123export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (input: {
124 readonly snapshot: ServerProvider;
125 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
126 readonly enableProviderUpdateChecks: boolean;
127 readonly environment: NodeJS.ProcessEnv;
128}) {
129 const { snapshot } = input;
130 const latestVersion =
131 input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version
132 ? yield* latestMuseVersion(input.environment)
133 : null;
134 return yield* enrichProviderSnapshotWithVersionAdvisory(
135 snapshot,
136 { ...input.maintenanceCapabilities, latestVersion },
137 { enableProviderUpdateChecks: input.enableProviderUpdateChecks },
138 );
139});
packages/provider-muse/src/server/modelCatalog.test.ts created+98
......@@ -0,0 +1,98 @@
1import { describe, expect, it } from "@effect/vitest";
2import { MuseSettings } from "../settings.ts";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5
6import type { MuseSdkHost } from "./sdk.ts";
7import {
8 discoverMuseModels,
9 museModelCapabilities,
10 resolveMuseReasoningEffort,
11} from "./modelCatalog.ts";
12
13const settings = Schema.decodeSync(MuseSettings)({});
14const host = (catalog: Record<string, unknown>): MuseSdkHost => ({
15 connection: {
16 request: async () => catalog,
17 command: async () => ({}),
18 mintCommandId: () => "test",
19 onNotification: () => {},
20 onServerRequest: () => {},
21 onProtocolError: () => {},
22 closed: new Promise<void>(() => {}),
23 },
24 initializeResult: { grantedCapabilities: [] },
25 exited: new Promise(() => {}),
26 close: async () => {},
27});
28
29const row = (modelId: string, extra: Record<string, unknown> = {}) => ({
30 modelId,
31 displayLabel: modelId,
32 providerId: "meta",
33 profileId: "tbh",
34 isDefault: false,
35 contextLimit: 1_000_000,
36 ...extra,
37});
38
39describe("Muse model catalog", () => {
40 it.effect("uses the labels, efforts and defaults that model/list sends", () =>
41 Effect.gen(function* () {
42 const models = yield* discoverMuseModels(settings, {}, undefined, async () =>
43 host({
44 providerId: "meta",
45 profileId: "tbh",
46 source: "providerCatalog",
47 models: [
48 row("restful-walrus", {
49 displayLabel: " Restful Walrus ",
50 isDefault: true,
51 variants: ["minimal", "low", "high", "future-tier"],
52 reasoningEffortVariants: [{ tier: "low", description: "Quick" }],
53 defaultReasoningEffort: "high",
54 }),
55 row("older-host"),
56 row("unknown-efforts", { variants: "unknown" }),
57 row("foreign", { providerId: "another" }),
58 ],
59 }),
60 ).pipe(Effect.scoped);
61 expect(models.map(({ slug, name, isDefault }) => [slug, name, isDefault])).toEqual([
62 ["restful-walrus", "Restful Walrus", true],
63 ["older-host", "older-host", false],
64 ["unknown-efforts", "unknown-efforts", false],
65 ]);
66 expect(models[0]?.capabilities?.optionDescriptors).toEqual([
67 {
68 id: "reasoningEffort",
69 label: "Reasoning",
70 type: "select",
71 currentValue: "high",
72 options: [
73 { id: "minimal", label: "Minimal" },
74 { id: "low", label: "Low", description: "Quick" },
75 { id: "high", label: "High", isDefault: true },
76 ],
77 },
78 ]);
79 // Older hosts and "unknown" fall back to Muse's documented tiers.
80 expect(models[1]?.capabilities).toEqual(museModelCapabilities());
81 expect(models[2]?.capabilities).toEqual(museModelCapabilities());
82 }),
83 );
84
85 it("normalizes saved and implicit efforts against the selected model before dispatch", () => {
86 const fallback = museModelCapabilities();
87 expect(resolveMuseReasoningEffort(fallback, undefined)).toBe("high");
88 expect(resolveMuseReasoningEffort(fallback, "max")).toBe("max");
89 expect(resolveMuseReasoningEffort(fallback, "ultra")).toBe("high");
90 const restricted = museModelCapabilities({ variants: ["xhigh"] });
91 expect(resolveMuseReasoningEffort(restricted, undefined)).toBe("xhigh");
92 expect(resolveMuseReasoningEffort(restricted, "low")).toBe("xhigh");
93 expect(
94 resolveMuseReasoningEffort(museModelCapabilities({ variants: [] }), "max"),
95 ).toBeUndefined();
96 expect(resolveMuseReasoningEffort(undefined, "max")).toBe("max");
97 });
98});
packages/provider-muse/src/server/modelCatalog.ts created+139
......@@ -0,0 +1,139 @@
1import type { SendUserTurnOptions } from "@muse-code/sdk";
2import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts";
3import { MuseSettings } from "../settings.ts";
4import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model";
5import * as Effect from "effect/Effect";
6import * as Schema from "effect/Schema";
7import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts";
8
9type MuseReasoningEffort = NonNullable<SendUserTurnOptions<never>["reasoningEffort"]>;
10
11const REASONING_EFFORT_LABELS = {
12 none: "None",
13 minimal: "Minimal",
14 low: "Low",
15 medium: "Medium",
16 high: "High",
17 xhigh: "Extra High",
18 max: "Max",
19 ultra: "Ultra",
20} satisfies Record<MuseReasoningEffort, string>;
21
22const isMuseReasoningEffort = (tier: string): tier is MuseReasoningEffort =>
23 Object.hasOwn(REASONING_EFFORT_LABELS, tier);
24
25/** Used when Muse does not report a model's efforts (custom slugs, older hosts, "unknown"). */
26const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
27/** Muse's own CLI default. */
28const FALLBACK_DEFAULT_EFFORT = "high";
29
30const ModelCatalogEntry = Schema.Struct({
31 modelId: Schema.NonEmptyString,
32 displayLabel: Schema.String,
33 providerId: Schema.String,
34 isDefault: Schema.Boolean,
35 variants: Schema.optional(Schema.Union([Schema.Array(Schema.String), Schema.Literal("unknown")])),
36 defaultReasoningEffort: Schema.optional(Schema.String),
37 reasoningEffortVariants: Schema.optional(
38 Schema.Array(
39 Schema.Struct({ tier: Schema.String, description: Schema.optional(Schema.String) }),
40 ),
41 ),
42});
43const decodeModelList = Schema.decodeUnknownEffect(
44 Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }),
45);
46
47/** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */
48export function museModelCapabilities(
49 model?: Pick<
50 typeof ModelCatalogEntry.Type,
51 "variants" | "defaultReasoningEffort" | "reasoningEffortVariants"
52 >,
53): ModelCapabilities {
54 const reported = model?.variants !== undefined && model.variants !== "unknown";
55 const efforts = (reported ? model.variants : FALLBACK_EFFORTS).filter(isMuseReasoningEffort);
56 const preferredDefault = reported ? model?.defaultReasoningEffort : FALLBACK_DEFAULT_EFFORT;
57 const defaultValue = efforts.find((tier) => tier === preferredDefault) ?? efforts[0];
58 if (!defaultValue) return createModelCapabilities({ optionDescriptors: [] });
59 return createModelCapabilities({
60 optionDescriptors: [
61 {
62 id: "reasoningEffort",
63 label: "Reasoning",
64 type: "select",
65 currentValue: defaultValue,
66 options: efforts.map((id) => {
67 const description = model?.reasoningEffortVariants?.find(
68 (variant) => variant.tier === id,
69 )?.description;
70 return {
71 id,
72 label: REASONING_EFFORT_LABELS[id],
73 ...(id === defaultValue ? { isDefault: true } : {}),
74 ...(description ? { description } : {}),
75 };
76 }),
77 },
78 ],
79 });
80}
81
82/** Apply the advertised model choices to saved, remembered and implicit efforts at dispatch. */
83export function resolveMuseReasoningEffort(
84 capabilities: ModelCapabilities | null | undefined,
85 effort: string | undefined,
86): string | undefined {
87 if (!capabilities) return effort;
88 const descriptor = getProviderOptionDescriptors({
89 caps: capabilities,
90 selections: effort ? [{ id: "reasoningEffort", value: effort }] : undefined,
91 }).find((descriptor) => descriptor.id === "reasoningEffort");
92 if (descriptor?.type !== "select") return undefined;
93 return descriptor.options.find((option) => option.id === descriptor.currentValue)?.id;
94}
95
96class MuseCatalogError extends Schema.TaggedError<MuseCatalogError>()("MuseCatalogError", {
97 detail: Schema.String,
98}) {}
99
100export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* (
101 settings: MuseSettings,
102 environment: NodeJS.ProcessEnv | undefined,
103 cwd?: string,
104 createHost: typeof createMuseSdkHost = createMuseSdkHost,
105) {
106 const host = yield* Effect.acquireRelease(
107 createMuseSdkHostEffect(
108 {
109 binaryPath: settings.binaryPath,
110 ...(environment ? { environment } : {}),
111 ...(cwd ? { cwd } : {}),
112 readOnly: true,
113 startupTimeoutMs: 8_000,
114 },
115 createHost,
116 ),
117 (host: MuseSdkHost) => Effect.promise(() => host.close()),
118 { interruptible: true },
119 );
120 const result = yield* Effect.tryPromise(() => host.connection.request("model/list", {}));
121 const catalog = yield* decodeModelList(result);
122 if (catalog.providerId !== "meta") {
123 return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." });
124 }
125 const seen = new Set<string>();
126 return catalog.models.flatMap((model): ServerProviderModel[] => {
127 if (model.providerId !== "meta" || seen.has(model.modelId)) return [];
128 seen.add(model.modelId);
129 return [
130 {
131 slug: model.modelId,
132 name: model.displayLabel.trim() || model.modelId,
133 isCustom: false,
134 isDefault: model.isDefault,
135 capabilities: museModelCapabilities(model),
136 },
137 ];
138 });
139});
packages/provider-muse/src/server/protocol.test.ts created+17
......@@ -0,0 +1,17 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { museApprovalChoices } from "./protocol.ts";
4
5// Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case.
6describe("Muse approval choices", () => {
7 it("keeps denied and abort separate when Muse offers both", () => {
8 const choices = museApprovalChoices({
9 availableChoices: [
10 { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" },
11 { choiceId: "abort", label: "Stop", decision: "abort", scope: "once" },
12 ],
13 });
14 expect(choices.get("decline")?.choiceId).toBe("deny");
15 expect(choices.get("cancel")?.choiceId).toBe("abort");
16 });
17});
packages/provider-muse/src/server/protocol.ts created+228
......@@ -0,0 +1,228 @@
1import type { ProviderApprovalDecision } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";
3
4const NonEmptyString = Schema.String.check(Schema.isMinLength(1));
5
6const MuseUsage = Schema.Struct({
7 inputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
8 outputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
9 cachedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
10 reasoningTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
11 cacheReadTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
12 cacheWriteTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
13});
14
15export const MuseItem = Schema.Struct({
16 /** `workflow`: the launched entry's name. */
17 entryId: Schema.optional(Schema.String),
18 /** `workflow`: each child agent's latest attempt, re-sent whole on every change. */
19 children: Schema.optional(
20 Schema.Array(
21 Schema.Struct({
22 childId: NonEmptyString,
23 attempt: Schema.Int,
24 status: Schema.String,
25 label: Schema.optional(Schema.String),
26 terminal: Schema.optional(Schema.String),
27 durationMs: Schema.optional(Schema.Finite),
28 failureReason: Schema.optional(Schema.String),
29 }),
30 ),
31 ),
32 itemId: NonEmptyString,
33 kind: NonEmptyString,
34 revision: Schema.Int.check(Schema.isGreaterThan(0)),
35 status: NonEmptyString,
36 turnId: Schema.optional(Schema.NullOr(Schema.String)),
37 text: Schema.optional(Schema.String),
38 summary: Schema.optional(Schema.Array(Schema.String)),
39 tool: Schema.optional(Schema.String),
40 args: Schema.optional(Schema.String),
41 visibleOutput: Schema.optional(Schema.String),
42 truncated: Schema.optional(Schema.Boolean),
43 failureReason: Schema.optional(Schema.String),
44 fallbackText: Schema.optional(Schema.String),
45 commandText: Schema.optional(Schema.String),
46 exitCode: Schema.optional(Schema.Int),
47 durationMs: Schema.optional(Schema.Finite),
48 subagentId: Schema.optional(Schema.String),
49 childSessionId: Schema.optional(Schema.String),
50 objective: Schema.optional(Schema.String),
51 role: Schema.optional(Schema.String),
52 controlStatus: Schema.optional(Schema.String),
53 result: Schema.optional(
54 Schema.Struct({ summary: Schema.String, text: Schema.optional(Schema.String) }),
55 ),
56 usage: Schema.optional(MuseUsage),
57 outcome: Schema.optional(Schema.String),
58 trigger: Schema.optional(Schema.String),
59 reason: Schema.optional(Schema.String),
60});
61export type MuseItem = typeof MuseItem.Type;
62
63export const MuseSessionResult = Schema.Struct({
64 session: Schema.Struct({
65 sessionId: NonEmptyString,
66 modelId: Schema.optional(Schema.NullOr(Schema.String)),
67 activeTurnId: Schema.optional(Schema.NullOr(Schema.String)),
68 }),
69 history: Schema.optional(
70 Schema.Struct({
71 items: Schema.NullOr(Schema.Array(MuseItem)),
72 snapshot: Schema.optional(
73 Schema.NullOr(
74 Schema.Struct({
75 schemaVersion: Schema.optional(Schema.Int),
76 state: Schema.Struct({ items: Schema.Array(MuseItem) }),
77 }),
78 ),
79 ),
80 }),
81 ),
82});
83export const MuseTurnStartResult = Schema.Struct({ turnId: NonEmptyString });
84
85export const MuseApproval = Schema.Struct({
86 approvalId: NonEmptyString,
87 protectedWrite: Schema.optional(Schema.Boolean),
88 judgeEscalated: Schema.optional(Schema.Boolean),
89 sessionId: NonEmptyString,
90 turnId: Schema.optional(Schema.String),
91 itemId: Schema.optional(Schema.String),
92 toolName: Schema.optional(Schema.String),
93 rawArgs: Schema.optional(Schema.String),
94 /** Set when a workflow child asks; `turnId` is then the child's run, not a session turn. */
95 subagentOrigin: Schema.optional(Schema.Struct({ subagentId: NonEmptyString })),
96 currentRequirementId: Schema.Struct({ approvalId: NonEmptyString, sourceIndex: Schema.Int }),
97 availableChoices: Schema.Array(
98 Schema.Struct({
99 choiceId: NonEmptyString,
100 label: Schema.String,
101 decision: NonEmptyString,
102 scope: Schema.String,
103 }),
104 ),
105 subject: Schema.Struct({
106 kind: Schema.String,
107 access: Schema.optional(Schema.String),
108 command: Schema.optional(Schema.String),
109 path: Schema.optional(Schema.String),
110 host: Schema.optional(Schema.String),
111 target: Schema.optional(Schema.String),
112 }),
113});
114export type MuseApproval = typeof MuseApproval.Type;
115
116export const MuseUserInput = Schema.Struct({
117 userInputId: NonEmptyString,
118 sessionId: NonEmptyString,
119 turnId: NonEmptyString,
120 questions: Schema.Array(
121 Schema.Struct({
122 id: NonEmptyString,
123 header: Schema.String,
124 question: Schema.String,
125 options: Schema.Array(
126 Schema.Struct({ label: Schema.String, description: Schema.optional(Schema.String) }),
127 ),
128 selection: Schema.Struct({
129 mode: Schema.Literals(["single", "multiple"]),
130 minSelections: Schema.optional(Schema.Int),
131 maxSelections: Schema.optional(Schema.Int),
132 }),
133 }),
134 ),
135});
136export type MuseUserInput = typeof MuseUserInput.Type;
137
138export const MuseTokenUsageEvent = Schema.Struct({
139 turnId: NonEmptyString,
140 promptTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
141 usage: MuseUsage,
142});
143export const MuseContextUsage = Schema.Struct({
144 usedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
145 windowTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
146});
147export const MuseTodoList = Schema.Struct({
148 items: Schema.Array(Schema.Struct({ text: Schema.String, status: Schema.String })),
149});
150export const MuseTurnCompleted = Schema.Struct({
151 turnId: NonEmptyString,
152 terminal: NonEmptyString,
153 reason: Schema.optional(Schema.String),
154 error: Schema.optional(Schema.Struct({ message: Schema.String })),
155 usage: Schema.optional(MuseUsage),
156});
157export const MuseTurnRetryScheduled = Schema.Struct({
158 turnId: NonEmptyString,
159 nextAttempt: Schema.Int.check(Schema.isGreaterThan(0)),
160 maxAttempts: Schema.Int.check(Schema.isGreaterThan(0)),
161 reason: NonEmptyString,
162 retryDelayMs: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
163});
164export const MuseCompactResult = Schema.Struct({
165 status: Schema.String,
166 reason: Schema.optional(Schema.String),
167});
168
169export const MuseItemEvent = Schema.Struct({ item: MuseItem });
170export const MuseDelta = Schema.Struct({
171 itemId: NonEmptyString,
172 delta: Schema.String,
173 field: Schema.optional(Schema.String),
174});
175
176/** The T3 decision for one native Muse choice, or none when T3 has no equivalent. */
177export function museApprovalDecision(
178 choice: Pick<MuseApproval["availableChoices"][number], "decision" | "scope">,
179): ProviderApprovalDecision | undefined {
180 switch (choice.decision) {
181 case "approved":
182 return "accept";
183 case "approvedForSession":
184 return "acceptForSession";
185 case "approvedPolicyAmendment":
186 return choice.scope === "session"
187 ? "acceptForSession"
188 : choice.scope === "localPersistent"
189 ? "acceptAlways"
190 : undefined;
191 case "denied":
192 case "deniedPolicyAmendment":
193 return "decline";
194 case "abort":
195 return "cancel";
196 default:
197 return undefined;
198 }
199}
200
201/**
202 * Maps each canonical decision to one native choice. Muse shell approvals offer
203 * no `denied` choice, only `abort` ("Reject"), so `decline` falls back to it.
204 */
205export function museApprovalChoices(approval: Pick<MuseApproval, "availableChoices">) {
206 const choices = new Map<ProviderApprovalDecision, MuseApproval["availableChoices"][number]>();
207 const rank = (choice: MuseApproval["availableChoices"][number]) =>
208 (choice.scope === "once" ? 0 : choice.scope === "session" ? 2 : 4) +
209 (choice.decision.endsWith("PolicyAmendment") ? 1 : 0);
210 for (const choice of approval.availableChoices) {
211 const decision = museApprovalDecision(choice);
212 if (!decision) continue;
213 const previous = choices.get(decision);
214 if (!previous || rank(choice) < rank(previous)) choices.set(decision, choice);
215 }
216 const abort = choices.get("cancel");
217 if (!choices.has("decline") && abort) choices.set("decline", abort);
218 return choices;
219}
220
221/** The approval buttons to show: one per native choice, preferring `decline` over `cancel`. */
222export function museApprovalOptions(approval: Pick<MuseApproval, "availableChoices">) {
223 const choices = museApprovalChoices(approval);
224 const declineChoiceId = choices.get("decline")?.choiceId;
225 return [...choices]
226 .filter(([decision, choice]) => decision !== "cancel" || choice.choiceId !== declineChoiceId)
227 .map(([decision, choice]) => ({ decision, label: choice.label }));
228}
packages/provider-muse/src/server/sdk.test.ts created+266
......@@ -0,0 +1,266 @@
1import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk";
2import { it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import { afterEach, describe, expect, vi } from "vite-plus/test";
6import {
7 createMuseSdkHost,
8 createMuseSdkHostEffect,
9 makeMuseEnvironment,
10 museApprovalMode,
11 type MuseSdkHost,
12} from "./sdk.ts";
13
14vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() }));
15
16function pending<A>() {
17 let resolve = (_value: A) => {};
18 let reject = (_error: unknown) => {};
19 const promise = new Promise<A>((yes, no) => {
20 resolve = yes;
21 reject = no;
22 });
23 return { promise, resolve, reject };
24}
25
26function mockSpawn() {
27 const startup = pending<SpawnedMspConnection>();
28 const shutdown = pending<{ code: number; signal: null }>();
29 const closing = pending<void>();
30 const initializing = pending<void>();
31 const initialize = vi.fn(() => {
32 initializing.resolve();
33 return startup.promise;
34 });
35 const close = vi.fn(() => {
36 closing.resolve();
37 return shutdown.promise;
38 });
39 const handshake = { initialize, close } as unknown as MspHandshake;
40 vi.mocked(spawnMspConnection).mockReturnValue(handshake);
41 const ready = {
42 connection: {},
43 initializeResult: { grantedCapabilities: [], schema: { version: 1 } },
44 exited: shutdown.promise,
45 fingerprintWarning: { warning: "additive optional" },
46 } as unknown as SpawnedMspConnection;
47 return { startup, shutdown, closing, initializing, initialize, close, ready };
48}
49
50afterEach(() => {
51 vi.useRealTimers();
52 vi.clearAllMocks();
53});
54
55describe("Muse SDK host", () => {
56 it.each(["META_API_KEY", "meta_api_key", "Meta_Api_Key"])(
57 "excludes %s without reading its value or mutating the supplied environment",
58 (key) => {
59 const source: NodeJS.ProcessEnv = { PATH: "/bin", MUSE_TEST: "yes" };
60 const read = vi.fn(() => {
61 throw new Error("The API override must not be read.");
62 });
63 Object.defineProperty(source, key, { enumerable: true, get: read });
64 expect(makeMuseEnvironment(source)).toEqual({
65 PATH: "/bin",
66 MUSE_TEST: "yes",
67 MUSE_NO_AUTO_UPDATE: "1",
68 });
69 expect(read).not.toHaveBeenCalled();
70 expect(Object.keys(source)).toContain(key);
71 },
72 );
73
74 it("selects full access explicitly and keeps other runtime modes sandboxed", async () => {
75 for (const mode of ["approval-required", "auto-accept-edits", "auto", "full-access"] as const) {
76 const fake = mockSpawn();
77 const running = createMuseSdkHost({
78 binaryPath: "/custom/muse",
79 cwd: "/workspace",
80 runtimeMode: mode,
81 environment: { PATH: "/bin" },
82 });
83 expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0]).toMatchObject({
84 command: "/custom/muse",
85 cwd: "/workspace",
86 args:
87 mode === "full-access"
88 ? ["serve", "--trust-workspace", "--disable-sandbox"]
89 : ["serve", "--trust-workspace"],
90 });
91 expect(museApprovalMode(mode)).toBe(mode === "full-access" ? "allowAll" : "promptUnmatched");
92 fake.startup.resolve(fake.ready);
93 const host = await running;
94 fake.shutdown.resolve({ code: 0, signal: null });
95 await host.close();
96 await host.close();
97 expect(fake.close).toHaveBeenCalledOnce();
98 }
99 });
100
101 it("bounds startup and waits for native shutdown after timeout", async () => {
102 vi.useFakeTimers();
103 const fake = mockSpawn();
104 const startup = createMuseSdkHost({ binaryPath: "muse", startupTimeoutMs: 30 });
105 let settled = false;
106 const outcome = startup.then(
107 () => {
108 settled = true;
109 },
110 (error: unknown) => {
111 settled = true;
112 return error;
113 },
114 );
115 await vi.advanceTimersByTimeAsync(31);
116 expect(fake.close).toHaveBeenCalledOnce();
117 expect(settled).toBe(false);
118 fake.shutdown.resolve({ code: 0, signal: null });
119 expect(await outcome).toMatchObject({ message: "Muse SDK initialization timed out." });
120 });
121
122 it("closes the pre-handshake host on abort and rejects only after shutdown", async () => {
123 const fake = mockSpawn();
124 const abort = new AbortController();
125 const started = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal });
126 const reason = new Error("Cancelled by caller");
127 const outcome = started.catch((error: unknown) => error);
128 abort.abort(reason);
129 expect(fake.close).toHaveBeenCalledOnce();
130 fake.shutdown.resolve({ code: 0, signal: null });
131 expect(await outcome).toBe(reason);
132 });
133
134 it("closes rejected initialization and keeps successful schema advisories usable", async () => {
135 const failed = mockSpawn();
136 const started = createMuseSdkHost({ binaryPath: "muse" });
137 const outcome = started.catch((error: unknown) => error);
138 failed.startup.reject(new Error("Handshake failed"));
139 failed.shutdown.resolve({ code: 0, signal: null });
140 expect(await outcome).toMatchObject({ message: "Handshake failed" });
141 expect(failed.close).toHaveBeenCalledOnce();
142 const success = mockSpawn();
143 const ready = createMuseSdkHost({ binaryPath: "muse", readOnly: true });
144 success.startup.resolve(success.ready);
145 const host = await ready;
146 expect(host.initializeResult.grantedCapabilities).toEqual([]);
147 expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0].args).toEqual([
148 "serve",
149 "--disable-shell",
150 "--disable-write",
151 "--no-session-log",
152 ]);
153 success.shutdown.resolve({ code: 0, signal: null });
154 await host.close();
155 });
156
157 it.each([undefined, 2])(
158 "rejects unsupported envelope version %s and awaits shutdown",
159 async (version) => {
160 const fake = mockSpawn();
161 const started = createMuseSdkHost({ binaryPath: "muse" });
162 let settled = false;
163 const outcome = started.catch((error: unknown) => {
164 settled = true;
165 return error;
166 });
167 fake.startup.resolve({
168 ...fake.ready,
169 initializeResult: {
170 ...fake.ready.initializeResult,
171 schema: { fingerprint: "test", version },
172 },
173 } as unknown as SpawnedMspConnection);
174 await fake.closing.promise;
175 expect(fake.close).toHaveBeenCalledOnce();
176 expect(settled).toBe(false);
177 fake.shutdown.resolve({ code: 0, signal: null });
178 expect(await outcome).toMatchObject({
179 message: "Muse SDK returned an unsupported protocol envelope version.",
180 });
181 },
182 );
183
184 it("does not spawn when already aborted and closes a ready host on later abort", async () => {
185 const aborted = new AbortController();
186 aborted.abort();
187 await expect(
188 createMuseSdkHost({ binaryPath: "muse", signal: aborted.signal }),
189 ).rejects.toBeDefined();
190 expect(spawnMspConnection).not.toHaveBeenCalled();
191 const fake = mockSpawn();
192 const abort = new AbortController();
193 const running = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal });
194 fake.startup.resolve(fake.ready);
195 const host = await running;
196 abort.abort();
197 fake.shutdown.resolve({ code: 0, signal: null });
198 await host.close();
199 expect(fake.close).toHaveBeenCalledOnce();
200 });
201
202 it.effect(
203 "keeps Effect startup resources alive until interrupted initialization shuts down",
204 () =>
205 Effect.gen(function* () {
206 const fake = mockSpawn();
207 let released = false;
208 const fiber = yield* Effect.forkChild(
209 createMuseSdkHostEffect({ binaryPath: "muse" }).pipe(
210 Effect.ensuring(
211 Effect.sync(() => {
212 released = true;
213 }),
214 ),
215 ),
216 );
217 yield* Effect.promise(() => fake.initializing.promise);
218 const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild);
219 yield* Effect.promise(() => fake.closing.promise);
220 expect(released).toBe(false);
221 fake.shutdown.resolve({ code: 0, signal: null });
222 yield* Fiber.join(interrupted);
223 expect(released).toBe(true);
224 expect(fake.close).toHaveBeenCalledOnce();
225 }),
226 );
227
228 it.effect("closes a fulfilled host when interruption wins before acquisition is delivered", () =>
229 Effect.gen(function* () {
230 const started = pending<void>();
231 const startup = pending<MuseSdkHost>();
232 const closing = pending<void>();
233 const shutdown = pending<void>();
234 const host = {
235 close: vi.fn(() => {
236 closing.resolve();
237 return shutdown.promise;
238 }),
239 } as unknown as MuseSdkHost;
240 let released = false;
241 const fiber = yield* Effect.forkChild(
242 createMuseSdkHostEffect({ binaryPath: "muse" }, () => {
243 started.resolve();
244 return startup.promise;
245 }).pipe(
246 Effect.ensuring(
247 Effect.sync(() => {
248 released = true;
249 }),
250 ),
251 ),
252 );
253 yield* Effect.promise(() => started.promise);
254 startup.resolve(host);
255 const interrupted = yield* Fiber.interrupt(fiber).pipe(
256 Effect.forkChild({ startImmediately: true }),
257 );
258 yield* Effect.promise(() => closing.promise);
259 expect(released).toBe(false);
260 shutdown.resolve();
261 yield* Fiber.join(interrupted);
262 expect(released).toBe(true);
263 expect(host.close).toHaveBeenCalledOnce();
264 }),
265 );
266});
packages/provider-muse/src/server/sdk.ts created+163
......@@ -0,0 +1,163 @@
1// @effect-diagnostics globalTimers:off
2// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists.
3import {
4 spawnMspConnection,
5 type Connection,
6 type ProcessExit,
7 type SpawnedMspConnection,
8} from "@muse-code/sdk";
9import type { RuntimeMode } from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11
12export interface MuseSdkHost {
13 readonly connection: Pick<
14 Connection,
15 | "command"
16 | "request"
17 | "mintCommandId"
18 | "onNotification"
19 | "onServerRequest"
20 | "onProtocolError"
21 | "closed"
22 >;
23 readonly initializeResult: Pick<SpawnedMspConnection["initializeResult"], "grantedCapabilities">;
24 readonly exited: Promise<ProcessExit>;
25 /** The last lines Muse wrote to stderr, for explaining an unexpected exit. */
26 readonly stderrTail?: () => ReadonlyArray<string>;
27 readonly close: () => Promise<void>;
28}
29
30export interface MuseSdkHostOptions {
31 readonly binaryPath: string;
32 readonly cwd?: string;
33 readonly environment?: NodeJS.ProcessEnv;
34 readonly runtimeMode?: RuntimeMode;
35 /** Disables shell and filesystem writes and workspace trust. */
36 readonly readOnly?: boolean;
37 /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */
38 readonly sessionLogging?: boolean;
39 readonly signal?: AbortSignal;
40 readonly startupTimeoutMs?: number;
41}
42
43/** Preserve the host's CLI login while excluding the API-key override before copying values. */
44export function makeMuseEnvironment(
45 environment: NodeJS.ProcessEnv = process.env,
46): NodeJS.ProcessEnv {
47 return {
48 ...Object.fromEntries(
49 Object.keys(environment)
50 .filter((key) => key.toUpperCase() !== "META_API_KEY")
51 .map((key) => [key, environment[key]]),
52 ),
53 MUSE_NO_AUTO_UPDATE: "1",
54 };
55}
56
57export function museApprovalMode(runtimeMode: RuntimeMode) {
58 return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched";
59}
60
61/** The `muse serve` arguments for a host: read-only hosts get no shell, writes, or workspace trust. */
62export function museServeArgs(
63 options: Pick<MuseSdkHostOptions, "readOnly" | "sessionLogging" | "runtimeMode">,
64) {
65 const args = ["serve"];
66 if (options.readOnly) {
67 args.push("--disable-shell", "--disable-write");
68 if (options.sessionLogging !== true) args.push("--no-session-log");
69 } else {
70 args.push("--trust-workspace");
71 if (options.runtimeMode === "full-access") args.push("--disable-sandbox");
72 }
73 return args;
74}
75
76/** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */
77export function museInitializeParams(readOnly = false) {
78 return {
79 clientInfo: { name: "t3_code", title: "T3 Code", version: "1" },
80 capabilities: { requestedCapabilities: readOnly ? [] : ["sessionMcp"] },
81 };
82}
83
84/** One SDK-owned process. The pre-handshake handle owns cleanup even when initialize never replies. */
85export async function createMuseSdkHost(
86 options: MuseSdkHostOptions,
87 spawn: typeof spawnMspConnection = spawnMspConnection,
88): Promise<MuseSdkHost> {
89 options.signal?.throwIfAborted();
90 const handshake = spawn({
91 command: options.binaryPath,
92 args: museServeArgs(options),
93 ...(options.cwd ? { cwd: options.cwd } : {}),
94 // Callers pass an environment already built with makeMuseEnvironment.
95 env: options.environment ?? makeMuseEnvironment(),
96 // A healthy host exits at once; this only bounds a hung one on close.
97 shutdownTimeoutMs: 10_000,
98 });
99 let closePromise: Promise<void> | undefined;
100 const close = () => {
101 options.signal?.removeEventListener("abort", onAbort);
102 return (closePromise ??= handshake.close().then(() => undefined));
103 };
104 let rejectStartup: (reason: unknown) => void = () => {};
105 const onAbort = () => {
106 rejectStartup(options.signal?.reason ?? new Error("Muse SDK startup aborted."));
107 void close().catch(() => {});
108 };
109 const interrupted = new Promise<never>((_resolve, reject) => {
110 rejectStartup = reject;
111 });
112 options.signal?.addEventListener("abort", onAbort, { once: true });
113 if (options.signal?.aborted) onAbort();
114 const timer = setTimeout(
115 () => rejectStartup(new Error("Muse SDK initialization timed out.")),
116 options.startupTimeoutMs ?? 20_000,
117 );
118 timer.unref();
119 try {
120 const host = await Promise.race([
121 handshake.initialize(museInitializeParams(options.readOnly)),
122 interrupted,
123 ]);
124 if (host.initializeResult.schema?.version !== 1) {
125 throw new Error("Muse SDK returned an unsupported protocol envelope version.");
126 }
127 // The SDK's fingerprint warning permits additive optional schema changes.
128 // Successful initialization, not fingerprint identity, determines readiness.
129 return {
130 connection: host.connection,
131 initializeResult: host.initializeResult,
132 exited: host.exited,
133 stderrTail: () => handshake.child.stderrTail,
134 close,
135 };
136 } catch (error) {
137 await close();
138 throw error;
139 } finally {
140 clearTimeout(timer);
141 }
142}
143
144/** Cancellation waits for native shutdown before callers release startup resources. */
145export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(function* (
146 options: Omit<MuseSdkHostOptions, "signal">,
147 createHost: typeof createMuseSdkHost = createMuseSdkHost,
148) {
149 let startup: Promise<MuseSdkHost> | undefined;
150 return yield* Effect.tryPromise((signal) => {
151 startup = createHost({ ...options, signal });
152 return startup;
153 }).pipe(
154 Effect.onInterrupt(() =>
155 Effect.promise(async () => {
156 await startup?.then(
157 (host) => host.close(),
158 () => {},
159 );
160 }),
161 ),
162 );
163});
packages/provider-muse/src/server/status.test.ts created+167
......@@ -0,0 +1,167 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { MuseSettings } from "../settings.ts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Schema from "effect/Schema";
7import { vi } from "vite-plus/test";
8
9import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
10import type { MuseSdkHost } from "./sdk.ts";
11import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe";
12import { checkMuseProviderStatus } from "./status.ts";
13
14const settings = Schema.decodeSync(MuseSettings);
15const makeHost = (catalog: Record<string, unknown>) => {
16 const host: MuseSdkHost = {
17 initializeResult: { grantedCapabilities: [] },
18 connection: {
19 request: vi.fn(async () => catalog),
20 command: vi.fn(async () => ({})),
21 mintCommandId: () => "test-command",
22 onNotification: () => {},
23 onServerRequest: () => {},
24 onProtocolError: () => {},
25 closed: new Promise(() => {}),
26 },
27 exited: new Promise(() => {}),
28 close: vi.fn(async () => {}),
29 };
30 return host;
31};
32const metaCatalog = {
33 providerId: "meta",
34 models: [
35 {
36 modelId: "muse-discovered",
37 displayLabel: "Muse Discovered",
38 providerId: "meta",
39 isDefault: true,
40 },
41 {
42 modelId: "foreign-model",
43 displayLabel: "Another provider",
44 providerId: "another",
45 isDefault: false,
46 },
47 ],
48};
49
50const fakeCli = (source = 'process.stdout.write("muse 1.0.3-R2198.1\\n");') =>
51 Effect.gen(function* () {
52 const fs = yield* FileSystem.FileSystem;
53 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-status-test-" });
54 return writeFakeCli({ directory, name: "muse", source });
55 });
56
57it.layer(NodeServices.layer)("Muse status", (it) => {
58 it.effect("does not start a host when disabled", () =>
59 Effect.gen(function* () {
60 const createHost = vi.fn(async () => makeHost(metaCatalog));
61 const snapshot = yield* checkMuseProviderStatus(settings({}), {}, undefined, createHost);
62 expect(snapshot.status).toBe("disabled");
63 expect(createHost).not.toHaveBeenCalled();
64 }),
65 );
66
67 it.effect("gives host-local install and login guidance for a missing binary", () =>
68 Effect.gen(function* () {
69 const snapshot = yield* checkMuseProviderStatus(
70 settings({ enabled: true, binaryPath: "/definitely-missing/muse" }),
71 );
72 expect(snapshot.installed).toBe(false);
73 expect(snapshot.status).toBe("error");
74 expect(snapshot.message).toContain("muse login");
75 expect(snapshot.message).toContain("this T3 server host");
76 }),
77 );
78
79 it.effect("does not expose CLI stderr or start an SDK host after a failed version check", () =>
80 Effect.scoped(
81 Effect.gen(function* () {
82 const binaryPath = yield* fakeCli(
83 'process.stderr.write("secret-test-value");process.exit(2);',
84 );
85 const createHost = vi.fn(async () => makeHost(metaCatalog));
86 const snapshot = yield* checkMuseProviderStatus(
87 settings({ enabled: true, binaryPath }),
88 undefined,
89 undefined,
90 createHost,
91 );
92 expect(snapshot.installed).toBe(true);
93 expect(snapshot.status).toBe("error");
94 expect(snapshot.message).not.toContain("secret-test-value");
95 expect(createHost).not.toHaveBeenCalled();
96 }),
97 ),
98 );
99
100 it.effect("discovers the Meta catalog without treating it as authentication proof", () =>
101 Effect.scoped(
102 Effect.gen(function* () {
103 const binaryPath = yield* fakeCli();
104 const host = makeHost(metaCatalog);
105 const snapshot = yield* checkMuseProviderStatus(
106 settings({ enabled: true, binaryPath, customModels: ["muse-custom"] }),
107 undefined,
108 undefined,
109 async () => host,
110 );
111 expect(snapshot.status).toBe("ready");
112 expect(snapshot.message).toBeUndefined();
113 expect(snapshot.slashCommands).toEqual([COMPACT_SLASH_COMMAND]);
114 expect(snapshot.auth).toEqual({ status: "unknown" });
115 expect(snapshot.version).toBe("1.0.3-R2198.1");
116 expect(snapshot.models.map((model) => model.slug)).toEqual([
117 "muse-discovered",
118 "muse-custom",
119 ]);
120 const descriptor = snapshot.models[0]?.capabilities?.optionDescriptors?.[0];
121 expect(
122 descriptor?.type === "select" && descriptor.options.map((option) => option.id),
123 ).toEqual(["low", "medium", "high", "xhigh", "max"]);
124 expect(host.connection.request).toHaveBeenCalledWith("model/list", {});
125 expect(host.connection.command).not.toHaveBeenCalled();
126 expect(host.close).toHaveBeenCalledOnce();
127 }),
128 ),
129 );
130
131 it.effect("closes the host after malformed metadata and reports unavailable", () =>
132 Effect.scoped(
133 Effect.gen(function* () {
134 const binaryPath = yield* fakeCli();
135 const host = makeHost({ providerId: "meta", models: [{ modelId: 1 }] });
136 const snapshot = yield* checkMuseProviderStatus(
137 settings({ enabled: true, binaryPath }),
138 undefined,
139 undefined,
140 async () => host,
141 );
142 expect(snapshot.status).toBe("error");
143 expect(snapshot.auth.status).toBe("unknown");
144 expect(snapshot.models).toEqual([]);
145 expect(host.close).toHaveBeenCalledOnce();
146 }),
147 ),
148 );
149
150 it.effect("keeps an empty discovered catalog empty instead of advertising a fallback model", () =>
151 Effect.scoped(
152 Effect.gen(function* () {
153 const binaryPath = yield* fakeCli();
154 const host = makeHost({ providerId: "meta", models: [] });
155 const snapshot = yield* checkMuseProviderStatus(
156 settings({ enabled: true, binaryPath }),
157 undefined,
158 undefined,
159 async () => host,
160 );
161 expect(snapshot.status).toBe("warning");
162 expect(snapshot.models).toEqual([]);
163 expect(host.close).toHaveBeenCalledOnce();
164 }),
165 ),
166 );
167});
packages/provider-muse/src/server/status.ts created+154
......@@ -0,0 +1,154 @@
1import { ServerProviderModel } from "@t3tools/contracts";
2import { MuseSettings } from "../settings.ts";
3import { resolveSpawnCommand } from "@t3tools/shared/shell";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Result from "effect/Result";
8import { ChildProcess } from "effect/process";
9
10import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts";
11import { parseMuseVersion } from "./maintenance.ts";
12import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts";
13import {
14 buildServerProvider,
15 COMPACT_SLASH_COMMAND,
16 DEFAULT_TIMEOUT_MS,
17 isCommandMissingCause,
18 providerModelsFromSettings,
19 spawnAndCollect,
20 type ProviderProbeResult,
21} from "@t3tools/provider-core/server/snapshotProbe";
22
23const MUSE_PRESENTATION = {
24 displayName: "Muse Code",
25 showInteractionModeToggle: false,
26 reportsContextWindow: true,
27 // Muse has no native "accept edits" or reviewer-backed mode, so T3 offers only
28 // the two it maps directly: promptUnmatched and allowAll.
29 supportedRuntimeModes: ["approval-required", "full-access"],
30} as const;
31
32const FALLBACK_CAPABILITIES = museModelCapabilities();
33
34export const makePendingMuseProvider = Effect.fn("makePendingMuseProvider")(function* (
35 settings: MuseSettings,
36) {
37 return buildServerProvider({
38 presentation: MUSE_PRESENTATION,
39 enabled: settings.enabled,
40 checkedAt: DateTime.formatIso(yield* DateTime.now),
41 models: providerModelsFromSettings([], settings.customModels, FALLBACK_CAPABILITIES),
42 slashCommands: settings.enabled ? [COMPACT_SLASH_COMMAND] : [],
43 probe: {
44 installed: false,
45 version: null,
46 status: "warning",
47 auth: { status: "unknown" },
48 message: settings.enabled
49 ? "Checking Muse Code CLI availability..."
50 : "Muse Code is disabled in T3 Code settings.",
51 },
52 });
53});
54
55export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(function* (
56 settings: MuseSettings,
57 environment?: NodeJS.ProcessEnv,
58 cwd?: string,
59 createHost: typeof createMuseSdkHost = createMuseSdkHost,
60) {
61 if (!settings.enabled) return yield* makePendingMuseProvider(settings);
62 const checkedAt = DateTime.formatIso(yield* DateTime.now);
63 // The driver passes a prepared environment; direct callers get the host's without META_API_KEY.
64 const museEnvironment = environment ?? makeMuseEnvironment();
65 const snapshot = (probe: ProviderProbeResult, models: ReadonlyArray<ServerProviderModel> = []) =>
66 buildServerProvider({
67 presentation: MUSE_PRESENTATION,
68 enabled: true,
69 checkedAt,
70 models: providerModelsFromSettings(models, settings.customModels, FALLBACK_CAPABILITIES),
71 slashCommands: [COMPACT_SLASH_COMMAND],
72 probe,
73 });
74 const versionResult = yield* Effect.gen(function* () {
75 const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], {
76 env: museEnvironment,
77 });
78 return yield* spawnAndCollect(
79 settings.binaryPath,
80 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
81 env: museEnvironment,
82 shell: spawnCommand.shell,
83 ...(cwd ? { cwd } : {}),
84 }),
85 );
86 }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result);
87
88 if (Result.isFailure(versionResult)) {
89 const missing = isCommandMissingCause(versionResult.failure);
90 return snapshot({
91 installed: !missing,
92 version: null,
93 status: "error",
94 auth: { status: "unknown" },
95 message: missing
96 ? "Muse Code CLI (`muse`) was not found. Install Muse Code and run `muse login` on this T3 server host."
97 : "Failed to execute Muse Code CLI. Check its binary path on this T3 server host.",
98 });
99 }
100 if (Option.isNone(versionResult.success)) {
101 return snapshot({
102 installed: true,
103 version: null,
104 status: "error",
105 auth: { status: "unknown" },
106 message: "Muse Code CLI version check timed out.",
107 });
108 }
109 const versionOutput = versionResult.success.value;
110 const version = parseMuseVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`);
111 if (versionOutput.code !== 0) {
112 return snapshot({
113 installed: true,
114 version,
115 status: "error",
116 auth: { status: "unknown" },
117 message: "Muse Code CLI is installed but failed to run.",
118 });
119 }
120
121 const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe(
122 Effect.scoped,
123 Effect.timeoutOption(12_000),
124 Effect.result,
125 );
126 if (Result.isFailure(catalog) || Option.isNone(catalog.success)) {
127 return snapshot({
128 installed: true,
129 version,
130 status: "error",
131 auth: { status: "unknown" },
132 message:
133 "Muse Code SDK could not read the model catalog. Check your Muse installation and run `muse login` on this T3 server host.",
134 });
135 }
136 const models = catalog.success.value;
137 // MSP has no auth signal, and model/list answers from Muse's cache even when logged out.
138 // Auth failures surface on the first turn instead.
139 return snapshot(
140 {
141 installed: true,
142 version,
143 auth: { status: "unknown" },
144 ...(models.length > 0
145 ? { status: "ready" }
146 : {
147 status: "warning",
148 message:
149 "Muse Code returned no models. Run `muse login` on this T3 server host and refresh its status.",
150 }),
151 },
152 models,
153 );
154});
packages/provider-muse/src/server/textGeneration.test.ts created+372
......@@ -0,0 +1,372 @@
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});
packages/provider-muse/src/server/textGeneration.ts created+350
......@@ -0,0 +1,350 @@
1import {
2 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
3 MUSE_DEFAULT_MODEL,
4 type ModelSelection,
5 type ServerProviderModel,
6 TextGenerationError,
7} from "@t3tools/contracts";
8import type { MuseSettings } from "../settings.ts";
9import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
11import { extractJsonObject } from "@t3tools/shared/schemaJson";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16
17import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts";
18import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts";
19import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
20import {
21 buildBranchNamePrompt,
22 buildCommitMessagePrompt,
23 buildPrContentPrompt,
24 buildThreadTitlePrompt,
25} from "@t3tools/provider-core/server/textGenerationPrompts";
26import {
27 sanitizeCommitSubject,
28 sanitizePrTitle,
29 sanitizeThreadTitle,
30 toJsonSchemaObject,
31} from "@t3tools/provider-core/server/textGenerationUtils";
32
33const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) });
34const ItemNotification = Schema.Struct({
35 sessionId: Schema.String,
36 item: Schema.Struct({
37 itemId: Schema.String,
38 kind: Schema.String,
39 revision: Schema.Int,
40 turnId: Schema.optionalKey(Schema.String),
41 text: Schema.optionalKey(Schema.String),
42 retracted: Schema.optionalKey(Schema.Boolean),
43 truncated: Schema.optionalKey(Schema.Boolean),
44 }),
45});
46const TurnCompleted = Schema.Struct({
47 sessionId: Schema.String,
48 turnId: Schema.String,
49 terminal: Schema.String,
50 error: Schema.optionalKey(Schema.Struct({ message: Schema.String })),
51});
52const ApprovalRequested = Schema.Struct({
53 sessionId: Schema.String,
54 approvalId: Schema.String,
55 currentRequirementId: Schema.Unknown,
56 availableChoices: Schema.Array(
57 Schema.Struct({ choiceId: Schema.String, decision: Schema.String }),
58 ),
59});
60const UserInputRequested = Schema.Struct({
61 sessionId: Schema.String,
62 userInputId: Schema.String,
63});
64const decodeSession = Schema.decodeUnknownSync(SessionStarted);
65const decodeItem = Schema.decodeUnknownSync(ItemNotification);
66const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted);
67const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested);
68const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested);
69const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
70const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
71
72type Operation = keyof ProviderTextGeneration;
73
74async function generateMuseText(
75 host: MuseSdkHost,
76 workspaceRoot: string,
77 prompt: string,
78 modelId: string | undefined,
79 reasoningEffort: string | undefined,
80 signal: AbortSignal,
81) {
82 let sessionId: string | undefined;
83 const turnId = host.connection.mintCommandId();
84 const messages = new Map<string, (typeof ItemNotification.Type)["item"]>();
85 let resolveCompletion: (text: string) => void = () => {};
86 let rejectCompletion: (error: unknown) => void = () => {};
87 const completion = new Promise<string>((resolve, reject) => {
88 resolveCompletion = resolve;
89 rejectCompletion = reject;
90 });
91 // Notifications may settle the turn before the turn/start acknowledgement arrives.
92 void completion.catch(() => {});
93 const rejectInteractive = async (method: string, params: Record<string, unknown>) => {
94 if (method === "approval/requested") {
95 const approval = decodeApproval(params);
96 if (approval.sessionId !== sessionId) return;
97 rejectCompletion(new Error("Muse requested an interactive action during text generation."));
98 const deny = approval.availableChoices.find((choice) => choice.decision === "denied");
99 if (deny) {
100 await host.connection.command("approval/decide", {
101 sessionId,
102 approvalId: approval.approvalId,
103 requirementId: approval.currentRequirementId,
104 choiceId: deny.choiceId,
105 });
106 }
107 } else {
108 const request = decodeUserInput(params);
109 if (request.sessionId !== sessionId) return;
110 rejectCompletion(new Error("Muse requested an interactive action during text generation."));
111 await host.connection.command("userInput/cancel", {
112 sessionId,
113 userInputId: request.userInputId,
114 reason: "T3 Code text generation cannot request user input.",
115 });
116 }
117 };
118 host.connection.onNotification((notification) => {
119 try {
120 if (notification.method === "item/completed" || notification.method === "item/updated") {
121 const { item, sessionId: itemSessionId } = decodeItem(notification.params);
122 if (itemSessionId !== sessionId || item.turnId !== turnId || item.kind !== "agentMessage")
123 return;
124 const previous = messages.get(item.itemId);
125 if (!previous || item.revision > previous.revision) messages.set(item.itemId, item);
126 } else if (notification.method === "turn/completed") {
127 const completed = decodeCompleted(notification.params);
128 if (completed.sessionId !== sessionId || completed.turnId !== turnId) return;
129 if (completed.terminal === "completed") {
130 // A turn may include commentary before its final structured answer.
131 const answer = [...messages.values()].findLast((item) => !item.retracted);
132 if (answer?.truncated) {
133 rejectCompletion(new Error("Muse text generation returned a truncated response."));
134 } else {
135 resolveCompletion(answer?.text ?? "");
136 }
137 } else {
138 rejectCompletion(
139 new Error(completed.error?.message ?? `Muse turn ${completed.terminal}.`),
140 );
141 }
142 } else if (
143 notification.method === "approval/requested" ||
144 notification.method === "userInput/requested"
145 ) {
146 void rejectInteractive(notification.method, notification.params ?? {}).catch(
147 rejectCompletion,
148 );
149 } else if (notification.method === "view/gap") {
150 rejectCompletion(new Error("Muse text generation lost part of its response."));
151 }
152 } catch (error) {
153 rejectCompletion(error);
154 }
155 });
156 host.connection.onProtocolError(rejectCompletion);
157 host.connection.onServerRequest(async () => {
158 const error = new Error("T3 Code text generation cannot approve interactive requests.");
159 rejectCompletion(error);
160 throw error;
161 });
162 void host.connection.closed.then(() => {
163 rejectCompletion(new Error("Muse connection closed before text generation completed."));
164 });
165 void host.exited.then(() => {
166 rejectCompletion(new Error("Muse exited before text generation completed."));
167 });
168 const onAbort = () => rejectCompletion(signal.reason);
169 signal.addEventListener("abort", onAbort, { once: true });
170 try {
171 signal.throwIfAborted();
172 const started = decodeSession(
173 await host.connection.command("session/start", {
174 workspaceRoot,
175 providerId: "meta",
176 ...(modelId ? { modelId } : {}),
177 approvalMode: "denyUnmatched",
178 }),
179 );
180 sessionId = started.session.sessionId;
181 const acknowledged = host.connection.command(
182 "turn/start",
183 {
184 sessionId,
185 input: [{ type: "text", text: prompt }],
186 ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
187 },
188 { commandId: turnId },
189 );
190 return await Promise.race([acknowledged.then(() => completion), completion]);
191 } finally {
192 signal.removeEventListener("abort", onAbort);
193 }
194}
195
196export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(function* (
197 settings: MuseSettings,
198 options: {
199 readonly environment?: NodeJS.ProcessEnv;
200 readonly modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>;
201 /** Test hook. */
202 readonly createHost?: typeof createMuseSdkHost;
203 } = {},
204) {
205 const { environment, modelCatalog = Effect.succeed([]), createHost } = options;
206 const fileSystem = yield* FileSystem.FileSystem;
207 const runMuseJson = Effect.fn("runMuseJson")(function* <S extends Schema.Top>(input: {
208 operation: Operation;
209 prompt: string;
210 outputSchema: S;
211 modelSelection: ModelSelection;
212 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
213 const { operation } = input;
214 if (!settings.enabled) {
215 return yield* new TextGenerationError({ operation, detail: "Muse Code is disabled." });
216 }
217 const selectedModel = input.modelSelection.model.trim();
218 const models = yield* modelCatalog;
219 // "default" is the catalog's default model, as in chat. Without one, Muse picks.
220 const catalogModel =
221 selectedModel && selectedModel !== MUSE_DEFAULT_MODEL
222 ? models.find((model) => model.slug === selectedModel)
223 : models.find((model) => model.isDefault && !model.isCustom);
224 const modelId =
225 selectedModel && selectedModel !== MUSE_DEFAULT_MODEL ? selectedModel : catalogModel?.slug;
226 const reasoningEffort = resolveMuseReasoningEffort(
227 catalogModel?.capabilities ?? museModelCapabilities(),
228 getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") ??
229 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
230 );
231 const jsonSchema = yield* encodeJson(toJsonSchemaObject(input.outputSchema)).pipe(
232 Effect.mapError(
233 (cause) => new TextGenerationError({ operation, detail: "Invalid output schema.", cause }),
234 ),
235 );
236 const response = yield* Effect.gen(function* () {
237 // Metadata requests already include their context and do not need checkout configuration.
238 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-muse-text-" });
239 const host = yield* Effect.acquireRelease(
240 createMuseSdkHostEffect(
241 {
242 binaryPath: settings.binaryPath,
243 cwd,
244 ...(environment ? { environment } : {}),
245 readOnly: true,
246 // Muse derives SDK turn events from its session log, including completion.
247 sessionLogging: true,
248 },
249 createHost,
250 ),
251 (host: MuseSdkHost) => Effect.promise(() => host.close()),
252 { interruptible: true },
253 );
254 return yield* Effect.tryPromise((signal) =>
255 generateMuseText(
256 host,
257 cwd,
258 `${input.prompt}\n\nReturn only a JSON object matching this schema, with no markdown fences. Do not use tools or ask questions.\n${jsonSchema}`,
259 modelId,
260 reasoningEffort,
261 signal,
262 ),
263 );
264 }).pipe(
265 Effect.scoped,
266 Effect.mapError(
267 (cause) =>
268 new TextGenerationError({
269 operation,
270 detail:
271 "Muse Code text generation failed. Check Muse login and availability on this T3 server host.",
272 cause,
273 }),
274 ),
275 Effect.timeoutOption(180_000),
276 Effect.flatMap(
277 Option.match({
278 onNone: () =>
279 Effect.fail(new TextGenerationError({ operation, detail: "Muse request timed out." })),
280 onSome: Effect.succeed,
281 }),
282 ),
283 );
284 const decodeOutput = Schema.decodeEffect(input.outputSchema);
285 return yield* decodeJson(extractJsonObject(response)).pipe(
286 Effect.flatMap(decodeOutput),
287 Effect.mapError(
288 (cause) =>
289 new TextGenerationError({
290 operation,
291 detail: "Muse returned invalid structured output.",
292 cause,
293 }),
294 ),
295 );
296 });
297
298 const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn(
299 "MuseTextGeneration.generateCommitMessage",
300 )(function* (input) {
301 const generated = yield* runMuseJson({
302 operation: "generateCommitMessage",
303 ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }),
304 modelSelection: input.modelSelection,
305 });
306 return {
307 subject: sanitizeCommitSubject(generated.subject),
308 body: generated.body.trim(),
309 ...("branch" in generated && typeof generated.branch === "string"
310 ? { branch: sanitizeFeatureBranchName(generated.branch) }
311 : {}),
312 };
313 });
314 const generatePrContent: ProviderTextGeneration["generatePrContent"] = Effect.fn(
315 "MuseTextGeneration.generatePrContent",
316 )(function* (input) {
317 const generated = yield* runMuseJson({
318 operation: "generatePrContent",
319 ...buildPrContentPrompt(input),
320 modelSelection: input.modelSelection,
321 });
322 return { title: sanitizePrTitle(generated.title), body: generated.body.trim() };
323 });
324 const generateBranchName: ProviderTextGeneration["generateBranchName"] = Effect.fn(
325 "MuseTextGeneration.generateBranchName",
326 )(function* (input) {
327 const generated = yield* runMuseJson({
328 operation: "generateBranchName",
329 ...buildBranchNamePrompt(input),
330 modelSelection: input.modelSelection,
331 });
332 return { branch: sanitizeBranchFragment(generated.branch) };
333 });
334 const generateThreadTitle: ProviderTextGeneration["generateThreadTitle"] = Effect.fn(
335 "MuseTextGeneration.generateThreadTitle",
336 )(function* (input) {
337 const generated = yield* runMuseJson({
338 operation: "generateThreadTitle",
339 ...buildThreadTitlePrompt(input),
340 modelSelection: input.modelSelection,
341 });
342 return { title: sanitizeThreadTitle(generated.title) };
343 });
344 return {
345 generateCommitMessage,
346 generatePrContent,
347 generateBranchName,
348 generateThreadTitle,
349 } satisfies ProviderTextGeneration;
350});
packages/provider-muse/src/settings.ts created+36
......@@ -0,0 +1,36 @@
1/**
2 * Muse Code instance settings. Shared by the server driver and the client
3 * settings form, so it holds only browser-safe schema code.
4 *
5 * @module provider-muse/settings
6 */
7import {
8 CustomModelSetting,
9 makeBinaryPathSetting,
10 makeProviderSettingsSchema,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";
14
15export const MuseSettings = makeProviderSettingsSchema(
16 {
17 enabled: Schema.Boolean.pipe(
18 Schema.withDecodingDefault(Effect.succeed(false)),
19 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
20 ),
21 binaryPath: makeBinaryPathSetting("muse").pipe(
22 Schema.annotateKey({
23 title: "Binary path",
24 description:
25 "Path to the Muse Code binary on this environment. Sign in with muse login on that host.",
26 providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" },
27 }),
28 ),
29 customModels: Schema.Array(CustomModelSetting).pipe(
30 Schema.withDecodingDefault(Effect.succeed([])),
31 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
32 ),
33 },
34 { order: ["binaryPath"] },
35);
36export type MuseSettings = typeof MuseSettings.Type;
packages/provider-muse/src/testing.ts created+14
......@@ -0,0 +1,14 @@
1/**
2 * Muse Code internals for replay tests and the fixture recorder: the MSP
3 * launch arguments, initialize parameters, environment, and version parser.
4 *
5 * @module provider-muse/testing
6 */
7export {
8 makeMuseEnvironment,
9 museInitializeParams,
10 museServeArgs,
11 type MuseSdkHost,
12 type MuseSdkHostOptions,
13} from "./server/sdk.ts";
14export { parseMuseVersion } from "./server/maintenance.ts";
packages/provider-muse/tsconfig.json created+7
......@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
packages/provider-testing/package.json+5
......@@ -18,6 +18,10 @@
1818 "./host": {
1919 "types": "./src/host.ts",
2020 "import": "./src/host.ts"
21 },
22 "./fakeCli": {
23 "types": "./src/fakeCli.ts",
24 "import": "./src/fakeCli.ts"
2125 }
2226 },
2327 "scripts": {
......@@ -28,6 +32,7 @@
2832 "@effect/platform-node": "catalog:",
2933 "@t3tools/contracts": "workspace:*",
3034 "@t3tools/provider-core": "workspace:*",
35 "@t3tools/shared": "workspace:*",
3136 "effect": "catalog:"
3237 },
3338 "devDependencies": {
packages/provider-testing/src/fakeCli.ts created+106
......@@ -0,0 +1,106 @@
1// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6
7export interface FakeCliOptions {
8 /** Directory the launcher and its stub are written into. */
9 readonly directory: string;
10 /** Command name as callers spawn it, e.g. `codex`. */
11 readonly name: string;
12 /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */
13 readonly source: string;
14 /** Environment set for the stub before it runs, on top of the inherited environment. */
15 readonly env?: Readonly<Record<string, string>>;
16 /**
17 * Platform the launcher is shaped for. Effect callers should pass the value
18 * they read from `HostProcessPlatform` so an injected override is honoured;
19 * defaults to the real host for plain tests.
20 */
21 readonly platform?: NodeJS.Platform;
22}
23
24/**
25 * Writes a fake CLI whose behaviour lives in a Node stub. On posix the
26 * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a
27 * shebang file is not executable there and `resolveSpawnCommand` routes
28 * `.cmd` through a shell. Returns the launcher path to hand to the code under
29 * test, which on Windows carries the `.cmd` extension.
30 */
31export function writeFakeCli(options: FakeCliOptions): string {
32 NodeFS.mkdirSync(options.directory, { recursive: true });
33 const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`);
34 // The environment rides in a JSON sidecar the stub applies to itself, since
35 // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely.
36 const envPath = NodePath.join(options.directory, `${options.name}-env.json`);
37 NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8");
38 NodeFS.writeFileSync(
39 stubPath,
40 [
41 'import { readFileSync as readFakeCliEnv } from "node:fs";',
42 `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`,
43 options.source,
44 ].join("\n"),
45 "utf8",
46 );
47
48 if ((options.platform ?? HostProcessPlatform.defaultValue()) === "win32") {
49 const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`);
50 NodeFS.writeFileSync(
51 launcherPath,
52 ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join(
53 "\r\n",
54 ),
55 "utf8",
56 );
57 return launcherPath;
58 }
59
60 const launcherPath = NodePath.join(options.directory, options.name);
61 NodeFS.writeFileSync(
62 launcherPath,
63 ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"),
64 "utf8",
65 );
66 NodeFS.chmodSync(launcherPath, 0o755);
67 return launcherPath;
68}
69
70/**
71 * Stub source that becomes `scriptPath`: after optionally requiring a leading
72 * argv prefix, it imports the script into its own process so the stub is the
73 * agent. Nothing sits between the code under test and the mock, so a kill or
74 * a closed stdin reaches it directly and its exit log is faithful on every
75 * host. The script must not read `process.argv`; the mock agents are driven
76 * by environment and stdin.
77 */
78export function execScriptSource(options: {
79 readonly scriptPath: string;
80 readonly expectedArgs?: ReadonlyArray<string>;
81 /** Tab-separated argv appended here per invocation, for launch-flag assertions. */
82 readonly argvLogPath?: string;
83 /** Wait before handing over, for tests that race a slow startup. */
84 readonly delayMs?: number;
85}): string {
86 return [
87 'import { appendFileSync } from "node:fs";',
88 'import { pathToFileURL } from "node:url";',
89 "const args = process.argv.slice(2);",
90 ...(options.argvLogPath === undefined
91 ? []
92 : [
93 `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`,
94 ]),
95 ...(options.delayMs === undefined
96 ? []
97 : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]),
98 `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`,
99 "if (expected.some((value, index) => args[index] !== value)) {",
100 ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);',
101 " process.exit(11);",
102 "}",
103 `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`,
104 "",
105 ].join("\n");
106}
pnpm-lock.yaml+46
......@@ -356,6 +356,9 @@ importers:
356356 '@t3tools/provider-core':
357357 specifier: workspace:*
358358 version: link:../../packages/provider-core
359 '@t3tools/provider-muse':
360 specifier: workspace:*
361 version: link:../../packages/provider-muse
359362 '@t3tools/provider-pi':
360363 specifier: workspace:*
361364 version: link:../../packages/provider-pi
......@@ -603,6 +606,9 @@ importers:
603606 '@t3tools/provider-core':
604607 specifier: workspace:*
605608 version: link:../../packages/provider-core
609 '@t3tools/provider-muse':
610 specifier: workspace:*
611 version: link:../../packages/provider-muse
606612 '@t3tools/provider-pi':
607613 specifier: workspace:*
608614 version: link:../../packages/provider-pi
......@@ -733,6 +739,9 @@ importers:
733739 '@t3tools/provider-core':
734740 specifier: workspace:*
735741 version: link:../../packages/provider-core
742 '@t3tools/provider-muse':
743 specifier: workspace:*
744 version: link:../../packages/provider-muse
736745 '@t3tools/provider-pi':
737746 specifier: workspace:*
738747 version: link:../../packages/provider-pi
......@@ -1099,6 +1108,40 @@ importers:
10991108 specifier: 1.0.0
11001109 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11011110
1111 packages/provider-muse:
1112 dependencies:
1113 '@muse-code/sdk':
1114 specifier: 1.4.2
1115 version: 1.4.2(@types/node@24.12.4)
1116 '@t3tools/contracts':
1117 specifier: workspace:*
1118 version: link:../contracts
1119 '@t3tools/provider-core':
1120 specifier: workspace:*
1121 version: link:../provider-core
1122 '@t3tools/shared':
1123 specifier: workspace:*
1124 version: link:../shared
1125 effect:
1126 specifier: 4.0.1
1127 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1128 devDependencies:
1129 '@effect/platform-node':
1130 specifier: 4.0.1
1131 version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6)
1132 '@effect/vitest':
1133 specifier: 4.0.1
1134 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1135 '@t3tools/provider-testing':
1136 specifier: workspace:*
1137 version: link:../provider-testing
1138 '@types/node':
1139 specifier: 24.12.4
1140 version: 24.12.4
1141 vite-plus:
1142 specifier: 1.0.0
1143 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1144
11021145 packages/provider-pi:
11031146 dependencies:
11041147 '@t3tools/contracts':
......@@ -1141,6 +1184,9 @@ importers:
11411184 '@t3tools/provider-core':
11421185 specifier: workspace:*
11431186 version: link:../provider-core
1187 '@t3tools/shared':
1188 specifier: workspace:*
1189 version: link:../shared
11441190 effect:
11451191 specifier: 4.0.1
11461192 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
scripts/release-smoke.ts+1
......@@ -31,6 +31,7 @@ const workspaceFiles = [
3131 "packages/provider-core/package.json",
3232 "packages/provider-testing/package.json",
3333 "packages/provider-pi/package.json",
34 "packages/provider-muse/package.json",
3435 "packages/effect-acp/package.json",
3536 "packages/effect-codex-app-server/package.json",
3637 "scripts/package.json",