| author | |
| committer | |
| log | 5b78b34bba8645a268900f946858ea1470621c41 |
| tree | ee273e850a0c217620f5b984dcc259195e54a127 |
| parent | e0c80c198cfbb2afe04528cd6a7ec1d2f21606dd |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 70 | 70 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 71 | 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 72 | 72 | "@t3tools/provider-core": "workspace:*", |
| 73 | "@t3tools/provider-muse": "workspace:*", | |
| 73 | 74 | "@t3tools/provider-pi": "workspace:*", |
| 74 | 75 | "@t3tools/shared": "workspace:*", |
| 75 | 76 | "@tabler/icons-react-native": "^3.44.0", |
apps/mobile/src/components/ProviderIcon.tsx-12| ... | ... | @@ -120,18 +120,6 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 120 | 120 | ); |
| 121 | 121 | } |
| 122 | 122 | |
| 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 | ||
| 135 | 123 | const packageIcon = getProviderClient(props.provider)?.icon; |
| 136 | 124 | if (packageIcon) { |
| 137 | 125 | const themeFill = (fill: { readonly light: string; readonly dark: string }) => |
apps/mobile/src/lib/providerClients.ts+2-1| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import { isProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 3 | import { museClient } from "@t3tools/provider-muse/client"; | |
| 3 | 4 | import { piClient } from "@t3tools/provider-pi/client"; |
| 4 | 5 | |
| 5 | 6 | /** The provider client definitions this mobile build ships. */ |
| 6 | const providerClients = makeProviderClientRegistry([piClient]); | |
| 7 | const providerClients = makeProviderClientRegistry([museClient, piClient]); | |
| 7 | 8 | |
| 8 | 9 | /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ |
| 9 | 10 | export function getProviderClient(driver: string | null | undefined) { |
apps/server/package.json+1| ... | ... | @@ -42,6 +42,7 @@ |
| 42 | 42 | "@opencode/protocol": "2.0.23", |
| 43 | 43 | "@opencode/schema": "2.0.23", |
| 44 | 44 | "@t3tools/provider-core": "workspace:*", |
| 45 | "@t3tools/provider-muse": "workspace:*", | |
| 45 | 46 | "@t3tools/provider-pi": "workspace:*", |
| 46 | 47 | "diff": "8.0.3", |
| 47 | 48 | "effect": "catalog:", |
apps/server/scripts/record-muse-msp-replay-fixture.ts+5-2| ... | ... | @@ -33,8 +33,11 @@ import { |
| 33 | 33 | MuseOrchestratorReplayHarness, |
| 34 | 34 | } from "../src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts"; |
| 35 | 35 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 36 | import { parseMuseVersion } from "../src/provider/museMaintenance.ts"; | |
| 37 | import { makeMuseEnvironment, museServeArgs } from "../src/provider/museSdk.ts"; | |
| 36 | import { | |
| 37 | makeMuseEnvironment, | |
| 38 | museServeArgs, | |
| 39 | parseMuseVersion, | |
| 40 | } from "@t3tools/provider-muse/testing"; | |
| 38 | 41 | import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; |
| 39 | 42 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; |
| 40 | 43 | import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; |
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts deleted-1108| ... | ... | @@ -1,1108 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { MspError } from "@muse-code/sdk"; | |
| 4 | import { | |
| 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"; | |
| 22 | import * as DateTime from "effect/DateTime"; | |
| 23 | import * as Deferred from "effect/Deferred"; | |
| 24 | import * as Effect from "effect/Effect"; | |
| 25 | import * as Option from "effect/Option"; | |
| 26 | import type * as Exit from "effect/Exit"; | |
| 27 | import * as FileSystem from "effect/FileSystem"; | |
| 28 | import * as Fiber from "effect/Fiber"; | |
| 29 | import * as Layer from "effect/Layer"; | |
| 30 | import * as Queue from "effect/Queue"; | |
| 31 | import * as Schema from "effect/Schema"; | |
| 32 | import * as Stream from "effect/Stream"; | |
| 33 | ||
| 34 | import * as ServerConfig from "../../config.ts"; | |
| 35 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 36 | import type { MuseItem } from "../../provider/museProtocol.ts"; | |
| 37 | import type { MuseSdkHost } from "../../provider/museSdk.ts"; | |
| 38 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 39 | import { | |
| 40 | ProviderAdapterV2RuntimePolicy, | |
| 41 | type ProviderAdapterV2Event, | |
| 42 | type ProviderAdapterV2TurnInput, | |
| 43 | } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 44 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 45 | import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; | |
| 46 | ||
| 47 | const 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 | ); | |
| 54 | const MUSE_PROVIDER = ProviderDriverKind.make("muse"); | |
| 55 | const INSTANCE_ID = ProviderInstanceId.make("muse_work"); | |
| 56 | const THREAD_ID = ThreadId.make("thread-muse-test"); | |
| 57 | const MODEL = "muse-spark-1.3-contributor"; | |
| 58 | const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true }); | |
| 59 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 60 | runtimeMode: "full-access", | |
| 61 | interactionMode: "default", | |
| 62 | cwd: null, | |
| 63 | }); | |
| 64 | const modelSelection = (instanceId = INSTANCE_ID) => ({ | |
| 65 | instanceId, | |
| 66 | model: MODEL, | |
| 67 | options: [{ id: "reasoningEffort", value: "max" }], | |
| 68 | }); | |
| 69 | ||
| 70 | interface NativeCall { | |
| 71 | readonly method: string; | |
| 72 | readonly params: Record<string, unknown>; | |
| 73 | readonly commandId?: string; | |
| 74 | } | |
| 75 | ||
| 76 | function 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. */ | |
| 87 | const 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 | ||
| 190 | const 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 | ||
| 254 | const 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 | ||
| 275 | const 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 | ||
| 327 | const 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 | ||
| 343 | const 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 | ||
| 354 | describe("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 @@ |
| 16 | 16 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 17 | 17 | import { Connection, type DuplexTransport } from "@muse-code/sdk"; |
| 18 | 18 | import { |
| 19 | MuseSettings, | |
| 20 | 19 | ProviderDriverKind, |
| 21 | 20 | ProviderInstanceId, |
| 22 | 21 | ProviderReplayEntry, |
| 23 | 22 | type ProviderReplayEntry as ProviderReplayEntryType, |
| 24 | 23 | type ProviderReplayTranscript, |
| 25 | 24 | } from "@t3tools/contracts"; |
| 25 | import { MuseSettings } from "@t3tools/provider-muse/settings"; | |
| 26 | 26 | import * as Effect from "effect/Effect"; |
| 27 | 27 | import * as FileSystem from "effect/FileSystem"; |
| 28 | 28 | import * as Layer from "effect/Layer"; |
| 29 | 29 | import * as Schema from "effect/Schema"; |
| 30 | 30 | |
| 31 | import * as ServerConfig from "../../config.ts"; | |
| 31 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 32 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 32 | 33 | import { |
| 33 | 34 | museInitializeParams, |
| 34 | 35 | museServeArgs, |
| 35 | 36 | type MuseSdkHost, |
| 36 | 37 | type MuseSdkHostOptions, |
| 37 | } from "../../provider/museSdk.ts"; | |
| 38 | } from "@t3tools/provider-muse/testing"; | |
| 38 | 39 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 39 | 40 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 40 | 41 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| 41 | import { | |
| 42 | makeReplayServerConfig, | |
| 43 | type OrchestratorV2ProviderReplayHarness, | |
| 44 | } from "../testkit/ProviderReplayHarness.ts"; | |
| 45 | import { makeMuseAdapterV2 } from "./MuseAdapterV2.ts"; | |
| 42 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; | |
| 43 | import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; | |
| 46 | 44 | |
| 47 | 45 | export const MUSE_PROVIDER_KIND = "muse"; |
| 48 | 46 | export const MUSE_MSP_REPLAY_PROTOCOL = "muse.msp-jsonl"; |
| ... | ... | @@ -448,10 +446,6 @@ export function layer(input: { |
| 448 | 446 | /** Replay needs none; a live recording passes the host environment. */ |
| 449 | 447 | readonly environment?: NodeJS.ProcessEnv; |
| 450 | 448 | }) { |
| 451 | const layerServerConfig = Layer.effect( | |
| 452 | ServerConfig.ServerConfig, | |
| 453 | makeReplayServerConfig(`muse-${input.scenario}`).pipe(Effect.orDie), | |
| 454 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 455 | 449 | return ProviderAdapterRegistry.layerFromAdaptersEffect( |
| 456 | 450 | Effect.gen(function* () { |
| 457 | 451 | return [ |
| ... | ... | @@ -460,7 +454,7 @@ export function layer(input: { |
| 460 | 454 | settings: DEFAULT_MUSE_SETTINGS, |
| 461 | 455 | environment: input.environment ?? {}, |
| 462 | 456 | idAllocator: yield* IdAllocator.IdAllocatorV2, |
| 463 | serverConfig: yield* ServerConfig.ServerConfig, | |
| 457 | host: yield* ProviderHost, | |
| 464 | 458 | fileSystem: yield* FileSystem.FileSystem, |
| 465 | 459 | createHost: input.createHost, |
| 466 | 460 | // Same queue the continuation worker drains when the fixture runs it. |
| ... | ... | @@ -468,7 +462,15 @@ export function layer(input: { |
| 468 | 462 | }), |
| 469 | 463 | ]; |
| 470 | 464 | }), |
| 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 | ); | |
| 472 | 474 | } |
| 473 | 475 | |
| 474 | 476 | export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< |
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts deleted-2058| ... | ... | @@ -1,2058 +0,0 @@ |
| 1 | import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; | |
| 2 | import { | |
| 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"; | |
| 25 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 26 | import * as Cause from "effect/Cause"; | |
| 27 | import * as DateTime from "effect/DateTime"; | |
| 28 | import * as Deferred from "effect/Deferred"; | |
| 29 | import * as Effect from "effect/Effect"; | |
| 30 | import * as Option from "effect/Option"; | |
| 31 | import * as FileSystem from "effect/FileSystem"; | |
| 32 | import * as Queue from "effect/Queue"; | |
| 33 | import * as Schema from "effect/Schema"; | |
| 34 | import * as Semaphore from "effect/Semaphore"; | |
| 35 | import * as Scope from "effect/Scope"; | |
| 36 | import * as Stream from "effect/Stream"; | |
| 37 | ||
| 38 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 39 | import * as ServerConfig from "../../config.ts"; | |
| 40 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 41 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 42 | import { | |
| 43 | museModelCapabilities, | |
| 44 | resolveMuseReasoningEffort, | |
| 45 | } from "../../provider/museModelCatalog.ts"; | |
| 46 | import { | |
| 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"; | |
| 64 | import { | |
| 65 | createMuseSdkHostEffect, | |
| 66 | museApprovalMode, | |
| 67 | type createMuseSdkHost, | |
| 68 | type MuseSdkHost, | |
| 69 | } from "../../provider/museSdk.ts"; | |
| 70 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; | |
| 71 | import { | |
| 72 | providerMessageTextWithAttachmentPaths, | |
| 73 | isProviderNativeImageAttachment, | |
| 74 | } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 75 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 76 | import { | |
| 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"; | |
| 99 | import { | |
| 100 | backgroundWorkNotification, | |
| 101 | type BackgroundWorkReport, | |
| 102 | } from "@t3tools/provider-core/server/notification"; | |
| 103 | import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 104 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 105 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 106 | import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; | |
| 107 | ||
| 108 | const MUSE_PROVIDER = ProviderDriverKind.make("muse"); | |
| 109 | const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError); | |
| 110 | const isProtocolError = Schema.is(ProviderAdapterProtocolError); | |
| 111 | ||
| 112 | const 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 | ||
| 206 | export 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 | ||
| 225 | interface 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 | ||
| 244 | interface 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 | ||
| 255 | const nativeRef = (nativeId: string) => ({ | |
| 256 | driver: MUSE_PROVIDER, | |
| 257 | nativeId, | |
| 258 | strength: "strong" as const, | |
| 259 | }); | |
| 260 | const recordSchema = Schema.Record(Schema.String, Schema.Unknown); | |
| 261 | // Notifications that only add detail. A malformed one is skipped instead of ending the session. | |
| 262 | const 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. | |
| 270 | const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]); | |
| 271 | const 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. */ | |
| 274 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import type { MuseItem } from "../../provider/museProtocol.ts"; | |
| 4 | import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; | |
| 5 | ||
| 6 | const item = (fields: Partial<MuseItem>): MuseItem => ({ | |
| 7 | itemId: "native-item", | |
| 8 | kind: "toolCall", | |
| 9 | status: "completed", | |
| 10 | revision: 1, | |
| 11 | ...fields, | |
| 12 | }); | |
| 13 | ||
| 14 | describe("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 @@ |
| 1 | import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import type { MuseItem } from "../../provider/museProtocol.ts"; | |
| 5 | ||
| 6 | const decodeToolArgs = Schema.decodeUnknownSync( | |
| 7 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | |
| 8 | ); | |
| 9 | ||
| 10 | type Fields< | |
| 11 | T extends OrchestrationV2TurnItem["type"], | |
| 12 | K extends keyof Extract<OrchestrationV2TurnItem, { type: T }>, | |
| 13 | > = Pick<Extract<OrchestrationV2TurnItem, { type: T }>, K>; | |
| 14 | type 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. */ | |
| 25 | export 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. */ | |
| 37 | export 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 @@ |
| 1 | import { MuseSettings, ProviderDriverKind } from "@t3tools/contracts"; | |
| 2 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Path from "effect/Path"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { HttpClient } from "effect/http"; | |
| 8 | import { ChildProcessSpawner } from "effect/process"; | |
| 9 | ||
| 10 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 11 | import * as ServerConfig from "../../config.ts"; | |
| 12 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | |
| 13 | import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration.ts"; | |
| 14 | import { ProviderDriverError } from "../Errors.ts"; | |
| 15 | import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts"; | |
| 16 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 17 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 18 | import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; | |
| 19 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; | |
| 20 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 21 | import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "../museMaintenance.ts"; | |
| 22 | import { makeMuseEnvironment } from "../museSdk.ts"; | |
| 23 | import { | |
| 24 | defaultProviderContinuationIdentity, | |
| 25 | type ProviderDriver, | |
| 26 | type ProviderInstance, | |
| 27 | } from "@t3tools/provider-core/server/driver"; | |
| 28 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 29 | import { | |
| 30 | makeCachedProviderMaintenanceResolution, | |
| 31 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 32 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 33 | import { | |
| 34 | haveProviderSnapshotSettingsChanged, | |
| 35 | makeProviderSnapshotSettingsSource, | |
| 36 | type ProviderSnapshotSettings, | |
| 37 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 38 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | |
| 39 | ||
| 40 | const DRIVER_KIND = ProviderDriverKind.make("muse"); | |
| 41 | const decodeMuseSettings = Schema.decodeSync(MuseSettings); | |
| 42 | ||
| 43 | export 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 | ||
| 53 | export 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 { |
| 18 | 18 | grokSlashCommandsFromInitialize, |
| 19 | 19 | parseGrokModelsCliOutput, |
| 20 | 20 | } from "./GrokProvider.ts"; |
| 21 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 21 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | |
| 22 | 22 | import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; |
| 23 | 23 | |
| 24 | 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
apps/server/src/provider/MuseProvider.test.ts deleted-167| ... | ... | @@ -1,167 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { MuseSettings } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { vi } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 10 | import type { MuseSdkHost } from "./museSdk.ts"; | |
| 11 | import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 12 | import { checkMuseProviderStatus } from "./MuseProvider.ts"; | |
| 13 | ||
| 14 | const settings = Schema.decodeSync(MuseSettings); | |
| 15 | const 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 | }; | |
| 32 | const 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 | ||
| 50 | const 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 | ||
| 57 | it.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 @@ |
| 1 | import type { MuseSettings, ServerProviderModel } from "@t3tools/contracts"; | |
| 2 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Result from "effect/Result"; | |
| 7 | import { ChildProcess } from "effect/process"; | |
| 8 | ||
| 9 | import { createMuseSdkHost, makeMuseEnvironment } from "./museSdk.ts"; | |
| 10 | import { parseMuseVersion } from "./museMaintenance.ts"; | |
| 11 | import { discoverMuseModels, museModelCapabilities } from "./museModelCatalog.ts"; | |
| 12 | import { | |
| 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 | ||
| 22 | const 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 | ||
| 31 | const FALLBACK_CAPABILITIES = museModelCapabilities(); | |
| 32 | ||
| 33 | export 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 | ||
| 54 | export 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"; |
| 27 | 27 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 28 | 28 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 29 | 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; |
| 30 | import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; | |
| 30 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; | |
| 31 | 31 | import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; |
| 32 | 32 | import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; |
| 33 | 33 |
apps/server/src/provider/museMaintenance.test.ts deleted-232| ... | ... | @@ -1,232 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 7 | ||
| 8 | import { | |
| 9 | compareMuseVersions, | |
| 10 | enrichMuseSnapshot, | |
| 11 | museMaintenance, | |
| 12 | latestMuseVersion, | |
| 13 | parseMuseVersion, | |
| 14 | } from "./museMaintenance.ts"; | |
| 15 | import { | |
| 16 | ProviderVersionCache, | |
| 17 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 18 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 19 | ||
| 20 | const maintenanceCapabilities = { | |
| 21 | ...makeManualOnlyProviderMaintenanceCapabilities({ | |
| 22 | provider: ProviderDriverKind.make("muse"), | |
| 23 | packageName: null, | |
| 24 | }), | |
| 25 | compareVersions: compareMuseVersions, | |
| 26 | }; | |
| 27 | const 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 | ||
| 41 | it.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 @@ |
| 1 | import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { HttpClient, HttpClientRequest } from "effect/http"; | |
| 8 | ||
| 9 | import { | |
| 10 | enrichProviderSnapshotWithVersionAdvisory, | |
| 11 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 12 | makeProviderMaintenanceCapabilities, | |
| 13 | ProviderVersionCache, | |
| 14 | type ProviderMaintenanceCapabilities, | |
| 15 | type ProviderMaintenanceCapabilitiesResolver, | |
| 16 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 17 | import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 18 | ||
| 19 | const DRIVER = ProviderDriverKind.make("muse"); | |
| 20 | /** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ | |
| 21 | const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); | |
| 22 | const 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. */ | |
| 27 | export function parseMuseVersion(output: string): string | null { | |
| 28 | return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output); | |
| 29 | } | |
| 30 | ||
| 31 | function 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 | ||
| 36 | const MUSE_REVISION = /-R\d+(?:\.\d+)?$/; | |
| 37 | ||
| 38 | /** A version printed without a revision is compared by its release alone. */ | |
| 39 | export 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. */ | |
| 45 | export 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 | ||
| 89 | function shellQuote(word: string) { | |
| 90 | return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`; | |
| 91 | } | |
| 92 | ||
| 93 | export 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. */ | |
| 123 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { MuseSettings } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | ||
| 6 | import type { MuseSdkHost } from "./museSdk.ts"; | |
| 7 | import { | |
| 8 | discoverMuseModels, | |
| 9 | museModelCapabilities, | |
| 10 | resolveMuseReasoningEffort, | |
| 11 | } from "./museModelCatalog.ts"; | |
| 12 | ||
| 13 | const settings = Schema.decodeSync(MuseSettings)({}); | |
| 14 | const 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 | ||
| 29 | const 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 | ||
| 39 | describe("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 @@ |
| 1 | import type { SendUserTurnOptions } from "@muse-code/sdk"; | |
| 2 | import type { ModelCapabilities, MuseSettings, ServerProviderModel } from "@t3tools/contracts"; | |
| 3 | import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./museSdk.ts"; | |
| 7 | ||
| 8 | type MuseReasoningEffort = NonNullable<SendUserTurnOptions<never>["reasoningEffort"]>; | |
| 9 | ||
| 10 | const 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 | ||
| 21 | const 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"). */ | |
| 25 | const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; | |
| 26 | /** Muse's own CLI default. */ | |
| 27 | const FALLBACK_DEFAULT_EFFORT = "high"; | |
| 28 | ||
| 29 | const 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 | }); | |
| 42 | const 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. */ | |
| 47 | export 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. */ | |
| 82 | export 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 | ||
| 95 | class MuseCatalogError extends Schema.TaggedError<MuseCatalogError>()("MuseCatalogError", { | |
| 96 | detail: Schema.String, | |
| 97 | }) {} | |
| 98 | ||
| 99 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { museApprovalChoices } from "./museProtocol.ts"; | |
| 4 | ||
| 5 | // Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case. | |
| 6 | describe("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 @@ |
| 1 | import type { ProviderApprovalDecision } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | const NonEmptyString = Schema.String.check(Schema.isMinLength(1)); | |
| 5 | ||
| 6 | const 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 | ||
| 15 | export 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 | }); | |
| 61 | export type MuseItem = typeof MuseItem.Type; | |
| 62 | ||
| 63 | export 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 | }); | |
| 83 | export const MuseTurnStartResult = Schema.Struct({ turnId: NonEmptyString }); | |
| 84 | ||
| 85 | export 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 | }); | |
| 114 | export type MuseApproval = typeof MuseApproval.Type; | |
| 115 | ||
| 116 | export 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 | }); | |
| 136 | export type MuseUserInput = typeof MuseUserInput.Type; | |
| 137 | ||
| 138 | export const MuseTokenUsageEvent = Schema.Struct({ | |
| 139 | turnId: NonEmptyString, | |
| 140 | promptTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 141 | usage: MuseUsage, | |
| 142 | }); | |
| 143 | export const MuseContextUsage = Schema.Struct({ | |
| 144 | usedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 145 | windowTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), | |
| 146 | }); | |
| 147 | export const MuseTodoList = Schema.Struct({ | |
| 148 | items: Schema.Array(Schema.Struct({ text: Schema.String, status: Schema.String })), | |
| 149 | }); | |
| 150 | export 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 | }); | |
| 157 | export 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 | }); | |
| 164 | export const MuseCompactResult = Schema.Struct({ | |
| 165 | status: Schema.String, | |
| 166 | reason: Schema.optional(Schema.String), | |
| 167 | }); | |
| 168 | ||
| 169 | export const MuseItemEvent = Schema.Struct({ item: MuseItem }); | |
| 170 | export 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. */ | |
| 177 | export 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 | */ | |
| 205 | export 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`. */ | |
| 222 | export 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 @@ |
| 1 | import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import { afterEach, describe, expect, vi } from "vite-plus/test"; | |
| 6 | import { | |
| 7 | createMuseSdkHost, | |
| 8 | createMuseSdkHostEffect, | |
| 9 | makeMuseEnvironment, | |
| 10 | museApprovalMode, | |
| 11 | type MuseSdkHost, | |
| 12 | } from "./museSdk.ts"; | |
| 13 | ||
| 14 | vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); | |
| 15 | ||
| 16 | function 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 | ||
| 26 | function 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 | ||
| 50 | afterEach(() => { | |
| 51 | vi.useRealTimers(); | |
| 52 | vi.clearAllMocks(); | |
| 53 | }); | |
| 54 | ||
| 55 | describe("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. | |
| 3 | import { | |
| 4 | spawnMspConnection, | |
| 5 | type Connection, | |
| 6 | type ProcessExit, | |
| 7 | type SpawnedMspConnection, | |
| 8 | } from "@muse-code/sdk"; | |
| 9 | import type { RuntimeMode } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | ||
| 12 | export 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 | ||
| 30 | export 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. */ | |
| 44 | export 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 | ||
| 57 | export 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. */ | |
| 62 | export 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. */ | |
| 77 | export 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. */ | |
| 85 | export 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. */ | |
| 145 | export 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. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodePath from "node:path"; | |
| 4 | ||
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 6 | ||
| 7 | export 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 | */ | |
| 31 | export 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 | */ | |
| 78 | export 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 { |
| 21 | 21 | import * as TextGeneration from "./TextGeneration.ts"; |
| 22 | 22 | import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils"; |
| 23 | 23 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; |
| 24 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 24 | import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | |
| 25 | 25 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 26 | 26 | |
| 27 | 27 | const 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 |
| 16 | 16 | import * as ServerConfig from "../config.ts"; |
| 17 | 17 | import * as TextGeneration from "./TextGeneration.ts"; |
| 18 | 18 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; |
| 19 | import { writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 19 | import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | |
| 20 | 20 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| 21 | 21 | |
| 22 | 22 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( |
apps/server/src/textGeneration/GrokTextGeneration.test.ts+1-1| ... | ... | @@ -16,7 +16,7 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; |
| 16 | 16 | import * as ServerConfig from "../config.ts"; |
| 17 | 17 | import * as TextGeneration from "./TextGeneration.ts"; |
| 18 | 18 | import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; |
| 19 | import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; | |
| 19 | import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | |
| 20 | 20 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 21 | 21 | |
| 22 | 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
apps/server/src/textGeneration/MuseTextGeneration.test.ts deleted-372| ... | ... | @@ -1,372 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import type { NotificationHandler } from "@muse-code/sdk"; | |
| 4 | import { | |
| 5 | MUSE_DEFAULT_MODEL, | |
| 6 | MuseSettings, | |
| 7 | ProviderInstanceId, | |
| 8 | type ServerProviderModel, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | import { vi } from "vite-plus/test"; | |
| 15 | ||
| 16 | import type { MuseSdkHost } from "../provider/museSdk.ts"; | |
| 17 | import { museModelCapabilities } from "../provider/museModelCatalog.ts"; | |
| 18 | import { makeMuseTextGeneration } from "./MuseTextGeneration.ts"; | |
| 19 | ||
| 20 | const settings = Schema.decodeSync(MuseSettings)({ enabled: true }); | |
| 21 | const modelSelection = createModelSelection( | |
| 22 | ProviderInstanceId.make("muse"), | |
| 23 | "muse-spark-1.3-contributor", | |
| 24 | [{ id: "reasoningEffort", value: "high" }], | |
| 25 | ); | |
| 26 | const titleInput = { | |
| 27 | cwd: "/project/should-not-be-used", | |
| 28 | message: "Fix the login form", | |
| 29 | modelSelection, | |
| 30 | }; | |
| 31 | ||
| 32 | function fixture( | |
| 33 | onTurn?: (emit: (method: string, params: Record<string, unknown>) => void) => void, | |
| 34 | acknowledgeTurn = true, | |
| 35 | modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>, | |
| 36 | ) { | |
| 37 | let handler: NotificationHandler = () => {}; | |
| 38 | let commandSequence = 0; | |
| 39 | let activeTurnId: string | undefined; | |
| 40 | const mintCommandId = () => `turn-${++commandSequence}`; | |
| 41 | const started = Promise.withResolvers<void>(); | |
| 42 | const closed = Promise.withResolvers<void>(); | |
| 43 | const emit = (method: string, params: Record<string, unknown>) => | |
| 44 | handler({ | |
| 45 | jsonrpc: "2.0", | |
| 46 | method, | |
| 47 | params: { | |
| 48 | sessionId: "session-1", | |
| 49 | turnId: activeTurnId, | |
| 50 | ...params, | |
| 51 | ...(params.item | |
| 52 | ? { | |
| 53 | item: { | |
| 54 | revision: 1, | |
| 55 | turnId: activeTurnId, | |
| 56 | ...(params.item as Record<string, unknown>), | |
| 57 | }, | |
| 58 | } | |
| 59 | : {}), | |
| 60 | }, | |
| 61 | }); | |
| 62 | const host: MuseSdkHost = { | |
| 63 | initializeResult: { grantedCapabilities: [] }, | |
| 64 | connection: { | |
| 65 | command: vi.fn(async (method, _params, options) => { | |
| 66 | // Connection.command overwrites params.commandId unless supplied in options. | |
| 67 | const commandId = options?.commandId ?? mintCommandId(); | |
| 68 | if (method === "session/start") return { session: { sessionId: "session-1" } }; | |
| 69 | if (method === "turn/start") { | |
| 70 | activeTurnId = commandId; | |
| 71 | started.resolve(); | |
| 72 | onTurn?.(emit); | |
| 73 | if (!acknowledgeTurn) return await new Promise<Record<string, unknown>>(() => {}); | |
| 74 | return { turnId: commandId }; | |
| 75 | } | |
| 76 | return {}; | |
| 77 | }), | |
| 78 | request: vi.fn(async () => ({})), | |
| 79 | mintCommandId, | |
| 80 | onNotification: (nextHandler) => { | |
| 81 | handler = nextHandler; | |
| 82 | }, | |
| 83 | onProtocolError: () => {}, | |
| 84 | onServerRequest: () => {}, | |
| 85 | closed: closed.promise, | |
| 86 | }, | |
| 87 | close: vi.fn(async () => { | |
| 88 | closed.resolve(); | |
| 89 | }), | |
| 90 | exited: new Promise(() => {}), | |
| 91 | }; | |
| 92 | const createHost = vi.fn(async () => host); | |
| 93 | const make = makeMuseTextGeneration(settings, { | |
| 94 | createHost, | |
| 95 | ...(modelCatalog ? { modelCatalog } : {}), | |
| 96 | }); | |
| 97 | return { | |
| 98 | host, | |
| 99 | createHost, | |
| 100 | started: started.promise, | |
| 101 | closeConnection: closed.resolve, | |
| 102 | emit, | |
| 103 | make, | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | const finish = | |
| 108 | (text: string) => (emit: (method: string, params: Record<string, unknown>) => void) => { | |
| 109 | emit("item/updated", { item: { itemId: "answer", kind: "agentMessage", text: "partial" } }); | |
| 110 | emit("item/completed", { | |
| 111 | item: { itemId: "answer", kind: "agentMessage", revision: 2, text }, | |
| 112 | }); | |
| 113 | emit("turn/completed", { terminal: "completed" }); | |
| 114 | }; | |
| 115 | ||
| 116 | it.layer(NodeServices.layer)("Muse text generation", (it) => { | |
| 117 | it.effect("validates the final item once and closes an isolated restrictive host", () => | |
| 118 | Effect.gen(function* () { | |
| 119 | const test = fixture(finish('{"title":"Fix login form"}')); | |
| 120 | const service = yield* test.make; | |
| 121 | expect(yield* service.generateThreadTitle(titleInput)).toEqual({ | |
| 122 | title: "Fix login form", | |
| 123 | }); | |
| 124 | expect(test.createHost).toHaveBeenCalledWith( | |
| 125 | expect.objectContaining({ readOnly: true, sessionLogging: true }), | |
| 126 | ); | |
| 127 | expect(test.createHost).not.toHaveBeenCalledWith( | |
| 128 | expect.objectContaining({ cwd: titleInput.cwd }), | |
| 129 | ); | |
| 130 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 131 | "session/start", | |
| 132 | expect.objectContaining({ | |
| 133 | providerId: "meta", | |
| 134 | modelId: modelSelection.model, | |
| 135 | approvalMode: "denyUnmatched", | |
| 136 | }), | |
| 137 | ); | |
| 138 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 139 | "turn/start", | |
| 140 | expect.objectContaining({ | |
| 141 | reasoningEffort: "high", | |
| 142 | }), | |
| 143 | { commandId: "turn-1" }, | |
| 144 | ); | |
| 145 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("uses low effort by default and normalizes stale efforts against the model", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | for (const { saved, variants, expected } of [ | |
| 152 | { saved: undefined, variants: undefined, expected: "low" }, | |
| 153 | { saved: undefined, variants: ["medium", "xhigh"], expected: "medium" }, | |
| 154 | { saved: "max", variants: ["minimal", "max"], expected: "max" }, | |
| 155 | { saved: "ultra", variants: ["medium", "xhigh"], expected: "medium" }, | |
| 156 | { saved: "high", variants: [], expected: undefined }, | |
| 157 | ]) { | |
| 158 | const test = fixture( | |
| 159 | finish('{"title":"Fix login form"}'), | |
| 160 | true, | |
| 161 | Effect.succeed([ | |
| 162 | { | |
| 163 | slug: modelSelection.model, | |
| 164 | name: modelSelection.model, | |
| 165 | isCustom: false, | |
| 166 | capabilities: museModelCapabilities(variants ? { variants } : undefined), | |
| 167 | }, | |
| 168 | ]), | |
| 169 | ); | |
| 170 | const service = yield* test.make; | |
| 171 | yield* service.generateThreadTitle({ | |
| 172 | ...titleInput, | |
| 173 | modelSelection: createModelSelection( | |
| 174 | ProviderInstanceId.make("muse"), | |
| 175 | modelSelection.model, | |
| 176 | saved !== undefined ? [{ id: "reasoningEffort", value: saved }] : undefined, | |
| 177 | ), | |
| 178 | }); | |
| 179 | const turn = vi | |
| 180 | .mocked(test.host.connection.command) | |
| 181 | .mock.calls.find(([method]) => method === "turn/start"); | |
| 182 | expect(turn?.[1].reasoningEffort).toBe(expected); | |
| 183 | expect(Object.hasOwn(turn?.[1] ?? {}, "reasoningEffort")).toBe(expected !== undefined); | |
| 184 | } | |
| 185 | }), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect("lets Muse choose the model when none is selected", () => | |
| 189 | Effect.gen(function* () { | |
| 190 | for (const model of [MUSE_DEFAULT_MODEL, " "]) { | |
| 191 | const test = fixture(finish('{"title":"Fix login form"}')); | |
| 192 | const service = yield* test.make; | |
| 193 | yield* service.generateThreadTitle({ | |
| 194 | ...titleInput, | |
| 195 | modelSelection: createModelSelection(ProviderInstanceId.make("muse"), model), | |
| 196 | }); | |
| 197 | const start = vi | |
| 198 | .mocked(test.host.connection.command) | |
| 199 | .mock.calls.find(([method]) => method === "session/start"); | |
| 200 | expect(start?.[1]).not.toHaveProperty("modelId"); | |
| 201 | } | |
| 202 | }), | |
| 203 | ); | |
| 204 | ||
| 205 | it.effect("rejects invalid structured output and releases the host", () => | |
| 206 | Effect.gen(function* () { | |
| 207 | const test = fixture(finish('{"title":42}')); | |
| 208 | const service = yield* test.make; | |
| 209 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 210 | _tag: "TextGenerationError", | |
| 211 | detail: "Muse returned invalid structured output.", | |
| 212 | }); | |
| 213 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 214 | }), | |
| 215 | ); | |
| 216 | ||
| 217 | it.effect("ignores output and terminal notifications for another turn", () => | |
| 218 | Effect.gen(function* () { | |
| 219 | const test = fixture((emit) => { | |
| 220 | emit("item/completed", { | |
| 221 | item: { | |
| 222 | itemId: "other-answer", | |
| 223 | kind: "agentMessage", | |
| 224 | turnId: "another-turn", | |
| 225 | text: '{"title":"Wrong title"}', | |
| 226 | }, | |
| 227 | }); | |
| 228 | emit("turn/completed", { turnId: "another-turn", terminal: "completed" }); | |
| 229 | finish('{"title":"Fix login form"}')(emit); | |
| 230 | }); | |
| 231 | const service = yield* test.make; | |
| 232 | expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); | |
| 233 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 234 | "turn/start", | |
| 235 | expect.objectContaining({ sessionId: "session-1" }), | |
| 236 | { commandId: "turn-1" }, | |
| 237 | ); | |
| 238 | }), | |
| 239 | ); | |
| 240 | ||
| 241 | it.effect("rejects a truncated response even when its prefix contains valid JSON", () => | |
| 242 | Effect.gen(function* () { | |
| 243 | const test = fixture((emit) => { | |
| 244 | emit("item/completed", { | |
| 245 | item: { | |
| 246 | itemId: "answer", | |
| 247 | kind: "agentMessage", | |
| 248 | text: '{"title":"Incomplete answer"}', | |
| 249 | truncated: true, | |
| 250 | }, | |
| 251 | }); | |
| 252 | emit("turn/completed", { terminal: "completed" }); | |
| 253 | }); | |
| 254 | const service = yield* test.make; | |
| 255 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 256 | _tag: "TextGenerationError", | |
| 257 | }); | |
| 258 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 259 | }), | |
| 260 | ); | |
| 261 | ||
| 262 | it.effect("settles a completed turn even when its start acknowledgement never arrives", () => | |
| 263 | Effect.gen(function* () { | |
| 264 | const test = fixture(finish('{"title":"Fix login form"}'), false); | |
| 265 | const service = yield* test.make; | |
| 266 | expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); | |
| 267 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 268 | }), | |
| 269 | ); | |
| 270 | ||
| 271 | it.effect("reports a failed turn without trying another provider", () => | |
| 272 | Effect.gen(function* () { | |
| 273 | const test = fixture((emit) => | |
| 274 | emit("turn/completed", { | |
| 275 | terminal: "failed", | |
| 276 | error: { message: "Authentication required. Run muse login." }, | |
| 277 | }), | |
| 278 | ); | |
| 279 | const service = yield* test.make; | |
| 280 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 281 | _tag: "TextGenerationError", | |
| 282 | }); | |
| 283 | expect(test.createHost).toHaveBeenCalledOnce(); | |
| 284 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 285 | }), | |
| 286 | ); | |
| 287 | ||
| 288 | it.effect("settles when the transport closes before completion", () => | |
| 289 | Effect.gen(function* () { | |
| 290 | const test = fixture(); | |
| 291 | const service = yield* test.make; | |
| 292 | const pending = yield* service | |
| 293 | .generateThreadTitle(titleInput) | |
| 294 | .pipe(Effect.flip, Effect.forkChild); | |
| 295 | yield* Effect.promise(() => test.started); | |
| 296 | test.closeConnection(); | |
| 297 | expect(yield* Fiber.join(pending)).toMatchObject({ _tag: "TextGenerationError" }); | |
| 298 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 299 | }), | |
| 300 | ); | |
| 301 | ||
| 302 | it.effect("releases the host when interrupted after the turn starts", () => | |
| 303 | Effect.gen(function* () { | |
| 304 | const test = fixture(); | |
| 305 | const service = yield* test.make; | |
| 306 | const fiber = yield* service.generateThreadTitle(titleInput).pipe(Effect.forkChild); | |
| 307 | yield* Effect.promise(() => test.started); | |
| 308 | yield* Fiber.interrupt(fiber); | |
| 309 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 310 | }), | |
| 311 | ); | |
| 312 | ||
| 313 | it.effect("denies approval requests and fails the noninteractive request", () => | |
| 314 | Effect.gen(function* () { | |
| 315 | const test = fixture((emit) => | |
| 316 | emit("approval/requested", { | |
| 317 | approvalId: "approval-1", | |
| 318 | currentRequirementId: "requirement-1", | |
| 319 | availableChoices: [{ choiceId: "deny-1", decision: "denied" }], | |
| 320 | }), | |
| 321 | ); | |
| 322 | const service = yield* test.make; | |
| 323 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 324 | _tag: "TextGenerationError", | |
| 325 | }); | |
| 326 | expect(test.host.connection.command).toHaveBeenCalledWith("approval/decide", { | |
| 327 | sessionId: "session-1", | |
| 328 | approvalId: "approval-1", | |
| 329 | requirementId: "requirement-1", | |
| 330 | choiceId: "deny-1", | |
| 331 | }); | |
| 332 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 333 | }), | |
| 334 | ); | |
| 335 | ||
| 336 | it.effect("cancels user input requests", () => | |
| 337 | Effect.gen(function* () { | |
| 338 | const test = fixture((emit) => emit("userInput/requested", { userInputId: "input-1" })); | |
| 339 | const service = yield* test.make; | |
| 340 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 341 | _tag: "TextGenerationError", | |
| 342 | }); | |
| 343 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 344 | "userInput/cancel", | |
| 345 | expect.objectContaining({ | |
| 346 | userInputId: "input-1", | |
| 347 | }), | |
| 348 | ); | |
| 349 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 350 | }), | |
| 351 | ); | |
| 352 | ||
| 353 | it.effect("generates and sanitizes commit content through Muse", () => | |
| 354 | Effect.gen(function* () { | |
| 355 | const test = fixture( | |
| 356 | finish('{"subject":"Fix login.","body":" Details ","branch":"fix-login"}'), | |
| 357 | ); | |
| 358 | const service = yield* test.make; | |
| 359 | const generated = yield* service.generateCommitMessage({ | |
| 360 | cwd: titleInput.cwd, | |
| 361 | branch: "main", | |
| 362 | stagedSummary: "login.ts", | |
| 363 | stagedPatch: "login change", | |
| 364 | includeBranch: true, | |
| 365 | modelSelection, | |
| 366 | }); | |
| 367 | expect(generated.subject).toBe("Fix login"); | |
| 368 | expect(generated.body).toBe("Details"); | |
| 369 | expect(generated.branch).toContain("fix-login"); | |
| 370 | }), | |
| 371 | ); | |
| 372 | }); |
apps/server/src/textGeneration/MuseTextGeneration.ts deleted-350| ... | ... | @@ -1,350 +0,0 @@ |
| 1 | import { | |
| 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"; | |
| 9 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 10 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 11 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | ||
| 17 | import { | |
| 18 | createMuseSdkHost, | |
| 19 | createMuseSdkHostEffect, | |
| 20 | type MuseSdkHost, | |
| 21 | } from "../provider/museSdk.ts"; | |
| 22 | import { museModelCapabilities, resolveMuseReasoningEffort } from "../provider/museModelCatalog.ts"; | |
| 23 | import type * as TextGeneration from "./TextGeneration.ts"; | |
| 24 | import { | |
| 25 | buildBranchNamePrompt, | |
| 26 | buildCommitMessagePrompt, | |
| 27 | buildPrContentPrompt, | |
| 28 | buildThreadTitlePrompt, | |
| 29 | } from "@t3tools/provider-core/server/textGenerationPrompts"; | |
| 30 | import { | |
| 31 | sanitizeCommitSubject, | |
| 32 | sanitizePrTitle, | |
| 33 | sanitizeThreadTitle, | |
| 34 | toJsonSchemaObject, | |
| 35 | } from "@t3tools/provider-core/server/textGenerationUtils"; | |
| 36 | ||
| 37 | const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) }); | |
| 38 | const 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 | }); | |
| 50 | const 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 | }); | |
| 56 | const 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 | }); | |
| 64 | const UserInputRequested = Schema.Struct({ | |
| 65 | sessionId: Schema.String, | |
| 66 | userInputId: Schema.String, | |
| 67 | }); | |
| 68 | const decodeSession = Schema.decodeUnknownSync(SessionStarted); | |
| 69 | const decodeItem = Schema.decodeUnknownSync(ItemNotification); | |
| 70 | const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted); | |
| 71 | const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested); | |
| 72 | const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); | |
| 73 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 74 | const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 75 | ||
| 76 | type Operation = keyof TextGeneration.TextGeneration["Service"]; | |
| 77 | ||
| 78 | async 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 | ||
| 200 | export 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 @@ |
| 29 | 29 | "@t3tools/client-runtime": "workspace:*", |
| 30 | 30 | "@t3tools/contracts": "workspace:*", |
| 31 | 31 | "@t3tools/provider-core": "workspace:*", |
| 32 | "@t3tools/provider-muse": "workspace:*", | |
| 32 | 33 | "@t3tools/provider-pi": "workspace:*", |
| 33 | 34 | "@t3tools/shared": "workspace:*", |
| 34 | 35 | "@tanstack/react-pacer": "^0.19.4", |
apps/web/src/components/Icons.tsx-10| ... | ... | @@ -815,13 +815,3 @@ export const ComputerUseAppIcon: Icon = (props) => { |
| 815 | 815 | </svg> |
| 816 | 816 | ); |
| 817 | 817 | }; |
| 818 | ||
| 819 | export 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 { |
| 8 | 8 | ClaudeAI, |
| 9 | 9 | CursorIcon, |
| 10 | 10 | GrokIcon, |
| 11 | MuseIcon, | |
| 12 | 11 | Icon, |
| 13 | 12 | OpenAI, |
| 14 | 13 | OpenCodeIcon, |
| ... | ... | @@ -29,7 +28,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 29 | 28 | [ProviderDriverKind.make("opencode")]: OpenCodeIcon, |
| 30 | 29 | [ProviderDriverKind.make("cursor")]: CursorIcon, |
| 31 | 30 | [ProviderDriverKind.make("grok")]: GrokIcon, |
| 32 | [ProviderDriverKind.make("muse")]: MuseIcon, | |
| 33 | 31 | [ProviderDriverKind.make("antigravity")]: AntigravityIcon, |
| 34 | 32 | }; |
| 35 | 33 |
apps/web/src/components/settings/providerDriverMeta.ts+2-7| ... | ... | @@ -6,10 +6,10 @@ import { |
| 6 | 6 | CursorSettings, |
| 7 | 7 | GrokSettings, |
| 8 | 8 | OpenCodeSettings, |
| 9 | MuseSettings, | |
| 10 | 9 | ProviderDriverKind, |
| 11 | 10 | } from "@t3tools/contracts"; |
| 12 | 11 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 12 | import { museClient } from "@t3tools/provider-muse/client"; | |
| 13 | 13 | import { piClient } from "@t3tools/provider-pi/client"; |
| 14 | 14 | |
| 15 | 15 | /** The provider client definitions this web build ships, in presentation order. */ |
| ... | ... | @@ -53,12 +53,7 @@ export const providerClients = makeProviderClientRegistry([ |
| 53 | 53 | label: "Antigravity", |
| 54 | 54 | settingsSchema: AntigravitySettings, |
| 55 | 55 | }, |
| 56 | { | |
| 57 | driverKind: ProviderDriverKind.make("muse"), | |
| 58 | label: "Muse Code", | |
| 59 | settingsSchema: MuseSettings, | |
| 60 | badgeLabel: "Beta", | |
| 61 | }, | |
| 56 | museClient, | |
| 62 | 57 | piClient, |
| 63 | 58 | { |
| 64 | 59 | driverKind: ProviderDriverKind.make("acpRegistry"), |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "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", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/contracts/src/settings.ts-23| ... | ... | @@ -840,29 +840,6 @@ export const AntigravitySettings = makeProviderSettingsSchema( |
| 840 | 840 | ); |
| 841 | 841 | export type AntigravitySettings = typeof AntigravitySettings.Type; |
| 842 | 842 | |
| 843 | export 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 | ); | |
| 864 | export type MuseSettings = typeof MuseSettings.Type; | |
| 865 | ||
| 866 | 843 | export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); |
| 867 | 844 | export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; |
| 868 | 845 |
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 | */ | |
| 7 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 8 | import { defineProviderClient } from "@t3tools/provider-core/client"; | |
| 9 | ||
| 10 | import { MuseSettings } from "./settings.ts"; | |
| 11 | ||
| 12 | export 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 | */ | |
| 7 | export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts"; | |
| 8 | export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts"; |
packages/provider-muse/src/server/adapter.test.ts created+1107| ... | ... | @@ -0,0 +1,1107 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { MspError } from "@muse-code/sdk"; | |
| 4 | import { | |
| 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"; | |
| 21 | import { MuseSettings } from "../settings.ts"; | |
| 22 | import * as DateTime from "effect/DateTime"; | |
| 23 | import * as Deferred from "effect/Deferred"; | |
| 24 | import * as Effect from "effect/Effect"; | |
| 25 | import * as Option from "effect/Option"; | |
| 26 | import type * as Exit from "effect/Exit"; | |
| 27 | import * as FileSystem from "effect/FileSystem"; | |
| 28 | import * as Fiber from "effect/Fiber"; | |
| 29 | import * as Layer from "effect/Layer"; | |
| 30 | import * as Queue from "effect/Queue"; | |
| 31 | import * as Schema from "effect/Schema"; | |
| 32 | import * as Stream from "effect/Stream"; | |
| 33 | ||
| 34 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 35 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 36 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 37 | import type { MuseItem } from "./protocol.ts"; | |
| 38 | import type { MuseSdkHost } from "./sdk.ts"; | |
| 39 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 40 | import { | |
| 41 | ProviderAdapterV2RuntimePolicy, | |
| 42 | type ProviderAdapterV2Event, | |
| 43 | type ProviderAdapterV2TurnInput, | |
| 44 | } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 45 | import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; | |
| 46 | import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts"; | |
| 47 | ||
| 48 | const testLayer = Layer.mergeAll( | |
| 49 | NodeServices.layer, | |
| 50 | IdAllocator.layer, | |
| 51 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 52 | ); | |
| 53 | const MUSE_PROVIDER = ProviderDriverKind.make("muse"); | |
| 54 | const INSTANCE_ID = ProviderInstanceId.make("muse_work"); | |
| 55 | const THREAD_ID = ThreadId.make("thread-muse-test"); | |
| 56 | const MODEL = "muse-spark-1.3-contributor"; | |
| 57 | const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true }); | |
| 58 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 59 | runtimeMode: "full-access", | |
| 60 | interactionMode: "default", | |
| 61 | cwd: null, | |
| 62 | }); | |
| 63 | const modelSelection = (instanceId = INSTANCE_ID) => ({ | |
| 64 | instanceId, | |
| 65 | model: MODEL, | |
| 66 | options: [{ id: "reasoningEffort", value: "max" }], | |
| 67 | }); | |
| 68 | ||
| 69 | interface NativeCall { | |
| 70 | readonly method: string; | |
| 71 | readonly params: Record<string, unknown>; | |
| 72 | readonly commandId?: string; | |
| 73 | } | |
| 74 | ||
| 75 | function 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. */ | |
| 86 | const 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 | ||
| 189 | const 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 | ||
| 253 | const 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 | ||
| 274 | const 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 | ||
| 326 | const 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 | ||
| 342 | const 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 | ||
| 353 | describe("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 @@ |
| 1 | import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; | |
| 2 | import { | |
| 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"; | |
| 24 | import type { MuseSettings } from "../settings.ts"; | |
| 25 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 26 | import * as Cause from "effect/Cause"; | |
| 27 | import * as DateTime from "effect/DateTime"; | |
| 28 | import * as Deferred from "effect/Deferred"; | |
| 29 | import * as Effect from "effect/Effect"; | |
| 30 | import * as Option from "effect/Option"; | |
| 31 | import * as FileSystem from "effect/FileSystem"; | |
| 32 | import * as Queue from "effect/Queue"; | |
| 33 | import * as Schema from "effect/Schema"; | |
| 34 | import * as Semaphore from "effect/Semaphore"; | |
| 35 | import * as Scope from "effect/Scope"; | |
| 36 | import * as Stream from "effect/Stream"; | |
| 37 | ||
| 38 | import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; | |
| 39 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 40 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 41 | import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; | |
| 42 | import { | |
| 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"; | |
| 60 | import { | |
| 61 | createMuseSdkHostEffect, | |
| 62 | museApprovalMode, | |
| 63 | type createMuseSdkHost, | |
| 64 | type MuseSdkHost, | |
| 65 | } from "./sdk.ts"; | |
| 66 | import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 67 | import { | |
| 68 | providerMessageTextWithAttachmentPaths, | |
| 69 | isProviderNativeImageAttachment, | |
| 70 | } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 71 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 72 | import { | |
| 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"; | |
| 95 | import { | |
| 96 | backgroundWorkNotification, | |
| 97 | type BackgroundWorkReport, | |
| 98 | } from "@t3tools/provider-core/server/notification"; | |
| 99 | import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 100 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 101 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 102 | import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; | |
| 103 | ||
| 104 | const MUSE_PROVIDER = ProviderDriverKind.make("muse"); | |
| 105 | const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError); | |
| 106 | const isProtocolError = Schema.is(ProviderAdapterProtocolError); | |
| 107 | ||
| 108 | const 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 | ||
| 202 | export 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 | ||
| 221 | interface 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 | ||
| 240 | interface 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 | ||
| 251 | const nativeRef = (nativeId: string) => ({ | |
| 252 | driver: MUSE_PROVIDER, | |
| 253 | nativeId, | |
| 254 | strength: "strong" as const, | |
| 255 | }); | |
| 256 | const recordSchema = Schema.Record(Schema.String, Schema.Unknown); | |
| 257 | // Notifications that only add detail. A malformed one is skipped instead of ending the session. | |
| 258 | const 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. | |
| 266 | const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]); | |
| 267 | const 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. */ | |
| 270 | export 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 @@ |
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 2 | import { MuseSettings } from "../settings.ts"; | |
| 3 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | import { HttpClient } from "effect/http"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 12 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | |
| 13 | import { makeMuseTextGeneration } from "./textGeneration.ts"; | |
| 14 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | |
| 15 | import { makeMuseAdapterV2 } from "./adapter.ts"; | |
| 16 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 17 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; | |
| 18 | import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; | |
| 19 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 20 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 21 | import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts"; | |
| 22 | import { makeMuseEnvironment } from "./sdk.ts"; | |
| 23 | import { | |
| 24 | defaultProviderContinuationIdentity, | |
| 25 | type ProviderDriver, | |
| 26 | type ProviderInstance, | |
| 27 | } from "@t3tools/provider-core/server/driver"; | |
| 28 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 29 | import { | |
| 30 | makeCachedProviderMaintenanceResolution, | |
| 31 | resolveProviderMaintenanceCapabilitiesEffect, | |
| 32 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 33 | import { | |
| 34 | haveProviderSnapshotSettingsChanged, | |
| 35 | makeProviderSnapshotSettingsSource, | |
| 36 | type ProviderSnapshotSettings, | |
| 37 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 38 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | |
| 39 | ||
| 40 | const DRIVER_KIND = ProviderDriverKind.make("muse"); | |
| 41 | const decodeMuseSettings = Schema.decodeSync(MuseSettings); | |
| 42 | ||
| 43 | export type MuseDriverEnv = | |
| 44 | | IdAllocator.IdAllocatorV2 | |
| 45 | | ProviderHost | |
| 46 | | ChildProcessSpawner.ChildProcessSpawner | |
| 47 | | FileSystem.FileSystem | |
| 48 | | HttpClient.HttpClient | |
| 49 | | Path.Path | |
| 50 | | ProviderEventLoggers.ProviderEventLoggers; | |
| 51 | ||
| 52 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import type { MuseItem } from "./protocol.ts"; | |
| 4 | import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; | |
| 5 | ||
| 6 | const item = (fields: Partial<MuseItem>): MuseItem => ({ | |
| 7 | itemId: "native-item", | |
| 8 | kind: "toolCall", | |
| 9 | status: "completed", | |
| 10 | revision: 1, | |
| 11 | ...fields, | |
| 12 | }); | |
| 13 | ||
| 14 | describe("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 @@ |
| 1 | import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | import type { MuseItem } from "./protocol.ts"; | |
| 5 | ||
| 6 | const decodeToolArgs = Schema.decodeUnknownSync( | |
| 7 | Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), | |
| 8 | ); | |
| 9 | ||
| 10 | type Fields< | |
| 11 | T extends OrchestrationV2TurnItem["type"], | |
| 12 | K extends keyof Extract<OrchestrationV2TurnItem, { type: T }>, | |
| 13 | > = Pick<Extract<OrchestrationV2TurnItem, { type: T }>, K>; | |
| 14 | type 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. */ | |
| 25 | export 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. */ | |
| 37 | export 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 7 | ||
| 8 | import { | |
| 9 | compareMuseVersions, | |
| 10 | enrichMuseSnapshot, | |
| 11 | museMaintenance, | |
| 12 | latestMuseVersion, | |
| 13 | parseMuseVersion, | |
| 14 | } from "./maintenance.ts"; | |
| 15 | import { | |
| 16 | ProviderVersionCache, | |
| 17 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 18 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 19 | ||
| 20 | const maintenanceCapabilities = { | |
| 21 | ...makeManualOnlyProviderMaintenanceCapabilities({ | |
| 22 | provider: ProviderDriverKind.make("muse"), | |
| 23 | packageName: null, | |
| 24 | }), | |
| 25 | compareVersions: compareMuseVersions, | |
| 26 | }; | |
| 27 | const 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 | ||
| 41 | it.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 @@ |
| 1 | import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { HttpClient, HttpClientRequest } from "effect/http"; | |
| 8 | ||
| 9 | import { | |
| 10 | enrichProviderSnapshotWithVersionAdvisory, | |
| 11 | makeManualOnlyProviderMaintenanceCapabilities, | |
| 12 | makeProviderMaintenanceCapabilities, | |
| 13 | ProviderVersionCache, | |
| 14 | type ProviderMaintenanceCapabilities, | |
| 15 | type ProviderMaintenanceCapabilitiesResolver, | |
| 16 | } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 17 | import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 18 | ||
| 19 | const DRIVER = ProviderDriverKind.make("muse"); | |
| 20 | /** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ | |
| 21 | const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); | |
| 22 | const 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. */ | |
| 27 | export function parseMuseVersion(output: string): string | null { | |
| 28 | return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output); | |
| 29 | } | |
| 30 | ||
| 31 | function 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 | ||
| 36 | const MUSE_REVISION = /-R\d+(?:\.\d+)?$/; | |
| 37 | ||
| 38 | /** A version printed without a revision is compared by its release alone. */ | |
| 39 | export 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. */ | |
| 45 | export 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 | ||
| 89 | function shellQuote(word: string) { | |
| 90 | return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`; | |
| 91 | } | |
| 92 | ||
| 93 | export 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. */ | |
| 123 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { MuseSettings } from "../settings.ts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | ||
| 6 | import type { MuseSdkHost } from "./sdk.ts"; | |
| 7 | import { | |
| 8 | discoverMuseModels, | |
| 9 | museModelCapabilities, | |
| 10 | resolveMuseReasoningEffort, | |
| 11 | } from "./modelCatalog.ts"; | |
| 12 | ||
| 13 | const settings = Schema.decodeSync(MuseSettings)({}); | |
| 14 | const 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 | ||
| 29 | const 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 | ||
| 39 | describe("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 @@ |
| 1 | import type { SendUserTurnOptions } from "@muse-code/sdk"; | |
| 2 | import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; | |
| 3 | import { MuseSettings } from "../settings.ts"; | |
| 4 | import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; | |
| 8 | ||
| 9 | type MuseReasoningEffort = NonNullable<SendUserTurnOptions<never>["reasoningEffort"]>; | |
| 10 | ||
| 11 | const 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 | ||
| 22 | const 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"). */ | |
| 26 | const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; | |
| 27 | /** Muse's own CLI default. */ | |
| 28 | const FALLBACK_DEFAULT_EFFORT = "high"; | |
| 29 | ||
| 30 | const 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 | }); | |
| 43 | const 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. */ | |
| 48 | export 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. */ | |
| 83 | export 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 | ||
| 96 | class MuseCatalogError extends Schema.TaggedError<MuseCatalogError>()("MuseCatalogError", { | |
| 97 | detail: Schema.String, | |
| 98 | }) {} | |
| 99 | ||
| 100 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { museApprovalChoices } from "./protocol.ts"; | |
| 4 | ||
| 5 | // Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case. | |
| 6 | describe("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 @@ |
| 1 | import type { ProviderApprovalDecision } from "@t3tools/contracts"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | const NonEmptyString = Schema.String.check(Schema.isMinLength(1)); | |
| 5 | ||
| 6 | const 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 | ||
| 15 | export 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 | }); | |
| 61 | export type MuseItem = typeof MuseItem.Type; | |
| 62 | ||
| 63 | export 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 | }); | |
| 83 | export const MuseTurnStartResult = Schema.Struct({ turnId: NonEmptyString }); | |
| 84 | ||
| 85 | export 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 | }); | |
| 114 | export type MuseApproval = typeof MuseApproval.Type; | |
| 115 | ||
| 116 | export 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 | }); | |
| 136 | export type MuseUserInput = typeof MuseUserInput.Type; | |
| 137 | ||
| 138 | export const MuseTokenUsageEvent = Schema.Struct({ | |
| 139 | turnId: NonEmptyString, | |
| 140 | promptTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 141 | usage: MuseUsage, | |
| 142 | }); | |
| 143 | export const MuseContextUsage = Schema.Struct({ | |
| 144 | usedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | |
| 145 | windowTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), | |
| 146 | }); | |
| 147 | export const MuseTodoList = Schema.Struct({ | |
| 148 | items: Schema.Array(Schema.Struct({ text: Schema.String, status: Schema.String })), | |
| 149 | }); | |
| 150 | export 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 | }); | |
| 157 | export 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 | }); | |
| 164 | export const MuseCompactResult = Schema.Struct({ | |
| 165 | status: Schema.String, | |
| 166 | reason: Schema.optional(Schema.String), | |
| 167 | }); | |
| 168 | ||
| 169 | export const MuseItemEvent = Schema.Struct({ item: MuseItem }); | |
| 170 | export 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. */ | |
| 177 | export 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 | */ | |
| 205 | export 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`. */ | |
| 222 | export 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 @@ |
| 1 | import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import { afterEach, describe, expect, vi } from "vite-plus/test"; | |
| 6 | import { | |
| 7 | createMuseSdkHost, | |
| 8 | createMuseSdkHostEffect, | |
| 9 | makeMuseEnvironment, | |
| 10 | museApprovalMode, | |
| 11 | type MuseSdkHost, | |
| 12 | } from "./sdk.ts"; | |
| 13 | ||
| 14 | vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); | |
| 15 | ||
| 16 | function 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 | ||
| 26 | function 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 | ||
| 50 | afterEach(() => { | |
| 51 | vi.useRealTimers(); | |
| 52 | vi.clearAllMocks(); | |
| 53 | }); | |
| 54 | ||
| 55 | describe("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. | |
| 3 | import { | |
| 4 | spawnMspConnection, | |
| 5 | type Connection, | |
| 6 | type ProcessExit, | |
| 7 | type SpawnedMspConnection, | |
| 8 | } from "@muse-code/sdk"; | |
| 9 | import type { RuntimeMode } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | ||
| 12 | export 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 | ||
| 30 | export 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. */ | |
| 44 | export 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 | ||
| 57 | export 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. */ | |
| 62 | export 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. */ | |
| 77 | export 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. */ | |
| 85 | export 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. */ | |
| 145 | export 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { MuseSettings } from "../settings.ts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import { vi } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; | |
| 10 | import type { MuseSdkHost } from "./sdk.ts"; | |
| 11 | import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 12 | import { checkMuseProviderStatus } from "./status.ts"; | |
| 13 | ||
| 14 | const settings = Schema.decodeSync(MuseSettings); | |
| 15 | const 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 | }; | |
| 32 | const 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 | ||
| 50 | const 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 | ||
| 57 | it.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 @@ |
| 1 | import { ServerProviderModel } from "@t3tools/contracts"; | |
| 2 | import { MuseSettings } from "../settings.ts"; | |
| 3 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Result from "effect/Result"; | |
| 8 | import { ChildProcess } from "effect/process"; | |
| 9 | ||
| 10 | import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts"; | |
| 11 | import { parseMuseVersion } from "./maintenance.ts"; | |
| 12 | import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts"; | |
| 13 | import { | |
| 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 | ||
| 23 | const 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 | ||
| 32 | const FALLBACK_CAPABILITIES = museModelCapabilities(); | |
| 33 | ||
| 34 | export 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 | ||
| 55 | export 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 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import type { NotificationHandler } from "@muse-code/sdk"; | |
| 4 | import { | |
| 5 | MUSE_DEFAULT_MODEL, | |
| 6 | ProviderInstanceId, | |
| 7 | type ServerProviderModel, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { MuseSettings } from "../settings.ts"; | |
| 10 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | import { vi } from "vite-plus/test"; | |
| 15 | ||
| 16 | import type { MuseSdkHost } from "./sdk.ts"; | |
| 17 | import { museModelCapabilities } from "./modelCatalog.ts"; | |
| 18 | import { makeMuseTextGeneration } from "./textGeneration.ts"; | |
| 19 | ||
| 20 | const settings = Schema.decodeSync(MuseSettings)({ enabled: true }); | |
| 21 | const modelSelection = createModelSelection( | |
| 22 | ProviderInstanceId.make("muse"), | |
| 23 | "muse-spark-1.3-contributor", | |
| 24 | [{ id: "reasoningEffort", value: "high" }], | |
| 25 | ); | |
| 26 | const titleInput = { | |
| 27 | cwd: "/project/should-not-be-used", | |
| 28 | message: "Fix the login form", | |
| 29 | modelSelection, | |
| 30 | }; | |
| 31 | ||
| 32 | function fixture( | |
| 33 | onTurn?: (emit: (method: string, params: Record<string, unknown>) => void) => void, | |
| 34 | acknowledgeTurn = true, | |
| 35 | modelCatalog?: Effect.Effect<ReadonlyArray<ServerProviderModel>>, | |
| 36 | ) { | |
| 37 | let handler: NotificationHandler = () => {}; | |
| 38 | let commandSequence = 0; | |
| 39 | let activeTurnId: string | undefined; | |
| 40 | const mintCommandId = () => `turn-${++commandSequence}`; | |
| 41 | const started = Promise.withResolvers<void>(); | |
| 42 | const closed = Promise.withResolvers<void>(); | |
| 43 | const emit = (method: string, params: Record<string, unknown>) => | |
| 44 | handler({ | |
| 45 | jsonrpc: "2.0", | |
| 46 | method, | |
| 47 | params: { | |
| 48 | sessionId: "session-1", | |
| 49 | turnId: activeTurnId, | |
| 50 | ...params, | |
| 51 | ...(params.item | |
| 52 | ? { | |
| 53 | item: { | |
| 54 | revision: 1, | |
| 55 | turnId: activeTurnId, | |
| 56 | ...(params.item as Record<string, unknown>), | |
| 57 | }, | |
| 58 | } | |
| 59 | : {}), | |
| 60 | }, | |
| 61 | }); | |
| 62 | const host: MuseSdkHost = { | |
| 63 | initializeResult: { grantedCapabilities: [] }, | |
| 64 | connection: { | |
| 65 | command: vi.fn(async (method, _params, options) => { | |
| 66 | // Connection.command overwrites params.commandId unless supplied in options. | |
| 67 | const commandId = options?.commandId ?? mintCommandId(); | |
| 68 | if (method === "session/start") return { session: { sessionId: "session-1" } }; | |
| 69 | if (method === "turn/start") { | |
| 70 | activeTurnId = commandId; | |
| 71 | started.resolve(); | |
| 72 | onTurn?.(emit); | |
| 73 | if (!acknowledgeTurn) return await new Promise<Record<string, unknown>>(() => {}); | |
| 74 | return { turnId: commandId }; | |
| 75 | } | |
| 76 | return {}; | |
| 77 | }), | |
| 78 | request: vi.fn(async () => ({})), | |
| 79 | mintCommandId, | |
| 80 | onNotification: (nextHandler) => { | |
| 81 | handler = nextHandler; | |
| 82 | }, | |
| 83 | onProtocolError: () => {}, | |
| 84 | onServerRequest: () => {}, | |
| 85 | closed: closed.promise, | |
| 86 | }, | |
| 87 | close: vi.fn(async () => { | |
| 88 | closed.resolve(); | |
| 89 | }), | |
| 90 | exited: new Promise(() => {}), | |
| 91 | }; | |
| 92 | const createHost = vi.fn(async () => host); | |
| 93 | const make = makeMuseTextGeneration(settings, { | |
| 94 | createHost, | |
| 95 | ...(modelCatalog ? { modelCatalog } : {}), | |
| 96 | }); | |
| 97 | return { | |
| 98 | host, | |
| 99 | createHost, | |
| 100 | started: started.promise, | |
| 101 | closeConnection: closed.resolve, | |
| 102 | emit, | |
| 103 | make, | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | const finish = | |
| 108 | (text: string) => (emit: (method: string, params: Record<string, unknown>) => void) => { | |
| 109 | emit("item/updated", { item: { itemId: "answer", kind: "agentMessage", text: "partial" } }); | |
| 110 | emit("item/completed", { | |
| 111 | item: { itemId: "answer", kind: "agentMessage", revision: 2, text }, | |
| 112 | }); | |
| 113 | emit("turn/completed", { terminal: "completed" }); | |
| 114 | }; | |
| 115 | ||
| 116 | it.layer(NodeServices.layer)("Muse text generation", (it) => { | |
| 117 | it.effect("validates the final item once and closes an isolated restrictive host", () => | |
| 118 | Effect.gen(function* () { | |
| 119 | const test = fixture(finish('{"title":"Fix login form"}')); | |
| 120 | const service = yield* test.make; | |
| 121 | expect(yield* service.generateThreadTitle(titleInput)).toEqual({ | |
| 122 | title: "Fix login form", | |
| 123 | }); | |
| 124 | expect(test.createHost).toHaveBeenCalledWith( | |
| 125 | expect.objectContaining({ readOnly: true, sessionLogging: true }), | |
| 126 | ); | |
| 127 | expect(test.createHost).not.toHaveBeenCalledWith( | |
| 128 | expect.objectContaining({ cwd: titleInput.cwd }), | |
| 129 | ); | |
| 130 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 131 | "session/start", | |
| 132 | expect.objectContaining({ | |
| 133 | providerId: "meta", | |
| 134 | modelId: modelSelection.model, | |
| 135 | approvalMode: "denyUnmatched", | |
| 136 | }), | |
| 137 | ); | |
| 138 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 139 | "turn/start", | |
| 140 | expect.objectContaining({ | |
| 141 | reasoningEffort: "high", | |
| 142 | }), | |
| 143 | { commandId: "turn-1" }, | |
| 144 | ); | |
| 145 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 146 | }), | |
| 147 | ); | |
| 148 | ||
| 149 | it.effect("uses low effort by default and normalizes stale efforts against the model", () => | |
| 150 | Effect.gen(function* () { | |
| 151 | for (const { saved, variants, expected } of [ | |
| 152 | { saved: undefined, variants: undefined, expected: "low" }, | |
| 153 | { saved: undefined, variants: ["medium", "xhigh"], expected: "medium" }, | |
| 154 | { saved: "max", variants: ["minimal", "max"], expected: "max" }, | |
| 155 | { saved: "ultra", variants: ["medium", "xhigh"], expected: "medium" }, | |
| 156 | { saved: "high", variants: [], expected: undefined }, | |
| 157 | ]) { | |
| 158 | const test = fixture( | |
| 159 | finish('{"title":"Fix login form"}'), | |
| 160 | true, | |
| 161 | Effect.succeed([ | |
| 162 | { | |
| 163 | slug: modelSelection.model, | |
| 164 | name: modelSelection.model, | |
| 165 | isCustom: false, | |
| 166 | capabilities: museModelCapabilities(variants ? { variants } : undefined), | |
| 167 | }, | |
| 168 | ]), | |
| 169 | ); | |
| 170 | const service = yield* test.make; | |
| 171 | yield* service.generateThreadTitle({ | |
| 172 | ...titleInput, | |
| 173 | modelSelection: createModelSelection( | |
| 174 | ProviderInstanceId.make("muse"), | |
| 175 | modelSelection.model, | |
| 176 | saved !== undefined ? [{ id: "reasoningEffort", value: saved }] : undefined, | |
| 177 | ), | |
| 178 | }); | |
| 179 | const turn = vi | |
| 180 | .mocked(test.host.connection.command) | |
| 181 | .mock.calls.find(([method]) => method === "turn/start"); | |
| 182 | expect(turn?.[1].reasoningEffort).toBe(expected); | |
| 183 | expect(Object.hasOwn(turn?.[1] ?? {}, "reasoningEffort")).toBe(expected !== undefined); | |
| 184 | } | |
| 185 | }), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect("lets Muse choose the model when none is selected", () => | |
| 189 | Effect.gen(function* () { | |
| 190 | for (const model of [MUSE_DEFAULT_MODEL, " "]) { | |
| 191 | const test = fixture(finish('{"title":"Fix login form"}')); | |
| 192 | const service = yield* test.make; | |
| 193 | yield* service.generateThreadTitle({ | |
| 194 | ...titleInput, | |
| 195 | modelSelection: createModelSelection(ProviderInstanceId.make("muse"), model), | |
| 196 | }); | |
| 197 | const start = vi | |
| 198 | .mocked(test.host.connection.command) | |
| 199 | .mock.calls.find(([method]) => method === "session/start"); | |
| 200 | expect(start?.[1]).not.toHaveProperty("modelId"); | |
| 201 | } | |
| 202 | }), | |
| 203 | ); | |
| 204 | ||
| 205 | it.effect("rejects invalid structured output and releases the host", () => | |
| 206 | Effect.gen(function* () { | |
| 207 | const test = fixture(finish('{"title":42}')); | |
| 208 | const service = yield* test.make; | |
| 209 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 210 | _tag: "TextGenerationError", | |
| 211 | detail: "Muse returned invalid structured output.", | |
| 212 | }); | |
| 213 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 214 | }), | |
| 215 | ); | |
| 216 | ||
| 217 | it.effect("ignores output and terminal notifications for another turn", () => | |
| 218 | Effect.gen(function* () { | |
| 219 | const test = fixture((emit) => { | |
| 220 | emit("item/completed", { | |
| 221 | item: { | |
| 222 | itemId: "other-answer", | |
| 223 | kind: "agentMessage", | |
| 224 | turnId: "another-turn", | |
| 225 | text: '{"title":"Wrong title"}', | |
| 226 | }, | |
| 227 | }); | |
| 228 | emit("turn/completed", { turnId: "another-turn", terminal: "completed" }); | |
| 229 | finish('{"title":"Fix login form"}')(emit); | |
| 230 | }); | |
| 231 | const service = yield* test.make; | |
| 232 | expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); | |
| 233 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 234 | "turn/start", | |
| 235 | expect.objectContaining({ sessionId: "session-1" }), | |
| 236 | { commandId: "turn-1" }, | |
| 237 | ); | |
| 238 | }), | |
| 239 | ); | |
| 240 | ||
| 241 | it.effect("rejects a truncated response even when its prefix contains valid JSON", () => | |
| 242 | Effect.gen(function* () { | |
| 243 | const test = fixture((emit) => { | |
| 244 | emit("item/completed", { | |
| 245 | item: { | |
| 246 | itemId: "answer", | |
| 247 | kind: "agentMessage", | |
| 248 | text: '{"title":"Incomplete answer"}', | |
| 249 | truncated: true, | |
| 250 | }, | |
| 251 | }); | |
| 252 | emit("turn/completed", { terminal: "completed" }); | |
| 253 | }); | |
| 254 | const service = yield* test.make; | |
| 255 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 256 | _tag: "TextGenerationError", | |
| 257 | }); | |
| 258 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 259 | }), | |
| 260 | ); | |
| 261 | ||
| 262 | it.effect("settles a completed turn even when its start acknowledgement never arrives", () => | |
| 263 | Effect.gen(function* () { | |
| 264 | const test = fixture(finish('{"title":"Fix login form"}'), false); | |
| 265 | const service = yield* test.make; | |
| 266 | expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); | |
| 267 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 268 | }), | |
| 269 | ); | |
| 270 | ||
| 271 | it.effect("reports a failed turn without trying another provider", () => | |
| 272 | Effect.gen(function* () { | |
| 273 | const test = fixture((emit) => | |
| 274 | emit("turn/completed", { | |
| 275 | terminal: "failed", | |
| 276 | error: { message: "Authentication required. Run muse login." }, | |
| 277 | }), | |
| 278 | ); | |
| 279 | const service = yield* test.make; | |
| 280 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 281 | _tag: "TextGenerationError", | |
| 282 | }); | |
| 283 | expect(test.createHost).toHaveBeenCalledOnce(); | |
| 284 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 285 | }), | |
| 286 | ); | |
| 287 | ||
| 288 | it.effect("settles when the transport closes before completion", () => | |
| 289 | Effect.gen(function* () { | |
| 290 | const test = fixture(); | |
| 291 | const service = yield* test.make; | |
| 292 | const pending = yield* service | |
| 293 | .generateThreadTitle(titleInput) | |
| 294 | .pipe(Effect.flip, Effect.forkChild); | |
| 295 | yield* Effect.promise(() => test.started); | |
| 296 | test.closeConnection(); | |
| 297 | expect(yield* Fiber.join(pending)).toMatchObject({ _tag: "TextGenerationError" }); | |
| 298 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 299 | }), | |
| 300 | ); | |
| 301 | ||
| 302 | it.effect("releases the host when interrupted after the turn starts", () => | |
| 303 | Effect.gen(function* () { | |
| 304 | const test = fixture(); | |
| 305 | const service = yield* test.make; | |
| 306 | const fiber = yield* service.generateThreadTitle(titleInput).pipe(Effect.forkChild); | |
| 307 | yield* Effect.promise(() => test.started); | |
| 308 | yield* Fiber.interrupt(fiber); | |
| 309 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 310 | }), | |
| 311 | ); | |
| 312 | ||
| 313 | it.effect("denies approval requests and fails the noninteractive request", () => | |
| 314 | Effect.gen(function* () { | |
| 315 | const test = fixture((emit) => | |
| 316 | emit("approval/requested", { | |
| 317 | approvalId: "approval-1", | |
| 318 | currentRequirementId: "requirement-1", | |
| 319 | availableChoices: [{ choiceId: "deny-1", decision: "denied" }], | |
| 320 | }), | |
| 321 | ); | |
| 322 | const service = yield* test.make; | |
| 323 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 324 | _tag: "TextGenerationError", | |
| 325 | }); | |
| 326 | expect(test.host.connection.command).toHaveBeenCalledWith("approval/decide", { | |
| 327 | sessionId: "session-1", | |
| 328 | approvalId: "approval-1", | |
| 329 | requirementId: "requirement-1", | |
| 330 | choiceId: "deny-1", | |
| 331 | }); | |
| 332 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 333 | }), | |
| 334 | ); | |
| 335 | ||
| 336 | it.effect("cancels user input requests", () => | |
| 337 | Effect.gen(function* () { | |
| 338 | const test = fixture((emit) => emit("userInput/requested", { userInputId: "input-1" })); | |
| 339 | const service = yield* test.make; | |
| 340 | expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ | |
| 341 | _tag: "TextGenerationError", | |
| 342 | }); | |
| 343 | expect(test.host.connection.command).toHaveBeenCalledWith( | |
| 344 | "userInput/cancel", | |
| 345 | expect.objectContaining({ | |
| 346 | userInputId: "input-1", | |
| 347 | }), | |
| 348 | ); | |
| 349 | expect(test.host.close).toHaveBeenCalledOnce(); | |
| 350 | }), | |
| 351 | ); | |
| 352 | ||
| 353 | it.effect("generates and sanitizes commit content through Muse", () => | |
| 354 | Effect.gen(function* () { | |
| 355 | const test = fixture( | |
| 356 | finish('{"subject":"Fix login.","body":" Details ","branch":"fix-login"}'), | |
| 357 | ); | |
| 358 | const service = yield* test.make; | |
| 359 | const generated = yield* service.generateCommitMessage({ | |
| 360 | cwd: titleInput.cwd, | |
| 361 | branch: "main", | |
| 362 | stagedSummary: "login.ts", | |
| 363 | stagedPatch: "login change", | |
| 364 | includeBranch: true, | |
| 365 | modelSelection, | |
| 366 | }); | |
| 367 | expect(generated.subject).toBe("Fix login"); | |
| 368 | expect(generated.body).toBe("Details"); | |
| 369 | expect(generated.branch).toContain("fix-login"); | |
| 370 | }), | |
| 371 | ); | |
| 372 | }); |
packages/provider-muse/src/server/textGeneration.ts created+350| ... | ... | @@ -0,0 +1,350 @@ |
| 1 | import { | |
| 2 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, | |
| 3 | MUSE_DEFAULT_MODEL, | |
| 4 | type ModelSelection, | |
| 5 | type ServerProviderModel, | |
| 6 | TextGenerationError, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import type { MuseSettings } from "../settings.ts"; | |
| 9 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 10 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 11 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | ||
| 17 | import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; | |
| 18 | import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; | |
| 19 | import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; | |
| 20 | import { | |
| 21 | buildBranchNamePrompt, | |
| 22 | buildCommitMessagePrompt, | |
| 23 | buildPrContentPrompt, | |
| 24 | buildThreadTitlePrompt, | |
| 25 | } from "@t3tools/provider-core/server/textGenerationPrompts"; | |
| 26 | import { | |
| 27 | sanitizeCommitSubject, | |
| 28 | sanitizePrTitle, | |
| 29 | sanitizeThreadTitle, | |
| 30 | toJsonSchemaObject, | |
| 31 | } from "@t3tools/provider-core/server/textGenerationUtils"; | |
| 32 | ||
| 33 | const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) }); | |
| 34 | const 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 | }); | |
| 46 | const 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 | }); | |
| 52 | const 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 | }); | |
| 60 | const UserInputRequested = Schema.Struct({ | |
| 61 | sessionId: Schema.String, | |
| 62 | userInputId: Schema.String, | |
| 63 | }); | |
| 64 | const decodeSession = Schema.decodeUnknownSync(SessionStarted); | |
| 65 | const decodeItem = Schema.decodeUnknownSync(ItemNotification); | |
| 66 | const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted); | |
| 67 | const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested); | |
| 68 | const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); | |
| 69 | const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 70 | const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 71 | ||
| 72 | type Operation = keyof ProviderTextGeneration; | |
| 73 | ||
| 74 | async 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 | ||
| 196 | export 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 | */ | |
| 7 | import { | |
| 8 | CustomModelSetting, | |
| 9 | makeBinaryPathSetting, | |
| 10 | makeProviderSettingsSchema, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Schema from "effect/Schema"; | |
| 14 | ||
| 15 | export 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 | ); | |
| 36 | export 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 | */ | |
| 7 | export { | |
| 8 | makeMuseEnvironment, | |
| 9 | museInitializeParams, | |
| 10 | museServeArgs, | |
| 11 | type MuseSdkHost, | |
| 12 | type MuseSdkHostOptions, | |
| 13 | } from "./server/sdk.ts"; | |
| 14 | export { 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 @@ |
| 18 | 18 | "./host": { |
| 19 | 19 | "types": "./src/host.ts", |
| 20 | 20 | "import": "./src/host.ts" |
| 21 | }, | |
| 22 | "./fakeCli": { | |
| 23 | "types": "./src/fakeCli.ts", | |
| 24 | "import": "./src/fakeCli.ts" | |
| 21 | 25 | } |
| 22 | 26 | }, |
| 23 | 27 | "scripts": { |
| ... | ... | @@ -28,6 +32,7 @@ |
| 28 | 32 | "@effect/platform-node": "catalog:", |
| 29 | 33 | "@t3tools/contracts": "workspace:*", |
| 30 | 34 | "@t3tools/provider-core": "workspace:*", |
| 35 | "@t3tools/shared": "workspace:*", | |
| 31 | 36 | "effect": "catalog:" |
| 32 | 37 | }, |
| 33 | 38 | "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. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodePath from "node:path"; | |
| 4 | ||
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 6 | ||
| 7 | export 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 | */ | |
| 31 | export 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 | */ | |
| 78 | export 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: |
| 356 | 356 | '@t3tools/provider-core': |
| 357 | 357 | specifier: workspace:* |
| 358 | 358 | version: link:../../packages/provider-core |
| 359 | '@t3tools/provider-muse': | |
| 360 | specifier: workspace:* | |
| 361 | version: link:../../packages/provider-muse | |
| 359 | 362 | '@t3tools/provider-pi': |
| 360 | 363 | specifier: workspace:* |
| 361 | 364 | version: link:../../packages/provider-pi |
| ... | ... | @@ -603,6 +606,9 @@ importers: |
| 603 | 606 | '@t3tools/provider-core': |
| 604 | 607 | specifier: workspace:* |
| 605 | 608 | version: link:../../packages/provider-core |
| 609 | '@t3tools/provider-muse': | |
| 610 | specifier: workspace:* | |
| 611 | version: link:../../packages/provider-muse | |
| 606 | 612 | '@t3tools/provider-pi': |
| 607 | 613 | specifier: workspace:* |
| 608 | 614 | version: link:../../packages/provider-pi |
| ... | ... | @@ -733,6 +739,9 @@ importers: |
| 733 | 739 | '@t3tools/provider-core': |
| 734 | 740 | specifier: workspace:* |
| 735 | 741 | version: link:../../packages/provider-core |
| 742 | '@t3tools/provider-muse': | |
| 743 | specifier: workspace:* | |
| 744 | version: link:../../packages/provider-muse | |
| 736 | 745 | '@t3tools/provider-pi': |
| 737 | 746 | specifier: workspace:* |
| 738 | 747 | version: link:../../packages/provider-pi |
| ... | ... | @@ -1099,6 +1108,40 @@ importers: |
| 1099 | 1108 | specifier: 1.0.0 |
| 1100 | 1109 | 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) |
| 1101 | 1110 | |
| 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 | ||
| 1102 | 1145 | packages/provider-pi: |
| 1103 | 1146 | dependencies: |
| 1104 | 1147 | '@t3tools/contracts': |
| ... | ... | @@ -1141,6 +1184,9 @@ importers: |
| 1141 | 1184 | '@t3tools/provider-core': |
| 1142 | 1185 | specifier: workspace:* |
| 1143 | 1186 | version: link:../provider-core |
| 1187 | '@t3tools/shared': | |
| 1188 | specifier: workspace:* | |
| 1189 | version: link:../shared | |
| 1144 | 1190 | effect: |
| 1145 | 1191 | specifier: 4.0.1 |
| 1146 | 1192 | version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) |
scripts/release-smoke.ts+1| ... | ... | @@ -31,6 +31,7 @@ const workspaceFiles = [ |
| 31 | 31 | "packages/provider-core/package.json", |
| 32 | 32 | "packages/provider-testing/package.json", |
| 33 | 33 | "packages/provider-pi/package.json", |
| 34 | "packages/provider-muse/package.json", | |
| 34 | 35 | "packages/effect-acp/package.json", |
| 35 | 36 | "packages/effect-codex-app-server/package.json", |
| 36 | 37 | "scripts/package.json", |