diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 35fec0c2769ac7372925b5ec71f63e0ca1873993..08307ad65c2ac6c5c048b57aa9ae85d2feb690b1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -70,6 +70,7 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tabler/icons-react-native": "^3.44.0", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 1675b15461d06033532e81a00fd6bd95e593ee94..0645e673981817cdd1b4eae9340d43c9f9949b45 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -120,18 +120,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "muse") { - return ( - - - - ); - } - const packageIcon = getProviderClient(props.provider)?.icon; if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index c8c1c05c01580e8e9e06b5750a7781458d9ffdee..2d3ec1224992afbee93798b67c5865208f17a392 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,9 +1,10 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { museClient } from "@t3tools/provider-muse/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ -const providerClients = makeProviderClientRegistry([piClient]); +const providerClients = makeProviderClientRegistry([museClient, piClient]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ export function getProviderClient(driver: string | null | undefined) { diff --git a/apps/server/package.json b/apps/server/package.json index 7672fbf534e3e8fb9765a8acac362e9da04cc52b..bb776149e4cd55099e572c832342ef2642029ab3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@opencode/protocol": "2.0.23", "@opencode/schema": "2.0.23", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-pi": "workspace:*", "diff": "8.0.3", "effect": "catalog:", diff --git a/apps/server/scripts/record-muse-msp-replay-fixture.ts b/apps/server/scripts/record-muse-msp-replay-fixture.ts index dde469487be077407738b0de633fab8cfef08c71..a3cbfc6f9d14cb2f4c060f50fd341c963d28fa2b 100644 --- a/apps/server/scripts/record-muse-msp-replay-fixture.ts +++ b/apps/server/scripts/record-muse-msp-replay-fixture.ts @@ -33,8 +33,11 @@ import { MuseOrchestratorReplayHarness, } from "../src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { parseMuseVersion } from "../src/provider/museMaintenance.ts"; -import { makeMuseEnvironment, museServeArgs } from "../src/provider/museSdk.ts"; +import { + makeMuseEnvironment, + museServeArgs, + parseMuseVersion, +} from "@t3tools/provider-muse/testing"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts deleted file mode 100644 index 77e12726a1834a02ae5c79184af93b84dc758f40..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts +++ /dev/null @@ -1,1108 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { MspError } from "@muse-code/sdk"; -import { - MUSE_DEFAULT_MODEL, - EnvironmentId, - MessageId, - MuseSettings, - NodeId, - ProjectId, - ProviderInstanceId, - ProviderDriverKind, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, - RunAttemptId, - RunId, - ThreadId, - type OrchestrationV2AppThread, - type OrchestrationV2ProviderThread, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import type * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import type { MuseItem } from "../../provider/museProtocol.ts"; -import type { MuseSdkHost } from "../../provider/museSdk.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterV2RuntimePolicy, - type ProviderAdapterV2Event, - type ProviderAdapterV2TurnInput, -} from "@t3tools/provider-core/server/ProviderAdapter"; -import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; -import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; - -const testLayer = Layer.mergeAll( - NodeServices.layer, - IdAllocator.layer, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-muse-v2-adapter-" }).pipe( - Layer.provide(NodeServices.layer), - ), -); -const MUSE_PROVIDER = ProviderDriverKind.make("muse"); -const INSTANCE_ID = ProviderInstanceId.make("muse_work"); -const THREAD_ID = ThreadId.make("thread-muse-test"); -const MODEL = "muse-spark-1.3-contributor"; -const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true }); -const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: null, -}); -const modelSelection = (instanceId = INSTANCE_ID) => ({ - instanceId, - model: MODEL, - options: [{ id: "reasoningEffort", value: "max" }], -}); - -interface NativeCall { - readonly method: string; - readonly params: Record; - readonly commandId?: string; -} - -function pendingPromise() { - let resolve!: (value: A) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - return { promise, resolve, reject }; -} - -/** SDK-shaped transport fake: registration deliberately returns void, as the SDK does. */ -const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") { - const requests = yield* Queue.unbounded(); - const calls: NativeCall[] = []; - const closed = pendingPromise(); - const exited = pendingPromise>(); - let notify: Parameters[0] = () => {}; - let sequence = 0; - let sessionId = ""; - let closeCount = 0; - let closeGate: Promise | undefined; - let history: MuseItem[] = []; - const responses = new Map< - string, - Array> | Record | Error> - >(); - const respond = async (call: NativeCall): Promise> => { - calls.push(call); - Queue.offerUnsafe(requests, call); - if (["session/start", "session/resume", "session/read"].includes(call.method)) { - sessionId = String(call.params.sessionId); - } - const queued = responses.get(call.method)?.shift(); - if (queued instanceof Error) throw queued; - if (queued) return queued; - switch (call.method) { - case "session/start": - case "session/resume": - case "session/read": - sessionId = String(call.params.sessionId); - return { - session: { sessionId, modelId: MODEL, activeTurnId: null }, - history: { items: history }, - }; - case "turn/start": - return { turnId: call.commandId }; - case "turn/steer": - return { turnId: call.params.expectedTurnId }; - case "session/compact": - return { status: "accepted" }; - default: - return {}; - } - }; - const host: MuseSdkHost = { - connection: { - mintCommandId: () => `${idPrefix}-${++sequence}`, - command: (method, params, options) => - respond({ - method, - params, - ...(options?.commandId ? { commandId: options.commandId } : {}), - }), - request: (method, params = {}) => respond({ method, params }), - onNotification: (handler) => { - notify = handler; - }, - onProtocolError: () => {}, - onServerRequest: () => {}, - closed: closed.promise, - }, - initializeResult: { grantedCapabilities: [] }, - exited: exited.promise, - close: async () => { - closeCount += 1; - await closeGate; - closed.resolve(); - exited.resolve({ code: 0, signal: null }); - }, - }; - return { - host, - calls, - closeCount: () => closeCount, - holdClose: (gate: Promise) => { - closeGate = gate; - }, - disconnect: () => - Effect.sync(() => { - closed.resolve(); - exited.resolve({ code: 1, signal: null }); - }), - setHistory: (items: MuseItem[]) => { - history = items; - }, - queueResponse: ( - method: string, - result: Record | Promise> | Error, - ) => { - responses.set(method, [...(responses.get(method) ?? []), result]); - }, - emit: (method: string, params: Record) => - Effect.sync(() => { - notify({ jsonrpc: "2.0", method, params: { sessionId, ...params } }); - }), - takeCall: Effect.fnUntraced(function* (method: string) { - while (true) { - const call = yield* Queue.take(requests); - if (call.method === method) return call; - } - }), - }; -}); - -const makeHarness = Effect.fnUntraced(function* ( - fake: Effect.Success>, - instanceId = INSTANCE_ID, - initialNativeThreadId?: string, - replacement?: Effect.Success>, - existingProviderThread?: OrchestrationV2ProviderThread, - policy = runtimePolicy, - overrides: Pick< - MuseAdapterV2Options, - "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests" - > = {}, -) { - let hostCount = 0; - const adapter = makeMuseAdapterV2({ - instanceId, - settings: museSettings, - environment: { PATH: "/fake/bin" }, - idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, - fileSystem: yield* FileSystem.FileSystem, - createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host), - ...overrides, - }); - const runtime = yield* adapter.openSession({ - threadId: THREAD_ID, - providerSessionId: ProviderSessionId.make(`session-${instanceId}`), - modelSelection: modelSelection(instanceId), - runtimePolicy: policy, - ...(initialNativeThreadId ? { initialNativeThreadId } : {}), - }); - const emitted = yield* Queue.unbounded(); - const allEvents: ProviderAdapterV2Event[] = []; - const eventsEnded = yield* Deferred.make>>(); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.gen(function* () { - allEvents.push(event); - yield* Queue.offer(emitted, event); - }), - ), - Effect.exit, - Effect.flatMap((exit) => Deferred.succeed(eventsEnded, exit)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection(instanceId), - runtimePolicy: policy, - ...(existingProviderThread ? { existingProviderThread } : {}), - }); - const takeEvent = Effect.fnUntraced(function* ( - type: T, - predicate: (event: Extract) => boolean = () => true, - ) { - while (true) { - const event = yield* Queue.take(emitted); - if (event.type === type && predicate(event as Extract)) { - return event as Extract; - } - } - }); - return { adapter, runtime, providerThread, takeEvent, allEvents, policy, eventsEnded }; -}); - -const preallocatedProviderThread = Effect.fnUntraced(function* () { - const now = yield* DateTime.now; - return { - id: ProviderThreadId.make("preallocated-muse-thread"), - driver: MUSE_PROVIDER, - providerInstanceId: INSTANCE_ID, - providerSessionId: ProviderSessionId.make(`session-${INSTANCE_ID}`), - appThreadId: THREAD_ID, - ownerNodeId: null, - nativeThreadRef: null, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - } satisfies OrchestrationV2ProviderThread; -}); - -const turnInput = Effect.fnUntraced(function* ( - providerThread: OrchestrationV2ProviderThread, - runOrdinal = 1, - policy = runtimePolicy, -): Effect.fn.Return { - const now = yield* DateTime.now; - const threadId = providerThread.appThreadId ?? THREAD_ID; - const selection = modelSelection(providerThread.providerInstanceId); - const appThread: OrchestrationV2AppThread = { - id: threadId, - projectId: ProjectId.make("project-muse-test"), - title: "Muse test", - createdBy: "user", - creationSource: "web", - providerInstanceId: providerThread.providerInstanceId, - modelSelection: selection, - runtimeMode: policy.runtimeMode, - interactionMode: policy.interactionMode, - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }; - return { - appThread, - threadId, - runId: RunId.make(`run-${runOrdinal}`), - runOrdinal, - providerTurnOrdinal: runOrdinal, - attemptId: RunAttemptId.make(`attempt-${runOrdinal}`), - rootNodeId: NodeId.make(`root-${runOrdinal}`), - providerThread, - message: { - messageId: MessageId.make(`message-${runOrdinal}`), - text: "Hello Muse", - attachments: [], - createdBy: "user", - creationSource: "web", - }, - modelSelection: selection, - runtimePolicy: policy, - }; -}); - -const startConversation = Effect.fnUntraced(function* ( - harness: Effect.Success>, - fake: Effect.Success>, - ordinal = 1, -) { - yield* harness.runtime.startTurn( - yield* turnInput(harness.providerThread, ordinal, harness.policy), - ); - const call = yield* fake.takeCall("turn/start"); - const event = yield* harness.takeEvent( - "provider_turn.updated", - (event) => event.providerTurn.status === "running", - ); - return { nativeId: call.commandId!, providerTurn: event.providerTurn }; -}); - -const approval = (turnId: string) => ({ - approvalId: "approval-1", - turnId, - currentRequirementId: { approvalId: "approval-1", sourceIndex: 0 }, - availableChoices: [ - { choiceId: "once", label: "Allow once", decision: "approved", scope: "once" }, - { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" }, - ], - subject: { kind: "shell", command: "touch result.txt" }, -}); - -describe("MuseAdapterV2", () => { - it.effect("connects the thread's MCP credential on native start and resume", () => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)), - ); - McpProviderSession.setMcpProviderSession({ - environmentId: EnvironmentId.make("test-environment"), - threadId: THREAD_ID, - providerSessionId: "test-session", - providerInstanceId: INSTANCE_ID, - endpoint: "http://127.0.0.1:43210/mcp", - authorizationHeader: "Bearer thread-scoped-test-token", - browserToolsAvailable: true, - }); - for (const nativeId of [undefined, "saved-native-session"]) { - const fake = yield* makeFakeMuse(); - fake.host.initializeResult.grantedCapabilities.push("sessionMcp"); - yield* makeHarness(fake, INSTANCE_ID, nativeId); - const call = yield* fake.takeCall(nativeId ? "session/resume" : "session/start"); - assert.deepStrictEqual(call.params.config, { - mcpServers: { - "t3-code": { - transport: "streamableHttp", - mode: "optional", - url: "http://127.0.0.1:43210/mcp", - headers: { Authorization: "Bearer thread-scoped-test-token" }, - }, - }, - }); - } - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("rejects a host without session MCP support before opening a native session", () => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)), - ); - McpProviderSession.setMcpProviderSession({ - environmentId: EnvironmentId.make("test-environment"), - threadId: THREAD_ID, - providerSessionId: "test-session", - providerInstanceId: INSTANCE_ID, - endpoint: "http://127.0.0.1:43210/mcp", - authorizationHeader: "Bearer thread-scoped-test-token", - browserToolsAvailable: false, - }); - const fake = yield* makeFakeMuse(); - const outcome = yield* makeHarness(fake).pipe(Effect.result); - assert.strictEqual(outcome._tag, "Failure"); - assert.isFalse(fake.calls.some((call) => call.method === "session/start")); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect( - "starts fresh after recovery clears a native ref instead of reusing the startup hint", - () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const cleared = yield* preallocatedProviderThread(); - const harness = yield* makeHarness( - fake, - INSTANCE_ID, - "failed-old-session", - undefined, - cleared, - ); - const started = yield* fake.takeCall("session/start"); - assert.isFalse(fake.calls.some((call) => call.method === "session/resume")); - assert.notStrictEqual(started.params.sessionId, "failed-old-session"); - assert.strictEqual(harness.providerThread.id, cleared.id); - yield* startConversation(harness, fake); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("retries a missing native session with a cleared ref on the same healthy host", () => - Effect.gen(function* () { - for (const kind of ["sessionNotFound", "notFound"] as const) { - const fake = yield* makeFakeMuse(); - const allocated = yield* preallocatedProviderThread(); - const adapter = makeMuseAdapterV2({ - instanceId: INSTANCE_ID, - settings: museSettings, - environment: { PATH: "/fake/bin" }, - idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, - fileSystem: yield* FileSystem.FileSystem, - createHost: async () => fake.host, - }); - const runtime = yield* adapter.openSession({ - threadId: THREAD_ID, - providerSessionId: ProviderSessionId.make(`missing-${kind}`), - modelSelection: modelSelection(), - runtimePolicy, - initialNativeThreadId: "missing-session", - }); - fake.queueResponse( - "session/resume", - new MspError({ code: -32000, message: "Saved session is absent", data: { kind } }), - ); - const failed = yield* runtime - .ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection(), - runtimePolicy, - existingProviderThread: { - ...allocated, - nativeThreadRef: { - driver: MUSE_PROVIDER, - nativeId: "missing-session", - strength: "strong", - }, - }, - }) - .pipe(Effect.exit); - assert.strictEqual(failed._tag, "Failure"); - assert.strictEqual(fake.closeCount(), 0); - const fresh = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection(), - runtimePolicy, - existingProviderThread: allocated, - }); - assert.strictEqual(fresh.id, allocated.id); - assert.notStrictEqual(fresh.nativeThreadRef?.nativeId, "missing-session"); - assert.strictEqual(fake.closeCount(), 0); - yield* runtime.startTurn(yield* turnInput(fresh)); - const admitted = yield* fake.takeCall("turn/start"); - assert.strictEqual(admitted.params.reasoningEffort, "max"); - yield* fake.emit("turn/completed", { turnId: admitted.commandId, terminal: "completed" }); - yield* Stream.runDrain( - runtime.events.pipe(Stream.takeUntil((event) => event.type === "turn.terminal")), - ); - } - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("rejects cross-instance ensure requests without closing the current valid host", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const rejected = yield* harness.runtime - .ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection(), - runtimePolicy, - existingProviderThread: { - ...harness.providerThread, - providerInstanceId: ProviderInstanceId.make("other-account"), - }, - }) - .pipe(Effect.exit); - assert.strictEqual(rejected._tag, "Failure"); - assert.strictEqual(fake.closeCount(), 0); - yield* startConversation(harness, fake); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("skips an unreadable usage notification without ending the turn", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("session/tokenUsage", { turnId: nativeId, usage: "not usage" }); - yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); - const terminal = yield* harness.takeEvent("turn.terminal"); - assert.strictEqual(terminal.status, "completed"); - assert.strictEqual(fake.closeCount(), 0); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("moves a resumed session on another model back to the catalog default", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - // The saved session last ran MODEL; the catalog's default is another model. - const harness = yield* makeHarness( - fake, - INSTANCE_ID, - "saved-session", - undefined, - undefined, - runtimePolicy, - { - modelCatalog: Effect.succeed([ - { - slug: "account-default", - name: "Default", - isCustom: false, - isDefault: true, - capabilities: null, - }, - { slug: MODEL, name: "Other", isCustom: false, isDefault: false, capabilities: null }, - ]), - }, - ); - const input = yield* turnInput(harness.providerThread, 1); - const selection = { ...input.modelSelection, model: MUSE_DEFAULT_MODEL }; - yield* harness.runtime.startTurn({ - ...input, - modelSelection: selection, - appThread: { ...input.appThread, modelSelection: selection }, - }); - const setModel = yield* fake.takeCall("session/setModel"); - assert.deepStrictEqual(setModel.params.model, { - modelId: "account-default", - providerId: "meta", - }); - yield* fake.takeCall("turn/start"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("settles partial messages and pending approvals before a provider failure", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("item/started", { - turnId: nativeId, - item: { - itemId: "partial", - turnId: nativeId, - kind: "agentMessage", - status: "inProgress", - revision: 1, - text: "Partial answer", - }, - }); - yield* fake.emit("approval/requested", approval(nativeId)); - yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "pending", - ); - yield* fake.emit("turn/completed", { - turnId: nativeId, - terminal: "failed", - error: { message: "Provider rejected this turn" }, - }); - const terminal = yield* harness.takeEvent("turn.terminal"); - assert.strictEqual(terminal.status, "failed"); - assert.strictEqual(terminal.threadDisposition, "reusable"); - assert.isNotNull(terminal.failure); - assert.isTrue( - harness.allEvents.some( - (event) => - event.type === "message.updated" && - !event.message.streaming && - event.message.text === "Partial answer", - ), - ); - assert.isTrue( - harness.allEvents.some( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled", - ), - ); - yield* startConversation(harness, fake, 2); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("closes its SDK host exactly once when its session scope ends", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - yield* makeHarness(fake).pipe(Effect.scoped); - assert.strictEqual(fake.closeCount(), 1); - }).pipe(Effect.provide(testLayer)), - ); - - it.effect( - "closes a newly opened host when native session registration returns another identity", - () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - fake.queueResponse("session/start", { - session: { sessionId: "unexpected-session", modelId: MODEL, activeTurnId: null }, - history: { items: [] }, - }); - const result = yield* makeHarness(fake).pipe(Effect.exit); - assert.strictEqual(result._tag, "Failure"); - // A failed host closes in the background so the failure shows at once. - yield* Effect.promise(() => fake.host.exited); - assert.strictEqual(fake.closeCount(), 1); - assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 0); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("settles an active turn once when both the connection and host exit unexpectedly", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("approval/requested", approval(nativeId)); - yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "pending", - ); - yield* fake.disconnect(); - const terminal = yield* harness.takeEvent("turn.terminal"); - assert.strictEqual(terminal.status, "failed"); - assert.strictEqual(terminal.threadDisposition, "broken"); - assert.isTrue( - harness.allEvents.some( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled", - ), - ); - const result = yield* harness.runtime - .startTurn(yield* turnInput(harness.providerThread, 2)) - .pipe(Effect.exit); - assert.strictEqual(result._tag, "Failure"); - assert.strictEqual( - harness.allEvents.filter((event) => event.type === "turn.terminal").length, - 1, - ); - assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("turns a delivery gap into a broken terminal and rejects subsequent work", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - yield* startConversation(harness, fake); - yield* fake.emit("view/gap", {}); - const terminal = yield* harness.takeEvent("turn.terminal"); - assert.strictEqual(terminal.status, "failed"); - assert.strictEqual(terminal.threadDisposition, "broken"); - yield* Effect.promise(() => fake.host.exited); - assert.strictEqual(fake.closeCount(), 1); - yield* Deferred.await(harness.eventsEnded); - const result = yield* harness.runtime - .startTurn(yield* turnInput(harness.providerThread, 2)) - .pipe(Effect.exit); - assert.strictEqual(result._tag, "Failure"); - assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("uses the latest native approval requirement and waits for its resolution event", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("approval/requested", approval(nativeId)); - const first = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "pending", - ); - yield* fake.emit("approval/updated", { - ...approval(nativeId), - currentRequirementId: { approvalId: "approval-1", sourceIndex: 1 }, - }); - const refreshed = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "pending", - ); - assert.strictEqual(refreshed.runtimeRequest.id, first.runtimeRequest.id); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: first.runtimeRequest.id, - decision: "accept", - }); - const decision = yield* fake.takeCall("approval/decide"); - assert.deepStrictEqual(decision.params.requirementId, { - approvalId: "approval-1", - sourceIndex: 1, - }); - assert.strictEqual(decision.params.choiceId, "once"); - assert.isFalse( - harness.allEvents.some( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status === "resolved", - ), - ); - yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: nativeId }); - const resolved = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "resolved", - ); - assert.strictEqual(resolved.runtimeRequest.id, first.runtimeRequest.id); - const repeated = yield* harness.runtime - .respondToRuntimeRequest({ requestId: first.runtimeRequest.id, decision: "accept" }) - .pipe(Effect.exit); - assert.strictEqual(repeated._tag, "Failure"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("keeps the user's decision when Muse settles before acknowledging it", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("approval/requested", approval(nativeId)); - const pending = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "pending", - ); - const acknowledgement = pendingPromise>(); - fake.queueResponse("approval/decide", acknowledgement.promise); - const responding = yield* harness.runtime - .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "decline" }) - .pipe(Effect.forkScoped); - yield* fake.takeCall("approval/decide"); - // Muse's native decision for this choice maps to decline too; "cancel" proves it was ours. - yield* fake.emit("approval/resolved", { - approvalId: "approval-1", - turnId: nativeId, - decision: "abort", - }); - const resolved = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "resolved", - ); - assert.strictEqual(resolved.runtimeRequest.decision, "decline"); - acknowledgement.resolve({}); - yield* Fiber.join(responding); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("lets a user turn take a held Muse report turn, approvals included", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const offers: Array = []; - const harness = yield* makeHarness( - fake, - INSTANCE_ID, - undefined, - undefined, - undefined, - runtimePolicy, - { continuationRequests: { offer: (request) => Effect.sync(() => offers.push(request)) } }, - ); - // A finished workflow's report turn starts while T3 runs nothing: it is held. - yield* fake.emit("turn/started", { turnId: "report-1" }); - yield* Effect.yieldNow; - assert.lengthOf(offers, 1); - // The user sends a message first; Muse runs the report before it. - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("approval/requested", approval("report-1")); - const asked = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "pending", - ); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: asked.runtimeRequest.id, - decision: "accept", - }); - yield* fake.takeCall("approval/decide"); - yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: "report-1" }); - yield* fake.emit("turn/completed", { turnId: "report-1", terminal: "completed" }); - yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); - const terminal = yield* harness.takeEvent("turn.terminal"); - assert.strictEqual(terminal.status, "completed"); - // The user turn took it, so the continuation is not dispatched. - const dispatched = yield* offers[0]!.dispatchIfCurrent!(Effect.succeed("run")); - assert.isTrue(Option.isNone(dispatched)); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("approves a workflow child's approval in full access after its turn ended", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); - yield* harness.takeEvent("turn.terminal"); - // Muse runs workflow children without the session's allowAll mode. - const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; - yield* fake.emit("approval/requested", childApproval); - const asked = yield* harness.takeEvent("runtime_request.updated"); - assert.strictEqual(asked.runtimeRequest.status, "pending"); - assert.isNull(asked.runtimeRequest.providerTurnId); - const decision = yield* fake.takeCall("approval/decide"); - assert.strictEqual(decision.params.choiceId, "once"); - yield* fake.emit("approval/resolved", childApproval); - const resolved = yield* harness.takeEvent("runtime_request.updated"); - assert.strictEqual(resolved.runtimeRequest.status, "resolved"); - assert.strictEqual(resolved.runtimeRequest.decision, "accept"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("keeps a workflow child's approval pending for the user past the turn's end", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const policy = ProviderAdapterV2RuntimePolicy.make({ - ...runtimePolicy, - runtimeMode: "approval-required", - }); - const harness = yield* makeHarness( - fake, - INSTANCE_ID, - undefined, - undefined, - undefined, - policy, - ); - const { nativeId } = yield* startConversation(harness, fake); - const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; - yield* fake.emit("approval/requested", childApproval); - const asked = yield* harness.takeEvent("runtime_request.updated"); - assert.isNull(asked.runtimeRequest.providerTurnId); - yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); - yield* harness.takeEvent("turn.terminal"); - assert.isFalse( - harness.allEvents.some( - (event) => - event.type === "runtime_request.updated" && event.runtimeRequest.status !== "pending", - ), - ); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: asked.runtimeRequest.id, - decision: "decline", - }); - const decision = yield* fake.takeCall("approval/decide"); - assert.strictEqual(decision.params.choiceId, "deny"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("validates structured answers and preserves selected labels with a custom note", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - yield* fake.emit("userInput/requested", { - userInputId: "question-1", - turnId: nativeId, - questions: [ - { - id: "approach", - header: "Approach", - question: "Which approach?", - options: [{ label: "Small" }, { label: "Large" }], - selection: { mode: "single" }, - }, - ], - }); - const pending = yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.kind === "user_input", - ); - const invalid = yield* harness.runtime - .respondToRuntimeRequest({ - requestId: pending.runtimeRequest.id, - answers: { approach: ["Small", "Large"] }, - }) - .pipe(Effect.exit); - assert.strictEqual(invalid._tag, "Failure"); - assert.isFalse(fake.calls.some((call) => call.method === "userInput/answer")); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: pending.runtimeRequest.id, - answers: { approach: ["Small", "Keep tests focused"] }, - }); - const response = yield* fake.takeCall("userInput/answer"); - assert.deepStrictEqual(response.params.answers, [ - { questionId: "approach", selectedLabel: "Small", note: "Keep tests focused" }, - ]); - yield* fake.emit("userInput/settled", { - userInputId: "question-1", - turnId: nativeId, - answers: response.params.answers, - }); - yield* harness.takeEvent( - "runtime_request.updated", - (event) => event.runtimeRequest.status === "resolved", - ); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("steers only the active native turn and waits for confirmed interruption", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const turn = yield* startConversation(harness, fake); - const input = yield* turnInput(harness.providerThread); - const invalid = yield* harness.runtime.steerTurn!({ - threadId: THREAD_ID, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: ProviderTurnId.make("unrelated-turn"), - message: input.message, - }).pipe(Effect.exit); - assert.strictEqual(invalid._tag, "Failure"); - assert.isFalse(fake.calls.some((call) => call.method === "turn/steer")); - yield* harness.runtime.steerTurn!({ - threadId: THREAD_ID, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: turn.providerTurn.id, - message: { ...input.message, text: "Focus on the parser" }, - }); - assert.strictEqual((yield* fake.takeCall("turn/steer")).params.expectedTurnId, turn.nativeId); - const interrupt = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: turn.providerTurn.id, - }) - .pipe(Effect.forkScoped); - assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, turn.nativeId); - yield* fake.emit("turn/completed", { turnId: turn.nativeId, terminal: "cancelled" }); - yield* Fiber.join(interrupt); - assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("resumes the requested native session and reads the same session snapshot", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const nativeItem: MuseItem = { - itemId: "prior-message", - turnId: "prior-turn", - kind: "agentMessage", - status: "completed", - revision: 1, - text: "Previous answer", - }; - fake.setHistory([nativeItem]); - const harness = yield* makeHarness(fake, INSTANCE_ID, "saved-session"); - const resumed = yield* fake.takeCall("session/resume"); - assert.strictEqual(resumed.params.sessionId, "saved-session"); - assert.strictEqual(resumed.params.excludeItems, true); - assert.isFalse(fake.calls.some((call) => call.method === "session/start")); - assert.strictEqual(harness.providerThread.nativeThreadRef?.nativeId, "saved-session"); - const snapshot = yield* harness.runtime.readThreadSnapshot!({ - providerThread: harness.providerThread, - }); - assert.strictEqual(snapshot.providerThread.nativeThreadRef?.nativeId, "saved-session"); - assert.deepStrictEqual(snapshot.providerPayload, [nativeItem]); - assert.strictEqual(snapshot.messages[0]?.text, "Previous answer"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("serializes interruption behind pending turn admission without losing the target", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const admission = pendingPromise>(); - fake.queueResponse("turn/start", admission.promise); - const starting = yield* harness.runtime - .startTurn(yield* turnInput(harness.providerThread)) - .pipe(Effect.forkScoped); - const call = yield* fake.takeCall("turn/start"); - const running = yield* harness.takeEvent( - "provider_turn.updated", - (event) => event.providerTurn.status === "running", - ); - const stopping = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: running.providerTurn.id, - }) - .pipe(Effect.forkScoped); - admission.resolve({ turnId: call.commandId }); - yield* Fiber.join(starting); - assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, call.commandId); - yield* fake.emit("turn/completed", { turnId: call.commandId, terminal: "cancelled" }); - yield* Fiber.join(stopping); - assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted"); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("reports a no-op compaction as a visible failure and permits another turn", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - fake.queueResponse("session/compact", { status: "noop", reason: "no_compactable_history" }); - yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread)); - const terminal = yield* harness.takeEvent("turn.terminal"); - assert.strictEqual(terminal.status, "failed"); - assert.strictEqual(terminal.threadDisposition, "reusable"); - yield* startConversation(harness, fake, 2); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); - - it.effect("projects native todos with stable identity and removes cancelled steps", () => - Effect.gen(function* () { - const fake = yield* makeFakeMuse(); - const harness = yield* makeHarness(fake); - const { nativeId } = yield* startConversation(harness, fake); - // Neither the tool call that wrote the list nor Muse's per-step reminder - // child may show as a generic tool row. - yield* fake.emit("item/completed", { - turnId: nativeId, - item: { - itemId: "todo-tool", - turnId: nativeId, - kind: "toolCall", - tool: "write_todos", - revision: 1, - status: "completed", - }, - }); - yield* fake.emit("item/started", { - turnId: nativeId, - item: { - itemId: "reminder", - turnId: nativeId, - kind: "reminderChild", - revision: 1, - status: "inProgress", - fallbackText: "Reminder child session", - }, - }); - yield* fake.emit("session/todoListChanged", { - items: [ - { text: " Investigate ", status: "completed" }, - { text: "Fix", status: "inProgress" }, - { text: "Discarded", status: "cancelled" }, - { text: " ", status: "pending" }, - ], - }); - const first = yield* harness.takeEvent( - "turn_item.updated", - (event) => event.turnItem.type === "todo_list", - ); - assert.strictEqual(first.turnItem.type, "todo_list"); - if (first.turnItem.type !== "todo_list") return; - assert.deepStrictEqual( - first.turnItem.steps.map(({ text, status }) => ({ text, status })), - [ - { text: "Investigate", status: "completed" }, - { text: "Fix", status: "running" }, - ], - ); - const planId = first.turnItem.planId; - yield* fake.emit("session/todoListChanged", { - items: [ - { text: "Investigate", status: "completed" }, - { text: "Fix", status: "completed" }, - ], - }); - const next = yield* harness.takeEvent( - "turn_item.updated", - (event) => event.turnItem.type === "todo_list", - ); - assert.strictEqual(next.turnItem.id, first.turnItem.id); - assert.strictEqual(next.turnItem.nodeId, first.turnItem.nodeId); - if (next.turnItem.type !== "todo_list") return; - assert.strictEqual(next.turnItem.planId, first.turnItem.planId); - assert.isTrue( - harness.allEvents.some( - (event) => - event.type === "plan.updated" && - event.plan.id === planId && - event.plan.status === "completed", - ), - ); - assert.isFalse( - harness.allEvents.some( - (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", - ), - ); - assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsPlanUpdated); - assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsTodoList); - }).pipe(Effect.scoped, Effect.provide(testLayer)), - ); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index cce14853f6fd026601a719d98d75d8fa58a096c8..f36d2c5959f57804dca3de9f83138e2b45a8f0a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -16,33 +16,31 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Connection, type DuplexTransport } from "@muse-code/sdk"; import { - MuseSettings, ProviderDriverKind, ProviderInstanceId, ProviderReplayEntry, type ProviderReplayEntry as ProviderReplayEntryType, type ProviderReplayTranscript, } from "@t3tools/contracts"; +import { MuseSettings } from "@t3tools/provider-muse/settings"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as ServerConfig from "../../config.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import { museInitializeParams, museServeArgs, type MuseSdkHost, type MuseSdkHostOptions, -} from "../../provider/museSdk.ts"; +} from "@t3tools/provider-muse/testing"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { - makeReplayServerConfig, - type OrchestratorV2ProviderReplayHarness, -} from "../testkit/ProviderReplayHarness.ts"; -import { makeMuseAdapterV2 } from "./MuseAdapterV2.ts"; +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; +import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; export const MUSE_PROVIDER_KIND = "muse"; export const MUSE_MSP_REPLAY_PROTOCOL = "muse.msp-jsonl"; @@ -448,10 +446,6 @@ export function layer(input: { /** Replay needs none; a live recording passes the host environment. */ readonly environment?: NodeJS.ProcessEnv; }) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(`muse-${input.scenario}`).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); return ProviderAdapterRegistry.layerFromAdaptersEffect( Effect.gen(function* () { return [ @@ -460,7 +454,7 @@ export function layer(input: { settings: DEFAULT_MUSE_SETTINGS, environment: input.environment ?? {}, idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig, + host: yield* ProviderHost, fileSystem: yield* FileSystem.FileSystem, createHost: input.createHost, // Same queue the continuation worker drains when the fixture runs it. @@ -468,7 +462,15 @@ export function layer(input: { }), ]; }), - ).pipe(Layer.provide(Layer.mergeAll(layerServerConfig, NodeServices.layer, IdAllocator.layer))); + ).pipe( + Layer.provide( + Layer.mergeAll( + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), + NodeServices.layer, + IdAllocator.layer, + ), + ), + ); } export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts deleted file mode 100644 index 30d7073463a76903bbe73a1953b70b97e6e93386..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.ts +++ /dev/null @@ -1,2058 +0,0 @@ -import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; -import { - MUSE_DEFAULT_MODEL, - type MuseSettings, - ProviderDriverKind, - type ModelSelection, - type OrchestrationV2ConversationMessage, - type OrchestrationV2ExecutionNode, - type OrchestrationV2ProviderCapabilities, - type OrchestrationV2ProviderSession, - type OrchestrationV2ProviderThread, - type OrchestrationV2ProviderTurn, - type OrchestrationV2RuntimeRequest, - type OrchestrationV2Subagent, - type OrchestrationV2TurnItemStatus, - type NodeId, - type OrchestrationV2TurnItem, - type ProviderInstanceId, - type PlanId, - type OrchestrationV2PlanStep, - type ProviderApprovalDecision, - type RuntimeRequestId, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as FileSystem from "effect/FileSystem"; -import * as Queue from "effect/Queue"; -import * as Schema from "effect/Schema"; -import * as Semaphore from "effect/Semaphore"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import { - museModelCapabilities, - resolveMuseReasoningEffort, -} from "../../provider/museModelCatalog.ts"; -import { - MuseApproval, - MuseCompactResult, - MuseContextUsage, - MuseDelta, - MuseItemEvent, - MuseSessionResult, - MuseTokenUsageEvent, - MuseTodoList, - MuseTurnRetryScheduled, - MuseTurnCompleted, - MuseTurnStartResult, - MuseUserInput, - museApprovalChoices, - museApprovalDecision, - museApprovalOptions, - type MuseItem, -} from "../../provider/museProtocol.ts"; -import { - createMuseSdkHostEffect, - museApprovalMode, - type createMuseSdkHost, - type MuseSdkHost, -} from "../../provider/museSdk.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import { - providerMessageTextWithAttachmentPaths, - isProviderNativeImageAttachment, -} from "@t3tools/provider-core/server/attachmentPrompt"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { - ProviderAdapterEnsureThreadError, - ProviderAdapterForkThreadError, - ProviderAdapterInterruptError, - ProviderAdapterOpenSessionError, - ProviderAdapterProtocolError, - ProviderAdapterReadThreadSnapshotError, - ProviderAdapterResumeThreadError, - ProviderAdapterRollbackThreadError, - ProviderAdapterRuntimeRequestResponseError, - ProviderAdapterSteerRunError, - ProviderAdapterTurnStartError, - ProviderAdapterV2, - type ProviderAdapterV2Error, - type ProviderAdapterV2Event, - type ProviderAdapterV2EnsureThreadInput, - type ProviderAdapterV2OpenSessionInput, - type ProviderAdapterV2SessionRuntime, - type ProviderAdapterV2Shape, - type ProviderAdapterV2ThreadSnapshot, - type ProviderAdapterV2TurnInput, - type ProviderAdapterV2TurnMessage, -} from "@t3tools/provider-core/server/ProviderAdapter"; -import { - backgroundWorkNotification, - type BackgroundWorkReport, -} from "@t3tools/provider-core/server/notification"; -import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; -import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; - -const MUSE_PROVIDER = ProviderDriverKind.make("muse"); -const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError); -const isProtocolError = Schema.is(ProviderAdapterProtocolError); - -const MuseProviderCapabilitiesV2 = { - runtimePolicy: { enforcement: "native" }, - sessions: { - supportsMultipleProviderThreadsPerSession: false, - supportsModelSwitchInSession: true, - supportsProviderSwitchingViaHandoff: true, - supportsRuntimeModeSwitchInSession: false, - pendingRequestsSurviveRestart: false, - }, - threads: { - canCreateEmptyThread: true, - canReadThreadSnapshot: true, - // Muse 1.2.1/1.3.0 reject tested completed-turn fork boundaries with InvalidCut. - // Public forks use portable context handoff until native boundaries are validated. - canRollbackThread: false, - canForkThread: false, - canForkFromTurn: false, - canForkFromSubagentThread: false, - exposesNativeThreadId: true, - }, - turns: { - exposesNativeTurnId: true, - emitsTurnStarted: true, - emitsTurnCompleted: true, - supportsInterrupt: true, - supportsActiveSteering: true, - supportsSteeringByInterruptRestart: false, - supportsQueuedMessages: true, - terminalStatusQuality: "strong", - }, - streaming: { - streamsAssistantText: true, - streamsReasoning: true, - streamsToolOutput: true, - streamsPlanText: false, - emitsMessageCompleted: true, - }, - tools: { - exposesToolItemIds: true, - emitsToolStarted: true, - emitsToolCompleted: true, - emitsToolOutput: true, - supportsMcpTools: true, - supportsDynamicToolCallbacks: false, - }, - approvals: { - supportsCommandApproval: true, - supportsFileReadApproval: true, - supportsFileChangeApproval: true, - supportsApplyPatchApproval: false, - approvalsHaveNativeRequestIds: true, - approvalCallbacksAreLiveOnly: true, - approvalsCanOriginateFromSubagents: true, - }, - planning: { - emitsPlanUpdated: true, - emitsTodoList: true, - emitsProposedPlan: false, - supportsStructuredQuestions: true, - planDeltasHaveItemIds: false, - }, - subagents: { - supportsSubagents: false, - exposesSubagentThreadIds: false, - emitsSubagentLifecycle: false, - canWaitForSubagents: false, - canCloseSubagents: false, - canForkSubagentThread: false, - }, - context: { - acceptsSystemContext: false, - acceptsDeveloperContext: false, - acceptsSyntheticUserContext: true, - canGenerateSummaries: false, - canConsumeHandoffSummaries: true, - supportsDeltaHandoff: true, - supportsFullThreadHandoff: true, - maxRecommendedHandoffChars: null, - }, - checkpointing: { - appCanCheckpointFilesystem: true, - supportsNestedCheckpointScopes: false, - providerCanRollbackConversation: false, - providerRollbackReturnsSnapshot: false, - providerCanReadConversationSnapshot: true, - }, - identity: { - nativeThreadIds: "strong", - nativeTurnIds: "strong", - nativeItemIds: "strong", - nativeRequestIds: "strong", - }, -} satisfies OrchestrationV2ProviderCapabilities; - -export interface MuseAdapterV2Options { - readonly instanceId: ProviderInstanceId; - readonly settings: MuseSettings; - readonly environment: NodeJS.ProcessEnv; - readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; - readonly fileSystem: FileSystem.FileSystem; - readonly modelCatalog?: Effect.Effect>; - readonly createHost?: typeof createMuseSdkHost; - readonly requestTimeoutMs?: number; - readonly nativeEventLogger?: EventNdjsonLogger; - /** Where a turn Muse starts on its own (a finished workflow's report) asks for its run. */ - readonly continuationRequests?: { - readonly offer: ( - request: ProviderContinuationRequests.ProviderContinuationRequest, - ) => Effect.Effect; - }; -} - -interface ActiveTurn { - readonly input: ProviderAdapterV2TurnInput; - providerTurn: OrchestrationV2ProviderTurn; - readonly nativeId: string; - /** Turns Muse started on its own while this run was active, which this run shows. */ - readonly joined: Set; - readonly items: Map; - readonly ordinals: Map; - readonly started: Map; - readonly dirty: Set; - readonly settledRequests: Set; - readonly done: Deferred.Deferred; - todoPlanId?: PlanId; - nextOrdinal: number; - flushScheduled: boolean; - interruptRequested: boolean; - compact: boolean; -} - -interface PendingRequest { - readonly native: - | { type: "approval"; value: MuseApproval } - | { type: "question"; value: MuseUserInput }; - request: OrchestrationV2RuntimeRequest; - node: OrchestrationV2ExecutionNode; - item: OrchestrationV2TurnItem; - /** What T3 answered, recorded on the request once Muse confirms it. */ - response?: Pick; -} - -const nativeRef = (nativeId: string) => ({ - driver: MUSE_PROVIDER, - nativeId, - strength: "strong" as const, -}); -const recordSchema = Schema.Record(Schema.String, Schema.Unknown); -// Notifications that only add detail. A malformed one is skipped instead of ending the session. -const INFORMATIONAL_NOTIFICATIONS = new Set([ - "session/contextUsage", - "session/tokenUsage", - "session/todoListChanged", - "turn/retryScheduled", -]); -// Tools whose results already show as their own rows: T3's todo list and question -// rows, and the workflow item a `workflow` call launches. -const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]); -const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]); - -/** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */ -export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapterV2Shape { - const { idAllocator } = options; - const protocolError = (detail: string, payload?: unknown) => - new ProviderAdapterProtocolError({ - driver: MUSE_PROVIDER, - detail, - ...(payload === undefined ? {} : { payload }), - }); - return ProviderAdapterV2.of({ - instanceId: options.instanceId, - driver: MUSE_PROVIDER, - getCapabilities: () => Effect.succeed(MuseProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), - openSession: Effect.fn("MuseAdapterV2.openSession")(function* ( - input: ProviderAdapterV2OpenSessionInput, - ) { - if (!options.settings.enabled) return yield* protocolError("Muse Code is disabled"); - if (input.runtimePolicy.interactionMode === "plan") - return yield* protocolError("Muse Code does not support dedicated Plan mode"); - const scope = yield* Effect.scope; - // Muse rejects non-canonical workspace roots (for example macOS /tmp) and - // reports canonical paths in approvals, so resolve symlinks once here. - const requestedCwd = input.runtimePolicy.cwd ?? options.serverConfig.cwd; - const cwd = yield* options.fileSystem - .realPath(requestedCwd) - .pipe(Effect.orElseSucceed(() => requestedCwd)); - const now = yield* DateTime.now; - let session: OrchestrationV2ProviderSession = { - id: input.providerSessionId, - driver: MUSE_PROVIDER, - providerInstanceId: options.instanceId, - status: "ready", - cwd, - model: input.modelSelection.model, - capabilities: MuseProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }; - const events = yield* Queue.unbounded< - ProviderAdapterV2Event, - ProviderAdapterV2Error | Cause.Done - >(); - type Inbox = - | { type: "notification"; method: string; params: unknown; epoch: number } - | { type: "failure"; cause: unknown; epoch: number } - | { type: "flush"; turn: ActiveTurn }; - const inbox = yield* Queue.unbounded(); - const commands = yield* Semaphore.make(1); - const eventPermit = yield* Semaphore.make(1); - let host: MuseSdkHost; - let hostEpoch = 0; - let closed = false; - let broken = false; - let nativeSessionId: string | undefined; - let thread: OrchestrationV2ProviderThread | undefined; - let active: ActiveTurn | undefined; - // The last finished turn, so context usage reported after it still lands on it. - let lastProviderTurn: OrchestrationV2ProviderTurn | undefined; - const agentEndedAt = new Map(); - // "default" is the catalog's default model, from the provider snapshot. Without - // one, Muse keeps whatever model the session already runs. - const resolveModel = Effect.fnUntraced(function* (selection: ModelSelection) { - if (selection.model !== MUSE_DEFAULT_MODEL) return selection.model; - const catalog = options.modelCatalog ? yield* options.modelCatalog : []; - return catalog.find((model) => model.isDefault && !model.isCustom)?.slug; - }); - const pending = new Map(); - const seenTerminals = new Set(); - // Workflows and subagents that outlive their turn, by item id, with the turn that owns them. - const observedChildren = new Map(); - // Background work that finished since the last turn, reported to the turn Muse starts for it. - const finishedBackground: Array = []; - // A turn Muse started on its own, held until the continuation run T3 opens for it takes it. - let wake: - | { readonly nativeId: string; readonly events: Array<[string, unknown]> } - | undefined; - const emit = (event: ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); - const decode = (schema: Schema.Codec, data: unknown) => - Schema.decodeUnknownEffect(schema)(data).pipe( - Effect.mapError((cause) => protocolError("Invalid Muse protocol response", cause)), - ); - const request = ( - method: string, - params: Record, - command = true, - commandId?: string, - ) => - Effect.tryPromise({ - try: () => - command - ? host.connection.command( - method, - { ...(nativeSessionId ? { sessionId: nativeSessionId } : {}), ...params }, - { - ...(method === "turn/start" ? {} : { maxAttempts: 1 }), - ...(commandId ? { commandId } : {}), - }, - ) - : host.connection.request(method, { - ...(nativeSessionId ? { sessionId: nativeSessionId } : {}), - ...params, - }), - catch: (cause) => - protocolError( - cause instanceof Error && cause.message - ? `Muse ${method} failed: ${cause.message}` - : `Muse ${method} failed`, - cause, - ), - }).pipe( - Effect.timeout(options.requestTimeoutMs ?? 30_000), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.gen(function* () { - yield* eventPermit.withPermits(1)(failHost(cause)); - return yield* protocolError(`Muse ${method} timed out; its host was closed`, cause); - }), - }), - ); - const updateSession = Effect.fnUntraced(function* ( - status: OrchestrationV2ProviderSession["status"], - lastError: string | null = null, - ) { - session = { ...session, status, lastError, updatedAt: yield* DateTime.now }; - yield* emit({ - type: "provider_session.updated", - driver: MUSE_PROVIDER, - providerSession: session, - }); - }); - const updateThread = Effect.fnUntraced(function* ( - patch: Partial, - ) { - if (!thread) return; - thread = { ...thread, ...patch, updatedAt: yield* DateTime.now }; - yield* emit({ - type: "provider_thread.updated", - driver: MUSE_PROVIDER, - providerThread: thread, - }); - }); - const itemIdentity = (turn: ActiveTurn, nativeId: string) => - `${options.instanceId}:${turn.input.providerThread.id}:${turn.nativeId}:${nativeId}`; - const baseItem = (turn: ActiveTurn, nativeId: string, time: DateTime.Utc) => { - if (!turn.ordinals.has(nativeId)) turn.ordinals.set(nativeId, turn.nextOrdinal++); - if (!turn.started.has(nativeId)) turn.started.set(nativeId, time); - const identity = itemIdentity(turn, nativeId); - return { - id: idAllocator.derive.turnItemFromProviderItem({ - driver: MUSE_PROVIDER, - nativeItemId: identity, - }), - nodeId: idAllocator.derive.nodeFromProviderItem({ - driver: MUSE_PROVIDER, - nativeItemId: identity, - }), - threadId: turn.input.threadId, - runId: turn.input.runId, - providerThreadId: turn.input.providerThread.id, - providerTurnId: turn.providerTurn.id, - nativeItemRef: nativeRef(nativeId), - parentItemId: null, - ordinal: turn.ordinals.get(nativeId)!, - startedAt: turn.started.get(nativeId)!, - updatedAt: time, - }; - }; - /** Lists running workflows and subagents on the thread, so it shows it still waits on them. */ - const syncBackground = Effect.fnUntraced(function* () { - yield* updateThread({ - pendingBackgroundTasks: [...observedChildren].flatMap(([itemId, owner]) => { - const item = owner.items.get(itemId); - const description = item?.objective ?? item?.entryId ?? item?.fallbackText; - return [ - { - taskId: itemId, - kind: - item?.kind === "subagent" ? ("subagent" as const) : ("background_task" as const), - ...(description ? { description } : {}), - }, - ]; - }), - }); - }); - const publishItem = Effect.fnUntraced(function* ( - turn: ActiveTurn, - item: MuseItem, - terminal?: OrchestrationV2TurnItem["status"], - ) { - // Muse's skill-reminder child runs on every model step; it is housekeeping, not work. - if (item.kind === "userMessage" || item.kind === "reminderChild") return; - // A failed call keeps its row: it may have no native row to show its error. - if ( - item.kind === "toolCall" && - item.tool && - TOOLS_WITH_NATIVE_ROWS.has(item.tool) && - item.status !== "failed" - ) - return; - const time = yield* DateTime.now; - const status = terminal ?? museItemStatus(item); - if (item.kind === "subagent" || item.kind === "workflow") { - const wasRunning = observedChildren.has(item.itemId); - if (status === "running") observedChildren.set(item.itemId, turn); - else if (observedChildren.delete(item.itemId) && active !== turn) - finishedBackground.push({ - kind: item.kind === "subagent" ? "subagent" : "background_task", - label: item.objective ?? item.entryId ?? item.fallbackText, - outcome: - status === "completed" - ? "completed" - : status === "cancelled" - ? "cancelled" - : "failed", - }); - if (wasRunning !== observedChildren.has(item.itemId)) yield* syncBackground(); - } - const streaming = status === "running"; - const base = { - ...baseItem(turn, item.itemId, time), - status, - completedAt: streaming ? null : time, - title: item.tool ?? null, - }; - const text = item.text ?? item.summary?.join("\n") ?? item.fallbackText ?? ""; - let turnItem: OrchestrationV2TurnItem; - if (item.kind === "agentMessage") { - const messageId = idAllocator.derive.messageFromProviderItem({ - driver: MUSE_PROVIDER, - nativeItemId: itemIdentity(turn, item.itemId), - }); - const message: OrchestrationV2ConversationMessage = { - id: messageId, - threadId: turn.input.threadId, - runId: turn.input.runId, - nodeId: base.nodeId, - role: "assistant", - text, - attachments: [], - streaming, - createdBy: "agent", - creationSource: "provider", - createdAt: base.startedAt, - updatedAt: time, - }; - yield* emit({ type: "message.updated", driver: MUSE_PROVIDER, message }); - turnItem = { ...base, type: "assistant_message", messageId, text, streaming }; - } else if (item.kind === "reasoning") { - turnItem = { ...base, type: "reasoning", text, streaming }; - } else if (item.kind === "compaction") { - turnItem = { - ...base, - type: "compaction", - driver: MUSE_PROVIDER, - summary: text || item.reason, - }; - } else { - turnItem = { ...base, ...museToolPresentation(item, status) }; - } - yield* emit({ - type: "node.updated", - driver: MUSE_PROVIDER, - node: { - id: base.nodeId, - threadId: base.threadId, - runId: base.runId, - parentNodeId: turn.input.rootNodeId, - rootNodeId: turn.input.rootNodeId, - kind: - item.kind === "agentMessage" - ? "assistant_message" - : item.kind === "reasoning" - ? "reasoning" - : "tool_call", - status, - countsForRun: false, - providerThreadId: base.providerThreadId, - providerTurnId: base.providerTurnId, - nativeItemRef: base.nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: base.startedAt, - completedAt: base.completedAt, - }, - }); - yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem }); - if (item.kind === "workflow") yield* publishWorkflowAgents(turn, item, base, status); - }); - /** - * Shows each workflow child as a native subagent under the workflow row. Muse - * reports only their status, label and outcome, not their conversations, so - * they get no child thread. - */ - const publishWorkflowAgents = Effect.fnUntraced(function* ( - turn: ActiveTurn, - item: MuseItem, - workflow: { readonly id: OrchestrationV2TurnItem["id"]; readonly nodeId: NodeId }, - workflowStatus: OrchestrationV2TurnItemStatus, - ) { - const latest = new Map((item.children ?? []).map((child) => [child.childId, child])); - for (const [index, child] of [...latest.values()].entries()) { - const time = yield* DateTime.now; - const base = baseItem(turn, `${item.itemId}:agent:${child.childId}`, time); - const status: OrchestrationV2TurnItemStatus = - child.terminal === "completed" - ? "completed" - : child.terminal === "cancelled" - ? "cancelled" - : child.terminal !== undefined - ? "failed" - : workflowStatus !== "running" - ? workflowStatus - : child.status === "scheduled" - ? "pending" - : "running"; - const settled = status !== "running" && status !== "pending"; - // Children are re-sent on every workflow change; keep each one's first end time. - const endKey = `${item.itemId}:${child.childId}:${child.attempt}`; - if (settled && !agentEndedAt.has(endKey)) agentEndedAt.set(endKey, time); - const title = child.label?.trim() || `Agent ${index + 1}`; - const result = child.failureReason ?? null; - const subagent: OrchestrationV2Subagent = { - id: base.nodeId, - threadId: base.threadId, - runId: base.runId, - parentNodeId: workflow.nodeId, - origin: "provider_native", - createdBy: "agent", - driver: MUSE_PROVIDER, - providerInstanceId: options.instanceId, - providerThreadId: base.providerThreadId, - childThreadId: null, - nativeTaskRef: nativeRef(child.childId), - prompt: title, - title, - model: null, - status, - result, - startedAt: base.startedAt, - completedAt: settled ? (agentEndedAt.get(endKey) ?? time) : null, - updatedAt: time, - }; - yield* emit({ type: "subagent.updated", driver: MUSE_PROVIDER, subagent }); - yield* emit({ - type: "node.updated", - driver: MUSE_PROVIDER, - node: { - id: base.nodeId, - threadId: base.threadId, - runId: base.runId, - parentNodeId: workflow.nodeId, - rootNodeId: turn.input.rootNodeId, - kind: "subagent", - status, - countsForRun: false, - providerThreadId: base.providerThreadId, - providerTurnId: base.providerTurnId, - nativeItemRef: subagent.nativeTaskRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: base.startedAt, - completedAt: subagent.completedAt, - }, - }); - yield* emit({ - type: "turn_item.updated", - driver: MUSE_PROVIDER, - turnItem: { - ...base, - nativeItemRef: subagent.nativeTaskRef, - parentItemId: workflow.id, - type: "subagent", - status, - title, - completedAt: subagent.completedAt, - subagentId: subagent.id, - origin: "provider_native", - driver: MUSE_PROVIDER, - providerInstanceId: options.instanceId, - childThreadId: null, - prompt: title, - result, - }, - }); - } - }); - const settleObservedChildren = Effect.fnUntraced(function* ( - status: "failed" | "cancelled" | "interrupted", - ) { - for (const [itemId, owner] of observedChildren) { - const item = owner.items.get(itemId); - if (item) yield* publishItem(owner, item, status); - } - observedChildren.clear(); - if (thread?.pendingBackgroundTasks?.length) yield* syncBackground(); - // Only workflow children's requests outlive a turn; they end with the workflow. - for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); - }); - const resolvePending = Effect.fnUntraced(function* ( - entry: PendingRequest, - status: "resolved" | "cancelled", - ) { - const time = yield* DateTime.now; - entry.request = { - ...entry.request, - ...(status === "resolved" ? entry.response : undefined), - status, - resolvedAt: time, - responseCapability: { type: "not_resumable", reason: "This Muse request has ended." }, - }; - entry.node = { - ...entry.node, - status: status === "resolved" ? "completed" : "cancelled", - completedAt: time, - }; - entry.item = { - ...entry.item, - status: status === "resolved" ? "completed" : "cancelled", - completedAt: time, - updatedAt: time, - }; - pending.delete(entry.request.id); - yield* emit({ - type: "runtime_request.updated", - driver: MUSE_PROVIDER, - threadId: input.threadId, - runtimeRequest: entry.request, - }); - yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node: entry.node }); - yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: entry.item }); - }); - const finish = Effect.fnUntraced(function* ( - turn: ActiveTurn, - status: "completed" | "cancelled" | "interrupted" | "failed", - detail?: string, - disposition: "reusable" | "broken" = "reusable", - ) { - if (active !== turn || seenTerminals.has(turn.nativeId)) return; - seenTerminals.add(turn.nativeId); - const completedAt = yield* DateTime.now; - for (const item of turn.items.values()) { - if ( - (item.kind === "subagent" || item.kind === "workflow") && - item.status === "inProgress" && - disposition === "reusable" - ) - continue; - if (turn.dirty.has(item.itemId) || item.status === "inProgress") - yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined); - } - turn.dirty.clear(); - // A workflow child's request is runless and keeps waiting after the turn ends. - for (const entry of pending.values()) - if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled"); - turn.providerTurn = { ...turn.providerTurn, status, completedAt }; - lastProviderTurn = turn.providerTurn; - yield* emit({ - type: "provider_turn.updated", - driver: MUSE_PROVIDER, - threadId: turn.input.threadId, - providerTurn: turn.providerTurn, - }); - yield* updateThread({ - status: disposition === "broken" ? "error" : "idle", - ...(!turn.compact && status === "completed" - ? { nativeConversationHeadRef: nativeRef(turn.nativeId) } - : {}), - }); - yield* updateSession(disposition === "broken" ? "error" : "ready", detail ?? null); - active = undefined; - if (status === "failed") { - const failure = makeProviderFailure({ - class: disposition === "broken" ? "transport_error" : "provider_error", - message: detail ?? "Muse turn failed.", - }); - const base = baseItem(turn, `failure:${turn.nativeId}`, completedAt); - yield* emit({ - type: "turn_item.updated", - driver: MUSE_PROVIDER, - turnItem: { - ...base, - type: "error", - status: "failed", - title: null, - completedAt, - failure, - }, - }); - yield* emit({ - type: "turn.terminal", - driver: MUSE_PROVIDER, - providerThreadId: turn.providerTurn.providerThreadId, - providerTurnId: turn.providerTurn.id, - runOrdinal: turn.input.runOrdinal, - status, - failure, - failureItemOrdinal: base.ordinal, - threadDisposition: disposition, - }); - } else { - yield* emit({ - type: "turn.terminal", - driver: MUSE_PROVIDER, - providerThreadId: turn.providerTurn.providerThreadId, - providerTurnId: turn.providerTurn.id, - runOrdinal: turn.input.runOrdinal, - status, - failure: null, - threadDisposition: disposition, - }); - } - yield* Deferred.succeed(turn.done, undefined); - }); - const failHost = Effect.fnUntraced(function* (cause: unknown) { - if (closed || broken) return; - broken = true; - const rootCause = Cause.isCause(cause) ? Cause.squash(cause) : cause; - const failure = isOpenSessionError(rootCause) ? rootCause.cause : rootCause; - const detail = isProtocolError(failure) - ? failure.detail - : failure instanceof Error - ? failure.message - : "Muse transport failed."; - if (active) yield* finish(active, "failed", detail, "broken"); - else { - yield* updateThread({ status: "error" }); - yield* updateSession("error", detail); - } - yield* settleObservedChildren("failed"); - yield* Queue.fail(events, protocolError(detail, cause)); - // Report the failure first; a hung host can take a while to close. - const failed = host; - yield* Effect.tryPromise(() => failed.close()).pipe(Effect.ignore, Effect.forkIn(scope)); - }); - const owns = (turn: ActiveTurn, turnId: string) => - turnId === turn.nativeId || turn.joined.has(turnId); - /** Sends T3's decision for a pending approval to Muse. */ - const decideApproval = Effect.fnUntraced(function* ( - entry: PendingRequest, - decision: ProviderApprovalDecision | undefined, - ) { - if (entry.native.type !== "approval") return; - const approval = entry.native.value; - const choice = decision && museApprovalChoices(approval).get(decision); - if (!choice) return yield* protocolError("Muse did not offer this approval decision"); - // Recorded first: Muse can settle the approval before it acknowledges the command. - entry.response = { decision }; - yield* request("approval/decide", { - approvalId: approval.approvalId, - requirementId: approval.currentRequirementId, - choiceId: choice.choiceId, - }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); - }); - /** - * Shows a Muse approval or question as a T3 request. A workflow child's approval - * is runless: the turn that started the workflow may end long before the child - * asks, and T3 keeps a runless request on the thread until Muse settles it. - */ - const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) { - const child = native.type === "approval" && native.value.subagentOrigin !== undefined; - const turn = child ? undefined : active; - if ( - !thread || - (!child && !turn) || - native.value.sessionId !== nativeSessionId || - (turn && native.value.turnId && !owns(turn, native.value.turnId)) - ) - return; - const nativeId = - native.type === "approval" ? native.value.approvalId : native.value.userInputId; - if (turn?.settledRequests.has(`${native.type}:${nativeId}`)) return; - const previous = [...pending.values()].find( - (entry) => - entry.native.type === native.type && - entry.request.nativeRequestRef?.nativeId === nativeId, - ); - const providerTurnId = turn?.providerTurn.id ?? null; - const threadId = turn?.input.threadId ?? thread.appThreadId ?? input.threadId; - const requestId = - previous?.request.id ?? - (yield* idAllocator.allocate - .runtimeRequest({ - driver: MUSE_PROVIDER, - ...(providerTurnId ? { providerTurnId } : {}), - nativeRequestId: nativeId, - }) - .pipe( - Effect.mapError((cause) => protocolError("Cannot allocate Muse request", cause)), - )); - const time = yield* DateTime.now; - const nodeId = idAllocator.derive.approvalNode({ requestId }); - const subject = native.type === "approval" ? native.value.subject : undefined; - const kind = !subject - ? "user_input" - : subject.kind === "shell" - ? "command" - : subject.kind !== "fileAccess" - ? "permission" - : subject.access === "read" - ? "file-read" - : "file-change"; - const runtimeRequest: OrchestrationV2RuntimeRequest = { - id: requestId, - nodeId, - providerTurnId, - nativeRequestRef: nativeRef(nativeId), - kind, - status: "pending", - responseCapability: { type: "live", providerSessionId: input.providerSessionId }, - createdAt: previous?.request.createdAt ?? time, - resolvedAt: null, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId, - runId: turn?.input.runId ?? null, - parentNodeId: turn?.input.rootNodeId ?? null, - rootNodeId: turn?.input.rootNodeId ?? nodeId, - kind: native.type === "approval" ? "approval_request" : "user_input_request", - status: "waiting", - countsForRun: false, - providerThreadId: thread.id, - providerTurnId, - nativeItemRef: nativeRef(nativeId), - runtimeRequestId: requestId, - checkpointScopeId: null, - startedAt: runtimeRequest.createdAt, - completedAt: null, - }; - const latestTurn = active?.providerTurn ?? lastProviderTurn; - const base = { - ...(turn - ? baseItem(turn, `request:${nativeId}`, time) - : { - threadId, - runId: null, - providerThreadId: thread.id, - providerTurnId: null, - nativeItemRef: nativeRef(nativeId), - parentItemId: null, - // Sorts after the latest turn's items. - ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100, - startedAt: runtimeRequest.createdAt, - updatedAt: time, - }), - id: idAllocator.derive.approvalTurnItem({ requestId }), - nodeId, - status: "waiting" as const, - title: null, - completedAt: null, - }; - const item: OrchestrationV2TurnItem = - native.type === "approval" - ? { - ...base, - type: "approval_request", - requestId, - requestKind: kind === "user_input" ? "command" : kind, - prompt: - native.value.subject.command ?? - native.value.subject.path ?? - native.value.subject.host ?? - native.value.subject.target ?? - native.value.toolName ?? - "Muse requests permission", - options: museApprovalOptions(native.value), - } - : { - ...base, - type: "user_input_request", - requestId, - questions: native.value.questions.map((question) => ({ - id: question.id, - header: question.header || "Question", - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description || option.label, - })), - multiSelect: question.selection.mode === "multiple", - allowCustomAnswer: true, - required: true, - })), - }; - const entry: PendingRequest = { native, request: runtimeRequest, node, item }; - pending.set(requestId, entry); - yield* emit({ - type: "runtime_request.updated", - driver: MUSE_PROVIDER, - threadId, - runtimeRequest, - }); - yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node }); - yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item }); - yield* updateSession("waiting"); - // Muse does not give workflow children the session's allowAll mode, so in full - // access T3 approves for them. If Muse rejects that, the request waits for the user. - if (child && input.runtimePolicy.runtimeMode === "full-access") - yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope)); - }); - /** Approval and question events; `turn` is undefined for a workflow child's approval. */ - const handleRequestEvent = Effect.fnUntraced(function* ( - method: string, - params: Record, - turn: ActiveTurn | undefined, - ) { - switch (method) { - case "approval/requested": - case "approval/updated": - if ( - method === "approval/updated" && - ![...pending.values()].some( - (entry) => - entry.native.type === "approval" && - entry.native.value.approvalId === params.approvalId, - ) - ) - return; - return yield* publishRequest({ - type: "approval", - value: yield* decode(MuseApproval, params), - }); - case "userInput/requested": - return yield* publishRequest({ - type: "question", - value: yield* decode(MuseUserInput, params), - }); - case "approval/resolved": - case "userInput/settled": { - const id = method === "approval/resolved" ? params.approvalId : params.userInputId; - if (typeof id === "string") - turn?.settledRequests.add( - `${method === "approval/resolved" ? "approval" : "question"}:${id}`, - ); - const entry = [...pending.values()].find( - (candidate) => candidate.request.nativeRequestRef?.nativeId === id, - ); - if (entry) { - // An approval settled outside T3 still shows Muse's own decision. - const native = - method === "approval/resolved" && typeof params.decision === "string" - ? museApprovalDecision({ decision: params.decision, scope: "once" }) - : undefined; - if (!entry.response && native) entry.response = { decision: native }; - yield* resolvePending(entry, "resolved"); - } - if (!pending.size) yield* updateSession(active ? "running" : "ready"); - } - } - }); - const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) { - const params = yield* decode(recordSchema, data); - if (params.sessionId !== nativeSessionId) return; - if (method === "view/gap") - return yield* protocolError("Muse delivery gap requires session recovery"); - if (method === "session/contextUsage") { - const usage = yield* decode(MuseContextUsage, params); - const currentTurn = active?.providerTurn ?? lastProviderTurn; - const updatedAt = DateTime.formatIso(yield* DateTime.now); - const tokenUsage = { - ...currentTurn?.tokenUsage, - usedTokens: usage.usedTokens, - maxTokens: usage.windowTokens ?? currentTurn?.tokenUsage?.maxTokens ?? undefined, - updatedAt, - }; - yield* updateThread({ contextUsage: tokenUsage }); - if (currentTurn) { - const updated = { ...currentTurn, tokenUsage }; - if (active) active.providerTurn = updated; - else lastProviderTurn = updated; - yield* emit({ - type: "provider_turn.updated", - driver: MUSE_PROVIDER, - providerTurn: updated, - }); - } - return; - } - const itemEvent = ["item/started", "item/updated", "item/completed"].includes(method) - ? (yield* decode(MuseItemEvent, params)).item - : undefined; - if (itemEvent) { - const owner = observedChildren.get(itemEvent.itemId); - if (owner && owner !== active && itemEvent.turnId === owner.nativeId) { - const previous = owner.items.get(itemEvent.itemId); - if (!previous || itemEvent.revision > previous.revision) { - owner.items.set(itemEvent.itemId, itemEvent); - yield* publishItem(owner, itemEvent); - } - return; - } - } - // A workflow child's approval comes under the child's own run id, during a turn - // or after it, so it skips turn routing. - if (params.subagentOrigin !== undefined && method.startsWith("approval/")) - return yield* handleRequestEvent(method, params, undefined); - // Muse starts a turn on its own when a workflow finishes, to report its result. - // During a run, that run shows it. Otherwise hold it and ask the orchestrator for - // a run; that run takes the held events, unless a user turn takes them first. - if ( - method === "turn/started" && - typeof params.turnId === "string" && - params.turnId !== active?.nativeId && - params.turnId !== wake?.nativeId - ) { - if (active && !active.compact) active.joined.add(params.turnId); - else if (!active && !wake) { - const held = { nativeId: params.turnId, events: [] }; - wake = held; - const notification = backgroundWorkNotification(finishedBackground.splice(0)); - if (thread?.appThreadId && options.continuationRequests) - yield* options.continuationRequests.offer({ - threadId: thread.appThreadId, - providerThreadId: thread.id, - driver: MUSE_PROVIDER, - detail: null, - ...(notification ? { notification } : {}), - dispatchIfCurrent: (dispatch) => - wake === held ? Effect.map(dispatch, Option.some) : Effect.succeed(Option.none()), - clearIfCurrent: () => - Effect.sync(() => { - if (wake === held) wake = undefined; - }), - }); - } - } - if (wake && active?.nativeId !== wake.nativeId) { - const turnId = typeof params.turnId === "string" ? params.turnId : itemEvent?.turnId; - if (turnId === wake.nativeId) { - wake.events.push([method, data]); - return; - } - } - const turn = active; - if (!turn) return; - if (typeof params.turnId === "string" && !owns(turn, params.turnId) && !turn.compact) - return; - switch (method) { - case "item/started": - case "item/updated": - case "item/completed": { - const item = itemEvent!; - if ( - item.turnId && - !owns(turn, item.turnId) && - !(turn.compact && item.kind === "compaction") - ) - return; - const previous = turn.items.get(item.itemId); - if (previous && previous.revision >= item.revision) return; - turn.items.set(item.itemId, item); - turn.dirty.delete(item.itemId); - yield* publishItem(turn, item); - if (turn.compact && item.kind === "compaction" && item.status !== "inProgress") { - yield* finish( - turn, - item.status === "completed" && (!item.outcome || item.outcome === "compacted") - ? "completed" - : item.status === "cancelled" - ? "cancelled" - : "failed", - item.failureReason ?? item.reason, - ); - } - break; - } - case "item/delta": { - const delta = yield* decode(MuseDelta, params); - const previous = turn.items.get(delta.itemId); - if (!previous || previous.status !== "inProgress") return; - const field = delta.field ?? "text"; - let item = previous; - if (field === "text") item = { ...previous, text: (previous.text ?? "") + delta.delta }; - else if (field === "output" || field === "visibleOutput") - item = { ...previous, visibleOutput: (previous.visibleOutput ?? "") + delta.delta }; - else if (field.startsWith("summary.")) { - const index = Number(field.slice(8)); - if (Number.isSafeInteger(index) && index >= 0 && index < 100) { - const summary = [...(previous.summary ?? [])]; - summary[index] = (summary[index] ?? "") + delta.delta; - item = { ...previous, summary }; - } - } - if (item === previous) return; - turn.items.set(item.itemId, item); - turn.dirty.add(item.itemId); - if (!turn.flushScheduled) { - turn.flushScheduled = true; - yield* Effect.sleep(50).pipe( - Effect.andThen(Queue.offer(inbox, { type: "flush", turn })), - Effect.forkIn(scope), - ); - } - break; - } - case "turn/completed": { - if (turn.compact) return; - const result = yield* decode(MuseTurnCompleted, params); - // A joined Muse turn ending does not end this run; its own turn does. - if (result.turnId !== turn.nativeId) break; - if (result.usage) { - const usage = result.usage; - turn.providerTurn = { - ...turn.providerTurn, - turnTokenUsage: { - usageScope: "main_agent", - usageStatus: "complete", - hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"), - inputTokens: turn.providerTurn.turnTokenUsage?.inputTokens ?? usage.inputTokens, - outputTokens: usage.outputTokens, - cachedInputTokens: usage.cacheReadTokens ?? usage.cachedTokens, - reasoningTokens: usage.reasoningTokens, - ...(usage.cacheWriteTokens === undefined - ? {} - : { cacheCreationTokens: usage.cacheWriteTokens }), - }, - }; - } - const status = - result.terminal === "completed" - ? "completed" - : result.terminal === "cancelled" - ? turn.interruptRequested - ? "interrupted" - : "cancelled" - : "failed"; - yield* finish(turn, status, result.error?.message ?? result.reason); - break; - } - case "approval/requested": - case "approval/updated": - case "approval/resolved": - case "userInput/requested": - case "userInput/settled": - yield* handleRequestEvent(method, params, turn); - break; - case "session/tokenUsage": { - const usage = yield* decode(MuseTokenUsageEvent, params); - const previous = turn.providerTurn.turnTokenUsage; - const contextUsage = thread?.contextUsage; - turn.providerTurn = { - ...turn.providerTurn, - ...(contextUsage - ? { - tokenUsage: { - ...turn.providerTurn.tokenUsage, - usedTokens: contextUsage.usedTokens, - inputTokens: usage.usage.inputTokens, - outputTokens: usage.usage.outputTokens, - cachedInputTokens: usage.usage.cacheReadTokens ?? usage.usage.cachedTokens, - reasoningOutputTokens: usage.usage.reasoningTokens, - updatedAt: DateTime.formatIso(yield* DateTime.now), - }, - } - : {}), - turnTokenUsage: { - usageScope: "main_agent", - usageStatus: "partial", - hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"), - inputTokens: (previous?.inputTokens ?? 0) + usage.promptTokens, - outputTokens: (previous?.outputTokens ?? 0) + usage.usage.outputTokens, - cachedInputTokens: - (previous?.cachedInputTokens ?? 0) + - (usage.usage.cacheReadTokens ?? usage.usage.cachedTokens), - ...(usage.usage.cacheWriteTokens === undefined && - previous?.cacheCreationTokens === undefined - ? {} - : { - cacheCreationTokens: - (previous?.cacheCreationTokens ?? 0) + (usage.usage.cacheWriteTokens ?? 0), - }), - reasoningTokens: (previous?.reasoningTokens ?? 0) + usage.usage.reasoningTokens, - }, - }; - yield* emit({ - type: "provider_turn.updated", - driver: MUSE_PROVIDER, - providerTurn: turn.providerTurn, - }); - break; - } - case "session/todoListChanged": { - const todo = yield* decode(MuseTodoList, params); - const steps: ReadonlyArray = todo.items - .filter((item) => item.text.trim() && item.status !== "cancelled") - .map((item, index) => ({ - id: `${turn.nativeId}:todo:${index}`, - text: item.text.trim(), - status: - item.status === "completed" - ? "completed" - : item.status === "inProgress" - ? "running" - : "pending", - })); - const time = yield* DateTime.now; - const base = baseItem(turn, "todo", time); - const planId = - turn.todoPlanId ?? - (yield* idAllocator.allocate - .plan({ - threadId: turn.input.threadId, - runId: turn.input.runId, - driver: MUSE_PROVIDER, - }) - .pipe( - Effect.mapError((cause) => - protocolError("Cannot allocate Muse todo list", cause), - ), - )); - turn.todoPlanId = planId; - yield* emit({ - type: "plan.updated", - driver: MUSE_PROVIDER, - plan: { - id: planId, - threadId: turn.input.threadId, - runId: turn.input.runId, - nodeId: base.nodeId, - kind: "todo_list", - status: steps.every((step) => step.status === "completed") ? "completed" : "active", - steps, - }, - }); - yield* emit({ - type: "node.updated", - driver: MUSE_PROVIDER, - node: { - id: base.nodeId, - threadId: base.threadId, - runId: base.runId, - parentNodeId: turn.input.rootNodeId, - rootNodeId: turn.input.rootNodeId, - kind: "todo_list", - status: "completed", - countsForRun: false, - providerThreadId: base.providerThreadId, - providerTurnId: base.providerTurnId, - nativeItemRef: null, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: base.startedAt, - completedAt: time, - }, - }); - yield* emit({ - type: "turn_item.updated", - driver: MUSE_PROVIDER, - turnItem: { - ...base, - nativeItemRef: null, - type: "todo_list", - status: "completed", - title: null, - completedAt: time, - planId, - steps, - }, - }); - break; - } - case "turn/retryScheduled": { - const retry = yield* decode(MuseTurnRetryScheduled, params); - const time = yield* DateTime.now; - yield* emit({ - type: "turn_item.updated", - driver: MUSE_PROVIDER, - turnItem: { - ...baseItem(turn, `retry:${retry.nextAttempt}`, time), - type: "system_notice", - status: "completed", - title: "Muse retry", - completedAt: time, - message: `Muse is retrying this turn (attempt ${retry.nextAttempt} of ${retry.maxAttempts}): ${retry.reason}`, - }, - }); - break; - } - } - }); - yield* Stream.fromQueue(inbox).pipe( - Stream.runForEach((entry) => - Effect.gen(function* () { - if (entry.type === "flush") { - const turn = entry.turn; - turn.flushScheduled = false; - if (active !== turn) return; - for (const id of turn.dirty) { - const item = turn.items.get(id); - if (item) yield* publishItem(turn, item); - } - turn.dirty.clear(); - } else if (entry.epoch === hostEpoch) { - if (entry.type === "failure") yield* failHost(entry.cause); - else { - if (options.nativeEventLogger && entry.method !== "item/delta") - yield* options.nativeEventLogger.write( - { provider: "muse", method: entry.method, params: entry.params }, - input.threadId, - ); - yield* handleNotification(entry.method, entry.params).pipe( - Effect.catch((cause) => - INFORMATIONAL_NOTIFICATIONS.has(entry.method) - ? Effect.logWarning(`Skipped an unreadable Muse ${entry.method}`, cause) - : failHost(cause), - ), - ); - } - } - }).pipe(eventPermit.withPermits(1)), - ), - Effect.forkIn(scope), - ); - const launchHost = Effect.fnUntraced(function* () { - const epoch = ++hostEpoch; - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - const created = yield* Effect.acquireRelease( - createMuseSdkHostEffect( - { - binaryPath: options.settings.binaryPath || "muse", - cwd, - environment: McpProviderSession.withAgentDeviceEnvironment( - options.environment, - mcpSession, - ), - runtimeMode: input.runtimePolicy.runtimeMode, - }, - options.createHost, - ).pipe( - Effect.mapError( - (error) => - new ProviderAdapterOpenSessionError({ - driver: MUSE_PROVIDER, - providerSessionId: input.providerSessionId, - cause: error.cause, - }), - ), - ), - (created) => - Effect.gen(function* () { - if (host === created) { - closed = true; - hostEpoch++; - } - yield* Effect.tryPromise(() => created.close()).pipe(Effect.ignore); - }), - { interruptible: true }, - ).pipe(Effect.provideService(Scope.Scope, scope)); - host = created; - created.connection.onNotification((notification) => { - Queue.offerUnsafe(inbox, { - type: "notification", - method: notification.method, - params: notification.params, - epoch, - }); - }); - created.connection.onProtocolError((cause) => { - Queue.offerUnsafe(inbox, { type: "failure", cause, epoch }); - }); - // These are presentation receipts only. The matching notifications drive the UI, - // and decisions travel as `approval/decide` and `userInput/answer` commands. - created.connection.onServerRequest(async (request) => { - if (request.method === "approval/request" || request.method === "userInput/request") - return {}; - throw new Error(`Unsupported Muse server request: ${request.method}`); - }); - void created.connection.closed.then(() => { - if (!closed && epoch === hostEpoch) - Queue.offerUnsafe(inbox, { - type: "failure", - cause: new Error("Muse connection closed unexpectedly"), - epoch, - }); - }); - void created.exited.then((exit) => { - if (!closed && epoch === hostEpoch) { - const reason = created - .stderrTail?.() - .findLast((line) => line.trim()) - ?.trim(); - Queue.offerUnsafe(inbox, { - type: "failure", - cause: new Error( - `Muse exited unexpectedly (${exit.code ?? exit.signal})${reason ? `: ${reason}` : ""}`, - ), - epoch, - }); - } - }); - }); - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - closed = true; - hostEpoch++; - if (active) yield* finish(active, "cancelled", "Muse session closed", "broken"); - yield* settleObservedChildren("cancelled"); - yield* Queue.shutdown(inbox); - yield* Queue.shutdown(events); - }).pipe(eventPermit.withPermits(1)), - ); - yield* launchHost(); - - const register = Effect.fnUntraced(function* (args: ProviderAdapterV2EnsureThreadInput) { - if (broken || closed) - return yield* protocolError("Muse host is unavailable; reopen the session"); - if (active) - return yield* protocolError("Cannot change Muse sessions during an active turn"); - const existing = args.existingProviderThread; - if ( - existing && - (existing.driver !== MUSE_PROVIDER || existing.providerInstanceId !== options.instanceId) - ) - return yield* protocolError("Muse thread belongs to another provider instance"); - const requestedId = existing - ? (existing.nativeThreadRef?.nativeId ?? undefined) - : input.initialNativeThreadId; - if ( - thread && - (!requestedId || requestedId === nativeSessionId) && - thread.appThreadId === args.threadId - ) - return thread; - if (thread) return yield* protocolError("Open a new host to attach another Muse session"); - let missingNativeSession = false; - return yield* Effect.gen(function* () { - nativeSessionId = requestedId ?? host.connection.mintCommandId(); - const mcpSession = McpProviderSession.readMcpProviderSession(args.threadId); - if (mcpSession && !host.initializeResult.grantedCapabilities.includes("sessionMcp")) - return yield* protocolError( - "Update Muse Code to a version that supports session MCP servers", - ); - const config = mcpSession - ? { - mcpServers: { - // Muse defaults to "required", which fails the whole run when T3's - // tools cannot be reached. The agent should still work without them. - "t3-code": { - transport: "streamableHttp", - mode: "optional", - url: mcpSession.endpoint, - headers: { Authorization: mcpSession.authorizationHeader }, - }, - }, - } - : undefined; - const startModel = yield* resolveModel(args.modelSelection); - const result = yield* request( - requestedId ? "session/resume" : "session/start", - requestedId - ? // T3 already has the transcript; skip Muse's history payload. - { excludeItems: true, ...(config ? { config } : {}) } - : { - workspaceRoot: cwd, - ...(config ? { config } : {}), - ...(startModel ? { modelId: startModel } : {}), - providerId: "meta", - approvalMode: museApprovalMode(args.runtimePolicy.runtimeMode), - }, - ).pipe( - Effect.catch((error) => { - missingNativeSession = - requestedId !== undefined && - error.payload instanceof MspError && - (error.payload.kind === "sessionNotFound" || error.payload.kind === "notFound"); - return Effect.fail(error); - }), - Effect.flatMap((value) => decode(MuseSessionResult, value)), - ); - if (result.session.sessionId !== nativeSessionId) - return yield* protocolError("Muse returned a different session identity"); - if (requestedId) - yield* request("session/setApprovalMode", { - mode: museApprovalMode(args.runtimePolicy.runtimeMode), - }); - // A resumed session keeps the model it last ran with; the next turn - // switches it when the selection differs. - const createdAt = yield* DateTime.now; - thread = existing - ? { - ...existing, - providerSessionId: input.providerSessionId, - nativeThreadRef: nativeRef(result.session.sessionId), - status: "idle", - updatedAt: createdAt, - } - : { - id: idAllocator.derive.providerThread({ - driver: MUSE_PROVIDER, - providerInstanceId: options.instanceId, - nativeThreadId: nativeSessionId, - }), - driver: MUSE_PROVIDER, - providerInstanceId: options.instanceId, - providerSessionId: input.providerSessionId, - appThreadId: args.threadId, - ownerNodeId: null, - nativeThreadRef: nativeRef(nativeSessionId), - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt, - updatedAt: createdAt, - }; - session = { - ...session, - model: result.session.modelId ?? args.modelSelection.model, - }; - yield* emit({ - type: "provider_thread.updated", - driver: MUSE_PROVIDER, - providerThread: thread, - }); - return thread; - }).pipe( - Effect.onError((cause) => - missingNativeSession ? Effect.void : eventPermit.withPermits(1)(failHost(cause)), - ), - ); - }); - const prompt = Effect.fnUntraced(function* (message: ProviderAdapterV2TurnMessage) { - const parts: SendUserTurnOptions["input"] = []; - const text = providerMessageTextWithAttachmentPaths({ - text: message.text, - attachments: message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: options.serverConfig.attachmentsDir, - attachment, - }), - }); - if (text) parts.push({ type: "text", text }); - for (const attachment of message.attachments) - if (isProviderNativeImageAttachment(attachment)) { - const path = resolveAttachmentPath({ - attachmentsDir: options.serverConfig.attachmentsDir, - attachment, - }); - if (!path) return yield* protocolError("Muse image attachment is missing"); - const bytes = yield* options.fileSystem - .readFile(path) - .pipe(Effect.mapError((cause) => protocolError("Cannot read Muse image", cause))); - parts.push({ - type: "image", - base64Data: Buffer.from(bytes).toString("base64"), - mediaType: attachment.mimeType, - }); - } - if (!parts.length) return yield* protocolError("Muse needs text or an image"); - return parts; - }); - const validateThread = (candidate: OrchestrationV2ProviderThread) => - thread !== undefined && - candidate.driver === MUSE_PROVIDER && - candidate.providerInstanceId === options.instanceId && - candidate.appThreadId === thread.appThreadId && - candidate.nativeThreadRef?.nativeId === nativeSessionId; - const selectionEffort = Effect.fnUntraced(function* (selection: ModelSelection) { - if (selection.instanceId !== options.instanceId) - return yield* protocolError("Model selection belongs to another Muse instance"); - const catalog = options.modelCatalog ? yield* options.modelCatalog : []; - const model = yield* resolveModel(selection); - const selected = getModelSelectionStringOptionValue(selection, "reasoningEffort"); - // With no saved choice, the model's own default applies. - return resolveMuseReasoningEffort( - catalog.find((entry) => entry.slug === model)?.capabilities ?? museModelCapabilities(), - selected, - ); - }); - const start = Effect.fnUntraced(function* ( - turnInput: ProviderAdapterV2TurnInput, - compact = false, - ) { - if (!validateThread(turnInput.providerThread) || broken || closed) - return yield* protocolError("Muse thread is not attached to this host"); - if (active) - return yield* protocolError( - "Muse already has an active turn; queue through the orchestrator", - ); - if (turnInput.runtimePolicy.interactionMode === "plan") - return yield* protocolError("Muse does not support dedicated Plan mode"); - if (turnInput.runtimePolicy.runtimeMode !== input.runtimePolicy.runtimeMode) - return yield* protocolError("Runtime policy changes require a fresh Muse host"); - // A continuation run takes the turn Muse already started; it sends no prompt. - const continuation = - turnInput.message.createdBy === "agent" && - turnInput.message.creationSource === "provider"; - const adopted = continuation ? wake : undefined; - const effort = continuation ? undefined : yield* selectionEffort(turnInput.modelSelection); - const parts = compact || continuation ? [] : yield* prompt(turnInput.message); - const targetModel = continuation - ? undefined - : yield* resolveModel(turnInput.modelSelection); - if (targetModel && session.model !== targetModel) { - yield* request("session/setModel", { - model: { modelId: targetModel, providerId: "meta" }, - }); - session = { ...session, model: targetModel }; - } - const nativeId = adopted?.nativeId ?? host.connection.mintCommandId(); - const startedAt = yield* DateTime.now; - const providerTurn: OrchestrationV2ProviderTurn = { - id: idAllocator.derive.providerTurn({ - driver: MUSE_PROVIDER, - nativeTurnId: `${options.instanceId}:${nativeSessionId}:${nativeId}`, - }), - providerThreadId: turnInput.providerThread.id, - nodeId: turnInput.rootNodeId, - runAttemptId: turnInput.attemptId, - nativeTurnRef: compact - ? { ...nativeRef(nativeId), strength: "weak" } - : nativeRef(nativeId), - ordinal: turnInput.providerTurnOrdinal, - status: "running", - startedAt, - completedAt: null, - }; - const turn: ActiveTurn = { - input: turnInput, - providerTurn, - nativeId, - joined: new Set(), - items: new Map(), - ordinals: new Map(), - started: new Map(), - dirty: new Set(), - settledRequests: new Set(), - done: yield* Deferred.make(), - nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, - flushScheduled: false, - interruptRequested: false, - compact, - }; - yield* Effect.gen(function* () { - thread = turnInput.providerThread; - active = turn; - yield* emit({ - type: "provider_turn.updated", - driver: MUSE_PROVIDER, - threadId: turnInput.threadId, - providerTurn, - }); - yield* updateThread({ - status: "active", - firstRunOrdinal: thread.firstRunOrdinal ?? turnInput.runOrdinal, - lastRunOrdinal: turnInput.runOrdinal, - }); - yield* updateSession("running"); - // A user turn takes a held Muse turn: Muse runs it first, so this run shows it - // (and its approvals), and the continuation asked for it is no longer needed. - const held = wake; - wake = undefined; - if (continuation && !held) return yield* finish(turn, "completed"); - if (held && !continuation) turn.joined.add(held.nativeId); - for (const [method, data] of held?.events ?? []) - yield* handleNotification(method, data).pipe(Effect.catch(failHost)); - }).pipe(eventPermit.withPermits(1)); - if (continuation) return; - yield* Effect.gen(function* () { - if (compact) { - const result = yield* request("session/compact", {}, true, nativeId).pipe( - Effect.flatMap((value) => decode(MuseCompactResult, value)), - ); - if (result.status === "noop") - yield* eventPermit.withPermits(1)( - finish(turn, "failed", result.reason ?? "Muse has no context to compact."), - ); - else if (result.status !== "accepted") - return yield* protocolError( - `Muse returned an unsupported compaction status: ${result.status}`, - ); - } else { - const result = yield* request( - "turn/start", - { - input: [ - { - type: "text", - text: buildRuntimeInstructions({ - harness: "Muse Code", - model: session.model ?? undefined, - reasoningEffort: effort, - }), - }, - ...parts, - ], - displayText: turnInput.message.text || "Image attachment", - // A resumed session keeps the root it was created with; pin it to - // this thread's current checkout so edits land where T3 tracks them. - workspaceRoots: [cwd], - ifBusy: "queue", - ...(effort ? { reasoningEffort: effort } : {}), - }, - true, - nativeId, - ).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value))); - if (result.turnId !== nativeId) { - yield* eventPermit.withPermits(1)( - failHost(new Error("Muse admitted an unowned turn")), - ); - return yield* protocolError("Muse admitted the turn under an unexpected identity"); - } - } - }).pipe( - Effect.onError((cause) => { - const error = Cause.squash(cause); - const detail = isProtocolError(error) - ? error.detail - : error instanceof Error - ? error.message - : "Muse could not start the turn."; - return eventPermit.withPermits(1)(finish(turn, "failed", detail)); - }), - ); - }); - // Only messages are read back; T3 owns turn and item history. - const snapshot = Effect.fnUntraced(function* ( - current: OrchestrationV2ProviderThread, - ): Effect.fn.Return { - const result = yield* request("session/read", { excludeItems: false }, false).pipe( - Effect.flatMap((value) => decode(MuseSessionResult, value)), - ); - if (result.session.sessionId !== nativeSessionId) - return yield* protocolError("Muse snapshot belongs to a different session"); - const items = result.history?.items ?? result.history?.snapshot?.state.items ?? []; - const now = yield* DateTime.now; - return { - providerThread: current, - providerTurns: [], - messages: items.flatMap((item): OrchestrationV2ConversationMessage[] => - item.kind === "agentMessage" || item.kind === "userMessage" - ? [ - { - id: idAllocator.derive.messageFromProviderItem({ - driver: MUSE_PROVIDER, - nativeItemId: `${options.instanceId}:${current.id}:${item.turnId ?? "history"}:${item.itemId}`, - }), - threadId: current.appThreadId ?? input.threadId, - runId: null, - nodeId: null, - role: item.kind === "agentMessage" ? "assistant" : "user", - text: item.text ?? "", - attachments: [], - streaming: item.status === "inProgress", - createdBy: item.kind === "agentMessage" ? "agent" : "user", - creationSource: "provider", - createdAt: now, - updatedAt: now, - }, - ] - : [], - ), - runtimeRequests: [...pending.values()].map((entry) => entry.request), - providerPayload: items, - }; - }); - const runtime: ProviderAdapterV2SessionRuntime = { - instanceId: options.instanceId, - driver: MUSE_PROVIDER, - providerSessionId: input.providerSessionId, - providerSession: session, - events: Stream.fromQueue(events), - // A running workflow or a held Muse turn keeps the host: idle release must wait. - hasPendingBackgroundWork: Effect.sync( - () => observedChildren.size > 0 || wake !== undefined, - ), - ensureThread: (args) => - register(args).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapterEnsureThreadError({ - driver: MUSE_PROVIDER, - threadId: args.threadId, - cause, - }), - ), - ), - resumeThread: (args) => - register({ - threadId: args.threadId ?? args.providerThread.appThreadId ?? input.threadId, - modelSelection: args.modelSelection ?? input.modelSelection, - runtimePolicy: args.runtimePolicy ?? input.runtimePolicy, - existingProviderThread: args.providerThread, - }).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapterResumeThreadError({ - driver: MUSE_PROVIDER, - providerSessionId: input.providerSessionId, - providerThreadId: args.providerThread.id, - cause, - }), - ), - ), - startTurn: (args) => - start(args).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapterTurnStartError({ - driver: MUSE_PROVIDER, - threadId: args.threadId, - providerThreadId: args.providerThread.id, - runId: args.runId, - cause, - }), - ), - ), - compactThread: (args) => start(args, true).pipe(commands.withPermits(1)), - steerTurn: (args) => - Effect.gen(function* () { - const turn = active; - if ( - !turn || - turn.providerTurn.id !== args.providerTurnId || - !validateThread(args.providerThread) || - turn.compact - ) - return yield* protocolError("This Muse turn cannot be steered"); - const parts = yield* prompt(args.message); - const result = yield* request("turn/steer", { - expectedTurnId: turn.nativeId, - input: parts, - }).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value))); - if (result.turnId !== turn.nativeId) - return yield* protocolError("Muse steering targeted a different turn"); - }).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapterSteerRunError({ - driver: MUSE_PROVIDER, - providerThreadId: args.providerThread.id, - providerTurnId: args.providerTurnId, - cause, - }), - ), - ), - interruptTurn: (args) => - Effect.gen(function* () { - const turn = active; - if ( - !turn || - turn.providerTurn.id !== args.providerTurnId || - !validateThread(args.providerThread) - ) - return yield* protocolError("This Muse turn is no longer active"); - turn.interruptRequested = true; - if (turn.compact) { - yield* eventPermit.withPermits(1)( - Effect.gen(function* () { - if (active !== turn) return; - hostEpoch++; - yield* Effect.tryPromise(() => host.close()).pipe( - Effect.mapError((cause) => protocolError("Cannot stop Muse compaction", cause)), - Effect.onError(failHost), - ); - broken = true; - yield* finish(turn, "interrupted", undefined, "broken"); - yield* settleObservedChildren("interrupted"); - yield* updateSession("stopped"); - yield* Queue.end(events); - }), - ); - return; - } - // Muse runs a joined turn first, so Stop must end it too. - for (const joined of turn.joined) - yield* request("turn/interrupt", { turnId: joined }).pipe(Effect.ignore); - yield* request("turn/interrupt", { turnId: turn.nativeId }); - yield* Deferred.await(turn.done).pipe( - Effect.timeout(options.requestTimeoutMs ?? 30_000), - Effect.mapError((cause) => protocolError("Muse did not confirm interruption", cause)), - Effect.onError((cause) => eventPermit.withPermits(1)(failHost(cause))), - ); - }).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapterInterruptError({ - driver: MUSE_PROVIDER, - providerThreadId: args.providerThread.id, - providerTurnId: args.providerTurnId, - cause, - }), - ), - ), - respondToRuntimeRequest: (args) => - Effect.gen(function* () { - const entry = pending.get(args.requestId); - if (!entry) return yield* protocolError("This Muse request is no longer pending"); - if (entry.native.type === "approval") yield* decideApproval(entry, args.decision); - else { - const questionRequest = entry.native.value; - const answers = []; - for (const question of questionRequest.questions) { - const raw = yield* decode(responseAnswerSchema, args.answers?.[question.id]); - const values = typeof raw === "string" ? [raw] : [...new Set(raw)]; - const selected = values.filter((value) => - question.options.some((option) => option.label === value), - ); - const freeText = values.filter((value) => !selected.includes(value)).join("\n"); - if (freeText.length > 500 || (!freeText && !selected.length)) - return yield* protocolError( - "Muse needs a nonempty answer with at most 500 custom characters", - ); - if ( - (question.selection.mode === "single" && selected.length > 1) || - (selected.length > 0 && - (selected.length < (question.selection.minSelections ?? 0) || - selected.length > (question.selection.maxSelections ?? Infinity))) - ) - return yield* protocolError("Muse question selection count is invalid"); - answers.push({ - questionId: question.id, - ...(selected.length - ? question.selection.mode === "single" - ? { selectedLabel: selected[0]! } - : { selectedLabels: selected } - : {}), - ...(freeText ? (selected.length ? { note: freeText } : { freeText }) : {}), - }); - } - if (args.answers) entry.response = { answers: args.answers }; - yield* request("userInput/answer", { - userInputId: questionRequest.userInputId, - answers, - }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); - } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRuntimeRequestResponseError({ - driver: MUSE_PROVIDER, - requestId: args.requestId, - cause, - }), - ), - ), - readThreadSnapshot: (args) => - (!validateThread(args.providerThread) - ? protocolError("Snapshot requested for a different Muse thread") - : snapshot(args.providerThread) - ).pipe( - commands.withPermits(1), - Effect.mapError( - (cause) => - new ProviderAdapterReadThreadSnapshotError({ - driver: MUSE_PROVIDER, - providerThreadId: args.providerThread.id, - cause, - }), - ), - ), - // Muse rejects forks at completed-turn boundaries (InvalidCut), so T3 uses - // portable context handoff for forks and does not rewind Muse conversations. - rollbackThread: (args) => - Effect.fail( - new ProviderAdapterRollbackThreadError({ - driver: MUSE_PROVIDER, - providerThreadId: args.providerThread.id, - checkpointId: args.target.checkpointId, - cause: "Muse Code does not support conversation rollback in T3 Code.", - }), - ), - forkThread: (args) => - Effect.fail( - new ProviderAdapterForkThreadError({ - driver: MUSE_PROVIDER, - providerThreadId: args.sourceProviderThread.id, - cause: "Muse Code does not support native forks in T3 Code.", - }), - ), - }; - return runtime; - }), - }); -} diff --git a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts b/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts deleted file mode 100644 index 7e4f489b5b00994398a3fcc2f2103f5bc55a4b7f..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import type { MuseItem } from "../../provider/museProtocol.ts"; -import { museItemStatus, museToolPresentation } from "./MuseItemPresentation.ts"; - -const item = (fields: Partial): MuseItem => ({ - itemId: "native-item", - kind: "toolCall", - status: "completed", - revision: 1, - ...fields, -}); - -describe("Muse item presentation", () => { - it("preserves an edit's file and exact old/new text for the shared diff UI", () => { - expect( - museToolPresentation( - item({ - tool: "edit_file", - args: JSON.stringify({ file_path: "src/index.ts", old_string: "old\n", new_string: "" }), - }), - ), - ).toMatchObject({ - type: "file_change", - status: "completed", - fileName: "src/index.ts", - oldStr: "old\n", - newStr: "", - }); - }); - - it("preserves write content and provider-supplied patches", () => { - expect( - museToolPresentation( - item({ - tool: "write_file", - args: JSON.stringify({ path: "README.md", content: "Hello\n" }), - }), - ), - ).toMatchObject({ type: "file_change", newStr: "Hello\n" }); - expect( - museToolPresentation( - item({ - tool: "apply_patch", - args: JSON.stringify({ path: "README.md", patch: "@@ -1 +1 @@\n-old\n+new" }), - }), - ), - ).toMatchObject({ type: "file_change", diffStr: "@@ -1 +1 @@\n-old\n+new" }); - }); - - it.each(["rejected", "timedOut", "handedOff"])( - "does not report native %s activity as successful", - (status) => { - const native = item({ tool: "search_files", status, fallbackText: "Native explanation" }); - expect(museItemStatus(native)).toBe("failed"); - expect(museToolPresentation(native)).toMatchObject({ - status: "failed", - output: `Muse reported ${status}.\nNative explanation`, - }); - }, - ); - - it("preserves failure details and a known cancelled status", () => { - expect( - museToolPresentation( - item({ - status: "failed", - failureReason: "Permission denied", - visibleOutput: "Partial output", - }), - ), - ).toMatchObject({ status: "failed", output: "Permission denied\nPartial output" }); - expect(museItemStatus(item({ status: "cancelled" }))).toBe("cancelled"); - }); - - it.each(["failed", "noop"])( - "keeps %s compaction outcomes from appearing completed", - (outcome) => { - expect(museItemStatus(item({ kind: "compaction", outcome }))).toBe("failed"); - }, - ); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts b/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts deleted file mode 100644 index 93df7761a3ebd7599f564605a84278ea6edbdd9a..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/MuseItemPresentation.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; - -import type { MuseItem } from "../../provider/museProtocol.ts"; - -const decodeToolArgs = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); - -type Fields< - T extends OrchestrationV2TurnItem["type"], - K extends keyof Extract, -> = Pick, K>; -type MuseToolPresentation = { - readonly status: OrchestrationV2TurnItemStatus; - readonly title: string | null; -} & ( - | Fields<"command_execution", "type" | "input" | "output" | "exitCode"> - | Fields<"file_change", "type" | "fileName" | "oldStr" | "newStr" | "diffStr"> - | Fields<"web_search", "type" | "patterns" | "results"> - | Fields<"dynamic_tool", "type" | "toolName" | "input" | "output"> -); - -/** Unrecognized native terminal states must not appear as successful work. */ -export function museItemStatus(item: MuseItem): OrchestrationV2TurnItemStatus { - if (item.status === "inProgress") return "running"; - if (item.status === "cancelled") return "cancelled"; - if ( - item.status === "completed" && - !(item.kind === "compaction" && item.outcome && item.outcome !== "compacted") - ) - return "completed"; - return "failed"; -} - -/** Converts native tool details into the shared timeline fields used by every client. */ -export function museToolPresentation( - item: MuseItem, - status = museItemStatus(item), -): MuseToolPresentation { - let args: Record | undefined; - if (item.args) { - try { - args = decodeToolArgs(item.args); - } catch { - // Streaming model-authored arguments may not be valid JSON yet. - } - } - const stringArg = (...keys: string[]) => { - for (const key of keys) { - const value = args?.[key]; - if (typeof value === "string") return value; - } - return undefined; - }; - const toolName = item.tool ?? item.kind; - const title = - item.kind === "subagent" - ? item.objective || item.role || "Muse agent" - : item.kind === "workflow" - ? item.entryId || "Workflow" - : toolName; - const detail = - status === "failed" ? item.failureReason || `Muse reported ${item.status}.` : undefined; - const shared = { status, title: detail ? `${title}: ${detail}` : title }; - const text = - item.visibleOutput ?? - item.result?.summary ?? - item.text ?? - item.summary?.join("\n") ?? - item.fallbackText ?? - item.objective ?? - ""; - const output = detail ? [detail, text].filter(Boolean).join("\n") : text; - - if (item.kind === "userShell" || /shell|bash|exec_command/i.test(item.tool ?? "")) { - return { - ...shared, - type: "command_execution", - input: item.commandText ?? stringArg("command", "cmd") ?? item.args ?? "", - output, - ...(item.exitCode === undefined ? {} : { exitCode: item.exitCode }), - }; - } - if (item.kind === "toolCall" && /write|edit|patch/i.test(toolName)) { - const fileName = stringArg("file_path", "path", "filePath")?.trim(); - if (fileName) { - const oldStr = stringArg("old_string", "old_str", "oldStr", "oldText"); - const newStr = stringArg("new_string", "new_str", "newStr", "newText", "content"); - const diffStr = stringArg("diff", "patch", "diffStr"); - return { - ...shared, - type: "file_change", - fileName, - ...(oldStr === undefined ? {} : { oldStr }), - ...(newStr === undefined ? {} : { newStr }), - ...(diffStr === undefined ? {} : { diffStr }), - }; - } - } - if (item.kind === "toolCall" && /web|browse|fetch/i.test(toolName)) { - const query = stringArg("query", "url", "pattern")?.trim(); - return { - ...shared, - type: "web_search", - ...(query ? { patterns: [query] } : {}), - ...(output ? { results: [{ snippet: output }] } : {}), - }; - } - return { - ...shared, - type: "dynamic_tool", - toolName, - input: args ?? item.args ?? "", - output, - }; -} diff --git a/apps/server/src/provider/Drivers/MuseDriver.ts b/apps/server/src/provider/Drivers/MuseDriver.ts deleted file mode 100644 index 16bed1c3dab27181b34f674e2050c9fbf02733c5..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/MuseDriver.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { MuseSettings, ProviderDriverKind } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; -import { ChildProcessSpawner } from "effect/process"; - -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; -import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; -import { makeMuseTextGeneration } from "../../textGeneration/MuseTextGeneration.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { makeMuseAdapterV2 } from "../../orchestration-v2/Adapters/MuseAdapterV2.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; -import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "../museMaintenance.ts"; -import { makeMuseEnvironment } from "../museSdk.ts"; -import { - defaultProviderContinuationIdentity, - type ProviderDriver, - type ProviderInstance, -} from "@t3tools/provider-core/server/driver"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { - makeCachedProviderMaintenanceResolution, - resolveProviderMaintenanceCapabilitiesEffect, -} from "@t3tools/provider-core/server/maintenanceResolver"; -import { - haveProviderSnapshotSettingsChanged, - makeProviderSnapshotSettingsSource, - type ProviderSnapshotSettings, -} from "@t3tools/provider-core/server/snapshotSettings"; -import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; - -const DRIVER_KIND = ProviderDriverKind.make("muse"); -const decodeMuseSettings = Schema.decodeSync(MuseSettings); - -export type MuseDriverEnv = - | IdAllocator.IdAllocatorV2 - | ProviderHost - | ChildProcessSpawner.ChildProcessSpawner - | FileSystem.FileSystem - | HttpClient.HttpClient - | Path.Path - | ProviderEventLoggers.ProviderEventLoggers - | ServerConfig.ServerConfig; - -export const MuseDriver: ProviderDriver = { - driverKind: DRIVER_KIND, - metadata: { displayName: "Muse Code", supportsMultipleInstances: true }, - configSchema: MuseSettings, - defaultConfig: () => decodeMuseSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const host = yield* ProviderHost; - const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; - const serverConfig = yield* ServerConfig.ServerConfig; - const { cwd } = serverConfig; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - const hostEnvironment = yield* HostProcessEnvironment; - // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins. - const processEnvironment = mergeProviderInstanceEnvironment( - environment, - makeMuseEnvironment(hostEnvironment), - ); - const effectiveConfig = { - ...config, - enabled, - binaryPath: expandHomePath(config.binaryPath), - } satisfies MuseSettings; - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName, - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); - const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( - resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { - binaryPath: effectiveConfig.binaryPath, - env: processEnvironment, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ); - const resolveMaintenance = (options?: { readonly fresh?: boolean }) => - Effect.gen(function* () { - const capabilities = yield* resolveInstallation(options); - // The maintenance runner requests fresh capabilities around an explicit update - // and needs the native target version to verify that the command actually upgraded. - const latestVersion = options?.fresh - ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ) - : undefined; - return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; - }); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance, - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), - checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - resolveMaintenance().pipe( - Effect.flatMap((maintenanceCapabilities) => - enrichMuseSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - environment: processEnvironment, - }), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.flatMap(publishSnapshot), - ), - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Muse Code snapshot.", - cause, - }), - ), - ); - const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models)); - const orchestrationAdapter = makeMuseAdapterV2({ - instanceId, - settings: effectiveConfig, - environment: processEnvironment, - idAllocator, - serverConfig, - fileSystem, - modelCatalog, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - continuationRequests, - }); - const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, { - environment: processEnvironment, - modelCatalog, - }); - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - snapshot, - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), -}; diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index f77ee10465e292d6cc9a2311059c2fdb614faada..000176e9bb2318e68142cd72a5f5ec954ab9657c 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -18,7 +18,7 @@ import { grokSlashCommandsFromInitialize, parseGrokModelsCliOutput, } from "./GrokProvider.ts"; -import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); diff --git a/apps/server/src/provider/MuseProvider.test.ts b/apps/server/src/provider/MuseProvider.test.ts deleted file mode 100644 index be7cafdbd6a152b16c0e05bb184eb76d167f042d..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/MuseProvider.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import { MuseSettings } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Schema from "effect/Schema"; -import { vi } from "vite-plus/test"; - -import { writeFakeCli } from "../testUtils/fakeCli.ts"; -import type { MuseSdkHost } from "./museSdk.ts"; -import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; -import { checkMuseProviderStatus } from "./MuseProvider.ts"; - -const settings = Schema.decodeSync(MuseSettings); -const makeHost = (catalog: Record) => { - const host: MuseSdkHost = { - initializeResult: { grantedCapabilities: [] }, - connection: { - request: vi.fn(async () => catalog), - command: vi.fn(async () => ({})), - mintCommandId: () => "test-command", - onNotification: () => {}, - onServerRequest: () => {}, - onProtocolError: () => {}, - closed: new Promise(() => {}), - }, - exited: new Promise(() => {}), - close: vi.fn(async () => {}), - }; - return host; -}; -const metaCatalog = { - providerId: "meta", - models: [ - { - modelId: "muse-discovered", - displayLabel: "Muse Discovered", - providerId: "meta", - isDefault: true, - }, - { - modelId: "foreign-model", - displayLabel: "Another provider", - providerId: "another", - isDefault: false, - }, - ], -}; - -const fakeCli = (source = 'process.stdout.write("muse 1.0.3-R2198.1\\n");') => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-status-test-" }); - return writeFakeCli({ directory, name: "muse", source }); - }); - -it.layer(NodeServices.layer)("Muse status", (it) => { - it.effect("does not start a host when disabled", () => - Effect.gen(function* () { - const createHost = vi.fn(async () => makeHost(metaCatalog)); - const snapshot = yield* checkMuseProviderStatus(settings({}), {}, undefined, createHost); - expect(snapshot.status).toBe("disabled"); - expect(createHost).not.toHaveBeenCalled(); - }), - ); - - it.effect("gives host-local install and login guidance for a missing binary", () => - Effect.gen(function* () { - const snapshot = yield* checkMuseProviderStatus( - settings({ enabled: true, binaryPath: "/definitely-missing/muse" }), - ); - expect(snapshot.installed).toBe(false); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).toContain("muse login"); - expect(snapshot.message).toContain("this T3 server host"); - }), - ); - - it.effect("does not expose CLI stderr or start an SDK host after a failed version check", () => - Effect.scoped( - Effect.gen(function* () { - const binaryPath = yield* fakeCli( - 'process.stderr.write("secret-test-value");process.exit(2);', - ); - const createHost = vi.fn(async () => makeHost(metaCatalog)); - const snapshot = yield* checkMuseProviderStatus( - settings({ enabled: true, binaryPath }), - undefined, - undefined, - createHost, - ); - expect(snapshot.installed).toBe(true); - expect(snapshot.status).toBe("error"); - expect(snapshot.message).not.toContain("secret-test-value"); - expect(createHost).not.toHaveBeenCalled(); - }), - ), - ); - - it.effect("discovers the Meta catalog without treating it as authentication proof", () => - Effect.scoped( - Effect.gen(function* () { - const binaryPath = yield* fakeCli(); - const host = makeHost(metaCatalog); - const snapshot = yield* checkMuseProviderStatus( - settings({ enabled: true, binaryPath, customModels: ["muse-custom"] }), - undefined, - undefined, - async () => host, - ); - expect(snapshot.status).toBe("ready"); - expect(snapshot.message).toBeUndefined(); - expect(snapshot.slashCommands).toEqual([COMPACT_SLASH_COMMAND]); - expect(snapshot.auth).toEqual({ status: "unknown" }); - expect(snapshot.version).toBe("1.0.3-R2198.1"); - expect(snapshot.models.map((model) => model.slug)).toEqual([ - "muse-discovered", - "muse-custom", - ]); - const descriptor = snapshot.models[0]?.capabilities?.optionDescriptors?.[0]; - expect( - descriptor?.type === "select" && descriptor.options.map((option) => option.id), - ).toEqual(["low", "medium", "high", "xhigh", "max"]); - expect(host.connection.request).toHaveBeenCalledWith("model/list", {}); - expect(host.connection.command).not.toHaveBeenCalled(); - expect(host.close).toHaveBeenCalledOnce(); - }), - ), - ); - - it.effect("closes the host after malformed metadata and reports unavailable", () => - Effect.scoped( - Effect.gen(function* () { - const binaryPath = yield* fakeCli(); - const host = makeHost({ providerId: "meta", models: [{ modelId: 1 }] }); - const snapshot = yield* checkMuseProviderStatus( - settings({ enabled: true, binaryPath }), - undefined, - undefined, - async () => host, - ); - expect(snapshot.status).toBe("error"); - expect(snapshot.auth.status).toBe("unknown"); - expect(snapshot.models).toEqual([]); - expect(host.close).toHaveBeenCalledOnce(); - }), - ), - ); - - it.effect("keeps an empty discovered catalog empty instead of advertising a fallback model", () => - Effect.scoped( - Effect.gen(function* () { - const binaryPath = yield* fakeCli(); - const host = makeHost({ providerId: "meta", models: [] }); - const snapshot = yield* checkMuseProviderStatus( - settings({ enabled: true, binaryPath }), - undefined, - undefined, - async () => host, - ); - expect(snapshot.status).toBe("warning"); - expect(snapshot.models).toEqual([]); - expect(host.close).toHaveBeenCalledOnce(); - }), - ), - ); -}); diff --git a/apps/server/src/provider/MuseProvider.ts b/apps/server/src/provider/MuseProvider.ts deleted file mode 100644 index 3d44431d0564bb70d064efb708c00f9d5d74d27c..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/MuseProvider.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { MuseSettings, ServerProviderModel } from "@t3tools/contracts"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { ChildProcess } from "effect/process"; - -import { createMuseSdkHost, makeMuseEnvironment } from "./museSdk.ts"; -import { parseMuseVersion } from "./museMaintenance.ts"; -import { discoverMuseModels, museModelCapabilities } from "./museModelCatalog.ts"; -import { - buildServerProvider, - COMPACT_SLASH_COMMAND, - DEFAULT_TIMEOUT_MS, - isCommandMissingCause, - providerModelsFromSettings, - spawnAndCollect, - type ProviderProbeResult, -} from "@t3tools/provider-core/server/snapshotProbe"; - -const MUSE_PRESENTATION = { - displayName: "Muse Code", - showInteractionModeToggle: false, - reportsContextWindow: true, - // Muse has no native "accept edits" or reviewer-backed mode, so T3 offers only - // the two it maps directly: promptUnmatched and allowAll. - supportedRuntimeModes: ["approval-required", "full-access"], -} as const; - -const FALLBACK_CAPABILITIES = museModelCapabilities(); - -export const makePendingMuseProvider = Effect.fn("makePendingMuseProvider")(function* ( - settings: MuseSettings, -) { - return buildServerProvider({ - presentation: MUSE_PRESENTATION, - enabled: settings.enabled, - checkedAt: DateTime.formatIso(yield* DateTime.now), - models: providerModelsFromSettings([], settings.customModels, FALLBACK_CAPABILITIES), - slashCommands: settings.enabled ? [COMPACT_SLASH_COMMAND] : [], - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: settings.enabled - ? "Checking Muse Code CLI availability..." - : "Muse Code is disabled in T3 Code settings.", - }, - }); -}); - -export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(function* ( - settings: MuseSettings, - environment?: NodeJS.ProcessEnv, - cwd?: string, - createHost: typeof createMuseSdkHost = createMuseSdkHost, -) { - if (!settings.enabled) return yield* makePendingMuseProvider(settings); - const checkedAt = DateTime.formatIso(yield* DateTime.now); - // The driver passes a prepared environment; direct callers get the host's without META_API_KEY. - const museEnvironment = environment ?? makeMuseEnvironment(); - const snapshot = (probe: ProviderProbeResult, models: ReadonlyArray = []) => - buildServerProvider({ - presentation: MUSE_PRESENTATION, - enabled: true, - checkedAt, - models: providerModelsFromSettings(models, settings.customModels, FALLBACK_CAPABILITIES), - slashCommands: [COMPACT_SLASH_COMMAND], - probe, - }); - const versionResult = yield* Effect.gen(function* () { - const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], { - env: museEnvironment, - }); - return yield* spawnAndCollect( - settings.binaryPath, - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: museEnvironment, - shell: spawnCommand.shell, - ...(cwd ? { cwd } : {}), - }), - ); - }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); - - if (Result.isFailure(versionResult)) { - const missing = isCommandMissingCause(versionResult.failure); - return snapshot({ - installed: !missing, - version: null, - status: "error", - auth: { status: "unknown" }, - message: missing - ? "Muse Code CLI (`muse`) was not found. Install Muse Code and run `muse login` on this T3 server host." - : "Failed to execute Muse Code CLI. Check its binary path on this T3 server host.", - }); - } - if (Option.isNone(versionResult.success)) { - return snapshot({ - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Muse Code CLI version check timed out.", - }); - } - const versionOutput = versionResult.success.value; - const version = parseMuseVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); - if (versionOutput.code !== 0) { - return snapshot({ - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Muse Code CLI is installed but failed to run.", - }); - } - - const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe( - Effect.scoped, - Effect.timeoutOption(12_000), - Effect.result, - ); - if (Result.isFailure(catalog) || Option.isNone(catalog.success)) { - return snapshot({ - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: - "Muse Code SDK could not read the model catalog. Check your Muse installation and run `muse login` on this T3 server host.", - }); - } - const models = catalog.success.value; - // MSP has no auth signal, and model/list answers from Muse's cache even when logged out. - // Auth failures surface on the first turn instead. - return snapshot( - { - installed: true, - version, - auth: { status: "unknown" }, - ...(models.length > 0 - ? { status: "ready" } - : { - status: "warning", - message: - "Muse Code returned no models. Run `muse login` on this T3 server host and refresh its status.", - }), - }, - models, - ); -}); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 08a403b70e048fba30fcaf00fbbe3940522ce725..1690a998c5f4016117d9a6bb154e06e402bcb418 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -27,7 +27,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; -import { MuseDriver, type MuseDriverEnv } from "./Drivers/MuseDriver.ts"; +import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; import type { AnyProviderDriver } from "@t3tools/provider-core/server/driver"; diff --git a/apps/server/src/provider/museMaintenance.test.ts b/apps/server/src/provider/museMaintenance.test.ts deleted file mode 100644 index 59d0868e768ddb528947d2ca3bdb2ccf63025bd4..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museMaintenance.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import { HttpClient, HttpClientResponse } from "effect/http"; - -import { - compareMuseVersions, - enrichMuseSnapshot, - museMaintenance, - latestMuseVersion, - parseMuseVersion, -} from "./museMaintenance.ts"; -import { - ProviderVersionCache, - makeManualOnlyProviderMaintenanceCapabilities, -} from "@t3tools/provider-core/server/maintenanceResolver"; - -const maintenanceCapabilities = { - ...makeManualOnlyProviderMaintenanceCapabilities({ - provider: ProviderDriverKind.make("muse"), - packageName: null, - }), - compareVersions: compareMuseVersions, -}; -const snapshot: ServerProvider = { - instanceId: ProviderInstanceId.make("muse"), - driver: ProviderDriverKind.make("muse"), - enabled: true, - installed: true, - version: "1.0.3", - status: "ready", - auth: { status: "unknown" }, - checkedAt: "2026-09-10T00:00:00.000Z", - models: [], - skills: [], - slashCommands: [], -}; - -it.layer(NodeServices.layer)("Muse maintenance", (it) => { - it.effect( - "refreshes a cached channel failure when an explicit update requests verification", - () => - Effect.gen(function* () { - let requests = 0; - const check = Effect.gen(function* () { - expect(yield* latestMuseVersion({})).toBeNull(); - expect(yield* latestMuseVersion({})).toBeNull(); - expect(yield* latestMuseVersion({}, { fresh: true })).toBe("1.1.1-R10.1"); - }); - yield* check.pipe( - Effect.provideService(ProviderVersionCache, new Map()), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests += 1; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - requests === 1 - ? new Response("unavailable", { status: 503 }) - : Response.json({ version: "1.1.1-R10.1" }), - ), - ); - }), - ), - ); - expect(requests).toBe(2); - }), - ); - it.effect( - "compares numeric native release revisions and preserves the CLI release identity", - () => - Effect.sync(() => { - expect(parseMuseVersion("Muse Code 1.1.1 (1.1.1-R2514.1)")).toBe("1.1.1-R2514.1"); - expect(compareMuseVersions("1.1.1-R9.1", "1.1.1-R10.1")).toBeLessThan(0); - expect(compareMuseVersions("1.1.1-R2514.1", "1.1.1-R2514.2")).toBeLessThan(0); - expect(compareMuseVersions("1.2.0-R1.1", "1.1.1-R2514.2")).toBeGreaterThan(0); - // A CLI that prints no revision compares by release, never as an older prerelease. - expect(compareMuseVersions("1.4.3", "1.4.3-R5018.1")).toBe(0); - expect(compareMuseVersions("1.4.2", "1.4.3-R5018.1")).toBeLessThan(0); - }), - ); - it.effect( - "finds releases from the instance's channel and caches the native version, not the SDK version", - () => - Effect.gen(function* () { - const requests: string[] = []; - const httpClient = HttpClient.make((request) => { - requests.push(request.url); - return Effect.succeed( - HttpClientResponse.fromWeb(request, Response.json({ version: "1.1.1-R2514.1" })), - ); - }); - const check = enrichMuseSnapshot({ - snapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: true, - environment: { MUSE_CHANNEL: "muse-canary" }, - }); - const [first, second] = yield* Effect.all([check, check], { concurrency: 1 }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(ProviderVersionCache, new Map()), - ); - expect(first.versionAdvisory).toMatchObject({ - status: "behind_latest", - currentVersion: "1.0.3", - latestVersion: "1.1.1-R2514.1", - canUpdate: false, - }); - expect(second.versionAdvisory?.latestVersion).toBe("1.1.1-R2514.1"); - expect(requests).toEqual(["https://api.meta.ai/muse-code/channels/muse-canary"]); - }), - ); - - it.effect("reports native release R9 behind R10 and R10 current against R9", () => - Effect.gen(function* () { - for (const [current, latest, status] of [ - ["1.4.2-R9.1", "1.4.2-R10.1", "behind_latest"], - ["1.4.2-R10.1", "1.4.2-R9.1", "current"], - ] as const) { - const result = yield* enrichMuseSnapshot({ - snapshot: { ...snapshot, version: current }, - maintenanceCapabilities, - enableProviderUpdateChecks: true, - environment: {}, - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb(request, Response.json({ version: latest })), - ), - ), - ), - Effect.provideService(ProviderVersionCache, new Map()), - ); - expect(result.versionAdvisory?.status).toBe(status); - } - }), - ); - - it.effect("skips network checks when disabled, uninstalled or disabled in settings", () => - Effect.gen(function* () { - for (const input of [ - { snapshot, enableProviderUpdateChecks: false }, - { snapshot: { ...snapshot, enabled: false }, enableProviderUpdateChecks: true }, - { snapshot: { ...snapshot, installed: false }, enableProviderUpdateChecks: true }, - ]) { - const result = yield* enrichMuseSnapshot({ - ...input, - maintenanceCapabilities, - environment: {}, - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("Unexpected release request")), - ), - ); - expect(result.versionAdvisory?.status).toBe("unknown"); - } - }), - ); - - it.effect("tolerates invalid release manifests and unavailable channels", () => - Effect.gen(function* () { - for (const response of [ - Response.json({ version: 12 }), - Response.json({ version: "1.1.1" }), - new Response("unavailable", { status: 503 }), - ]) { - const result = yield* enrichMuseSnapshot({ - snapshot, - maintenanceCapabilities, - enableProviderUpdateChecks: true, - environment: {}, - }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, response)), - ), - ), - Effect.provideService(ProviderVersionCache, new Map()), - ); - expect(result.status).toBe("ready"); - expect(result.versionAdvisory?.status).toBe("unknown"); - } - }), - ); - - it.effect("updates only a recognized launcher at the resolved instance path", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }); - const binaryPath = `${directory}/custom muse`; - const context = { - binaryPath, - resolvedCommandPath: binaryPath, - realCommandPath: binaryPath, - platform: "linux" as const, - env: { MUSE_CHANNEL: "muse-canary" }, - }; - yield* fs.writeFileString( - binaryPath, - "#!/bin/bash\n# muse-code/launcher-2\n# MUSE_SYNC_UPDATE\n", - ); - const result = yield* museMaintenance.resolve(context); - expect(result.update).toMatchObject({ - executable: binaryPath, - args: ["--version"], - lockKey: `muse:${binaryPath}`, - env: { - MUSE_CHANNEL: "muse-canary", - MUSE_NO_AUTO_UPDATE: "0", - MUSE_SYNC_UPDATE: "1", - MUSE_UPDATE_INTERVAL_SECONDS: "0", - }, - }); - expect(result.update?.command).toBe( - `MUSE_CHANNEL=muse-canary MUSE_NO_AUTO_UPDATE=0 MUSE_SYNC_UPDATE=1 MUSE_UPDATE_INTERVAL_SECONDS=0 '${binaryPath}' --version`, - ); - expect(result.compareVersions).toBe(compareMuseVersions); - yield* fs.writeFileString(binaryPath, "standalone binary"); - expect((yield* museMaintenance.resolve(context)).update).toBeNull(); - expect((yield* museMaintenance.resolve(null)).update).toBeNull(); - }), - ), - ); -}); diff --git a/apps/server/src/provider/museMaintenance.ts b/apps/server/src/provider/museMaintenance.ts deleted file mode 100644 index 0770b494a819176657236f0ca502d1d83c8c8c42..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museMaintenance.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import { compareSemverVersions } from "@t3tools/shared/semver"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest } from "effect/http"; - -import { - enrichProviderSnapshotWithVersionAdvisory, - makeManualOnlyProviderMaintenanceCapabilities, - makeProviderMaintenanceCapabilities, - ProviderVersionCache, - type ProviderMaintenanceCapabilities, - type ProviderMaintenanceCapabilitiesResolver, -} from "@t3tools/provider-core/server/maintenanceResolver"; -import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; - -const DRIVER = ProviderDriverKind.make("muse"); -/** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ -const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); -const ChannelManifest = Schema.Struct({ - version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)), -}); - -/** Muse prints both a display version and a release revision; prefer the latter. */ -export function parseMuseVersion(output: string): string | null { - return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output); -} - -function comparableMuseVersion(version: string): string { - // Make the release sequence numeric for semver comparison (R9 precedes R10). - return version.replace(/-R(\d+)(?:\.(\d+))?$/, "-release.$1.$2").replace(/\.$/, ".0"); -} - -const MUSE_REVISION = /-R\d+(?:\.\d+)?$/; - -/** A version printed without a revision is compared by its release alone. */ -export const compareMuseVersions = (current: string, latest: string) => - MUSE_REVISION.test(current) === MUSE_REVISION.test(latest) - ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest)) - : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, "")); - -/** Only the official launcher owns an update channel; a standalone binary cannot update itself. */ -export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { - resolve: (context) => - Effect.gen(function* () { - const manual = { - ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }), - compareVersions: compareMuseVersions, - }; - if (!context || context.platform === "win32") return manual; - const fs = yield* FileSystem.FileSystem; - const isLauncher = yield* Effect.gen(function* () { - const stat = yield* fs.stat(context.realCommandPath); - if (Number(stat.size) > 128 * 1_024) return false; - const source = yield* fs.readFileString(context.realCommandPath); - return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); - }).pipe(Effect.orElseSucceed(() => false)); - if (!isLauncher) return manual; - // The launcher has no `update` subcommand. Its documented launch environment - // forces a synchronous refresh of this executable's own installation. - const updateEnv = { - ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}), - MUSE_NO_AUTO_UPDATE: "0", - MUSE_SYNC_UPDATE: "1", - MUSE_UPDATE_INTERVAL_SECONDS: "0", - }; - return { - ...makeProviderMaintenanceCapabilities({ - provider: DRIVER, - packageName: null, - updateExecutable: context.resolvedCommandPath, - updateArgs: ["--version"], - updateCommand: [ - ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), - shellQuote(context.resolvedCommandPath), - "--version", - ].join(" "), - updateLockKey: `muse:${context.realCommandPath}`, - platform: context.platform, - env: { ...context.env, ...updateEnv }, - }), - compareVersions: compareMuseVersions, - }; - }), -}; - -function shellQuote(word: string) { - return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`; -} - -export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* ( - environment: NodeJS.ProcessEnv, - options?: { readonly fresh?: boolean }, -) { - const channel = environment.MUSE_CHANNEL || "muse-stable"; - if (!MUSE_CHANNELS.has(channel)) return null; - const channelUrl = `https://api.meta.ai/muse-code/channels/${channel}`; - const cache = yield* ProviderVersionCache; - const key = `muse:${channelUrl}`; - const now = DateTime.toEpochMillis(yield* DateTime.now); - const cached = cache.get(key); - if (!options?.fresh && cached && cached.expiresAt > now) return cached.version; - const client = yield* HttpClient.HttpClient; - const version = yield* client.execute(HttpClientRequest.get(channelUrl)).pipe( - Effect.flatMap((response) => - response.status >= 200 && response.status < 300 - ? response.json.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)), - Effect.map((manifest) => parseMuseVersion(manifest.version)), - ) - : Effect.succeed(null), - ), - Effect.timeout(4_000), - Effect.orElseSucceed(() => null), - ); - cache.set(key, { version, expiresAt: now + (version === null ? 60_000 : 60 * 60 * 1_000) }); - return version; -}); - -/** Feed Muse's release channel into the same advisory and notification flow as other drivers. */ -export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (input: { - readonly snapshot: ServerProvider; - readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; - readonly enableProviderUpdateChecks: boolean; - readonly environment: NodeJS.ProcessEnv; -}) { - const { snapshot } = input; - const latestVersion = - input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version - ? yield* latestMuseVersion(input.environment) - : null; - return yield* enrichProviderSnapshotWithVersionAdvisory( - snapshot, - { ...input.maintenanceCapabilities, latestVersion }, - { enableProviderUpdateChecks: input.enableProviderUpdateChecks }, - ); -}); diff --git a/apps/server/src/provider/museModelCatalog.test.ts b/apps/server/src/provider/museModelCatalog.test.ts deleted file mode 100644 index 2679be85e85350de213b715fa3a50e495fe540ce..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museModelCatalog.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { MuseSettings } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import type { MuseSdkHost } from "./museSdk.ts"; -import { - discoverMuseModels, - museModelCapabilities, - resolveMuseReasoningEffort, -} from "./museModelCatalog.ts"; - -const settings = Schema.decodeSync(MuseSettings)({}); -const host = (catalog: Record): MuseSdkHost => ({ - connection: { - request: async () => catalog, - command: async () => ({}), - mintCommandId: () => "test", - onNotification: () => {}, - onServerRequest: () => {}, - onProtocolError: () => {}, - closed: new Promise(() => {}), - }, - initializeResult: { grantedCapabilities: [] }, - exited: new Promise(() => {}), - close: async () => {}, -}); - -const row = (modelId: string, extra: Record = {}) => ({ - modelId, - displayLabel: modelId, - providerId: "meta", - profileId: "tbh", - isDefault: false, - contextLimit: 1_000_000, - ...extra, -}); - -describe("Muse model catalog", () => { - it.effect("uses the labels, efforts and defaults that model/list sends", () => - Effect.gen(function* () { - const models = yield* discoverMuseModels(settings, {}, undefined, async () => - host({ - providerId: "meta", - profileId: "tbh", - source: "providerCatalog", - models: [ - row("restful-walrus", { - displayLabel: " Restful Walrus ", - isDefault: true, - variants: ["minimal", "low", "high", "future-tier"], - reasoningEffortVariants: [{ tier: "low", description: "Quick" }], - defaultReasoningEffort: "high", - }), - row("older-host"), - row("unknown-efforts", { variants: "unknown" }), - row("foreign", { providerId: "another" }), - ], - }), - ).pipe(Effect.scoped); - expect(models.map(({ slug, name, isDefault }) => [slug, name, isDefault])).toEqual([ - ["restful-walrus", "Restful Walrus", true], - ["older-host", "older-host", false], - ["unknown-efforts", "unknown-efforts", false], - ]); - expect(models[0]?.capabilities?.optionDescriptors).toEqual([ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: "high", - options: [ - { id: "minimal", label: "Minimal" }, - { id: "low", label: "Low", description: "Quick" }, - { id: "high", label: "High", isDefault: true }, - ], - }, - ]); - // Older hosts and "unknown" fall back to Muse's documented tiers. - expect(models[1]?.capabilities).toEqual(museModelCapabilities()); - expect(models[2]?.capabilities).toEqual(museModelCapabilities()); - }), - ); - - it("normalizes saved and implicit efforts against the selected model before dispatch", () => { - const fallback = museModelCapabilities(); - expect(resolveMuseReasoningEffort(fallback, undefined)).toBe("high"); - expect(resolveMuseReasoningEffort(fallback, "max")).toBe("max"); - expect(resolveMuseReasoningEffort(fallback, "ultra")).toBe("high"); - const restricted = museModelCapabilities({ variants: ["xhigh"] }); - expect(resolveMuseReasoningEffort(restricted, undefined)).toBe("xhigh"); - expect(resolveMuseReasoningEffort(restricted, "low")).toBe("xhigh"); - expect( - resolveMuseReasoningEffort(museModelCapabilities({ variants: [] }), "max"), - ).toBeUndefined(); - expect(resolveMuseReasoningEffort(undefined, "max")).toBe("max"); - }); -}); diff --git a/apps/server/src/provider/museModelCatalog.ts b/apps/server/src/provider/museModelCatalog.ts deleted file mode 100644 index 7a75cf26f6d5851e8181b8e358c79b885efb4b61..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museModelCatalog.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { SendUserTurnOptions } from "@muse-code/sdk"; -import type { ModelCapabilities, MuseSettings, ServerProviderModel } from "@t3tools/contracts"; -import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./museSdk.ts"; - -type MuseReasoningEffort = NonNullable["reasoningEffort"]>; - -const REASONING_EFFORT_LABELS = { - none: "None", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - max: "Max", - ultra: "Ultra", -} satisfies Record; - -const isMuseReasoningEffort = (tier: string): tier is MuseReasoningEffort => - Object.hasOwn(REASONING_EFFORT_LABELS, tier); - -/** Used when Muse does not report a model's efforts (custom slugs, older hosts, "unknown"). */ -const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -/** Muse's own CLI default. */ -const FALLBACK_DEFAULT_EFFORT = "high"; - -const ModelCatalogEntry = Schema.Struct({ - modelId: Schema.NonEmptyString, - displayLabel: Schema.String, - providerId: Schema.String, - isDefault: Schema.Boolean, - variants: Schema.optional(Schema.Union([Schema.Array(Schema.String), Schema.Literal("unknown")])), - defaultReasoningEffort: Schema.optional(Schema.String), - reasoningEffortVariants: Schema.optional( - Schema.Array( - Schema.Struct({ tier: Schema.String, description: Schema.optional(Schema.String) }), - ), - ), -}); -const decodeModelList = Schema.decodeUnknownEffect( - Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }), -); - -/** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */ -export function museModelCapabilities( - model?: Pick< - typeof ModelCatalogEntry.Type, - "variants" | "defaultReasoningEffort" | "reasoningEffortVariants" - >, -): ModelCapabilities { - const reported = model?.variants !== undefined && model.variants !== "unknown"; - const efforts = (reported ? model.variants : FALLBACK_EFFORTS).filter(isMuseReasoningEffort); - const preferredDefault = reported ? model?.defaultReasoningEffort : FALLBACK_DEFAULT_EFFORT; - const defaultValue = efforts.find((tier) => tier === preferredDefault) ?? efforts[0]; - if (!defaultValue) return createModelCapabilities({ optionDescriptors: [] }); - return createModelCapabilities({ - optionDescriptors: [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - currentValue: defaultValue, - options: efforts.map((id) => { - const description = model?.reasoningEffortVariants?.find( - (variant) => variant.tier === id, - )?.description; - return { - id, - label: REASONING_EFFORT_LABELS[id], - ...(id === defaultValue ? { isDefault: true } : {}), - ...(description ? { description } : {}), - }; - }), - }, - ], - }); -} - -/** Apply the advertised model choices to saved, remembered and implicit efforts at dispatch. */ -export function resolveMuseReasoningEffort( - capabilities: ModelCapabilities | null | undefined, - effort: string | undefined, -): string | undefined { - if (!capabilities) return effort; - const descriptor = getProviderOptionDescriptors({ - caps: capabilities, - selections: effort ? [{ id: "reasoningEffort", value: effort }] : undefined, - }).find((descriptor) => descriptor.id === "reasoningEffort"); - if (descriptor?.type !== "select") return undefined; - return descriptor.options.find((option) => option.id === descriptor.currentValue)?.id; -} - -class MuseCatalogError extends Schema.TaggedError()("MuseCatalogError", { - detail: Schema.String, -}) {} - -export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( - settings: MuseSettings, - environment: NodeJS.ProcessEnv | undefined, - cwd?: string, - createHost: typeof createMuseSdkHost = createMuseSdkHost, -) { - const host = yield* Effect.acquireRelease( - createMuseSdkHostEffect( - { - binaryPath: settings.binaryPath, - ...(environment ? { environment } : {}), - ...(cwd ? { cwd } : {}), - readOnly: true, - startupTimeoutMs: 8_000, - }, - createHost, - ), - (host: MuseSdkHost) => Effect.promise(() => host.close()), - { interruptible: true }, - ); - const result = yield* Effect.tryPromise(() => host.connection.request("model/list", {})); - const catalog = yield* decodeModelList(result); - if (catalog.providerId !== "meta") { - return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." }); - } - const seen = new Set(); - return catalog.models.flatMap((model): ServerProviderModel[] => { - if (model.providerId !== "meta" || seen.has(model.modelId)) return []; - seen.add(model.modelId); - return [ - { - slug: model.modelId, - name: model.displayLabel.trim() || model.modelId, - isCustom: false, - isDefault: model.isDefault, - capabilities: museModelCapabilities(model), - }, - ]; - }); -}); diff --git a/apps/server/src/provider/museProtocol.test.ts b/apps/server/src/provider/museProtocol.test.ts deleted file mode 100644 index 479da19a4fdb99a01a31c9f16938ae708eb5531e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museProtocol.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { museApprovalChoices } from "./museProtocol.ts"; - -// Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case. -describe("Muse approval choices", () => { - it("keeps denied and abort separate when Muse offers both", () => { - const choices = museApprovalChoices({ - availableChoices: [ - { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" }, - { choiceId: "abort", label: "Stop", decision: "abort", scope: "once" }, - ], - }); - expect(choices.get("decline")?.choiceId).toBe("deny"); - expect(choices.get("cancel")?.choiceId).toBe("abort"); - }); -}); diff --git a/apps/server/src/provider/museProtocol.ts b/apps/server/src/provider/museProtocol.ts deleted file mode 100644 index 91befa21e0485d2e3ed2d5125b9d03c0ae7498db..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museProtocol.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { ProviderApprovalDecision } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; - -const NonEmptyString = Schema.String.check(Schema.isMinLength(1)); - -const MuseUsage = Schema.Struct({ - inputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - outputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - cachedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - reasoningTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - cacheReadTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - cacheWriteTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), -}); - -export const MuseItem = Schema.Struct({ - /** `workflow`: the launched entry's name. */ - entryId: Schema.optional(Schema.String), - /** `workflow`: each child agent's latest attempt, re-sent whole on every change. */ - children: Schema.optional( - Schema.Array( - Schema.Struct({ - childId: NonEmptyString, - attempt: Schema.Int, - status: Schema.String, - label: Schema.optional(Schema.String), - terminal: Schema.optional(Schema.String), - durationMs: Schema.optional(Schema.Finite), - failureReason: Schema.optional(Schema.String), - }), - ), - ), - itemId: NonEmptyString, - kind: NonEmptyString, - revision: Schema.Int.check(Schema.isGreaterThan(0)), - status: NonEmptyString, - turnId: Schema.optional(Schema.NullOr(Schema.String)), - text: Schema.optional(Schema.String), - summary: Schema.optional(Schema.Array(Schema.String)), - tool: Schema.optional(Schema.String), - args: Schema.optional(Schema.String), - visibleOutput: Schema.optional(Schema.String), - truncated: Schema.optional(Schema.Boolean), - failureReason: Schema.optional(Schema.String), - fallbackText: Schema.optional(Schema.String), - commandText: Schema.optional(Schema.String), - exitCode: Schema.optional(Schema.Int), - durationMs: Schema.optional(Schema.Finite), - subagentId: Schema.optional(Schema.String), - childSessionId: Schema.optional(Schema.String), - objective: Schema.optional(Schema.String), - role: Schema.optional(Schema.String), - controlStatus: Schema.optional(Schema.String), - result: Schema.optional( - Schema.Struct({ summary: Schema.String, text: Schema.optional(Schema.String) }), - ), - usage: Schema.optional(MuseUsage), - outcome: Schema.optional(Schema.String), - trigger: Schema.optional(Schema.String), - reason: Schema.optional(Schema.String), -}); -export type MuseItem = typeof MuseItem.Type; - -export const MuseSessionResult = Schema.Struct({ - session: Schema.Struct({ - sessionId: NonEmptyString, - modelId: Schema.optional(Schema.NullOr(Schema.String)), - activeTurnId: Schema.optional(Schema.NullOr(Schema.String)), - }), - history: Schema.optional( - Schema.Struct({ - items: Schema.NullOr(Schema.Array(MuseItem)), - snapshot: Schema.optional( - Schema.NullOr( - Schema.Struct({ - schemaVersion: Schema.optional(Schema.Int), - state: Schema.Struct({ items: Schema.Array(MuseItem) }), - }), - ), - ), - }), - ), -}); -export const MuseTurnStartResult = Schema.Struct({ turnId: NonEmptyString }); - -export const MuseApproval = Schema.Struct({ - approvalId: NonEmptyString, - protectedWrite: Schema.optional(Schema.Boolean), - judgeEscalated: Schema.optional(Schema.Boolean), - sessionId: NonEmptyString, - turnId: Schema.optional(Schema.String), - itemId: Schema.optional(Schema.String), - toolName: Schema.optional(Schema.String), - rawArgs: Schema.optional(Schema.String), - /** Set when a workflow child asks; `turnId` is then the child's run, not a session turn. */ - subagentOrigin: Schema.optional(Schema.Struct({ subagentId: NonEmptyString })), - currentRequirementId: Schema.Struct({ approvalId: NonEmptyString, sourceIndex: Schema.Int }), - availableChoices: Schema.Array( - Schema.Struct({ - choiceId: NonEmptyString, - label: Schema.String, - decision: NonEmptyString, - scope: Schema.String, - }), - ), - subject: Schema.Struct({ - kind: Schema.String, - access: Schema.optional(Schema.String), - command: Schema.optional(Schema.String), - path: Schema.optional(Schema.String), - host: Schema.optional(Schema.String), - target: Schema.optional(Schema.String), - }), -}); -export type MuseApproval = typeof MuseApproval.Type; - -export const MuseUserInput = Schema.Struct({ - userInputId: NonEmptyString, - sessionId: NonEmptyString, - turnId: NonEmptyString, - questions: Schema.Array( - Schema.Struct({ - id: NonEmptyString, - header: Schema.String, - question: Schema.String, - options: Schema.Array( - Schema.Struct({ label: Schema.String, description: Schema.optional(Schema.String) }), - ), - selection: Schema.Struct({ - mode: Schema.Literals(["single", "multiple"]), - minSelections: Schema.optional(Schema.Int), - maxSelections: Schema.optional(Schema.Int), - }), - }), - ), -}); -export type MuseUserInput = typeof MuseUserInput.Type; - -export const MuseTokenUsageEvent = Schema.Struct({ - turnId: NonEmptyString, - promptTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - usage: MuseUsage, -}); -export const MuseContextUsage = Schema.Struct({ - usedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - windowTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), -}); -export const MuseTodoList = Schema.Struct({ - items: Schema.Array(Schema.Struct({ text: Schema.String, status: Schema.String })), -}); -export const MuseTurnCompleted = Schema.Struct({ - turnId: NonEmptyString, - terminal: NonEmptyString, - reason: Schema.optional(Schema.String), - error: Schema.optional(Schema.Struct({ message: Schema.String })), - usage: Schema.optional(MuseUsage), -}); -export const MuseTurnRetryScheduled = Schema.Struct({ - turnId: NonEmptyString, - nextAttempt: Schema.Int.check(Schema.isGreaterThan(0)), - maxAttempts: Schema.Int.check(Schema.isGreaterThan(0)), - reason: NonEmptyString, - retryDelayMs: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), -}); -export const MuseCompactResult = Schema.Struct({ - status: Schema.String, - reason: Schema.optional(Schema.String), -}); - -export const MuseItemEvent = Schema.Struct({ item: MuseItem }); -export const MuseDelta = Schema.Struct({ - itemId: NonEmptyString, - delta: Schema.String, - field: Schema.optional(Schema.String), -}); - -/** The T3 decision for one native Muse choice, or none when T3 has no equivalent. */ -export function museApprovalDecision( - choice: Pick, -): ProviderApprovalDecision | undefined { - switch (choice.decision) { - case "approved": - return "accept"; - case "approvedForSession": - return "acceptForSession"; - case "approvedPolicyAmendment": - return choice.scope === "session" - ? "acceptForSession" - : choice.scope === "localPersistent" - ? "acceptAlways" - : undefined; - case "denied": - case "deniedPolicyAmendment": - return "decline"; - case "abort": - return "cancel"; - default: - return undefined; - } -} - -/** - * Maps each canonical decision to one native choice. Muse shell approvals offer - * no `denied` choice, only `abort` ("Reject"), so `decline` falls back to it. - */ -export function museApprovalChoices(approval: Pick) { - const choices = new Map(); - const rank = (choice: MuseApproval["availableChoices"][number]) => - (choice.scope === "once" ? 0 : choice.scope === "session" ? 2 : 4) + - (choice.decision.endsWith("PolicyAmendment") ? 1 : 0); - for (const choice of approval.availableChoices) { - const decision = museApprovalDecision(choice); - if (!decision) continue; - const previous = choices.get(decision); - if (!previous || rank(choice) < rank(previous)) choices.set(decision, choice); - } - const abort = choices.get("cancel"); - if (!choices.has("decline") && abort) choices.set("decline", abort); - return choices; -} - -/** The approval buttons to show: one per native choice, preferring `decline` over `cancel`. */ -export function museApprovalOptions(approval: Pick) { - const choices = museApprovalChoices(approval); - const declineChoiceId = choices.get("decline")?.choiceId; - return [...choices] - .filter(([decision, choice]) => decision !== "cancel" || choice.choiceId !== declineChoiceId) - .map(([decision, choice]) => ({ decision, label: choice.label })); -} diff --git a/apps/server/src/provider/museSdk.test.ts b/apps/server/src/provider/museSdk.test.ts deleted file mode 100644 index ff8de0c1abf946839074321a45a5a223d60c95c6..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museSdk.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import { afterEach, describe, expect, vi } from "vite-plus/test"; -import { - createMuseSdkHost, - createMuseSdkHostEffect, - makeMuseEnvironment, - museApprovalMode, - type MuseSdkHost, -} from "./museSdk.ts"; - -vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); - -function pending() { - let resolve = (_value: A) => {}; - let reject = (_error: unknown) => {}; - const promise = new Promise((yes, no) => { - resolve = yes; - reject = no; - }); - return { promise, resolve, reject }; -} - -function mockSpawn() { - const startup = pending(); - const shutdown = pending<{ code: number; signal: null }>(); - const closing = pending(); - const initializing = pending(); - const initialize = vi.fn(() => { - initializing.resolve(); - return startup.promise; - }); - const close = vi.fn(() => { - closing.resolve(); - return shutdown.promise; - }); - const handshake = { initialize, close } as unknown as MspHandshake; - vi.mocked(spawnMspConnection).mockReturnValue(handshake); - const ready = { - connection: {}, - initializeResult: { grantedCapabilities: [], schema: { version: 1 } }, - exited: shutdown.promise, - fingerprintWarning: { warning: "additive optional" }, - } as unknown as SpawnedMspConnection; - return { startup, shutdown, closing, initializing, initialize, close, ready }; -} - -afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); -}); - -describe("Muse SDK host", () => { - it.each(["META_API_KEY", "meta_api_key", "Meta_Api_Key"])( - "excludes %s without reading its value or mutating the supplied environment", - (key) => { - const source: NodeJS.ProcessEnv = { PATH: "/bin", MUSE_TEST: "yes" }; - const read = vi.fn(() => { - throw new Error("The API override must not be read."); - }); - Object.defineProperty(source, key, { enumerable: true, get: read }); - expect(makeMuseEnvironment(source)).toEqual({ - PATH: "/bin", - MUSE_TEST: "yes", - MUSE_NO_AUTO_UPDATE: "1", - }); - expect(read).not.toHaveBeenCalled(); - expect(Object.keys(source)).toContain(key); - }, - ); - - it("selects full access explicitly and keeps other runtime modes sandboxed", async () => { - for (const mode of ["approval-required", "auto-accept-edits", "auto", "full-access"] as const) { - const fake = mockSpawn(); - const running = createMuseSdkHost({ - binaryPath: "/custom/muse", - cwd: "/workspace", - runtimeMode: mode, - environment: { PATH: "/bin" }, - }); - expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0]).toMatchObject({ - command: "/custom/muse", - cwd: "/workspace", - args: - mode === "full-access" - ? ["serve", "--trust-workspace", "--disable-sandbox"] - : ["serve", "--trust-workspace"], - }); - expect(museApprovalMode(mode)).toBe(mode === "full-access" ? "allowAll" : "promptUnmatched"); - fake.startup.resolve(fake.ready); - const host = await running; - fake.shutdown.resolve({ code: 0, signal: null }); - await host.close(); - await host.close(); - expect(fake.close).toHaveBeenCalledOnce(); - } - }); - - it("bounds startup and waits for native shutdown after timeout", async () => { - vi.useFakeTimers(); - const fake = mockSpawn(); - const startup = createMuseSdkHost({ binaryPath: "muse", startupTimeoutMs: 30 }); - let settled = false; - const outcome = startup.then( - () => { - settled = true; - }, - (error: unknown) => { - settled = true; - return error; - }, - ); - await vi.advanceTimersByTimeAsync(31); - expect(fake.close).toHaveBeenCalledOnce(); - expect(settled).toBe(false); - fake.shutdown.resolve({ code: 0, signal: null }); - expect(await outcome).toMatchObject({ message: "Muse SDK initialization timed out." }); - }); - - it("closes the pre-handshake host on abort and rejects only after shutdown", async () => { - const fake = mockSpawn(); - const abort = new AbortController(); - const started = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal }); - const reason = new Error("Cancelled by caller"); - const outcome = started.catch((error: unknown) => error); - abort.abort(reason); - expect(fake.close).toHaveBeenCalledOnce(); - fake.shutdown.resolve({ code: 0, signal: null }); - expect(await outcome).toBe(reason); - }); - - it("closes rejected initialization and keeps successful schema advisories usable", async () => { - const failed = mockSpawn(); - const started = createMuseSdkHost({ binaryPath: "muse" }); - const outcome = started.catch((error: unknown) => error); - failed.startup.reject(new Error("Handshake failed")); - failed.shutdown.resolve({ code: 0, signal: null }); - expect(await outcome).toMatchObject({ message: "Handshake failed" }); - expect(failed.close).toHaveBeenCalledOnce(); - const success = mockSpawn(); - const ready = createMuseSdkHost({ binaryPath: "muse", readOnly: true }); - success.startup.resolve(success.ready); - const host = await ready; - expect(host.initializeResult.grantedCapabilities).toEqual([]); - expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0].args).toEqual([ - "serve", - "--disable-shell", - "--disable-write", - "--no-session-log", - ]); - success.shutdown.resolve({ code: 0, signal: null }); - await host.close(); - }); - - it.each([undefined, 2])( - "rejects unsupported envelope version %s and awaits shutdown", - async (version) => { - const fake = mockSpawn(); - const started = createMuseSdkHost({ binaryPath: "muse" }); - let settled = false; - const outcome = started.catch((error: unknown) => { - settled = true; - return error; - }); - fake.startup.resolve({ - ...fake.ready, - initializeResult: { - ...fake.ready.initializeResult, - schema: { fingerprint: "test", version }, - }, - } as unknown as SpawnedMspConnection); - await fake.closing.promise; - expect(fake.close).toHaveBeenCalledOnce(); - expect(settled).toBe(false); - fake.shutdown.resolve({ code: 0, signal: null }); - expect(await outcome).toMatchObject({ - message: "Muse SDK returned an unsupported protocol envelope version.", - }); - }, - ); - - it("does not spawn when already aborted and closes a ready host on later abort", async () => { - const aborted = new AbortController(); - aborted.abort(); - await expect( - createMuseSdkHost({ binaryPath: "muse", signal: aborted.signal }), - ).rejects.toBeDefined(); - expect(spawnMspConnection).not.toHaveBeenCalled(); - const fake = mockSpawn(); - const abort = new AbortController(); - const running = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal }); - fake.startup.resolve(fake.ready); - const host = await running; - abort.abort(); - fake.shutdown.resolve({ code: 0, signal: null }); - await host.close(); - expect(fake.close).toHaveBeenCalledOnce(); - }); - - it.effect( - "keeps Effect startup resources alive until interrupted initialization shuts down", - () => - Effect.gen(function* () { - const fake = mockSpawn(); - let released = false; - const fiber = yield* Effect.forkChild( - createMuseSdkHostEffect({ binaryPath: "muse" }).pipe( - Effect.ensuring( - Effect.sync(() => { - released = true; - }), - ), - ), - ); - yield* Effect.promise(() => fake.initializing.promise); - const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild); - yield* Effect.promise(() => fake.closing.promise); - expect(released).toBe(false); - fake.shutdown.resolve({ code: 0, signal: null }); - yield* Fiber.join(interrupted); - expect(released).toBe(true); - expect(fake.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("closes a fulfilled host when interruption wins before acquisition is delivered", () => - Effect.gen(function* () { - const started = pending(); - const startup = pending(); - const closing = pending(); - const shutdown = pending(); - const host = { - close: vi.fn(() => { - closing.resolve(); - return shutdown.promise; - }), - } as unknown as MuseSdkHost; - let released = false; - const fiber = yield* Effect.forkChild( - createMuseSdkHostEffect({ binaryPath: "muse" }, () => { - started.resolve(); - return startup.promise; - }).pipe( - Effect.ensuring( - Effect.sync(() => { - released = true; - }), - ), - ), - ); - yield* Effect.promise(() => started.promise); - startup.resolve(host); - const interrupted = yield* Fiber.interrupt(fiber).pipe( - Effect.forkChild({ startImmediately: true }), - ); - yield* Effect.promise(() => closing.promise); - expect(released).toBe(false); - shutdown.resolve(); - yield* Fiber.join(interrupted); - expect(released).toBe(true); - expect(host.close).toHaveBeenCalledOnce(); - }), - ); -}); diff --git a/apps/server/src/provider/museSdk.ts b/apps/server/src/provider/museSdk.ts deleted file mode 100644 index dcc778d4f77973d25832451b7bcd47f6390342cb..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/museSdk.ts +++ /dev/null @@ -1,163 +0,0 @@ -// @effect-diagnostics globalTimers:off -// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists. -import { - spawnMspConnection, - type Connection, - type ProcessExit, - type SpawnedMspConnection, -} from "@muse-code/sdk"; -import type { RuntimeMode } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; - -export interface MuseSdkHost { - readonly connection: Pick< - Connection, - | "command" - | "request" - | "mintCommandId" - | "onNotification" - | "onServerRequest" - | "onProtocolError" - | "closed" - >; - readonly initializeResult: Pick; - readonly exited: Promise; - /** The last lines Muse wrote to stderr, for explaining an unexpected exit. */ - readonly stderrTail?: () => ReadonlyArray; - readonly close: () => Promise; -} - -export interface MuseSdkHostOptions { - readonly binaryPath: string; - readonly cwd?: string; - readonly environment?: NodeJS.ProcessEnv; - readonly runtimeMode?: RuntimeMode; - /** Disables shell and filesystem writes and workspace trust. */ - readonly readOnly?: boolean; - /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */ - readonly sessionLogging?: boolean; - readonly signal?: AbortSignal; - readonly startupTimeoutMs?: number; -} - -/** Preserve the host's CLI login while excluding the API-key override before copying values. */ -export function makeMuseEnvironment( - environment: NodeJS.ProcessEnv = process.env, -): NodeJS.ProcessEnv { - return { - ...Object.fromEntries( - Object.keys(environment) - .filter((key) => key.toUpperCase() !== "META_API_KEY") - .map((key) => [key, environment[key]]), - ), - MUSE_NO_AUTO_UPDATE: "1", - }; -} - -export function museApprovalMode(runtimeMode: RuntimeMode) { - return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched"; -} - -/** The `muse serve` arguments for a host: read-only hosts get no shell, writes, or workspace trust. */ -export function museServeArgs( - options: Pick, -) { - const args = ["serve"]; - if (options.readOnly) { - args.push("--disable-shell", "--disable-write"); - if (options.sessionLogging !== true) args.push("--no-session-log"); - } else { - args.push("--trust-workspace"); - if (options.runtimeMode === "full-access") args.push("--disable-sandbox"); - } - return args; -} - -/** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */ -export function museInitializeParams(readOnly = false) { - return { - clientInfo: { name: "t3_code", title: "T3 Code", version: "1" }, - capabilities: { requestedCapabilities: readOnly ? [] : ["sessionMcp"] }, - }; -} - -/** One SDK-owned process. The pre-handshake handle owns cleanup even when initialize never replies. */ -export async function createMuseSdkHost( - options: MuseSdkHostOptions, - spawn: typeof spawnMspConnection = spawnMspConnection, -): Promise { - options.signal?.throwIfAborted(); - const handshake = spawn({ - command: options.binaryPath, - args: museServeArgs(options), - ...(options.cwd ? { cwd: options.cwd } : {}), - // Callers pass an environment already built with makeMuseEnvironment. - env: options.environment ?? makeMuseEnvironment(), - // A healthy host exits at once; this only bounds a hung one on close. - shutdownTimeoutMs: 10_000, - }); - let closePromise: Promise | undefined; - const close = () => { - options.signal?.removeEventListener("abort", onAbort); - return (closePromise ??= handshake.close().then(() => undefined)); - }; - let rejectStartup: (reason: unknown) => void = () => {}; - const onAbort = () => { - rejectStartup(options.signal?.reason ?? new Error("Muse SDK startup aborted.")); - void close().catch(() => {}); - }; - const interrupted = new Promise((_resolve, reject) => { - rejectStartup = reject; - }); - options.signal?.addEventListener("abort", onAbort, { once: true }); - if (options.signal?.aborted) onAbort(); - const timer = setTimeout( - () => rejectStartup(new Error("Muse SDK initialization timed out.")), - options.startupTimeoutMs ?? 20_000, - ); - timer.unref(); - try { - const host = await Promise.race([ - handshake.initialize(museInitializeParams(options.readOnly)), - interrupted, - ]); - if (host.initializeResult.schema?.version !== 1) { - throw new Error("Muse SDK returned an unsupported protocol envelope version."); - } - // The SDK's fingerprint warning permits additive optional schema changes. - // Successful initialization, not fingerprint identity, determines readiness. - return { - connection: host.connection, - initializeResult: host.initializeResult, - exited: host.exited, - stderrTail: () => handshake.child.stderrTail, - close, - }; - } catch (error) { - await close(); - throw error; - } finally { - clearTimeout(timer); - } -} - -/** Cancellation waits for native shutdown before callers release startup resources. */ -export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(function* ( - options: Omit, - createHost: typeof createMuseSdkHost = createMuseSdkHost, -) { - let startup: Promise | undefined; - return yield* Effect.tryPromise((signal) => { - startup = createHost({ ...options, signal }); - return startup; - }).pipe( - Effect.onInterrupt(() => - Effect.promise(async () => { - await startup?.then( - (host) => host.close(), - () => {}, - ); - }), - ), - ); -}); diff --git a/apps/server/src/testUtils/fakeCli.ts b/apps/server/src/testUtils/fakeCli.ts deleted file mode 100644 index 68ab157aed7aecb1a0419a896f67ed92cc17db7f..0000000000000000000000000000000000000000 --- a/apps/server/src/testUtils/fakeCli.ts +++ /dev/null @@ -1,106 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike. -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -export interface FakeCliOptions { - /** Directory the launcher and its stub are written into. */ - readonly directory: string; - /** Command name as callers spawn it, e.g. `codex`. */ - readonly name: string; - /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */ - readonly source: string; - /** Environment set for the stub before it runs, on top of the inherited environment. */ - readonly env?: Readonly>; - /** - * Platform the launcher is shaped for. Effect callers should pass the value - * they read from `HostProcessPlatform` so an injected override is honoured; - * defaults to the real host for plain tests. - */ - readonly platform?: NodeJS.Platform; -} - -/** - * Writes a fake CLI whose behaviour lives in a Node stub. On posix the - * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a - * shebang file is not executable there and `resolveSpawnCommand` routes - * `.cmd` through a shell. Returns the launcher path to hand to the code under - * test, which on Windows carries the `.cmd` extension. - */ -export function writeFakeCli(options: FakeCliOptions): string { - NodeFS.mkdirSync(options.directory, { recursive: true }); - const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`); - // The environment rides in a JSON sidecar the stub applies to itself, since - // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely. - const envPath = NodePath.join(options.directory, `${options.name}-env.json`); - NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8"); - NodeFS.writeFileSync( - stubPath, - [ - 'import { readFileSync as readFakeCliEnv } from "node:fs";', - `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`, - options.source, - ].join("\n"), - "utf8", - ); - - if ((options.platform ?? HostProcessPlatform.defaultValue()) === "win32") { - const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`); - NodeFS.writeFileSync( - launcherPath, - ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join( - "\r\n", - ), - "utf8", - ); - return launcherPath; - } - - const launcherPath = NodePath.join(options.directory, options.name); - NodeFS.writeFileSync( - launcherPath, - ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"), - "utf8", - ); - NodeFS.chmodSync(launcherPath, 0o755); - return launcherPath; -} - -/** - * Stub source that becomes `scriptPath`: after optionally requiring a leading - * argv prefix, it imports the script into its own process so the stub is the - * agent. Nothing sits between the code under test and the mock, so a kill or - * a closed stdin reaches it directly and its exit log is faithful on every - * host. The script must not read `process.argv`; the mock agents are driven - * by environment and stdin. - */ -export function execScriptSource(options: { - readonly scriptPath: string; - readonly expectedArgs?: ReadonlyArray; - /** Tab-separated argv appended here per invocation, for launch-flag assertions. */ - readonly argvLogPath?: string; - /** Wait before handing over, for tests that race a slow startup. */ - readonly delayMs?: number; -}): string { - return [ - 'import { appendFileSync } from "node:fs";', - 'import { pathToFileURL } from "node:url";', - "const args = process.argv.slice(2);", - ...(options.argvLogPath === undefined - ? [] - : [ - `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`, - ]), - ...(options.delayMs === undefined - ? [] - : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]), - `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`, - "if (expected.some((value, index) => args[index] !== value)) {", - ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);', - " process.exit(11);", - "}", - `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`, - "", - ].join("\n"); -} diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 46e015123df24b0f9ba9335616be751cd1432d6d..29c0f950f7c895eb994ca54fa10c44524f458adb 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -21,7 +21,7 @@ import { import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeThreadTitle } from "@t3tools/provider-core/server/textGenerationUtils"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; -import { writeFakeCli } from "../testUtils/fakeCli.ts"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const layerClaudeTextGenerationTest = ServerConfig.ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index fbfbe909f962b6f90b35478bf3dd1884f97dc829..e991ddf17fdfcd1d56711b2f04adf10f750b5685 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -16,7 +16,7 @@ import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; -import { writeFakeCli } from "../testUtils/fakeCli.ts"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index a577065913ab28bf15a2a7a6eee67614881bdd6b..8165683f62cf02a8f28884cd82bb5f2b9b1e3744 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -16,7 +16,7 @@ import { GrokSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { makeGrokTextGeneration } from "./GrokTextGeneration.ts"; -import { execScriptSource, writeFakeCli } from "../testUtils/fakeCli.ts"; +import { execScriptSource, writeFakeCli } from "@t3tools/provider-testing/fakeCli"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); diff --git a/apps/server/src/textGeneration/MuseTextGeneration.test.ts b/apps/server/src/textGeneration/MuseTextGeneration.test.ts deleted file mode 100644 index 5f3be054870512c4a28750097df8e30ab39b4ff5..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/MuseTextGeneration.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import type { NotificationHandler } from "@muse-code/sdk"; -import { - MUSE_DEFAULT_MODEL, - MuseSettings, - ProviderInstanceId, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Schema from "effect/Schema"; -import { vi } from "vite-plus/test"; - -import type { MuseSdkHost } from "../provider/museSdk.ts"; -import { museModelCapabilities } from "../provider/museModelCatalog.ts"; -import { makeMuseTextGeneration } from "./MuseTextGeneration.ts"; - -const settings = Schema.decodeSync(MuseSettings)({ enabled: true }); -const modelSelection = createModelSelection( - ProviderInstanceId.make("muse"), - "muse-spark-1.3-contributor", - [{ id: "reasoningEffort", value: "high" }], -); -const titleInput = { - cwd: "/project/should-not-be-used", - message: "Fix the login form", - modelSelection, -}; - -function fixture( - onTurn?: (emit: (method: string, params: Record) => void) => void, - acknowledgeTurn = true, - modelCatalog?: Effect.Effect>, -) { - let handler: NotificationHandler = () => {}; - let commandSequence = 0; - let activeTurnId: string | undefined; - const mintCommandId = () => `turn-${++commandSequence}`; - const started = Promise.withResolvers(); - const closed = Promise.withResolvers(); - const emit = (method: string, params: Record) => - handler({ - jsonrpc: "2.0", - method, - params: { - sessionId: "session-1", - turnId: activeTurnId, - ...params, - ...(params.item - ? { - item: { - revision: 1, - turnId: activeTurnId, - ...(params.item as Record), - }, - } - : {}), - }, - }); - const host: MuseSdkHost = { - initializeResult: { grantedCapabilities: [] }, - connection: { - command: vi.fn(async (method, _params, options) => { - // Connection.command overwrites params.commandId unless supplied in options. - const commandId = options?.commandId ?? mintCommandId(); - if (method === "session/start") return { session: { sessionId: "session-1" } }; - if (method === "turn/start") { - activeTurnId = commandId; - started.resolve(); - onTurn?.(emit); - if (!acknowledgeTurn) return await new Promise>(() => {}); - return { turnId: commandId }; - } - return {}; - }), - request: vi.fn(async () => ({})), - mintCommandId, - onNotification: (nextHandler) => { - handler = nextHandler; - }, - onProtocolError: () => {}, - onServerRequest: () => {}, - closed: closed.promise, - }, - close: vi.fn(async () => { - closed.resolve(); - }), - exited: new Promise(() => {}), - }; - const createHost = vi.fn(async () => host); - const make = makeMuseTextGeneration(settings, { - createHost, - ...(modelCatalog ? { modelCatalog } : {}), - }); - return { - host, - createHost, - started: started.promise, - closeConnection: closed.resolve, - emit, - make, - }; -} - -const finish = - (text: string) => (emit: (method: string, params: Record) => void) => { - emit("item/updated", { item: { itemId: "answer", kind: "agentMessage", text: "partial" } }); - emit("item/completed", { - item: { itemId: "answer", kind: "agentMessage", revision: 2, text }, - }); - emit("turn/completed", { terminal: "completed" }); - }; - -it.layer(NodeServices.layer)("Muse text generation", (it) => { - it.effect("validates the final item once and closes an isolated restrictive host", () => - Effect.gen(function* () { - const test = fixture(finish('{"title":"Fix login form"}')); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput)).toEqual({ - title: "Fix login form", - }); - expect(test.createHost).toHaveBeenCalledWith( - expect.objectContaining({ readOnly: true, sessionLogging: true }), - ); - expect(test.createHost).not.toHaveBeenCalledWith( - expect.objectContaining({ cwd: titleInput.cwd }), - ); - expect(test.host.connection.command).toHaveBeenCalledWith( - "session/start", - expect.objectContaining({ - providerId: "meta", - modelId: modelSelection.model, - approvalMode: "denyUnmatched", - }), - ); - expect(test.host.connection.command).toHaveBeenCalledWith( - "turn/start", - expect.objectContaining({ - reasoningEffort: "high", - }), - { commandId: "turn-1" }, - ); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("uses low effort by default and normalizes stale efforts against the model", () => - Effect.gen(function* () { - for (const { saved, variants, expected } of [ - { saved: undefined, variants: undefined, expected: "low" }, - { saved: undefined, variants: ["medium", "xhigh"], expected: "medium" }, - { saved: "max", variants: ["minimal", "max"], expected: "max" }, - { saved: "ultra", variants: ["medium", "xhigh"], expected: "medium" }, - { saved: "high", variants: [], expected: undefined }, - ]) { - const test = fixture( - finish('{"title":"Fix login form"}'), - true, - Effect.succeed([ - { - slug: modelSelection.model, - name: modelSelection.model, - isCustom: false, - capabilities: museModelCapabilities(variants ? { variants } : undefined), - }, - ]), - ); - const service = yield* test.make; - yield* service.generateThreadTitle({ - ...titleInput, - modelSelection: createModelSelection( - ProviderInstanceId.make("muse"), - modelSelection.model, - saved !== undefined ? [{ id: "reasoningEffort", value: saved }] : undefined, - ), - }); - const turn = vi - .mocked(test.host.connection.command) - .mock.calls.find(([method]) => method === "turn/start"); - expect(turn?.[1].reasoningEffort).toBe(expected); - expect(Object.hasOwn(turn?.[1] ?? {}, "reasoningEffort")).toBe(expected !== undefined); - } - }), - ); - - it.effect("lets Muse choose the model when none is selected", () => - Effect.gen(function* () { - for (const model of [MUSE_DEFAULT_MODEL, " "]) { - const test = fixture(finish('{"title":"Fix login form"}')); - const service = yield* test.make; - yield* service.generateThreadTitle({ - ...titleInput, - modelSelection: createModelSelection(ProviderInstanceId.make("muse"), model), - }); - const start = vi - .mocked(test.host.connection.command) - .mock.calls.find(([method]) => method === "session/start"); - expect(start?.[1]).not.toHaveProperty("modelId"); - } - }), - ); - - it.effect("rejects invalid structured output and releases the host", () => - Effect.gen(function* () { - const test = fixture(finish('{"title":42}')); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ - _tag: "TextGenerationError", - detail: "Muse returned invalid structured output.", - }); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("ignores output and terminal notifications for another turn", () => - Effect.gen(function* () { - const test = fixture((emit) => { - emit("item/completed", { - item: { - itemId: "other-answer", - kind: "agentMessage", - turnId: "another-turn", - text: '{"title":"Wrong title"}', - }, - }); - emit("turn/completed", { turnId: "another-turn", terminal: "completed" }); - finish('{"title":"Fix login form"}')(emit); - }); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); - expect(test.host.connection.command).toHaveBeenCalledWith( - "turn/start", - expect.objectContaining({ sessionId: "session-1" }), - { commandId: "turn-1" }, - ); - }), - ); - - it.effect("rejects a truncated response even when its prefix contains valid JSON", () => - Effect.gen(function* () { - const test = fixture((emit) => { - emit("item/completed", { - item: { - itemId: "answer", - kind: "agentMessage", - text: '{"title":"Incomplete answer"}', - truncated: true, - }, - }); - emit("turn/completed", { terminal: "completed" }); - }); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ - _tag: "TextGenerationError", - }); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("settles a completed turn even when its start acknowledgement never arrives", () => - Effect.gen(function* () { - const test = fixture(finish('{"title":"Fix login form"}'), false); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("reports a failed turn without trying another provider", () => - Effect.gen(function* () { - const test = fixture((emit) => - emit("turn/completed", { - terminal: "failed", - error: { message: "Authentication required. Run muse login." }, - }), - ); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ - _tag: "TextGenerationError", - }); - expect(test.createHost).toHaveBeenCalledOnce(); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("settles when the transport closes before completion", () => - Effect.gen(function* () { - const test = fixture(); - const service = yield* test.make; - const pending = yield* service - .generateThreadTitle(titleInput) - .pipe(Effect.flip, Effect.forkChild); - yield* Effect.promise(() => test.started); - test.closeConnection(); - expect(yield* Fiber.join(pending)).toMatchObject({ _tag: "TextGenerationError" }); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("releases the host when interrupted after the turn starts", () => - Effect.gen(function* () { - const test = fixture(); - const service = yield* test.make; - const fiber = yield* service.generateThreadTitle(titleInput).pipe(Effect.forkChild); - yield* Effect.promise(() => test.started); - yield* Fiber.interrupt(fiber); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("denies approval requests and fails the noninteractive request", () => - Effect.gen(function* () { - const test = fixture((emit) => - emit("approval/requested", { - approvalId: "approval-1", - currentRequirementId: "requirement-1", - availableChoices: [{ choiceId: "deny-1", decision: "denied" }], - }), - ); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ - _tag: "TextGenerationError", - }); - expect(test.host.connection.command).toHaveBeenCalledWith("approval/decide", { - sessionId: "session-1", - approvalId: "approval-1", - requirementId: "requirement-1", - choiceId: "deny-1", - }); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("cancels user input requests", () => - Effect.gen(function* () { - const test = fixture((emit) => emit("userInput/requested", { userInputId: "input-1" })); - const service = yield* test.make; - expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ - _tag: "TextGenerationError", - }); - expect(test.host.connection.command).toHaveBeenCalledWith( - "userInput/cancel", - expect.objectContaining({ - userInputId: "input-1", - }), - ); - expect(test.host.close).toHaveBeenCalledOnce(); - }), - ); - - it.effect("generates and sanitizes commit content through Muse", () => - Effect.gen(function* () { - const test = fixture( - finish('{"subject":"Fix login.","body":" Details ","branch":"fix-login"}'), - ); - const service = yield* test.make; - const generated = yield* service.generateCommitMessage({ - cwd: titleInput.cwd, - branch: "main", - stagedSummary: "login.ts", - stagedPatch: "login change", - includeBranch: true, - modelSelection, - }); - expect(generated.subject).toBe("Fix login"); - expect(generated.body).toBe("Details"); - expect(generated.branch).toContain("fix-login"); - }), - ); -}); diff --git a/apps/server/src/textGeneration/MuseTextGeneration.ts b/apps/server/src/textGeneration/MuseTextGeneration.ts deleted file mode 100644 index e0d4f3f3afa519ead68c1d325083c1e89146d4e5..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/MuseTextGeneration.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { - DEFAULT_TEXT_GENERATION_REASONING_EFFORT, - MUSE_DEFAULT_MODEL, - type ModelSelection, - type MuseSettings, - type ServerProviderModel, - TextGenerationError, -} from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { - createMuseSdkHost, - createMuseSdkHostEffect, - type MuseSdkHost, -} from "../provider/museSdk.ts"; -import { museModelCapabilities, resolveMuseReasoningEffort } from "../provider/museModelCatalog.ts"; -import type * as TextGeneration from "./TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "@t3tools/provider-core/server/textGenerationPrompts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, - toJsonSchemaObject, -} from "@t3tools/provider-core/server/textGenerationUtils"; - -const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) }); -const ItemNotification = Schema.Struct({ - sessionId: Schema.String, - item: Schema.Struct({ - itemId: Schema.String, - kind: Schema.String, - revision: Schema.Int, - turnId: Schema.optionalKey(Schema.String), - text: Schema.optionalKey(Schema.String), - retracted: Schema.optionalKey(Schema.Boolean), - truncated: Schema.optionalKey(Schema.Boolean), - }), -}); -const TurnCompleted = Schema.Struct({ - sessionId: Schema.String, - turnId: Schema.String, - terminal: Schema.String, - error: Schema.optionalKey(Schema.Struct({ message: Schema.String })), -}); -const ApprovalRequested = Schema.Struct({ - sessionId: Schema.String, - approvalId: Schema.String, - currentRequirementId: Schema.Unknown, - availableChoices: Schema.Array( - Schema.Struct({ choiceId: Schema.String, decision: Schema.String }), - ), -}); -const UserInputRequested = Schema.Struct({ - sessionId: Schema.String, - userInputId: Schema.String, -}); -const decodeSession = Schema.decodeUnknownSync(SessionStarted); -const decodeItem = Schema.decodeUnknownSync(ItemNotification); -const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted); -const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested); -const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); -const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); - -type Operation = keyof TextGeneration.TextGeneration["Service"]; - -async function generateMuseText( - host: MuseSdkHost, - workspaceRoot: string, - prompt: string, - modelId: string | undefined, - reasoningEffort: string | undefined, - signal: AbortSignal, -) { - let sessionId: string | undefined; - const turnId = host.connection.mintCommandId(); - const messages = new Map(); - let resolveCompletion: (text: string) => void = () => {}; - let rejectCompletion: (error: unknown) => void = () => {}; - const completion = new Promise((resolve, reject) => { - resolveCompletion = resolve; - rejectCompletion = reject; - }); - // Notifications may settle the turn before the turn/start acknowledgement arrives. - void completion.catch(() => {}); - const rejectInteractive = async (method: string, params: Record) => { - if (method === "approval/requested") { - const approval = decodeApproval(params); - if (approval.sessionId !== sessionId) return; - rejectCompletion(new Error("Muse requested an interactive action during text generation.")); - const deny = approval.availableChoices.find((choice) => choice.decision === "denied"); - if (deny) { - await host.connection.command("approval/decide", { - sessionId, - approvalId: approval.approvalId, - requirementId: approval.currentRequirementId, - choiceId: deny.choiceId, - }); - } - } else { - const request = decodeUserInput(params); - if (request.sessionId !== sessionId) return; - rejectCompletion(new Error("Muse requested an interactive action during text generation.")); - await host.connection.command("userInput/cancel", { - sessionId, - userInputId: request.userInputId, - reason: "T3 Code text generation cannot request user input.", - }); - } - }; - host.connection.onNotification((notification) => { - try { - if (notification.method === "item/completed" || notification.method === "item/updated") { - const { item, sessionId: itemSessionId } = decodeItem(notification.params); - if (itemSessionId !== sessionId || item.turnId !== turnId || item.kind !== "agentMessage") - return; - const previous = messages.get(item.itemId); - if (!previous || item.revision > previous.revision) messages.set(item.itemId, item); - } else if (notification.method === "turn/completed") { - const completed = decodeCompleted(notification.params); - if (completed.sessionId !== sessionId || completed.turnId !== turnId) return; - if (completed.terminal === "completed") { - // A turn may include commentary before its final structured answer. - const answer = [...messages.values()].findLast((item) => !item.retracted); - if (answer?.truncated) { - rejectCompletion(new Error("Muse text generation returned a truncated response.")); - } else { - resolveCompletion(answer?.text ?? ""); - } - } else { - rejectCompletion( - new Error(completed.error?.message ?? `Muse turn ${completed.terminal}.`), - ); - } - } else if ( - notification.method === "approval/requested" || - notification.method === "userInput/requested" - ) { - void rejectInteractive(notification.method, notification.params ?? {}).catch( - rejectCompletion, - ); - } else if (notification.method === "view/gap") { - rejectCompletion(new Error("Muse text generation lost part of its response.")); - } - } catch (error) { - rejectCompletion(error); - } - }); - host.connection.onProtocolError(rejectCompletion); - host.connection.onServerRequest(async () => { - const error = new Error("T3 Code text generation cannot approve interactive requests."); - rejectCompletion(error); - throw error; - }); - void host.connection.closed.then(() => { - rejectCompletion(new Error("Muse connection closed before text generation completed.")); - }); - void host.exited.then(() => { - rejectCompletion(new Error("Muse exited before text generation completed.")); - }); - const onAbort = () => rejectCompletion(signal.reason); - signal.addEventListener("abort", onAbort, { once: true }); - try { - signal.throwIfAborted(); - const started = decodeSession( - await host.connection.command("session/start", { - workspaceRoot, - providerId: "meta", - ...(modelId ? { modelId } : {}), - approvalMode: "denyUnmatched", - }), - ); - sessionId = started.session.sessionId; - const acknowledged = host.connection.command( - "turn/start", - { - sessionId, - input: [{ type: "text", text: prompt }], - ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), - }, - { commandId: turnId }, - ); - return await Promise.race([acknowledged.then(() => completion), completion]); - } finally { - signal.removeEventListener("abort", onAbort); - } -} - -export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(function* ( - settings: MuseSettings, - options: { - readonly environment?: NodeJS.ProcessEnv; - readonly modelCatalog?: Effect.Effect>; - /** Test hook. */ - readonly createHost?: typeof createMuseSdkHost; - } = {}, -) { - const { environment, modelCatalog = Effect.succeed([]), createHost } = options; - const fileSystem = yield* FileSystem.FileSystem; - const runMuseJson = Effect.fn("runMuseJson")(function* (input: { - operation: Operation; - prompt: string; - outputSchema: S; - modelSelection: ModelSelection; - }): Effect.fn.Return { - const { operation } = input; - if (!settings.enabled) { - return yield* new TextGenerationError({ operation, detail: "Muse Code is disabled." }); - } - const selectedModel = input.modelSelection.model.trim(); - const models = yield* modelCatalog; - // "default" is the catalog's default model, as in chat. Without one, Muse picks. - const catalogModel = - selectedModel && selectedModel !== MUSE_DEFAULT_MODEL - ? models.find((model) => model.slug === selectedModel) - : models.find((model) => model.isDefault && !model.isCustom); - const modelId = - selectedModel && selectedModel !== MUSE_DEFAULT_MODEL ? selectedModel : catalogModel?.slug; - const reasoningEffort = resolveMuseReasoningEffort( - catalogModel?.capabilities ?? museModelCapabilities(), - getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") ?? - DEFAULT_TEXT_GENERATION_REASONING_EFFORT, - ); - const jsonSchema = yield* encodeJson(toJsonSchemaObject(input.outputSchema)).pipe( - Effect.mapError( - (cause) => new TextGenerationError({ operation, detail: "Invalid output schema.", cause }), - ), - ); - const response = yield* Effect.gen(function* () { - // Metadata requests already include their context and do not need checkout configuration. - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-muse-text-" }); - const host = yield* Effect.acquireRelease( - createMuseSdkHostEffect( - { - binaryPath: settings.binaryPath, - cwd, - ...(environment ? { environment } : {}), - readOnly: true, - // Muse derives SDK turn events from its session log, including completion. - sessionLogging: true, - }, - createHost, - ), - (host: MuseSdkHost) => Effect.promise(() => host.close()), - { interruptible: true }, - ); - return yield* Effect.tryPromise((signal) => - generateMuseText( - host, - cwd, - `${input.prompt}\n\nReturn only a JSON object matching this schema, with no markdown fences. Do not use tools or ask questions.\n${jsonSchema}`, - modelId, - reasoningEffort, - signal, - ), - ); - }).pipe( - Effect.scoped, - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: - "Muse Code text generation failed. Check Muse login and availability on this T3 server host.", - cause, - }), - ), - Effect.timeoutOption(180_000), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail(new TextGenerationError({ operation, detail: "Muse request timed out." })), - onSome: Effect.succeed, - }), - ), - ); - const decodeOutput = Schema.decodeEffect(input.outputSchema); - return yield* decodeJson(extractJsonObject(response)).pipe( - Effect.flatMap(decodeOutput), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: "Muse returned invalid structured output.", - cause, - }), - ), - ); - }); - - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("MuseTextGeneration.generateCommitMessage")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateCommitMessage", - ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }), - modelSelection: input.modelSelection, - }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn("MuseTextGeneration.generatePrContent")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generatePrContent", - ...buildPrContentPrompt(input), - modelSelection: input.modelSelection, - }); - return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; - }); - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn("MuseTextGeneration.generateBranchName")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateBranchName", - ...buildBranchNamePrompt(input), - modelSelection: input.modelSelection, - }); - return { branch: sanitizeBranchFragment(generated.branch) }; - }); - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("MuseTextGeneration.generateThreadTitle")(function* (input) { - const generated = yield* runMuseJson({ - operation: "generateThreadTitle", - ...buildThreadTitlePrompt(input), - modelSelection: input.modelSelection, - }); - return { title: sanitizeThreadTitle(generated.title) }; - }); - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; -}); diff --git a/apps/web/package.json b/apps/web/package.json index ab2ae7b35ea32e757b08df5adf9fd1506b0b12cd..63a5f5c2ad357b62aa031ba5a4f78b563a9a1c7b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-pi": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 66e170dacf61bf3a93f88f2336ed7fdc03f01882..7f38d65f2a14e426134172d89be89d8cc4778193 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -815,13 +815,3 @@ export const ComputerUseAppIcon: Icon = (props) => { ); }; - -export const MuseIcon: Icon = (props) => ( - - - -); diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index ddfffd4fa9d3b6145755643cf66000ca8a5cca17..e9e54575c9103ffa5ae296bce6a4897544205de1 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -8,7 +8,6 @@ import { ClaudeAI, CursorIcon, GrokIcon, - MuseIcon, Icon, OpenAI, OpenCodeIcon, @@ -29,7 +28,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, - [ProviderDriverKind.make("muse")]: MuseIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 59b42d0f6751bc3c17286d48d83b0024d0e1c42c..c967100888c85aa05f7eab4b1edd47a1ea8ac0d7 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -6,10 +6,10 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, - MuseSettings, ProviderDriverKind, } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { museClient } from "@t3tools/provider-muse/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this web build ships, in presentation order. */ @@ -53,12 +53,7 @@ export const providerClients = makeProviderClientRegistry([ label: "Antigravity", settingsSchema: AntigravitySettings, }, - { - driverKind: ProviderDriverKind.make("muse"), - label: "Muse Code", - settingsSchema: MuseSettings, - badgeLabel: "Beta", - }, + museClient, piClient, { driverKind: ProviderDriverKind.make("acpRegistry"), diff --git a/package.json b/package.json index eece70e89ef5e0f61f902356bcd0b83e1f3e526e..a345b307273db1b89d716eb8830e526229d88d9e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "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", + "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", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c9b3c00136dc750fa598ece6acd644bec38390f8..e62f4ff578b791a07ef161e328c00bbf02c57ccc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -840,29 +840,6 @@ export const AntigravitySettings = makeProviderSettingsSchema( ); export type AntigravitySettings = typeof AntigravitySettings.Type; -export const MuseSettings = makeProviderSettingsSchema( - { - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("muse").pipe( - Schema.annotateKey({ - title: "Binary path", - description: - "Path to the Muse Code binary on this environment. Sign in with muse login on that host.", - providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" }, - }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { order: ["binaryPath"] }, -); -export type MuseSettings = typeof MuseSettings.Type; - export const AcpRegistryDistributionPreference = Schema.Literals(["auto", "binary", "npx", "uvx"]); export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPreference.Type; diff --git a/packages/provider-muse/package.json b/packages/provider-muse/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a59c6cd20c8dfe3d5eb1b47669972efb45e3694c --- /dev/null +++ b/packages/provider-muse/package.json @@ -0,0 +1,41 @@ +{ + "name": "@t3tools/provider-muse", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@muse-code/sdk": "1.4.2", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-muse/src/client.ts b/packages/provider-muse/src/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..d709e86893955be24f259969bed0aa54b0e37af7 --- /dev/null +++ b/packages/provider-muse/src/client.ts @@ -0,0 +1,27 @@ +/** + * Muse Code's client definition: settings form, label, badge, and glyph. + * Browser- and React Native-safe. + * + * @module provider-muse/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { MuseSettings } from "./settings.ts"; + +export const museClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("muse"), + label: "Muse Code", + settingsSchema: MuseSettings, + badgeLabel: "Beta", + icon: { + viewBox: "0 0 150.4828 100", + fill: { light: "#171717", dark: "#e5e5e5" }, + paths: [ + { + 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", + fillRule: "evenodd", + }, + ], + }, +}); diff --git a/packages/provider-muse/src/server.ts b/packages/provider-muse/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..7100802d7e13d8a0a7053a7695c33fa61f4987bc --- /dev/null +++ b/packages/provider-muse/src/server.ts @@ -0,0 +1,8 @@ +/** + * Muse Code's server entry: the driver the server registers, plus the + * adapter factory the replay testkit and fixture recorder build on. + * + * @module provider-muse/server + */ +export { MuseDriver, type MuseDriverEnv } from "./server/driver.ts"; +export { makeMuseAdapterV2, type MuseAdapterV2Options } from "./server/adapter.ts"; diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..59f8adec402682450152bb3d07834ee26623b784 --- /dev/null +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -0,0 +1,1107 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { MspError } from "@muse-code/sdk"; +import { + MUSE_DEFAULT_MODEL, + EnvironmentId, + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderDriverKind, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + RunAttemptId, + RunId, + ThreadId, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, +} from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import type { MuseItem } from "./protocol.ts"; +import type { MuseSdkHost } from "./sdk.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { + ProviderAdapterV2RuntimePolicy, + type ProviderAdapterV2Event, + type ProviderAdapterV2TurnInput, +} from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; +import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./adapter.ts"; + +const testLayer = Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), +); +const MUSE_PROVIDER = ProviderDriverKind.make("muse"); +const INSTANCE_ID = ProviderInstanceId.make("muse_work"); +const THREAD_ID = ThreadId.make("thread-muse-test"); +const MODEL = "muse-spark-1.3-contributor"; +const museSettings = Schema.decodeSync(MuseSettings)({ enabled: true }); +const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: null, +}); +const modelSelection = (instanceId = INSTANCE_ID) => ({ + instanceId, + model: MODEL, + options: [{ id: "reasoningEffort", value: "max" }], +}); + +interface NativeCall { + readonly method: string; + readonly params: Record; + readonly commandId?: string; +} + +function pendingPromise() { + let resolve!: (value: A) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +/** SDK-shaped transport fake: registration deliberately returns void, as the SDK does. */ +const makeFakeMuse = Effect.fnUntraced(function* (idPrefix = "native") { + const requests = yield* Queue.unbounded(); + const calls: NativeCall[] = []; + const closed = pendingPromise(); + const exited = pendingPromise>(); + let notify: Parameters[0] = () => {}; + let sequence = 0; + let sessionId = ""; + let closeCount = 0; + let closeGate: Promise | undefined; + let history: MuseItem[] = []; + const responses = new Map< + string, + Array> | Record | Error> + >(); + const respond = async (call: NativeCall): Promise> => { + calls.push(call); + Queue.offerUnsafe(requests, call); + if (["session/start", "session/resume", "session/read"].includes(call.method)) { + sessionId = String(call.params.sessionId); + } + const queued = responses.get(call.method)?.shift(); + if (queued instanceof Error) throw queued; + if (queued) return queued; + switch (call.method) { + case "session/start": + case "session/resume": + case "session/read": + sessionId = String(call.params.sessionId); + return { + session: { sessionId, modelId: MODEL, activeTurnId: null }, + history: { items: history }, + }; + case "turn/start": + return { turnId: call.commandId }; + case "turn/steer": + return { turnId: call.params.expectedTurnId }; + case "session/compact": + return { status: "accepted" }; + default: + return {}; + } + }; + const host: MuseSdkHost = { + connection: { + mintCommandId: () => `${idPrefix}-${++sequence}`, + command: (method, params, options) => + respond({ + method, + params, + ...(options?.commandId ? { commandId: options.commandId } : {}), + }), + request: (method, params = {}) => respond({ method, params }), + onNotification: (handler) => { + notify = handler; + }, + onProtocolError: () => {}, + onServerRequest: () => {}, + closed: closed.promise, + }, + initializeResult: { grantedCapabilities: [] }, + exited: exited.promise, + close: async () => { + closeCount += 1; + await closeGate; + closed.resolve(); + exited.resolve({ code: 0, signal: null }); + }, + }; + return { + host, + calls, + closeCount: () => closeCount, + holdClose: (gate: Promise) => { + closeGate = gate; + }, + disconnect: () => + Effect.sync(() => { + closed.resolve(); + exited.resolve({ code: 1, signal: null }); + }), + setHistory: (items: MuseItem[]) => { + history = items; + }, + queueResponse: ( + method: string, + result: Record | Promise> | Error, + ) => { + responses.set(method, [...(responses.get(method) ?? []), result]); + }, + emit: (method: string, params: Record) => + Effect.sync(() => { + notify({ jsonrpc: "2.0", method, params: { sessionId, ...params } }); + }), + takeCall: Effect.fnUntraced(function* (method: string) { + while (true) { + const call = yield* Queue.take(requests); + if (call.method === method) return call; + } + }), + }; +}); + +const makeHarness = Effect.fnUntraced(function* ( + fake: Effect.Success>, + instanceId = INSTANCE_ID, + initialNativeThreadId?: string, + replacement?: Effect.Success>, + existingProviderThread?: OrchestrationV2ProviderThread, + policy = runtimePolicy, + overrides: Pick< + MuseAdapterV2Options, + "createHost" | "nativeEventLogger" | "modelCatalog" | "continuationRequests" + > = {}, +) { + let hostCount = 0; + const adapter = makeMuseAdapterV2({ + instanceId, + settings: museSettings, + environment: { PATH: "/fake/bin" }, + idAllocator: yield* IdAllocator.IdAllocatorV2, + host: yield* ProviderHost, + fileSystem: yield* FileSystem.FileSystem, + createHost: async () => (hostCount++ === 0 ? fake.host : (replacement ?? fake).host), + ...overrides, + }); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: ProviderSessionId.make(`session-${instanceId}`), + modelSelection: modelSelection(instanceId), + runtimePolicy: policy, + ...(initialNativeThreadId ? { initialNativeThreadId } : {}), + }); + const emitted = yield* Queue.unbounded(); + const allEvents: ProviderAdapterV2Event[] = []; + const eventsEnded = yield* Deferred.make>>(); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + allEvents.push(event); + yield* Queue.offer(emitted, event); + }), + ), + Effect.exit, + Effect.flatMap((exit) => Deferred.succeed(eventsEnded, exit)), + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection(instanceId), + runtimePolicy: policy, + ...(existingProviderThread ? { existingProviderThread } : {}), + }); + const takeEvent = Effect.fnUntraced(function* ( + type: T, + predicate: (event: Extract) => boolean = () => true, + ) { + while (true) { + const event = yield* Queue.take(emitted); + if (event.type === type && predicate(event as Extract)) { + return event as Extract; + } + } + }); + return { adapter, runtime, providerThread, takeEvent, allEvents, policy, eventsEnded }; +}); + +const preallocatedProviderThread = Effect.fnUntraced(function* () { + const now = yield* DateTime.now; + return { + id: ProviderThreadId.make("preallocated-muse-thread"), + driver: MUSE_PROVIDER, + providerInstanceId: INSTANCE_ID, + providerSessionId: ProviderSessionId.make(`session-${INSTANCE_ID}`), + appThreadId: THREAD_ID, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + } satisfies OrchestrationV2ProviderThread; +}); + +const turnInput = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + runOrdinal = 1, + policy = runtimePolicy, +): Effect.fn.Return { + const now = yield* DateTime.now; + const threadId = providerThread.appThreadId ?? THREAD_ID; + const selection = modelSelection(providerThread.providerInstanceId); + const appThread: OrchestrationV2AppThread = { + id: threadId, + projectId: ProjectId.make("project-muse-test"), + title: "Muse test", + createdBy: "user", + creationSource: "web", + providerInstanceId: providerThread.providerInstanceId, + modelSelection: selection, + runtimeMode: policy.runtimeMode, + interactionMode: policy.interactionMode, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + return { + appThread, + threadId, + runId: RunId.make(`run-${runOrdinal}`), + runOrdinal, + providerTurnOrdinal: runOrdinal, + attemptId: RunAttemptId.make(`attempt-${runOrdinal}`), + rootNodeId: NodeId.make(`root-${runOrdinal}`), + providerThread, + message: { + messageId: MessageId.make(`message-${runOrdinal}`), + text: "Hello Muse", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: selection, + runtimePolicy: policy, + }; +}); + +const startConversation = Effect.fnUntraced(function* ( + harness: Effect.Success>, + fake: Effect.Success>, + ordinal = 1, +) { + yield* harness.runtime.startTurn( + yield* turnInput(harness.providerThread, ordinal, harness.policy), + ); + const call = yield* fake.takeCall("turn/start"); + const event = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + return { nativeId: call.commandId!, providerTurn: event.providerTurn }; +}); + +const approval = (turnId: string) => ({ + approvalId: "approval-1", + turnId, + currentRequirementId: { approvalId: "approval-1", sourceIndex: 0 }, + availableChoices: [ + { choiceId: "once", label: "Allow once", decision: "approved", scope: "once" }, + { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" }, + ], + subject: { kind: "shell", command: "touch result.txt" }, +}); + +describe("MuseAdapterV2", () => { + it.effect("connects the thread's MCP credential on native start and resume", () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)), + ); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("test-environment"), + threadId: THREAD_ID, + providerSessionId: "test-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43210/mcp", + authorizationHeader: "Bearer thread-scoped-test-token", + browserToolsAvailable: true, + }); + for (const nativeId of [undefined, "saved-native-session"]) { + const fake = yield* makeFakeMuse(); + fake.host.initializeResult.grantedCapabilities.push("sessionMcp"); + yield* makeHarness(fake, INSTANCE_ID, nativeId); + const call = yield* fake.takeCall(nativeId ? "session/resume" : "session/start"); + assert.deepStrictEqual(call.params.config, { + mcpServers: { + "t3-code": { + transport: "streamableHttp", + mode: "optional", + url: "http://127.0.0.1:43210/mcp", + headers: { Authorization: "Bearer thread-scoped-test-token" }, + }, + }, + }); + } + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("rejects a host without session MCP support before opening a native session", () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(THREAD_ID)), + ); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("test-environment"), + threadId: THREAD_ID, + providerSessionId: "test-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43210/mcp", + authorizationHeader: "Bearer thread-scoped-test-token", + browserToolsAvailable: false, + }); + const fake = yield* makeFakeMuse(); + const outcome = yield* makeHarness(fake).pipe(Effect.result); + assert.strictEqual(outcome._tag, "Failure"); + assert.isFalse(fake.calls.some((call) => call.method === "session/start")); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect( + "starts fresh after recovery clears a native ref instead of reusing the startup hint", + () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const cleared = yield* preallocatedProviderThread(); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + "failed-old-session", + undefined, + cleared, + ); + const started = yield* fake.takeCall("session/start"); + assert.isFalse(fake.calls.some((call) => call.method === "session/resume")); + assert.notStrictEqual(started.params.sessionId, "failed-old-session"); + assert.strictEqual(harness.providerThread.id, cleared.id); + yield* startConversation(harness, fake); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("retries a missing native session with a cleared ref on the same healthy host", () => + Effect.gen(function* () { + for (const kind of ["sessionNotFound", "notFound"] as const) { + const fake = yield* makeFakeMuse(); + const allocated = yield* preallocatedProviderThread(); + const adapter = makeMuseAdapterV2({ + instanceId: INSTANCE_ID, + settings: museSettings, + environment: { PATH: "/fake/bin" }, + idAllocator: yield* IdAllocator.IdAllocatorV2, + host: yield* ProviderHost, + fileSystem: yield* FileSystem.FileSystem, + createHost: async () => fake.host, + }); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: ProviderSessionId.make(`missing-${kind}`), + modelSelection: modelSelection(), + runtimePolicy, + initialNativeThreadId: "missing-session", + }); + fake.queueResponse( + "session/resume", + new MspError({ code: -32000, message: "Saved session is absent", data: { kind } }), + ); + const failed = yield* runtime + .ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection(), + runtimePolicy, + existingProviderThread: { + ...allocated, + nativeThreadRef: { + driver: MUSE_PROVIDER, + nativeId: "missing-session", + strength: "strong", + }, + }, + }) + .pipe(Effect.exit); + assert.strictEqual(failed._tag, "Failure"); + assert.strictEqual(fake.closeCount(), 0); + const fresh = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection(), + runtimePolicy, + existingProviderThread: allocated, + }); + assert.strictEqual(fresh.id, allocated.id); + assert.notStrictEqual(fresh.nativeThreadRef?.nativeId, "missing-session"); + assert.strictEqual(fake.closeCount(), 0); + yield* runtime.startTurn(yield* turnInput(fresh)); + const admitted = yield* fake.takeCall("turn/start"); + assert.strictEqual(admitted.params.reasoningEffort, "max"); + yield* fake.emit("turn/completed", { turnId: admitted.commandId, terminal: "completed" }); + yield* Stream.runDrain( + runtime.events.pipe(Stream.takeUntil((event) => event.type === "turn.terminal")), + ); + } + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("rejects cross-instance ensure requests without closing the current valid host", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const rejected = yield* harness.runtime + .ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection(), + runtimePolicy, + existingProviderThread: { + ...harness.providerThread, + providerInstanceId: ProviderInstanceId.make("other-account"), + }, + }) + .pipe(Effect.exit); + assert.strictEqual(rejected._tag, "Failure"); + assert.strictEqual(fake.closeCount(), 0); + yield* startConversation(harness, fake); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("skips an unreadable usage notification without ending the turn", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("session/tokenUsage", { turnId: nativeId, usage: "not usage" }); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "completed"); + assert.strictEqual(fake.closeCount(), 0); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("moves a resumed session on another model back to the catalog default", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + // The saved session last ran MODEL; the catalog's default is another model. + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + "saved-session", + undefined, + undefined, + runtimePolicy, + { + modelCatalog: Effect.succeed([ + { + slug: "account-default", + name: "Default", + isCustom: false, + isDefault: true, + capabilities: null, + }, + { slug: MODEL, name: "Other", isCustom: false, isDefault: false, capabilities: null }, + ]), + }, + ); + const input = yield* turnInput(harness.providerThread, 1); + const selection = { ...input.modelSelection, model: MUSE_DEFAULT_MODEL }; + yield* harness.runtime.startTurn({ + ...input, + modelSelection: selection, + appThread: { ...input.appThread, modelSelection: selection }, + }); + const setModel = yield* fake.takeCall("session/setModel"); + assert.deepStrictEqual(setModel.params.model, { + modelId: "account-default", + providerId: "meta", + }); + yield* fake.takeCall("turn/start"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("settles partial messages and pending approvals before a provider failure", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("item/started", { + turnId: nativeId, + item: { + itemId: "partial", + turnId: nativeId, + kind: "agentMessage", + status: "inProgress", + revision: 1, + text: "Partial answer", + }, + }); + yield* fake.emit("approval/requested", approval(nativeId)); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + yield* fake.emit("turn/completed", { + turnId: nativeId, + terminal: "failed", + error: { message: "Provider rejected this turn" }, + }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.strictEqual(terminal.threadDisposition, "reusable"); + assert.isNotNull(terminal.failure); + assert.isTrue( + harness.allEvents.some( + (event) => + event.type === "message.updated" && + !event.message.streaming && + event.message.text === "Partial answer", + ), + ); + assert.isTrue( + harness.allEvents.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled", + ), + ); + yield* startConversation(harness, fake, 2); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("closes its SDK host exactly once when its session scope ends", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + yield* makeHarness(fake).pipe(Effect.scoped); + assert.strictEqual(fake.closeCount(), 1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect( + "closes a newly opened host when native session registration returns another identity", + () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + fake.queueResponse("session/start", { + session: { sessionId: "unexpected-session", modelId: MODEL, activeTurnId: null }, + history: { items: [] }, + }); + const result = yield* makeHarness(fake).pipe(Effect.exit); + assert.strictEqual(result._tag, "Failure"); + // A failed host closes in the background so the failure shows at once. + yield* Effect.promise(() => fake.host.exited); + assert.strictEqual(fake.closeCount(), 1); + assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 0); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("settles an active turn once when both the connection and host exit unexpectedly", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("approval/requested", approval(nativeId)); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + yield* fake.disconnect(); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.strictEqual(terminal.threadDisposition, "broken"); + assert.isTrue( + harness.allEvents.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "cancelled", + ), + ); + const result = yield* harness.runtime + .startTurn(yield* turnInput(harness.providerThread, 2)) + .pipe(Effect.exit); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual( + harness.allEvents.filter((event) => event.type === "turn.terminal").length, + 1, + ); + assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("turns a delivery gap into a broken terminal and rejects subsequent work", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + yield* startConversation(harness, fake); + yield* fake.emit("view/gap", {}); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.strictEqual(terminal.threadDisposition, "broken"); + yield* Effect.promise(() => fake.host.exited); + assert.strictEqual(fake.closeCount(), 1); + yield* Deferred.await(harness.eventsEnded); + const result = yield* harness.runtime + .startTurn(yield* turnInput(harness.providerThread, 2)) + .pipe(Effect.exit); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(fake.calls.filter((call) => call.method === "turn/start").length, 1); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("uses the latest native approval requirement and waits for its resolution event", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("approval/requested", approval(nativeId)); + const first = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + yield* fake.emit("approval/updated", { + ...approval(nativeId), + currentRequirementId: { approvalId: "approval-1", sourceIndex: 1 }, + }); + const refreshed = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + assert.strictEqual(refreshed.runtimeRequest.id, first.runtimeRequest.id); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: first.runtimeRequest.id, + decision: "accept", + }); + const decision = yield* fake.takeCall("approval/decide"); + assert.deepStrictEqual(decision.params.requirementId, { + approvalId: "approval-1", + sourceIndex: 1, + }); + assert.strictEqual(decision.params.choiceId, "once"); + assert.isFalse( + harness.allEvents.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "resolved", + ), + ); + yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: nativeId }); + const resolved = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "resolved", + ); + assert.strictEqual(resolved.runtimeRequest.id, first.runtimeRequest.id); + const repeated = yield* harness.runtime + .respondToRuntimeRequest({ requestId: first.runtimeRequest.id, decision: "accept" }) + .pipe(Effect.exit); + assert.strictEqual(repeated._tag, "Failure"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps the user's decision when Muse settles before acknowledging it", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("approval/requested", approval(nativeId)); + const pending = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + const acknowledgement = pendingPromise>(); + fake.queueResponse("approval/decide", acknowledgement.promise); + const responding = yield* harness.runtime + .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "decline" }) + .pipe(Effect.forkScoped); + yield* fake.takeCall("approval/decide"); + // Muse's native decision for this choice maps to decline too; "cancel" proves it was ours. + yield* fake.emit("approval/resolved", { + approvalId: "approval-1", + turnId: nativeId, + decision: "abort", + }); + const resolved = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "resolved", + ); + assert.strictEqual(resolved.runtimeRequest.decision, "decline"); + acknowledgement.resolve({}); + yield* Fiber.join(responding); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("lets a user turn take a held Muse report turn, approvals included", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const offers: Array = []; + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + runtimePolicy, + { continuationRequests: { offer: (request) => Effect.sync(() => offers.push(request)) } }, + ); + // A finished workflow's report turn starts while T3 runs nothing: it is held. + yield* fake.emit("turn/started", { turnId: "report-1" }); + yield* Effect.yieldNow; + assert.lengthOf(offers, 1); + // The user sends a message first; Muse runs the report before it. + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("approval/requested", approval("report-1")); + const asked = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: asked.runtimeRequest.id, + decision: "accept", + }); + yield* fake.takeCall("approval/decide"); + yield* fake.emit("approval/resolved", { approvalId: "approval-1", turnId: "report-1" }); + yield* fake.emit("turn/completed", { turnId: "report-1", terminal: "completed" }); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "completed"); + // The user turn took it, so the continuation is not dispatched. + const dispatched = yield* offers[0]!.dispatchIfCurrent!(Effect.succeed("run")); + assert.isTrue(Option.isNone(dispatched)); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("approves a workflow child's approval in full access after its turn ended", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + // Muse runs workflow children without the session's allowAll mode. + const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; + yield* fake.emit("approval/requested", childApproval); + const asked = yield* harness.takeEvent("runtime_request.updated"); + assert.strictEqual(asked.runtimeRequest.status, "pending"); + assert.isNull(asked.runtimeRequest.providerTurnId); + const decision = yield* fake.takeCall("approval/decide"); + assert.strictEqual(decision.params.choiceId, "once"); + yield* fake.emit("approval/resolved", childApproval); + const resolved = yield* harness.takeEvent("runtime_request.updated"); + assert.strictEqual(resolved.runtimeRequest.status, "resolved"); + assert.strictEqual(resolved.runtimeRequest.decision, "accept"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("keeps a workflow child's approval pending for the user past the turn's end", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const policy = ProviderAdapterV2RuntimePolicy.make({ + ...runtimePolicy, + runtimeMode: "approval-required", + }); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + policy, + ); + const { nativeId } = yield* startConversation(harness, fake); + const childApproval = { ...approval("child-run-1"), subagentOrigin: { subagentId: "a7" } }; + yield* fake.emit("approval/requested", childApproval); + const asked = yield* harness.takeEvent("runtime_request.updated"); + assert.isNull(asked.runtimeRequest.providerTurnId); + yield* fake.emit("turn/completed", { turnId: nativeId, terminal: "completed" }); + yield* harness.takeEvent("turn.terminal"); + assert.isFalse( + harness.allEvents.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status !== "pending", + ), + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: asked.runtimeRequest.id, + decision: "decline", + }); + const decision = yield* fake.takeCall("approval/decide"); + assert.strictEqual(decision.params.choiceId, "deny"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("validates structured answers and preserves selected labels with a custom note", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + yield* fake.emit("userInput/requested", { + userInputId: "question-1", + turnId: nativeId, + questions: [ + { + id: "approach", + header: "Approach", + question: "Which approach?", + options: [{ label: "Small" }, { label: "Large" }], + selection: { mode: "single" }, + }, + ], + }); + const pending = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.kind === "user_input", + ); + const invalid = yield* harness.runtime + .respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + answers: { approach: ["Small", "Large"] }, + }) + .pipe(Effect.exit); + assert.strictEqual(invalid._tag, "Failure"); + assert.isFalse(fake.calls.some((call) => call.method === "userInput/answer")); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + answers: { approach: ["Small", "Keep tests focused"] }, + }); + const response = yield* fake.takeCall("userInput/answer"); + assert.deepStrictEqual(response.params.answers, [ + { questionId: "approach", selectedLabel: "Small", note: "Keep tests focused" }, + ]); + yield* fake.emit("userInput/settled", { + userInputId: "question-1", + turnId: nativeId, + answers: response.params.answers, + }); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "resolved", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("steers only the active native turn and waits for confirmed interruption", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const turn = yield* startConversation(harness, fake); + const input = yield* turnInput(harness.providerThread); + const invalid = yield* harness.runtime.steerTurn!({ + threadId: THREAD_ID, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: ProviderTurnId.make("unrelated-turn"), + message: input.message, + }).pipe(Effect.exit); + assert.strictEqual(invalid._tag, "Failure"); + assert.isFalse(fake.calls.some((call) => call.method === "turn/steer")); + yield* harness.runtime.steerTurn!({ + threadId: THREAD_ID, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: turn.providerTurn.id, + message: { ...input.message, text: "Focus on the parser" }, + }); + assert.strictEqual((yield* fake.takeCall("turn/steer")).params.expectedTurnId, turn.nativeId); + const interrupt = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: turn.providerTurn.id, + }) + .pipe(Effect.forkScoped); + assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, turn.nativeId); + yield* fake.emit("turn/completed", { turnId: turn.nativeId, terminal: "cancelled" }); + yield* Fiber.join(interrupt); + assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("resumes the requested native session and reads the same session snapshot", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const nativeItem: MuseItem = { + itemId: "prior-message", + turnId: "prior-turn", + kind: "agentMessage", + status: "completed", + revision: 1, + text: "Previous answer", + }; + fake.setHistory([nativeItem]); + const harness = yield* makeHarness(fake, INSTANCE_ID, "saved-session"); + const resumed = yield* fake.takeCall("session/resume"); + assert.strictEqual(resumed.params.sessionId, "saved-session"); + assert.strictEqual(resumed.params.excludeItems, true); + assert.isFalse(fake.calls.some((call) => call.method === "session/start")); + assert.strictEqual(harness.providerThread.nativeThreadRef?.nativeId, "saved-session"); + const snapshot = yield* harness.runtime.readThreadSnapshot!({ + providerThread: harness.providerThread, + }); + assert.strictEqual(snapshot.providerThread.nativeThreadRef?.nativeId, "saved-session"); + assert.deepStrictEqual(snapshot.providerPayload, [nativeItem]); + assert.strictEqual(snapshot.messages[0]?.text, "Previous answer"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("serializes interruption behind pending turn admission without losing the target", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const admission = pendingPromise>(); + fake.queueResponse("turn/start", admission.promise); + const starting = yield* harness.runtime + .startTurn(yield* turnInput(harness.providerThread)) + .pipe(Effect.forkScoped); + const call = yield* fake.takeCall("turn/start"); + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + const stopping = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: running.providerTurn.id, + }) + .pipe(Effect.forkScoped); + admission.resolve({ turnId: call.commandId }); + yield* Fiber.join(starting); + assert.strictEqual((yield* fake.takeCall("turn/interrupt")).params.turnId, call.commandId); + yield* fake.emit("turn/completed", { turnId: call.commandId, terminal: "cancelled" }); + yield* Fiber.join(stopping); + assert.strictEqual((yield* harness.takeEvent("turn.terminal")).status, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("reports a no-op compaction as a visible failure and permits another turn", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + fake.queueResponse("session/compact", { status: "noop", reason: "no_compactable_history" }); + yield* harness.runtime.compactThread!(yield* turnInput(harness.providerThread)); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.strictEqual(terminal.status, "failed"); + assert.strictEqual(terminal.threadDisposition, "reusable"); + yield* startConversation(harness, fake, 2); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + + it.effect("projects native todos with stable identity and removes cancelled steps", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + const harness = yield* makeHarness(fake); + const { nativeId } = yield* startConversation(harness, fake); + // Neither the tool call that wrote the list nor Muse's per-step reminder + // child may show as a generic tool row. + yield* fake.emit("item/completed", { + turnId: nativeId, + item: { + itemId: "todo-tool", + turnId: nativeId, + kind: "toolCall", + tool: "write_todos", + revision: 1, + status: "completed", + }, + }); + yield* fake.emit("item/started", { + turnId: nativeId, + item: { + itemId: "reminder", + turnId: nativeId, + kind: "reminderChild", + revision: 1, + status: "inProgress", + fallbackText: "Reminder child session", + }, + }); + yield* fake.emit("session/todoListChanged", { + items: [ + { text: " Investigate ", status: "completed" }, + { text: "Fix", status: "inProgress" }, + { text: "Discarded", status: "cancelled" }, + { text: " ", status: "pending" }, + ], + }); + const first = yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "todo_list", + ); + assert.strictEqual(first.turnItem.type, "todo_list"); + if (first.turnItem.type !== "todo_list") return; + assert.deepStrictEqual( + first.turnItem.steps.map(({ text, status }) => ({ text, status })), + [ + { text: "Investigate", status: "completed" }, + { text: "Fix", status: "running" }, + ], + ); + const planId = first.turnItem.planId; + yield* fake.emit("session/todoListChanged", { + items: [ + { text: "Investigate", status: "completed" }, + { text: "Fix", status: "completed" }, + ], + }); + const next = yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "todo_list", + ); + assert.strictEqual(next.turnItem.id, first.turnItem.id); + assert.strictEqual(next.turnItem.nodeId, first.turnItem.nodeId); + if (next.turnItem.type !== "todo_list") return; + assert.strictEqual(next.turnItem.planId, first.turnItem.planId); + assert.isTrue( + harness.allEvents.some( + (event) => + event.type === "plan.updated" && + event.plan.id === planId && + event.plan.status === "completed", + ), + ); + assert.isFalse( + harness.allEvents.some( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool", + ), + ); + assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsPlanUpdated); + assert.isTrue((yield* harness.adapter.getCapabilities()).planning.emitsTodoList); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); +}); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf1699055534c0b08ecd0399b33d79338abc190f --- /dev/null +++ b/packages/provider-muse/src/server/adapter.ts @@ -0,0 +1,2047 @@ +import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; +import { + MUSE_DEFAULT_MODEL, + ProviderDriverKind, + type ModelSelection, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItemStatus, + type NodeId, + type OrchestrationV2TurnItem, + type ProviderInstanceId, + type PlanId, + type OrchestrationV2PlanStep, + type ProviderApprovalDecision, + type RuntimeRequestId, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type { MuseSettings } from "../settings.ts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as FileSystem from "effect/FileSystem"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import type { ProviderHostShape } from "@t3tools/provider-core/server/ProviderHost"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; +import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; +import { + MuseApproval, + MuseCompactResult, + MuseContextUsage, + MuseDelta, + MuseItemEvent, + MuseSessionResult, + MuseTokenUsageEvent, + MuseTodoList, + MuseTurnRetryScheduled, + MuseTurnCompleted, + MuseTurnStartResult, + MuseUserInput, + museApprovalChoices, + museApprovalDecision, + museApprovalOptions, + type MuseItem, +} from "./protocol.ts"; +import { + createMuseSdkHostEffect, + museApprovalMode, + type createMuseSdkHost, + type MuseSdkHost, +} from "./sdk.ts"; +import type { EventNdjsonLogger } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { + providerMessageTextWithAttachmentPaths, + isProviderNativeImageAttachment, +} from "@t3tools/provider-core/server/attachmentPrompt"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunError, + ProviderAdapterTurnStartError, + ProviderAdapterV2, + type ProviderAdapterV2Error, + type ProviderAdapterV2Event, + type ProviderAdapterV2EnsureThreadInput, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2ThreadSnapshot, + type ProviderAdapterV2TurnInput, + type ProviderAdapterV2TurnMessage, +} from "@t3tools/provider-core/server/ProviderAdapter"; +import { + backgroundWorkNotification, + type BackgroundWorkReport, +} from "@t3tools/provider-core/server/notification"; +import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; + +const MUSE_PROVIDER = ProviderDriverKind.make("muse"); +const isOpenSessionError = Schema.is(ProviderAdapterOpenSessionError); +const isProtocolError = Schema.is(ProviderAdapterProtocolError); + +const MuseProviderCapabilitiesV2 = { + runtimePolicy: { enforcement: "native" }, + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + // Muse 1.2.1/1.3.0 reject tested completed-turn fork boundaries with InvalidCut. + // Public forks use portable context handoff until native boundaries are validated. + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: true, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: true, + supportsFileChangeApproval: true, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: true, + }, + planning: { + emitsPlanUpdated: true, + emitsTodoList: true, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: false, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "strong", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface MuseAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: MuseSettings; + readonly environment: NodeJS.ProcessEnv; + readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; + readonly host: ProviderHostShape; + readonly fileSystem: FileSystem.FileSystem; + readonly modelCatalog?: Effect.Effect>; + readonly createHost?: typeof createMuseSdkHost; + readonly requestTimeoutMs?: number; + readonly nativeEventLogger?: EventNdjsonLogger; + /** Where a turn Muse starts on its own (a finished workflow's report) asks for its run. */ + readonly continuationRequests?: { + readonly offer: ( + request: ProviderContinuationRequests.ProviderContinuationRequest, + ) => Effect.Effect; + }; +} + +interface ActiveTurn { + readonly input: ProviderAdapterV2TurnInput; + providerTurn: OrchestrationV2ProviderTurn; + readonly nativeId: string; + /** Turns Muse started on its own while this run was active, which this run shows. */ + readonly joined: Set; + readonly items: Map; + readonly ordinals: Map; + readonly started: Map; + readonly dirty: Set; + readonly settledRequests: Set; + readonly done: Deferred.Deferred; + todoPlanId?: PlanId; + nextOrdinal: number; + flushScheduled: boolean; + interruptRequested: boolean; + compact: boolean; +} + +interface PendingRequest { + readonly native: + | { type: "approval"; value: MuseApproval } + | { type: "question"; value: MuseUserInput }; + request: OrchestrationV2RuntimeRequest; + node: OrchestrationV2ExecutionNode; + item: OrchestrationV2TurnItem; + /** What T3 answered, recorded on the request once Muse confirms it. */ + response?: Pick; +} + +const nativeRef = (nativeId: string) => ({ + driver: MUSE_PROVIDER, + nativeId, + strength: "strong" as const, +}); +const recordSchema = Schema.Record(Schema.String, Schema.Unknown); +// Notifications that only add detail. A malformed one is skipped instead of ending the session. +const INFORMATIONAL_NOTIFICATIONS = new Set([ + "session/contextUsage", + "session/tokenUsage", + "session/todoListChanged", + "turn/retryScheduled", +]); +// Tools whose results already show as their own rows: T3's todo list and question +// rows, and the workflow item a `workflow` call launches. +const TOOLS_WITH_NATIVE_ROWS = new Set(["write_todos", "request_user_input", "workflow"]); +const responseAnswerSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]); + +/** One scoped Muse host owns one native session; the orchestrator owns app runs and queuing. */ +export function makeMuseAdapterV2(options: MuseAdapterV2Options): ProviderAdapterV2Shape { + const { idAllocator } = options; + const protocolError = (detail: string, payload?: unknown) => + new ProviderAdapterProtocolError({ + driver: MUSE_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); + return ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: MUSE_PROVIDER, + getCapabilities: () => Effect.succeed(MuseProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + openSession: Effect.fn("MuseAdapterV2.openSession")(function* ( + input: ProviderAdapterV2OpenSessionInput, + ) { + if (!options.settings.enabled) return yield* protocolError("Muse Code is disabled"); + if (input.runtimePolicy.interactionMode === "plan") + return yield* protocolError("Muse Code does not support dedicated Plan mode"); + const scope = yield* Effect.scope; + // Muse rejects non-canonical workspace roots (for example macOS /tmp) and + // reports canonical paths in approvals, so resolve symlinks once here. + const requestedCwd = input.runtimePolicy.cwd ?? options.host.paths.cwd; + const cwd = yield* options.fileSystem + .realPath(requestedCwd) + .pipe(Effect.orElseSucceed(() => requestedCwd)); + const now = yield* DateTime.now; + let session: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: MUSE_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: MuseProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded< + ProviderAdapterV2Event, + ProviderAdapterV2Error | Cause.Done + >(); + type Inbox = + | { type: "notification"; method: string; params: unknown; epoch: number } + | { type: "failure"; cause: unknown; epoch: number } + | { type: "flush"; turn: ActiveTurn }; + const inbox = yield* Queue.unbounded(); + const commands = yield* Semaphore.make(1); + const eventPermit = yield* Semaphore.make(1); + let host: MuseSdkHost; + let hostEpoch = 0; + let closed = false; + let broken = false; + let nativeSessionId: string | undefined; + let thread: OrchestrationV2ProviderThread | undefined; + let active: ActiveTurn | undefined; + // The last finished turn, so context usage reported after it still lands on it. + let lastProviderTurn: OrchestrationV2ProviderTurn | undefined; + const agentEndedAt = new Map(); + // "default" is the catalog's default model, from the provider snapshot. Without + // one, Muse keeps whatever model the session already runs. + const resolveModel = Effect.fnUntraced(function* (selection: ModelSelection) { + if (selection.model !== MUSE_DEFAULT_MODEL) return selection.model; + const catalog = options.modelCatalog ? yield* options.modelCatalog : []; + return catalog.find((model) => model.isDefault && !model.isCustom)?.slug; + }); + const pending = new Map(); + const seenTerminals = new Set(); + // Workflows and subagents that outlive their turn, by item id, with the turn that owns them. + const observedChildren = new Map(); + // Background work that finished since the last turn, reported to the turn Muse starts for it. + const finishedBackground: Array = []; + // A turn Muse started on its own, held until the continuation run T3 opens for it takes it. + let wake: + | { readonly nativeId: string; readonly events: Array<[string, unknown]> } + | undefined; + const emit = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + const decode = (schema: Schema.Codec, data: unknown) => + Schema.decodeUnknownEffect(schema)(data).pipe( + Effect.mapError((cause) => protocolError("Invalid Muse protocol response", cause)), + ); + const request = ( + method: string, + params: Record, + command = true, + commandId?: string, + ) => + Effect.tryPromise({ + try: () => + command + ? host.connection.command( + method, + { ...(nativeSessionId ? { sessionId: nativeSessionId } : {}), ...params }, + { + ...(method === "turn/start" ? {} : { maxAttempts: 1 }), + ...(commandId ? { commandId } : {}), + }, + ) + : host.connection.request(method, { + ...(nativeSessionId ? { sessionId: nativeSessionId } : {}), + ...params, + }), + catch: (cause) => + protocolError( + cause instanceof Error && cause.message + ? `Muse ${method} failed: ${cause.message}` + : `Muse ${method} failed`, + cause, + ), + }).pipe( + Effect.timeout(options.requestTimeoutMs ?? 30_000), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.gen(function* () { + yield* eventPermit.withPermits(1)(failHost(cause)); + return yield* protocolError(`Muse ${method} timed out; its host was closed`, cause); + }), + }), + ); + const updateSession = Effect.fnUntraced(function* ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = null, + ) { + session = { ...session, status, lastError, updatedAt: yield* DateTime.now }; + yield* emit({ + type: "provider_session.updated", + driver: MUSE_PROVIDER, + providerSession: session, + }); + }); + const updateThread = Effect.fnUntraced(function* ( + patch: Partial, + ) { + if (!thread) return; + thread = { ...thread, ...patch, updatedAt: yield* DateTime.now }; + yield* emit({ + type: "provider_thread.updated", + driver: MUSE_PROVIDER, + providerThread: thread, + }); + }); + const itemIdentity = (turn: ActiveTurn, nativeId: string) => + `${options.instanceId}:${turn.input.providerThread.id}:${turn.nativeId}:${nativeId}`; + const baseItem = (turn: ActiveTurn, nativeId: string, time: DateTime.Utc) => { + if (!turn.ordinals.has(nativeId)) turn.ordinals.set(nativeId, turn.nextOrdinal++); + if (!turn.started.has(nativeId)) turn.started.set(nativeId, time); + const identity = itemIdentity(turn, nativeId); + return { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: MUSE_PROVIDER, + nativeItemId: identity, + }), + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: MUSE_PROVIDER, + nativeItemId: identity, + }), + threadId: turn.input.threadId, + runId: turn.input.runId, + providerThreadId: turn.input.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: nativeRef(nativeId), + parentItemId: null, + ordinal: turn.ordinals.get(nativeId)!, + startedAt: turn.started.get(nativeId)!, + updatedAt: time, + }; + }; + /** Lists running workflows and subagents on the thread, so it shows it still waits on them. */ + const syncBackground = Effect.fnUntraced(function* () { + yield* updateThread({ + pendingBackgroundTasks: [...observedChildren].flatMap(([itemId, owner]) => { + const item = owner.items.get(itemId); + const description = item?.objective ?? item?.entryId ?? item?.fallbackText; + return [ + { + taskId: itemId, + kind: + item?.kind === "subagent" ? ("subagent" as const) : ("background_task" as const), + ...(description ? { description } : {}), + }, + ]; + }), + }); + }); + const publishItem = Effect.fnUntraced(function* ( + turn: ActiveTurn, + item: MuseItem, + terminal?: OrchestrationV2TurnItem["status"], + ) { + // Muse's skill-reminder child runs on every model step; it is housekeeping, not work. + if (item.kind === "userMessage" || item.kind === "reminderChild") return; + // A failed call keeps its row: it may have no native row to show its error. + if ( + item.kind === "toolCall" && + item.tool && + TOOLS_WITH_NATIVE_ROWS.has(item.tool) && + item.status !== "failed" + ) + return; + const time = yield* DateTime.now; + const status = terminal ?? museItemStatus(item); + if (item.kind === "subagent" || item.kind === "workflow") { + const wasRunning = observedChildren.has(item.itemId); + if (status === "running") observedChildren.set(item.itemId, turn); + else if (observedChildren.delete(item.itemId) && active !== turn) + finishedBackground.push({ + kind: item.kind === "subagent" ? "subagent" : "background_task", + label: item.objective ?? item.entryId ?? item.fallbackText, + outcome: + status === "completed" + ? "completed" + : status === "cancelled" + ? "cancelled" + : "failed", + }); + if (wasRunning !== observedChildren.has(item.itemId)) yield* syncBackground(); + } + const streaming = status === "running"; + const base = { + ...baseItem(turn, item.itemId, time), + status, + completedAt: streaming ? null : time, + title: item.tool ?? null, + }; + const text = item.text ?? item.summary?.join("\n") ?? item.fallbackText ?? ""; + let turnItem: OrchestrationV2TurnItem; + if (item.kind === "agentMessage") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: MUSE_PROVIDER, + nativeItemId: itemIdentity(turn, item.itemId), + }); + const message: OrchestrationV2ConversationMessage = { + id: messageId, + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId: base.nodeId, + role: "assistant", + text, + attachments: [], + streaming, + createdBy: "agent", + creationSource: "provider", + createdAt: base.startedAt, + updatedAt: time, + }; + yield* emit({ type: "message.updated", driver: MUSE_PROVIDER, message }); + turnItem = { ...base, type: "assistant_message", messageId, text, streaming }; + } else if (item.kind === "reasoning") { + turnItem = { ...base, type: "reasoning", text, streaming }; + } else if (item.kind === "compaction") { + turnItem = { + ...base, + type: "compaction", + driver: MUSE_PROVIDER, + summary: text || item.reason, + }; + } else { + turnItem = { ...base, ...museToolPresentation(item, status) }; + } + yield* emit({ + type: "node.updated", + driver: MUSE_PROVIDER, + node: { + id: base.nodeId, + threadId: base.threadId, + runId: base.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind: + item.kind === "agentMessage" + ? "assistant_message" + : item.kind === "reasoning" + ? "reasoning" + : "tool_call", + status, + countsForRun: false, + providerThreadId: base.providerThreadId, + providerTurnId: base.providerTurnId, + nativeItemRef: base.nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: base.startedAt, + completedAt: base.completedAt, + }, + }); + yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem }); + if (item.kind === "workflow") yield* publishWorkflowAgents(turn, item, base, status); + }); + /** + * Shows each workflow child as a native subagent under the workflow row. Muse + * reports only their status, label and outcome, not their conversations, so + * they get no child thread. + */ + const publishWorkflowAgents = Effect.fnUntraced(function* ( + turn: ActiveTurn, + item: MuseItem, + workflow: { readonly id: OrchestrationV2TurnItem["id"]; readonly nodeId: NodeId }, + workflowStatus: OrchestrationV2TurnItemStatus, + ) { + const latest = new Map((item.children ?? []).map((child) => [child.childId, child])); + for (const [index, child] of [...latest.values()].entries()) { + const time = yield* DateTime.now; + const base = baseItem(turn, `${item.itemId}:agent:${child.childId}`, time); + const status: OrchestrationV2TurnItemStatus = + child.terminal === "completed" + ? "completed" + : child.terminal === "cancelled" + ? "cancelled" + : child.terminal !== undefined + ? "failed" + : workflowStatus !== "running" + ? workflowStatus + : child.status === "scheduled" + ? "pending" + : "running"; + const settled = status !== "running" && status !== "pending"; + // Children are re-sent on every workflow change; keep each one's first end time. + const endKey = `${item.itemId}:${child.childId}:${child.attempt}`; + if (settled && !agentEndedAt.has(endKey)) agentEndedAt.set(endKey, time); + const title = child.label?.trim() || `Agent ${index + 1}`; + const result = child.failureReason ?? null; + const subagent: OrchestrationV2Subagent = { + id: base.nodeId, + threadId: base.threadId, + runId: base.runId, + parentNodeId: workflow.nodeId, + origin: "provider_native", + createdBy: "agent", + driver: MUSE_PROVIDER, + providerInstanceId: options.instanceId, + providerThreadId: base.providerThreadId, + childThreadId: null, + nativeTaskRef: nativeRef(child.childId), + prompt: title, + title, + model: null, + status, + result, + startedAt: base.startedAt, + completedAt: settled ? (agentEndedAt.get(endKey) ?? time) : null, + updatedAt: time, + }; + yield* emit({ type: "subagent.updated", driver: MUSE_PROVIDER, subagent }); + yield* emit({ + type: "node.updated", + driver: MUSE_PROVIDER, + node: { + id: base.nodeId, + threadId: base.threadId, + runId: base.runId, + parentNodeId: workflow.nodeId, + rootNodeId: turn.input.rootNodeId, + kind: "subagent", + status, + countsForRun: false, + providerThreadId: base.providerThreadId, + providerTurnId: base.providerTurnId, + nativeItemRef: subagent.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: base.startedAt, + completedAt: subagent.completedAt, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: MUSE_PROVIDER, + turnItem: { + ...base, + nativeItemRef: subagent.nativeTaskRef, + parentItemId: workflow.id, + type: "subagent", + status, + title, + completedAt: subagent.completedAt, + subagentId: subagent.id, + origin: "provider_native", + driver: MUSE_PROVIDER, + providerInstanceId: options.instanceId, + childThreadId: null, + prompt: title, + result, + }, + }); + } + }); + const settleObservedChildren = Effect.fnUntraced(function* ( + status: "failed" | "cancelled" | "interrupted", + ) { + for (const [itemId, owner] of observedChildren) { + const item = owner.items.get(itemId); + if (item) yield* publishItem(owner, item, status); + } + observedChildren.clear(); + if (thread?.pendingBackgroundTasks?.length) yield* syncBackground(); + // Only workflow children's requests outlive a turn; they end with the workflow. + for (const entry of pending.values()) yield* resolvePending(entry, "cancelled"); + }); + const resolvePending = Effect.fnUntraced(function* ( + entry: PendingRequest, + status: "resolved" | "cancelled", + ) { + const time = yield* DateTime.now; + entry.request = { + ...entry.request, + ...(status === "resolved" ? entry.response : undefined), + status, + resolvedAt: time, + responseCapability: { type: "not_resumable", reason: "This Muse request has ended." }, + }; + entry.node = { + ...entry.node, + status: status === "resolved" ? "completed" : "cancelled", + completedAt: time, + }; + entry.item = { + ...entry.item, + status: status === "resolved" ? "completed" : "cancelled", + completedAt: time, + updatedAt: time, + }; + pending.delete(entry.request.id); + yield* emit({ + type: "runtime_request.updated", + driver: MUSE_PROVIDER, + threadId: input.threadId, + runtimeRequest: entry.request, + }); + yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node: entry.node }); + yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: entry.item }); + }); + const finish = Effect.fnUntraced(function* ( + turn: ActiveTurn, + status: "completed" | "cancelled" | "interrupted" | "failed", + detail?: string, + disposition: "reusable" | "broken" = "reusable", + ) { + if (active !== turn || seenTerminals.has(turn.nativeId)) return; + seenTerminals.add(turn.nativeId); + const completedAt = yield* DateTime.now; + for (const item of turn.items.values()) { + if ( + (item.kind === "subagent" || item.kind === "workflow") && + item.status === "inProgress" && + disposition === "reusable" + ) + continue; + if (turn.dirty.has(item.itemId) || item.status === "inProgress") + yield* publishItem(turn, item, item.status === "inProgress" ? status : undefined); + } + turn.dirty.clear(); + // A workflow child's request is runless and keeps waiting after the turn ends. + for (const entry of pending.values()) + if (entry.request.providerTurnId !== null) yield* resolvePending(entry, "cancelled"); + turn.providerTurn = { ...turn.providerTurn, status, completedAt }; + lastProviderTurn = turn.providerTurn; + yield* emit({ + type: "provider_turn.updated", + driver: MUSE_PROVIDER, + threadId: turn.input.threadId, + providerTurn: turn.providerTurn, + }); + yield* updateThread({ + status: disposition === "broken" ? "error" : "idle", + ...(!turn.compact && status === "completed" + ? { nativeConversationHeadRef: nativeRef(turn.nativeId) } + : {}), + }); + yield* updateSession(disposition === "broken" ? "error" : "ready", detail ?? null); + active = undefined; + if (status === "failed") { + const failure = makeProviderFailure({ + class: disposition === "broken" ? "transport_error" : "provider_error", + message: detail ?? "Muse turn failed.", + }); + const base = baseItem(turn, `failure:${turn.nativeId}`, completedAt); + yield* emit({ + type: "turn_item.updated", + driver: MUSE_PROVIDER, + turnItem: { + ...base, + type: "error", + status: "failed", + title: null, + completedAt, + failure, + }, + }); + yield* emit({ + type: "turn.terminal", + driver: MUSE_PROVIDER, + providerThreadId: turn.providerTurn.providerThreadId, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.input.runOrdinal, + status, + failure, + failureItemOrdinal: base.ordinal, + threadDisposition: disposition, + }); + } else { + yield* emit({ + type: "turn.terminal", + driver: MUSE_PROVIDER, + providerThreadId: turn.providerTurn.providerThreadId, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.input.runOrdinal, + status, + failure: null, + threadDisposition: disposition, + }); + } + yield* Deferred.succeed(turn.done, undefined); + }); + const failHost = Effect.fnUntraced(function* (cause: unknown) { + if (closed || broken) return; + broken = true; + const rootCause = Cause.isCause(cause) ? Cause.squash(cause) : cause; + const failure = isOpenSessionError(rootCause) ? rootCause.cause : rootCause; + const detail = isProtocolError(failure) + ? failure.detail + : failure instanceof Error + ? failure.message + : "Muse transport failed."; + if (active) yield* finish(active, "failed", detail, "broken"); + else { + yield* updateThread({ status: "error" }); + yield* updateSession("error", detail); + } + yield* settleObservedChildren("failed"); + yield* Queue.fail(events, protocolError(detail, cause)); + // Report the failure first; a hung host can take a while to close. + const failed = host; + yield* Effect.tryPromise(() => failed.close()).pipe(Effect.ignore, Effect.forkIn(scope)); + }); + const owns = (turn: ActiveTurn, turnId: string) => + turnId === turn.nativeId || turn.joined.has(turnId); + /** Sends T3's decision for a pending approval to Muse. */ + const decideApproval = Effect.fnUntraced(function* ( + entry: PendingRequest, + decision: ProviderApprovalDecision | undefined, + ) { + if (entry.native.type !== "approval") return; + const approval = entry.native.value; + const choice = decision && museApprovalChoices(approval).get(decision); + if (!choice) return yield* protocolError("Muse did not offer this approval decision"); + // Recorded first: Muse can settle the approval before it acknowledges the command. + entry.response = { decision }; + yield* request("approval/decide", { + approvalId: approval.approvalId, + requirementId: approval.currentRequirementId, + choiceId: choice.choiceId, + }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); + }); + /** + * Shows a Muse approval or question as a T3 request. A workflow child's approval + * is runless: the turn that started the workflow may end long before the child + * asks, and T3 keeps a runless request on the thread until Muse settles it. + */ + const publishRequest = Effect.fnUntraced(function* (native: PendingRequest["native"]) { + const child = native.type === "approval" && native.value.subagentOrigin !== undefined; + const turn = child ? undefined : active; + if ( + !thread || + (!child && !turn) || + native.value.sessionId !== nativeSessionId || + (turn && native.value.turnId && !owns(turn, native.value.turnId)) + ) + return; + const nativeId = + native.type === "approval" ? native.value.approvalId : native.value.userInputId; + if (turn?.settledRequests.has(`${native.type}:${nativeId}`)) return; + const previous = [...pending.values()].find( + (entry) => + entry.native.type === native.type && + entry.request.nativeRequestRef?.nativeId === nativeId, + ); + const providerTurnId = turn?.providerTurn.id ?? null; + const threadId = turn?.input.threadId ?? thread.appThreadId ?? input.threadId; + const requestId = + previous?.request.id ?? + (yield* idAllocator.allocate + .runtimeRequest({ + driver: MUSE_PROVIDER, + ...(providerTurnId ? { providerTurnId } : {}), + nativeRequestId: nativeId, + }) + .pipe( + Effect.mapError((cause) => protocolError("Cannot allocate Muse request", cause)), + )); + const time = yield* DateTime.now; + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const subject = native.type === "approval" ? native.value.subject : undefined; + const kind = !subject + ? "user_input" + : subject.kind === "shell" + ? "command" + : subject.kind !== "fileAccess" + ? "permission" + : subject.access === "read" + ? "file-read" + : "file-change"; + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId, + nativeRequestRef: nativeRef(nativeId), + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt: previous?.request.createdAt ?? time, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId, + runId: turn?.input.runId ?? null, + parentNodeId: turn?.input.rootNodeId ?? null, + rootNodeId: turn?.input.rootNodeId ?? nodeId, + kind: native.type === "approval" ? "approval_request" : "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: thread.id, + providerTurnId, + nativeItemRef: nativeRef(nativeId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: runtimeRequest.createdAt, + completedAt: null, + }; + const latestTurn = active?.providerTurn ?? lastProviderTurn; + const base = { + ...(turn + ? baseItem(turn, `request:${nativeId}`, time) + : { + threadId, + runId: null, + providerThreadId: thread.id, + providerTurnId: null, + nativeItemRef: nativeRef(nativeId), + parentItemId: null, + // Sorts after the latest turn's items. + ordinal: previous?.item.ordinal ?? ((latestTurn?.ordinal ?? 0) + 1) * 100, + startedAt: runtimeRequest.createdAt, + updatedAt: time, + }), + id: idAllocator.derive.approvalTurnItem({ requestId }), + nodeId, + status: "waiting" as const, + title: null, + completedAt: null, + }; + const item: OrchestrationV2TurnItem = + native.type === "approval" + ? { + ...base, + type: "approval_request", + requestId, + requestKind: kind === "user_input" ? "command" : kind, + prompt: + native.value.subject.command ?? + native.value.subject.path ?? + native.value.subject.host ?? + native.value.subject.target ?? + native.value.toolName ?? + "Muse requests permission", + options: museApprovalOptions(native.value), + } + : { + ...base, + type: "user_input_request", + requestId, + questions: native.value.questions.map((question) => ({ + id: question.id, + header: question.header || "Question", + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description || option.label, + })), + multiSelect: question.selection.mode === "multiple", + allowCustomAnswer: true, + required: true, + })), + }; + const entry: PendingRequest = { native, request: runtimeRequest, node, item }; + pending.set(requestId, entry); + yield* emit({ + type: "runtime_request.updated", + driver: MUSE_PROVIDER, + threadId, + runtimeRequest, + }); + yield* emit({ type: "node.updated", driver: MUSE_PROVIDER, node }); + yield* emit({ type: "turn_item.updated", driver: MUSE_PROVIDER, turnItem: item }); + yield* updateSession("waiting"); + // Muse does not give workflow children the session's allowAll mode, so in full + // access T3 approves for them. If Muse rejects that, the request waits for the user. + if (child && input.runtimePolicy.runtimeMode === "full-access") + yield* decideApproval(entry, "accept").pipe(Effect.ignore, Effect.forkIn(scope)); + }); + /** Approval and question events; `turn` is undefined for a workflow child's approval. */ + const handleRequestEvent = Effect.fnUntraced(function* ( + method: string, + params: Record, + turn: ActiveTurn | undefined, + ) { + switch (method) { + case "approval/requested": + case "approval/updated": + if ( + method === "approval/updated" && + ![...pending.values()].some( + (entry) => + entry.native.type === "approval" && + entry.native.value.approvalId === params.approvalId, + ) + ) + return; + return yield* publishRequest({ + type: "approval", + value: yield* decode(MuseApproval, params), + }); + case "userInput/requested": + return yield* publishRequest({ + type: "question", + value: yield* decode(MuseUserInput, params), + }); + case "approval/resolved": + case "userInput/settled": { + const id = method === "approval/resolved" ? params.approvalId : params.userInputId; + if (typeof id === "string") + turn?.settledRequests.add( + `${method === "approval/resolved" ? "approval" : "question"}:${id}`, + ); + const entry = [...pending.values()].find( + (candidate) => candidate.request.nativeRequestRef?.nativeId === id, + ); + if (entry) { + // An approval settled outside T3 still shows Muse's own decision. + const native = + method === "approval/resolved" && typeof params.decision === "string" + ? museApprovalDecision({ decision: params.decision, scope: "once" }) + : undefined; + if (!entry.response && native) entry.response = { decision: native }; + yield* resolvePending(entry, "resolved"); + } + if (!pending.size) yield* updateSession(active ? "running" : "ready"); + } + } + }); + const handleNotification = Effect.fnUntraced(function* (method: string, data: unknown) { + const params = yield* decode(recordSchema, data); + if (params.sessionId !== nativeSessionId) return; + if (method === "view/gap") + return yield* protocolError("Muse delivery gap requires session recovery"); + if (method === "session/contextUsage") { + const usage = yield* decode(MuseContextUsage, params); + const currentTurn = active?.providerTurn ?? lastProviderTurn; + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const tokenUsage = { + ...currentTurn?.tokenUsage, + usedTokens: usage.usedTokens, + maxTokens: usage.windowTokens ?? currentTurn?.tokenUsage?.maxTokens ?? undefined, + updatedAt, + }; + yield* updateThread({ contextUsage: tokenUsage }); + if (currentTurn) { + const updated = { ...currentTurn, tokenUsage }; + if (active) active.providerTurn = updated; + else lastProviderTurn = updated; + yield* emit({ + type: "provider_turn.updated", + driver: MUSE_PROVIDER, + providerTurn: updated, + }); + } + return; + } + const itemEvent = ["item/started", "item/updated", "item/completed"].includes(method) + ? (yield* decode(MuseItemEvent, params)).item + : undefined; + if (itemEvent) { + const owner = observedChildren.get(itemEvent.itemId); + if (owner && owner !== active && itemEvent.turnId === owner.nativeId) { + const previous = owner.items.get(itemEvent.itemId); + if (!previous || itemEvent.revision > previous.revision) { + owner.items.set(itemEvent.itemId, itemEvent); + yield* publishItem(owner, itemEvent); + } + return; + } + } + // A workflow child's approval comes under the child's own run id, during a turn + // or after it, so it skips turn routing. + if (params.subagentOrigin !== undefined && method.startsWith("approval/")) + return yield* handleRequestEvent(method, params, undefined); + // Muse starts a turn on its own when a workflow finishes, to report its result. + // During a run, that run shows it. Otherwise hold it and ask the orchestrator for + // a run; that run takes the held events, unless a user turn takes them first. + if ( + method === "turn/started" && + typeof params.turnId === "string" && + params.turnId !== active?.nativeId && + params.turnId !== wake?.nativeId + ) { + if (active && !active.compact) active.joined.add(params.turnId); + else if (!active && !wake) { + const held = { nativeId: params.turnId, events: [] }; + wake = held; + const notification = backgroundWorkNotification(finishedBackground.splice(0)); + if (thread?.appThreadId && options.continuationRequests) + yield* options.continuationRequests.offer({ + threadId: thread.appThreadId, + providerThreadId: thread.id, + driver: MUSE_PROVIDER, + detail: null, + ...(notification ? { notification } : {}), + dispatchIfCurrent: (dispatch) => + wake === held ? Effect.map(dispatch, Option.some) : Effect.succeed(Option.none()), + clearIfCurrent: () => + Effect.sync(() => { + if (wake === held) wake = undefined; + }), + }); + } + } + if (wake && active?.nativeId !== wake.nativeId) { + const turnId = typeof params.turnId === "string" ? params.turnId : itemEvent?.turnId; + if (turnId === wake.nativeId) { + wake.events.push([method, data]); + return; + } + } + const turn = active; + if (!turn) return; + if (typeof params.turnId === "string" && !owns(turn, params.turnId) && !turn.compact) + return; + switch (method) { + case "item/started": + case "item/updated": + case "item/completed": { + const item = itemEvent!; + if ( + item.turnId && + !owns(turn, item.turnId) && + !(turn.compact && item.kind === "compaction") + ) + return; + const previous = turn.items.get(item.itemId); + if (previous && previous.revision >= item.revision) return; + turn.items.set(item.itemId, item); + turn.dirty.delete(item.itemId); + yield* publishItem(turn, item); + if (turn.compact && item.kind === "compaction" && item.status !== "inProgress") { + yield* finish( + turn, + item.status === "completed" && (!item.outcome || item.outcome === "compacted") + ? "completed" + : item.status === "cancelled" + ? "cancelled" + : "failed", + item.failureReason ?? item.reason, + ); + } + break; + } + case "item/delta": { + const delta = yield* decode(MuseDelta, params); + const previous = turn.items.get(delta.itemId); + if (!previous || previous.status !== "inProgress") return; + const field = delta.field ?? "text"; + let item = previous; + if (field === "text") item = { ...previous, text: (previous.text ?? "") + delta.delta }; + else if (field === "output" || field === "visibleOutput") + item = { ...previous, visibleOutput: (previous.visibleOutput ?? "") + delta.delta }; + else if (field.startsWith("summary.")) { + const index = Number(field.slice(8)); + if (Number.isSafeInteger(index) && index >= 0 && index < 100) { + const summary = [...(previous.summary ?? [])]; + summary[index] = (summary[index] ?? "") + delta.delta; + item = { ...previous, summary }; + } + } + if (item === previous) return; + turn.items.set(item.itemId, item); + turn.dirty.add(item.itemId); + if (!turn.flushScheduled) { + turn.flushScheduled = true; + yield* Effect.sleep(50).pipe( + Effect.andThen(Queue.offer(inbox, { type: "flush", turn })), + Effect.forkIn(scope), + ); + } + break; + } + case "turn/completed": { + if (turn.compact) return; + const result = yield* decode(MuseTurnCompleted, params); + // A joined Muse turn ending does not end this run; its own turn does. + if (result.turnId !== turn.nativeId) break; + if (result.usage) { + const usage = result.usage; + turn.providerTurn = { + ...turn.providerTurn, + turnTokenUsage: { + usageScope: "main_agent", + usageStatus: "complete", + hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"), + inputTokens: turn.providerTurn.turnTokenUsage?.inputTokens ?? usage.inputTokens, + outputTokens: usage.outputTokens, + cachedInputTokens: usage.cacheReadTokens ?? usage.cachedTokens, + reasoningTokens: usage.reasoningTokens, + ...(usage.cacheWriteTokens === undefined + ? {} + : { cacheCreationTokens: usage.cacheWriteTokens }), + }, + }; + } + const status = + result.terminal === "completed" + ? "completed" + : result.terminal === "cancelled" + ? turn.interruptRequested + ? "interrupted" + : "cancelled" + : "failed"; + yield* finish(turn, status, result.error?.message ?? result.reason); + break; + } + case "approval/requested": + case "approval/updated": + case "approval/resolved": + case "userInput/requested": + case "userInput/settled": + yield* handleRequestEvent(method, params, turn); + break; + case "session/tokenUsage": { + const usage = yield* decode(MuseTokenUsageEvent, params); + const previous = turn.providerTurn.turnTokenUsage; + const contextUsage = thread?.contextUsage; + turn.providerTurn = { + ...turn.providerTurn, + ...(contextUsage + ? { + tokenUsage: { + ...turn.providerTurn.tokenUsage, + usedTokens: contextUsage.usedTokens, + inputTokens: usage.usage.inputTokens, + outputTokens: usage.usage.outputTokens, + cachedInputTokens: usage.usage.cacheReadTokens ?? usage.usage.cachedTokens, + reasoningOutputTokens: usage.usage.reasoningTokens, + updatedAt: DateTime.formatIso(yield* DateTime.now), + }, + } + : {}), + turnTokenUsage: { + usageScope: "main_agent", + usageStatus: "partial", + hasSubagents: [...turn.items.values()].some((item) => item.kind === "subagent"), + inputTokens: (previous?.inputTokens ?? 0) + usage.promptTokens, + outputTokens: (previous?.outputTokens ?? 0) + usage.usage.outputTokens, + cachedInputTokens: + (previous?.cachedInputTokens ?? 0) + + (usage.usage.cacheReadTokens ?? usage.usage.cachedTokens), + ...(usage.usage.cacheWriteTokens === undefined && + previous?.cacheCreationTokens === undefined + ? {} + : { + cacheCreationTokens: + (previous?.cacheCreationTokens ?? 0) + (usage.usage.cacheWriteTokens ?? 0), + }), + reasoningTokens: (previous?.reasoningTokens ?? 0) + usage.usage.reasoningTokens, + }, + }; + yield* emit({ + type: "provider_turn.updated", + driver: MUSE_PROVIDER, + providerTurn: turn.providerTurn, + }); + break; + } + case "session/todoListChanged": { + const todo = yield* decode(MuseTodoList, params); + const steps: ReadonlyArray = todo.items + .filter((item) => item.text.trim() && item.status !== "cancelled") + .map((item, index) => ({ + id: `${turn.nativeId}:todo:${index}`, + text: item.text.trim(), + status: + item.status === "completed" + ? "completed" + : item.status === "inProgress" + ? "running" + : "pending", + })); + const time = yield* DateTime.now; + const base = baseItem(turn, "todo", time); + const planId = + turn.todoPlanId ?? + (yield* idAllocator.allocate + .plan({ + threadId: turn.input.threadId, + runId: turn.input.runId, + driver: MUSE_PROVIDER, + }) + .pipe( + Effect.mapError((cause) => + protocolError("Cannot allocate Muse todo list", cause), + ), + )); + turn.todoPlanId = planId; + yield* emit({ + type: "plan.updated", + driver: MUSE_PROVIDER, + plan: { + id: planId, + threadId: turn.input.threadId, + runId: turn.input.runId, + nodeId: base.nodeId, + kind: "todo_list", + status: steps.every((step) => step.status === "completed") ? "completed" : "active", + steps, + }, + }); + yield* emit({ + type: "node.updated", + driver: MUSE_PROVIDER, + node: { + id: base.nodeId, + threadId: base.threadId, + runId: base.runId, + parentNodeId: turn.input.rootNodeId, + rootNodeId: turn.input.rootNodeId, + kind: "todo_list", + status: "completed", + countsForRun: false, + providerThreadId: base.providerThreadId, + providerTurnId: base.providerTurnId, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: base.startedAt, + completedAt: time, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: MUSE_PROVIDER, + turnItem: { + ...base, + nativeItemRef: null, + type: "todo_list", + status: "completed", + title: null, + completedAt: time, + planId, + steps, + }, + }); + break; + } + case "turn/retryScheduled": { + const retry = yield* decode(MuseTurnRetryScheduled, params); + const time = yield* DateTime.now; + yield* emit({ + type: "turn_item.updated", + driver: MUSE_PROVIDER, + turnItem: { + ...baseItem(turn, `retry:${retry.nextAttempt}`, time), + type: "system_notice", + status: "completed", + title: "Muse retry", + completedAt: time, + message: `Muse is retrying this turn (attempt ${retry.nextAttempt} of ${retry.maxAttempts}): ${retry.reason}`, + }, + }); + break; + } + } + }); + yield* Stream.fromQueue(inbox).pipe( + Stream.runForEach((entry) => + Effect.gen(function* () { + if (entry.type === "flush") { + const turn = entry.turn; + turn.flushScheduled = false; + if (active !== turn) return; + for (const id of turn.dirty) { + const item = turn.items.get(id); + if (item) yield* publishItem(turn, item); + } + turn.dirty.clear(); + } else if (entry.epoch === hostEpoch) { + if (entry.type === "failure") yield* failHost(entry.cause); + else { + if (options.nativeEventLogger && entry.method !== "item/delta") + yield* options.nativeEventLogger.write( + { provider: "muse", method: entry.method, params: entry.params }, + input.threadId, + ); + yield* handleNotification(entry.method, entry.params).pipe( + Effect.catch((cause) => + INFORMATIONAL_NOTIFICATIONS.has(entry.method) + ? Effect.logWarning(`Skipped an unreadable Muse ${entry.method}`, cause) + : failHost(cause), + ), + ); + } + } + }).pipe(eventPermit.withPermits(1)), + ), + Effect.forkIn(scope), + ); + const launchHost = Effect.fnUntraced(function* () { + const epoch = ++hostEpoch; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const created = yield* Effect.acquireRelease( + createMuseSdkHostEffect( + { + binaryPath: options.settings.binaryPath || "muse", + cwd, + environment: McpProviderSession.withAgentDeviceEnvironment( + options.environment, + mcpSession, + ), + runtimeMode: input.runtimePolicy.runtimeMode, + }, + options.createHost, + ).pipe( + Effect.mapError( + (error) => + new ProviderAdapterOpenSessionError({ + driver: MUSE_PROVIDER, + providerSessionId: input.providerSessionId, + cause: error.cause, + }), + ), + ), + (created) => + Effect.gen(function* () { + if (host === created) { + closed = true; + hostEpoch++; + } + yield* Effect.tryPromise(() => created.close()).pipe(Effect.ignore); + }), + { interruptible: true }, + ).pipe(Effect.provideService(Scope.Scope, scope)); + host = created; + created.connection.onNotification((notification) => { + Queue.offerUnsafe(inbox, { + type: "notification", + method: notification.method, + params: notification.params, + epoch, + }); + }); + created.connection.onProtocolError((cause) => { + Queue.offerUnsafe(inbox, { type: "failure", cause, epoch }); + }); + // These are presentation receipts only. The matching notifications drive the UI, + // and decisions travel as `approval/decide` and `userInput/answer` commands. + created.connection.onServerRequest(async (request) => { + if (request.method === "approval/request" || request.method === "userInput/request") + return {}; + throw new Error(`Unsupported Muse server request: ${request.method}`); + }); + void created.connection.closed.then(() => { + if (!closed && epoch === hostEpoch) + Queue.offerUnsafe(inbox, { + type: "failure", + cause: new Error("Muse connection closed unexpectedly"), + epoch, + }); + }); + void created.exited.then((exit) => { + if (!closed && epoch === hostEpoch) { + const reason = created + .stderrTail?.() + .findLast((line) => line.trim()) + ?.trim(); + Queue.offerUnsafe(inbox, { + type: "failure", + cause: new Error( + `Muse exited unexpectedly (${exit.code ?? exit.signal})${reason ? `: ${reason}` : ""}`, + ), + epoch, + }); + } + }); + }); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + closed = true; + hostEpoch++; + if (active) yield* finish(active, "cancelled", "Muse session closed", "broken"); + yield* settleObservedChildren("cancelled"); + yield* Queue.shutdown(inbox); + yield* Queue.shutdown(events); + }).pipe(eventPermit.withPermits(1)), + ); + yield* launchHost(); + + const register = Effect.fnUntraced(function* (args: ProviderAdapterV2EnsureThreadInput) { + if (broken || closed) + return yield* protocolError("Muse host is unavailable; reopen the session"); + if (active) + return yield* protocolError("Cannot change Muse sessions during an active turn"); + const existing = args.existingProviderThread; + if ( + existing && + (existing.driver !== MUSE_PROVIDER || existing.providerInstanceId !== options.instanceId) + ) + return yield* protocolError("Muse thread belongs to another provider instance"); + const requestedId = existing + ? (existing.nativeThreadRef?.nativeId ?? undefined) + : input.initialNativeThreadId; + if ( + thread && + (!requestedId || requestedId === nativeSessionId) && + thread.appThreadId === args.threadId + ) + return thread; + if (thread) return yield* protocolError("Open a new host to attach another Muse session"); + let missingNativeSession = false; + return yield* Effect.gen(function* () { + nativeSessionId = requestedId ?? host.connection.mintCommandId(); + const mcpSession = McpProviderSession.readMcpProviderSession(args.threadId); + if (mcpSession && !host.initializeResult.grantedCapabilities.includes("sessionMcp")) + return yield* protocolError( + "Update Muse Code to a version that supports session MCP servers", + ); + const config = mcpSession + ? { + mcpServers: { + // Muse defaults to "required", which fails the whole run when T3's + // tools cannot be reached. The agent should still work without them. + "t3-code": { + transport: "streamableHttp", + mode: "optional", + url: mcpSession.endpoint, + headers: { Authorization: mcpSession.authorizationHeader }, + }, + }, + } + : undefined; + const startModel = yield* resolveModel(args.modelSelection); + const result = yield* request( + requestedId ? "session/resume" : "session/start", + requestedId + ? // T3 already has the transcript; skip Muse's history payload. + { excludeItems: true, ...(config ? { config } : {}) } + : { + workspaceRoot: cwd, + ...(config ? { config } : {}), + ...(startModel ? { modelId: startModel } : {}), + providerId: "meta", + approvalMode: museApprovalMode(args.runtimePolicy.runtimeMode), + }, + ).pipe( + Effect.catch((error) => { + missingNativeSession = + requestedId !== undefined && + error.payload instanceof MspError && + (error.payload.kind === "sessionNotFound" || error.payload.kind === "notFound"); + return Effect.fail(error); + }), + Effect.flatMap((value) => decode(MuseSessionResult, value)), + ); + if (result.session.sessionId !== nativeSessionId) + return yield* protocolError("Muse returned a different session identity"); + if (requestedId) + yield* request("session/setApprovalMode", { + mode: museApprovalMode(args.runtimePolicy.runtimeMode), + }); + // A resumed session keeps the model it last ran with; the next turn + // switches it when the selection differs. + const createdAt = yield* DateTime.now; + thread = existing + ? { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: nativeRef(result.session.sessionId), + status: "idle", + updatedAt: createdAt, + } + : { + id: idAllocator.derive.providerThread({ + driver: MUSE_PROVIDER, + providerInstanceId: options.instanceId, + nativeThreadId: nativeSessionId, + }), + driver: MUSE_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: args.threadId, + ownerNodeId: null, + nativeThreadRef: nativeRef(nativeSessionId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt, + updatedAt: createdAt, + }; + session = { + ...session, + model: result.session.modelId ?? args.modelSelection.model, + }; + yield* emit({ + type: "provider_thread.updated", + driver: MUSE_PROVIDER, + providerThread: thread, + }); + return thread; + }).pipe( + Effect.onError((cause) => + missingNativeSession ? Effect.void : eventPermit.withPermits(1)(failHost(cause)), + ), + ); + }); + const prompt = Effect.fnUntraced(function* (message: ProviderAdapterV2TurnMessage) { + const parts: SendUserTurnOptions["input"] = []; + const text = providerMessageTextWithAttachmentPaths({ + text: message.text, + attachments: message.attachments, + resolveAttachmentPath: options.host.resolveAttachmentPath, + }); + if (text) parts.push({ type: "text", text }); + for (const attachment of message.attachments) + if (isProviderNativeImageAttachment(attachment)) { + const path = options.host.resolveAttachmentPath(attachment); + if (!path) return yield* protocolError("Muse image attachment is missing"); + const bytes = yield* options.fileSystem + .readFile(path) + .pipe(Effect.mapError((cause) => protocolError("Cannot read Muse image", cause))); + parts.push({ + type: "image", + base64Data: Buffer.from(bytes).toString("base64"), + mediaType: attachment.mimeType, + }); + } + if (!parts.length) return yield* protocolError("Muse needs text or an image"); + return parts; + }); + const validateThread = (candidate: OrchestrationV2ProviderThread) => + thread !== undefined && + candidate.driver === MUSE_PROVIDER && + candidate.providerInstanceId === options.instanceId && + candidate.appThreadId === thread.appThreadId && + candidate.nativeThreadRef?.nativeId === nativeSessionId; + const selectionEffort = Effect.fnUntraced(function* (selection: ModelSelection) { + if (selection.instanceId !== options.instanceId) + return yield* protocolError("Model selection belongs to another Muse instance"); + const catalog = options.modelCatalog ? yield* options.modelCatalog : []; + const model = yield* resolveModel(selection); + const selected = getModelSelectionStringOptionValue(selection, "reasoningEffort"); + // With no saved choice, the model's own default applies. + return resolveMuseReasoningEffort( + catalog.find((entry) => entry.slug === model)?.capabilities ?? museModelCapabilities(), + selected, + ); + }); + const start = Effect.fnUntraced(function* ( + turnInput: ProviderAdapterV2TurnInput, + compact = false, + ) { + if (!validateThread(turnInput.providerThread) || broken || closed) + return yield* protocolError("Muse thread is not attached to this host"); + if (active) + return yield* protocolError( + "Muse already has an active turn; queue through the orchestrator", + ); + if (turnInput.runtimePolicy.interactionMode === "plan") + return yield* protocolError("Muse does not support dedicated Plan mode"); + if (turnInput.runtimePolicy.runtimeMode !== input.runtimePolicy.runtimeMode) + return yield* protocolError("Runtime policy changes require a fresh Muse host"); + // A continuation run takes the turn Muse already started; it sends no prompt. + const continuation = + turnInput.message.createdBy === "agent" && + turnInput.message.creationSource === "provider"; + const adopted = continuation ? wake : undefined; + const effort = continuation ? undefined : yield* selectionEffort(turnInput.modelSelection); + const parts = compact || continuation ? [] : yield* prompt(turnInput.message); + const targetModel = continuation + ? undefined + : yield* resolveModel(turnInput.modelSelection); + if (targetModel && session.model !== targetModel) { + yield* request("session/setModel", { + model: { modelId: targetModel, providerId: "meta" }, + }); + session = { ...session, model: targetModel }; + } + const nativeId = adopted?.nativeId ?? host.connection.mintCommandId(); + const startedAt = yield* DateTime.now; + const providerTurn: OrchestrationV2ProviderTurn = { + id: idAllocator.derive.providerTurn({ + driver: MUSE_PROVIDER, + nativeTurnId: `${options.instanceId}:${nativeSessionId}:${nativeId}`, + }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: compact + ? { ...nativeRef(nativeId), strength: "weak" } + : nativeRef(nativeId), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + const turn: ActiveTurn = { + input: turnInput, + providerTurn, + nativeId, + joined: new Set(), + items: new Map(), + ordinals: new Map(), + started: new Map(), + dirty: new Set(), + settledRequests: new Set(), + done: yield* Deferred.make(), + nextOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + flushScheduled: false, + interruptRequested: false, + compact, + }; + yield* Effect.gen(function* () { + thread = turnInput.providerThread; + active = turn; + yield* emit({ + type: "provider_turn.updated", + driver: MUSE_PROVIDER, + threadId: turnInput.threadId, + providerTurn, + }); + yield* updateThread({ + status: "active", + firstRunOrdinal: thread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateSession("running"); + // A user turn takes a held Muse turn: Muse runs it first, so this run shows it + // (and its approvals), and the continuation asked for it is no longer needed. + const held = wake; + wake = undefined; + if (continuation && !held) return yield* finish(turn, "completed"); + if (held && !continuation) turn.joined.add(held.nativeId); + for (const [method, data] of held?.events ?? []) + yield* handleNotification(method, data).pipe(Effect.catch(failHost)); + }).pipe(eventPermit.withPermits(1)); + if (continuation) return; + yield* Effect.gen(function* () { + if (compact) { + const result = yield* request("session/compact", {}, true, nativeId).pipe( + Effect.flatMap((value) => decode(MuseCompactResult, value)), + ); + if (result.status === "noop") + yield* eventPermit.withPermits(1)( + finish(turn, "failed", result.reason ?? "Muse has no context to compact."), + ); + else if (result.status !== "accepted") + return yield* protocolError( + `Muse returned an unsupported compaction status: ${result.status}`, + ); + } else { + const result = yield* request( + "turn/start", + { + input: [ + { + type: "text", + text: buildRuntimeInstructions({ + harness: "Muse Code", + model: session.model ?? undefined, + reasoningEffort: effort, + }), + }, + ...parts, + ], + displayText: turnInput.message.text || "Image attachment", + // A resumed session keeps the root it was created with; pin it to + // this thread's current checkout so edits land where T3 tracks them. + workspaceRoots: [cwd], + ifBusy: "queue", + ...(effort ? { reasoningEffort: effort } : {}), + }, + true, + nativeId, + ).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value))); + if (result.turnId !== nativeId) { + yield* eventPermit.withPermits(1)( + failHost(new Error("Muse admitted an unowned turn")), + ); + return yield* protocolError("Muse admitted the turn under an unexpected identity"); + } + } + }).pipe( + Effect.onError((cause) => { + const error = Cause.squash(cause); + const detail = isProtocolError(error) + ? error.detail + : error instanceof Error + ? error.message + : "Muse could not start the turn."; + return eventPermit.withPermits(1)(finish(turn, "failed", detail)); + }), + ); + }); + // Only messages are read back; T3 owns turn and item history. + const snapshot = Effect.fnUntraced(function* ( + current: OrchestrationV2ProviderThread, + ): Effect.fn.Return { + const result = yield* request("session/read", { excludeItems: false }, false).pipe( + Effect.flatMap((value) => decode(MuseSessionResult, value)), + ); + if (result.session.sessionId !== nativeSessionId) + return yield* protocolError("Muse snapshot belongs to a different session"); + const items = result.history?.items ?? result.history?.snapshot?.state.items ?? []; + const now = yield* DateTime.now; + return { + providerThread: current, + providerTurns: [], + messages: items.flatMap((item): OrchestrationV2ConversationMessage[] => + item.kind === "agentMessage" || item.kind === "userMessage" + ? [ + { + id: idAllocator.derive.messageFromProviderItem({ + driver: MUSE_PROVIDER, + nativeItemId: `${options.instanceId}:${current.id}:${item.turnId ?? "history"}:${item.itemId}`, + }), + threadId: current.appThreadId ?? input.threadId, + runId: null, + nodeId: null, + role: item.kind === "agentMessage" ? "assistant" : "user", + text: item.text ?? "", + attachments: [], + streaming: item.status === "inProgress", + createdBy: item.kind === "agentMessage" ? "agent" : "user", + creationSource: "provider", + createdAt: now, + updatedAt: now, + }, + ] + : [], + ), + runtimeRequests: [...pending.values()].map((entry) => entry.request), + providerPayload: items, + }; + }); + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: MUSE_PROVIDER, + providerSessionId: input.providerSessionId, + providerSession: session, + events: Stream.fromQueue(events), + // A running workflow or a held Muse turn keeps the host: idle release must wait. + hasPendingBackgroundWork: Effect.sync( + () => observedChildren.size > 0 || wake !== undefined, + ), + ensureThread: (args) => + register(args).pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: MUSE_PROVIDER, + threadId: args.threadId, + cause, + }), + ), + ), + resumeThread: (args) => + register({ + threadId: args.threadId ?? args.providerThread.appThreadId ?? input.threadId, + modelSelection: args.modelSelection ?? input.modelSelection, + runtimePolicy: args.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: args.providerThread, + }).pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: MUSE_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: args.providerThread.id, + cause, + }), + ), + ), + startTurn: (args) => + start(args).pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: MUSE_PROVIDER, + threadId: args.threadId, + providerThreadId: args.providerThread.id, + runId: args.runId, + cause, + }), + ), + ), + compactThread: (args) => start(args, true).pipe(commands.withPermits(1)), + steerTurn: (args) => + Effect.gen(function* () { + const turn = active; + if ( + !turn || + turn.providerTurn.id !== args.providerTurnId || + !validateThread(args.providerThread) || + turn.compact + ) + return yield* protocolError("This Muse turn cannot be steered"); + const parts = yield* prompt(args.message); + const result = yield* request("turn/steer", { + expectedTurnId: turn.nativeId, + input: parts, + }).pipe(Effect.flatMap((value) => decode(MuseTurnStartResult, value))); + if (result.turnId !== turn.nativeId) + return yield* protocolError("Muse steering targeted a different turn"); + }).pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterSteerRunError({ + driver: MUSE_PROVIDER, + providerThreadId: args.providerThread.id, + providerTurnId: args.providerTurnId, + cause, + }), + ), + ), + interruptTurn: (args) => + Effect.gen(function* () { + const turn = active; + if ( + !turn || + turn.providerTurn.id !== args.providerTurnId || + !validateThread(args.providerThread) + ) + return yield* protocolError("This Muse turn is no longer active"); + turn.interruptRequested = true; + if (turn.compact) { + yield* eventPermit.withPermits(1)( + Effect.gen(function* () { + if (active !== turn) return; + hostEpoch++; + yield* Effect.tryPromise(() => host.close()).pipe( + Effect.mapError((cause) => protocolError("Cannot stop Muse compaction", cause)), + Effect.onError(failHost), + ); + broken = true; + yield* finish(turn, "interrupted", undefined, "broken"); + yield* settleObservedChildren("interrupted"); + yield* updateSession("stopped"); + yield* Queue.end(events); + }), + ); + return; + } + // Muse runs a joined turn first, so Stop must end it too. + for (const joined of turn.joined) + yield* request("turn/interrupt", { turnId: joined }).pipe(Effect.ignore); + yield* request("turn/interrupt", { turnId: turn.nativeId }); + yield* Deferred.await(turn.done).pipe( + Effect.timeout(options.requestTimeoutMs ?? 30_000), + Effect.mapError((cause) => protocolError("Muse did not confirm interruption", cause)), + Effect.onError((cause) => eventPermit.withPermits(1)(failHost(cause))), + ); + }).pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: MUSE_PROVIDER, + providerThreadId: args.providerThread.id, + providerTurnId: args.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (args) => + Effect.gen(function* () { + const entry = pending.get(args.requestId); + if (!entry) return yield* protocolError("This Muse request is no longer pending"); + if (entry.native.type === "approval") yield* decideApproval(entry, args.decision); + else { + const questionRequest = entry.native.value; + const answers = []; + for (const question of questionRequest.questions) { + const raw = yield* decode(responseAnswerSchema, args.answers?.[question.id]); + const values = typeof raw === "string" ? [raw] : [...new Set(raw)]; + const selected = values.filter((value) => + question.options.some((option) => option.label === value), + ); + const freeText = values.filter((value) => !selected.includes(value)).join("\n"); + if (freeText.length > 500 || (!freeText && !selected.length)) + return yield* protocolError( + "Muse needs a nonempty answer with at most 500 custom characters", + ); + if ( + (question.selection.mode === "single" && selected.length > 1) || + (selected.length > 0 && + (selected.length < (question.selection.minSelections ?? 0) || + selected.length > (question.selection.maxSelections ?? Infinity))) + ) + return yield* protocolError("Muse question selection count is invalid"); + answers.push({ + questionId: question.id, + ...(selected.length + ? question.selection.mode === "single" + ? { selectedLabel: selected[0]! } + : { selectedLabels: selected } + : {}), + ...(freeText ? (selected.length ? { note: freeText } : { freeText }) : {}), + }); + } + if (args.answers) entry.response = { answers: args.answers }; + yield* request("userInput/answer", { + userInputId: questionRequest.userInputId, + answers, + }).pipe(Effect.tapError(() => Effect.sync(() => delete entry.response))); + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRuntimeRequestResponseError({ + driver: MUSE_PROVIDER, + requestId: args.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (args) => + (!validateThread(args.providerThread) + ? protocolError("Snapshot requested for a different Muse thread") + : snapshot(args.providerThread) + ).pipe( + commands.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: MUSE_PROVIDER, + providerThreadId: args.providerThread.id, + cause, + }), + ), + ), + // Muse rejects forks at completed-turn boundaries (InvalidCut), so T3 uses + // portable context handoff for forks and does not rewind Muse conversations. + rollbackThread: (args) => + Effect.fail( + new ProviderAdapterRollbackThreadError({ + driver: MUSE_PROVIDER, + providerThreadId: args.providerThread.id, + checkpointId: args.target.checkpointId, + cause: "Muse Code does not support conversation rollback in T3 Code.", + }), + ), + forkThread: (args) => + Effect.fail( + new ProviderAdapterForkThreadError({ + driver: MUSE_PROVIDER, + providerThreadId: args.sourceProviderThread.id, + cause: "Muse Code does not support native forks in T3 Code.", + }), + ), + }; + return runtime; + }), + }); +} diff --git a/packages/provider-muse/src/server/driver.ts b/packages/provider-muse/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..87f6b2818907832260665d4d9f46936facd2c425 --- /dev/null +++ b/packages/provider-muse/src/server/driver.ts @@ -0,0 +1,178 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; + +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { makeMuseTextGeneration } from "./textGeneration.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { makeMuseAdapterV2 } from "./adapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import { checkMuseProviderStatus, makePendingMuseProvider } from "./status.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { enrichMuseSnapshot, latestMuseVersion, museMaintenance } from "./maintenance.ts"; +import { makeMuseEnvironment } from "./sdk.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + makeCachedProviderMaintenanceResolution, + resolveProviderMaintenanceCapabilitiesEffect, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; + +const DRIVER_KIND = ProviderDriverKind.make("muse"); +const decodeMuseSettings = Schema.decodeSync(MuseSettings); + +export type MuseDriverEnv = + | IdAllocator.IdAllocatorV2 + | ProviderHost + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers.ProviderEventLoggers; + +export const MuseDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { displayName: "Muse Code", supportsMultipleInstances: true }, + configSchema: MuseSettings, + defaultConfig: () => decodeMuseSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const host = yield* ProviderHost; + const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers; + const { cwd } = host.paths; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; + const hostEnvironment = yield* HostProcessEnvironment; + // Drop an inherited META_API_KEY so Muse uses its login; an instance value still wins. + const processEnvironment = mergeProviderInstanceEnvironment( + environment, + makeMuseEnvironment(hostEnvironment), + ); + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies MuseSettings; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const resolveInstallation = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(museMaintenance, { + binaryPath: effectiveConfig.binaryPath, + env: processEnvironment, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); + const resolveMaintenance = (options?: { readonly fresh?: boolean }) => + Effect.gen(function* () { + const capabilities = yield* resolveInstallation(options); + // The maintenance runner requests fresh capabilities around an explicit update + // and needs the native target version to verify that the command actually upgraded. + const latestVersion = options?.fresh + ? yield* latestMuseVersion(processEnvironment, { fresh: true }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ) + : undefined; + return latestVersion !== undefined ? { ...capabilities, latestVersion } : capabilities; + }); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingMuseProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider: checkMuseProviderStatus(effectiveConfig, processEnvironment, cwd).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichMuseSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + environment: processEnvironment, + }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap(publishSnapshot), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Muse Code snapshot.", + cause, + }), + ), + ); + const modelCatalog = snapshot.getSnapshot.pipe(Effect.map((current) => current.models)); + const orchestrationAdapter = makeMuseAdapterV2({ + instanceId, + settings: effectiveConfig, + environment: processEnvironment, + idAllocator, + host, + fileSystem, + modelCatalog, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + continuationRequests, + }); + const textGeneration = yield* makeMuseTextGeneration(effectiveConfig, { + environment: processEnvironment, + modelCatalog, + }); + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-muse/src/server/itemPresentation.test.ts b/packages/provider-muse/src/server/itemPresentation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..59e48db95fd1652f0a3714913cd5733aa2466c67 --- /dev/null +++ b/packages/provider-muse/src/server/itemPresentation.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { MuseItem } from "./protocol.ts"; +import { museItemStatus, museToolPresentation } from "./itemPresentation.ts"; + +const item = (fields: Partial): MuseItem => ({ + itemId: "native-item", + kind: "toolCall", + status: "completed", + revision: 1, + ...fields, +}); + +describe("Muse item presentation", () => { + it("preserves an edit's file and exact old/new text for the shared diff UI", () => { + expect( + museToolPresentation( + item({ + tool: "edit_file", + args: JSON.stringify({ file_path: "src/index.ts", old_string: "old\n", new_string: "" }), + }), + ), + ).toMatchObject({ + type: "file_change", + status: "completed", + fileName: "src/index.ts", + oldStr: "old\n", + newStr: "", + }); + }); + + it("preserves write content and provider-supplied patches", () => { + expect( + museToolPresentation( + item({ + tool: "write_file", + args: JSON.stringify({ path: "README.md", content: "Hello\n" }), + }), + ), + ).toMatchObject({ type: "file_change", newStr: "Hello\n" }); + expect( + museToolPresentation( + item({ + tool: "apply_patch", + args: JSON.stringify({ path: "README.md", patch: "@@ -1 +1 @@\n-old\n+new" }), + }), + ), + ).toMatchObject({ type: "file_change", diffStr: "@@ -1 +1 @@\n-old\n+new" }); + }); + + it.each(["rejected", "timedOut", "handedOff"])( + "does not report native %s activity as successful", + (status) => { + const native = item({ tool: "search_files", status, fallbackText: "Native explanation" }); + expect(museItemStatus(native)).toBe("failed"); + expect(museToolPresentation(native)).toMatchObject({ + status: "failed", + output: `Muse reported ${status}.\nNative explanation`, + }); + }, + ); + + it("preserves failure details and a known cancelled status", () => { + expect( + museToolPresentation( + item({ + status: "failed", + failureReason: "Permission denied", + visibleOutput: "Partial output", + }), + ), + ).toMatchObject({ status: "failed", output: "Permission denied\nPartial output" }); + expect(museItemStatus(item({ status: "cancelled" }))).toBe("cancelled"); + }); + + it.each(["failed", "noop"])( + "keeps %s compaction outcomes from appearing completed", + (outcome) => { + expect(museItemStatus(item({ kind: "compaction", outcome }))).toBe("failed"); + }, + ); +}); diff --git a/packages/provider-muse/src/server/itemPresentation.ts b/packages/provider-muse/src/server/itemPresentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..e05490698bef0e7e37efdfef1b71ae346ec5b225 --- /dev/null +++ b/packages/provider-muse/src/server/itemPresentation.ts @@ -0,0 +1,117 @@ +import type { OrchestrationV2TurnItem, OrchestrationV2TurnItemStatus } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import type { MuseItem } from "./protocol.ts"; + +const decodeToolArgs = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +type Fields< + T extends OrchestrationV2TurnItem["type"], + K extends keyof Extract, +> = Pick, K>; +type MuseToolPresentation = { + readonly status: OrchestrationV2TurnItemStatus; + readonly title: string | null; +} & ( + | Fields<"command_execution", "type" | "input" | "output" | "exitCode"> + | Fields<"file_change", "type" | "fileName" | "oldStr" | "newStr" | "diffStr"> + | Fields<"web_search", "type" | "patterns" | "results"> + | Fields<"dynamic_tool", "type" | "toolName" | "input" | "output"> +); + +/** Unrecognized native terminal states must not appear as successful work. */ +export function museItemStatus(item: MuseItem): OrchestrationV2TurnItemStatus { + if (item.status === "inProgress") return "running"; + if (item.status === "cancelled") return "cancelled"; + if ( + item.status === "completed" && + !(item.kind === "compaction" && item.outcome && item.outcome !== "compacted") + ) + return "completed"; + return "failed"; +} + +/** Converts native tool details into the shared timeline fields used by every client. */ +export function museToolPresentation( + item: MuseItem, + status = museItemStatus(item), +): MuseToolPresentation { + let args: Record | undefined; + if (item.args) { + try { + args = decodeToolArgs(item.args); + } catch { + // Streaming model-authored arguments may not be valid JSON yet. + } + } + const stringArg = (...keys: string[]) => { + for (const key of keys) { + const value = args?.[key]; + if (typeof value === "string") return value; + } + return undefined; + }; + const toolName = item.tool ?? item.kind; + const title = + item.kind === "subagent" + ? item.objective || item.role || "Muse agent" + : item.kind === "workflow" + ? item.entryId || "Workflow" + : toolName; + const detail = + status === "failed" ? item.failureReason || `Muse reported ${item.status}.` : undefined; + const shared = { status, title: detail ? `${title}: ${detail}` : title }; + const text = + item.visibleOutput ?? + item.result?.summary ?? + item.text ?? + item.summary?.join("\n") ?? + item.fallbackText ?? + item.objective ?? + ""; + const output = detail ? [detail, text].filter(Boolean).join("\n") : text; + + if (item.kind === "userShell" || /shell|bash|exec_command/i.test(item.tool ?? "")) { + return { + ...shared, + type: "command_execution", + input: item.commandText ?? stringArg("command", "cmd") ?? item.args ?? "", + output, + ...(item.exitCode === undefined ? {} : { exitCode: item.exitCode }), + }; + } + if (item.kind === "toolCall" && /write|edit|patch/i.test(toolName)) { + const fileName = stringArg("file_path", "path", "filePath")?.trim(); + if (fileName) { + const oldStr = stringArg("old_string", "old_str", "oldStr", "oldText"); + const newStr = stringArg("new_string", "new_str", "newStr", "newText", "content"); + const diffStr = stringArg("diff", "patch", "diffStr"); + return { + ...shared, + type: "file_change", + fileName, + ...(oldStr === undefined ? {} : { oldStr }), + ...(newStr === undefined ? {} : { newStr }), + ...(diffStr === undefined ? {} : { diffStr }), + }; + } + } + if (item.kind === "toolCall" && /web|browse|fetch/i.test(toolName)) { + const query = stringArg("query", "url", "pattern")?.trim(); + return { + ...shared, + type: "web_search", + ...(query ? { patterns: [query] } : {}), + ...(output ? { results: [{ snippet: output }] } : {}), + }; + } + return { + ...shared, + type: "dynamic_tool", + toolName, + input: args ?? item.args ?? "", + output, + }; +} diff --git a/packages/provider-muse/src/server/maintenance.test.ts b/packages/provider-muse/src/server/maintenance.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ac4a48cae602743128a6de93d33699ec725f084 --- /dev/null +++ b/packages/provider-muse/src/server/maintenance.test.ts @@ -0,0 +1,232 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import { + compareMuseVersions, + enrichMuseSnapshot, + museMaintenance, + latestMuseVersion, + parseMuseVersion, +} from "./maintenance.ts"; +import { + ProviderVersionCache, + makeManualOnlyProviderMaintenanceCapabilities, +} from "@t3tools/provider-core/server/maintenanceResolver"; + +const maintenanceCapabilities = { + ...makeManualOnlyProviderMaintenanceCapabilities({ + provider: ProviderDriverKind.make("muse"), + packageName: null, + }), + compareVersions: compareMuseVersions, +}; +const snapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("muse"), + driver: ProviderDriverKind.make("muse"), + enabled: true, + installed: true, + version: "1.0.3", + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-09-10T00:00:00.000Z", + models: [], + skills: [], + slashCommands: [], +}; + +it.layer(NodeServices.layer)("Muse maintenance", (it) => { + it.effect( + "refreshes a cached channel failure when an explicit update requests verification", + () => + Effect.gen(function* () { + let requests = 0; + const check = Effect.gen(function* () { + expect(yield* latestMuseVersion({})).toBeNull(); + expect(yield* latestMuseVersion({})).toBeNull(); + expect(yield* latestMuseVersion({}, { fresh: true })).toBe("1.1.1-R10.1"); + }); + yield* check.pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + requests === 1 + ? new Response("unavailable", { status: 503 }) + : Response.json({ version: "1.1.1-R10.1" }), + ), + ); + }), + ), + ); + expect(requests).toBe(2); + }), + ); + it.effect( + "compares numeric native release revisions and preserves the CLI release identity", + () => + Effect.sync(() => { + expect(parseMuseVersion("Muse Code 1.1.1 (1.1.1-R2514.1)")).toBe("1.1.1-R2514.1"); + expect(compareMuseVersions("1.1.1-R9.1", "1.1.1-R10.1")).toBeLessThan(0); + expect(compareMuseVersions("1.1.1-R2514.1", "1.1.1-R2514.2")).toBeLessThan(0); + expect(compareMuseVersions("1.2.0-R1.1", "1.1.1-R2514.2")).toBeGreaterThan(0); + // A CLI that prints no revision compares by release, never as an older prerelease. + expect(compareMuseVersions("1.4.3", "1.4.3-R5018.1")).toBe(0); + expect(compareMuseVersions("1.4.2", "1.4.3-R5018.1")).toBeLessThan(0); + }), + ); + it.effect( + "finds releases from the instance's channel and caches the native version, not the SDK version", + () => + Effect.gen(function* () { + const requests: string[] = []; + const httpClient = HttpClient.make((request) => { + requests.push(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ version: "1.1.1-R2514.1" })), + ); + }); + const check = enrichMuseSnapshot({ + snapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: true, + environment: { MUSE_CHANNEL: "muse-canary" }, + }); + const [first, second] = yield* Effect.all([check, check], { concurrency: 1 }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, new Map()), + ); + expect(first.versionAdvisory).toMatchObject({ + status: "behind_latest", + currentVersion: "1.0.3", + latestVersion: "1.1.1-R2514.1", + canUpdate: false, + }); + expect(second.versionAdvisory?.latestVersion).toBe("1.1.1-R2514.1"); + expect(requests).toEqual(["https://api.meta.ai/muse-code/channels/muse-canary"]); + }), + ); + + it.effect("reports native release R9 behind R10 and R10 current against R9", () => + Effect.gen(function* () { + for (const [current, latest, status] of [ + ["1.4.2-R9.1", "1.4.2-R10.1", "behind_latest"], + ["1.4.2-R10.1", "1.4.2-R9.1", "current"], + ] as const) { + const result = yield* enrichMuseSnapshot({ + snapshot: { ...snapshot, version: current }, + maintenanceCapabilities, + enableProviderUpdateChecks: true, + environment: {}, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ version: latest })), + ), + ), + ), + Effect.provideService(ProviderVersionCache, new Map()), + ); + expect(result.versionAdvisory?.status).toBe(status); + } + }), + ); + + it.effect("skips network checks when disabled, uninstalled or disabled in settings", () => + Effect.gen(function* () { + for (const input of [ + { snapshot, enableProviderUpdateChecks: false }, + { snapshot: { ...snapshot, enabled: false }, enableProviderUpdateChecks: true }, + { snapshot: { ...snapshot, installed: false }, enableProviderUpdateChecks: true }, + ]) { + const result = yield* enrichMuseSnapshot({ + ...input, + maintenanceCapabilities, + environment: {}, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected release request")), + ), + ); + expect(result.versionAdvisory?.status).toBe("unknown"); + } + }), + ); + + it.effect("tolerates invalid release manifests and unavailable channels", () => + Effect.gen(function* () { + for (const response of [ + Response.json({ version: 12 }), + Response.json({ version: "1.1.1" }), + new Response("unavailable", { status: 503 }), + ]) { + const result = yield* enrichMuseSnapshot({ + snapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: true, + environment: {}, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response)), + ), + ), + Effect.provideService(ProviderVersionCache, new Map()), + ); + expect(result.status).toBe("ready"); + expect(result.versionAdvisory?.status).toBe("unknown"); + } + }), + ); + + it.effect("updates only a recognized launcher at the resolved instance path", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-maintenance-" }); + const binaryPath = `${directory}/custom muse`; + const context = { + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + platform: "linux" as const, + env: { MUSE_CHANNEL: "muse-canary" }, + }; + yield* fs.writeFileString( + binaryPath, + "#!/bin/bash\n# muse-code/launcher-2\n# MUSE_SYNC_UPDATE\n", + ); + const result = yield* museMaintenance.resolve(context); + expect(result.update).toMatchObject({ + executable: binaryPath, + args: ["--version"], + lockKey: `muse:${binaryPath}`, + env: { + MUSE_CHANNEL: "muse-canary", + MUSE_NO_AUTO_UPDATE: "0", + MUSE_SYNC_UPDATE: "1", + MUSE_UPDATE_INTERVAL_SECONDS: "0", + }, + }); + expect(result.update?.command).toBe( + `MUSE_CHANNEL=muse-canary MUSE_NO_AUTO_UPDATE=0 MUSE_SYNC_UPDATE=1 MUSE_UPDATE_INTERVAL_SECONDS=0 '${binaryPath}' --version`, + ); + expect(result.compareVersions).toBe(compareMuseVersions); + yield* fs.writeFileString(binaryPath, "standalone binary"); + expect((yield* museMaintenance.resolve(context)).update).toBeNull(); + expect((yield* museMaintenance.resolve(null)).update).toBeNull(); + }), + ), + ); +}); diff --git a/packages/provider-muse/src/server/maintenance.ts b/packages/provider-muse/src/server/maintenance.ts new file mode 100644 index 0000000000000000000000000000000000000000..0770b494a819176657236f0ca502d1d83c8c8c42 --- /dev/null +++ b/packages/provider-muse/src/server/maintenance.ts @@ -0,0 +1,139 @@ +import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/http"; + +import { + enrichProviderSnapshotWithVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + ProviderVersionCache, + type ProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { parseGenericCliVersion } from "@t3tools/provider-core/server/snapshotProbe"; + +const DRIVER = ProviderDriverKind.make("muse"); +/** Mirrors the official launcher, which reads MUSE_CHANNEL and accepts only these two. */ +const MUSE_CHANNELS = new Set(["muse-stable", "muse-canary"]); +const ChannelManifest = Schema.Struct({ + version: Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+-R\d+(?:\.\d+)?$/)), +}); + +/** Muse prints both a display version and a release revision; prefer the latter. */ +export function parseMuseVersion(output: string): string | null { + return output.match(/\b\d+\.\d+\.\d+-R\d+(?:\.\d+)?\b/)?.[0] ?? parseGenericCliVersion(output); +} + +function comparableMuseVersion(version: string): string { + // Make the release sequence numeric for semver comparison (R9 precedes R10). + return version.replace(/-R(\d+)(?:\.(\d+))?$/, "-release.$1.$2").replace(/\.$/, ".0"); +} + +const MUSE_REVISION = /-R\d+(?:\.\d+)?$/; + +/** A version printed without a revision is compared by its release alone. */ +export const compareMuseVersions = (current: string, latest: string) => + MUSE_REVISION.test(current) === MUSE_REVISION.test(latest) + ? compareSemverVersions(comparableMuseVersion(current), comparableMuseVersion(latest)) + : compareSemverVersions(current.replace(MUSE_REVISION, ""), latest.replace(MUSE_REVISION, "")); + +/** Only the official launcher owns an update channel; a standalone binary cannot update itself. */ +export const museMaintenance: ProviderMaintenanceCapabilitiesResolver = { + resolve: (context) => + Effect.gen(function* () { + const manual = { + ...makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER, packageName: null }), + compareVersions: compareMuseVersions, + }; + if (!context || context.platform === "win32") return manual; + const fs = yield* FileSystem.FileSystem; + const isLauncher = yield* Effect.gen(function* () { + const stat = yield* fs.stat(context.realCommandPath); + if (Number(stat.size) > 128 * 1_024) return false; + const source = yield* fs.readFileString(context.realCommandPath); + return source.includes("muse-code/launcher-") && source.includes("MUSE_SYNC_UPDATE"); + }).pipe(Effect.orElseSucceed(() => false)); + if (!isLauncher) return manual; + // The launcher has no `update` subcommand. Its documented launch environment + // forces a synchronous refresh of this executable's own installation. + const updateEnv = { + ...(context.env.MUSE_CHANNEL ? { MUSE_CHANNEL: context.env.MUSE_CHANNEL } : {}), + MUSE_NO_AUTO_UPDATE: "0", + MUSE_SYNC_UPDATE: "1", + MUSE_UPDATE_INTERVAL_SECONDS: "0", + }; + return { + ...makeProviderMaintenanceCapabilities({ + provider: DRIVER, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["--version"], + updateCommand: [ + ...Object.entries(updateEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), + shellQuote(context.resolvedCommandPath), + "--version", + ].join(" "), + updateLockKey: `muse:${context.realCommandPath}`, + platform: context.platform, + env: { ...context.env, ...updateEnv }, + }), + compareVersions: compareMuseVersions, + }; + }), +}; + +function shellQuote(word: string) { + return /^[\w./:@=-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`; +} + +export const latestMuseVersion = Effect.fn("latestMuseVersion")(function* ( + environment: NodeJS.ProcessEnv, + options?: { readonly fresh?: boolean }, +) { + const channel = environment.MUSE_CHANNEL || "muse-stable"; + if (!MUSE_CHANNELS.has(channel)) return null; + const channelUrl = `https://api.meta.ai/muse-code/channels/${channel}`; + const cache = yield* ProviderVersionCache; + const key = `muse:${channelUrl}`; + const now = DateTime.toEpochMillis(yield* DateTime.now); + const cached = cache.get(key); + if (!options?.fresh && cached && cached.expiresAt > now) return cached.version; + const client = yield* HttpClient.HttpClient; + const version = yield* client.execute(HttpClientRequest.get(channelUrl)).pipe( + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? response.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(ChannelManifest)), + Effect.map((manifest) => parseMuseVersion(manifest.version)), + ) + : Effect.succeed(null), + ), + Effect.timeout(4_000), + Effect.orElseSucceed(() => null), + ); + cache.set(key, { version, expiresAt: now + (version === null ? 60_000 : 60 * 60 * 1_000) }); + return version; +}); + +/** Feed Muse's release channel into the same advisory and notification flow as other drivers. */ +export const enrichMuseSnapshot = Effect.fn("enrichMuseSnapshot")(function* (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks: boolean; + readonly environment: NodeJS.ProcessEnv; +}) { + const { snapshot } = input; + const latestVersion = + input.enableProviderUpdateChecks && snapshot.enabled && snapshot.installed && snapshot.version + ? yield* latestMuseVersion(input.environment) + : null; + return yield* enrichProviderSnapshotWithVersionAdvisory( + snapshot, + { ...input.maintenanceCapabilities, latestVersion }, + { enableProviderUpdateChecks: input.enableProviderUpdateChecks }, + ); +}); diff --git a/packages/provider-muse/src/server/modelCatalog.test.ts b/packages/provider-muse/src/server/modelCatalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..628628a161bd33320984cb8d2a789c7a2dba9398 --- /dev/null +++ b/packages/provider-muse/src/server/modelCatalog.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest"; +import { MuseSettings } from "../settings.ts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { MuseSdkHost } from "./sdk.ts"; +import { + discoverMuseModels, + museModelCapabilities, + resolveMuseReasoningEffort, +} from "./modelCatalog.ts"; + +const settings = Schema.decodeSync(MuseSettings)({}); +const host = (catalog: Record): MuseSdkHost => ({ + connection: { + request: async () => catalog, + command: async () => ({}), + mintCommandId: () => "test", + onNotification: () => {}, + onServerRequest: () => {}, + onProtocolError: () => {}, + closed: new Promise(() => {}), + }, + initializeResult: { grantedCapabilities: [] }, + exited: new Promise(() => {}), + close: async () => {}, +}); + +const row = (modelId: string, extra: Record = {}) => ({ + modelId, + displayLabel: modelId, + providerId: "meta", + profileId: "tbh", + isDefault: false, + contextLimit: 1_000_000, + ...extra, +}); + +describe("Muse model catalog", () => { + it.effect("uses the labels, efforts and defaults that model/list sends", () => + Effect.gen(function* () { + const models = yield* discoverMuseModels(settings, {}, undefined, async () => + host({ + providerId: "meta", + profileId: "tbh", + source: "providerCatalog", + models: [ + row("restful-walrus", { + displayLabel: " Restful Walrus ", + isDefault: true, + variants: ["minimal", "low", "high", "future-tier"], + reasoningEffortVariants: [{ tier: "low", description: "Quick" }], + defaultReasoningEffort: "high", + }), + row("older-host"), + row("unknown-efforts", { variants: "unknown" }), + row("foreign", { providerId: "another" }), + ], + }), + ).pipe(Effect.scoped); + expect(models.map(({ slug, name, isDefault }) => [slug, name, isDefault])).toEqual([ + ["restful-walrus", "Restful Walrus", true], + ["older-host", "older-host", false], + ["unknown-efforts", "unknown-efforts", false], + ]); + expect(models[0]?.capabilities?.optionDescriptors).toEqual([ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { id: "minimal", label: "Minimal" }, + { id: "low", label: "Low", description: "Quick" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ]); + // Older hosts and "unknown" fall back to Muse's documented tiers. + expect(models[1]?.capabilities).toEqual(museModelCapabilities()); + expect(models[2]?.capabilities).toEqual(museModelCapabilities()); + }), + ); + + it("normalizes saved and implicit efforts against the selected model before dispatch", () => { + const fallback = museModelCapabilities(); + expect(resolveMuseReasoningEffort(fallback, undefined)).toBe("high"); + expect(resolveMuseReasoningEffort(fallback, "max")).toBe("max"); + expect(resolveMuseReasoningEffort(fallback, "ultra")).toBe("high"); + const restricted = museModelCapabilities({ variants: ["xhigh"] }); + expect(resolveMuseReasoningEffort(restricted, undefined)).toBe("xhigh"); + expect(resolveMuseReasoningEffort(restricted, "low")).toBe("xhigh"); + expect( + resolveMuseReasoningEffort(museModelCapabilities({ variants: [] }), "max"), + ).toBeUndefined(); + expect(resolveMuseReasoningEffort(undefined, "max")).toBe("max"); + }); +}); diff --git a/packages/provider-muse/src/server/modelCatalog.ts b/packages/provider-muse/src/server/modelCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..f390d062803c9ab81892d23856aa11770eea5a8c --- /dev/null +++ b/packages/provider-muse/src/server/modelCatalog.ts @@ -0,0 +1,139 @@ +import type { SendUserTurnOptions } from "@muse-code/sdk"; +import { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; +import { createModelCapabilities, getProviderOptionDescriptors } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; + +type MuseReasoningEffort = NonNullable["reasoningEffort"]>; + +const REASONING_EFFORT_LABELS = { + none: "None", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", + max: "Max", + ultra: "Ultra", +} satisfies Record; + +const isMuseReasoningEffort = (tier: string): tier is MuseReasoningEffort => + Object.hasOwn(REASONING_EFFORT_LABELS, tier); + +/** Used when Muse does not report a model's efforts (custom slugs, older hosts, "unknown"). */ +const FALLBACK_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +/** Muse's own CLI default. */ +const FALLBACK_DEFAULT_EFFORT = "high"; + +const ModelCatalogEntry = Schema.Struct({ + modelId: Schema.NonEmptyString, + displayLabel: Schema.String, + providerId: Schema.String, + isDefault: Schema.Boolean, + variants: Schema.optional(Schema.Union([Schema.Array(Schema.String), Schema.Literal("unknown")])), + defaultReasoningEffort: Schema.optional(Schema.String), + reasoningEffortVariants: Schema.optional( + Schema.Array( + Schema.Struct({ tier: Schema.String, description: Schema.optional(Schema.String) }), + ), + ), +}); +const decodeModelList = Schema.decodeUnknownEffect( + Schema.Struct({ providerId: Schema.String, models: Schema.Array(ModelCatalogEntry) }), +); + +/** Builds the reasoning picker from a `model/list` row, or the fallback when called without one. */ +export function museModelCapabilities( + model?: Pick< + typeof ModelCatalogEntry.Type, + "variants" | "defaultReasoningEffort" | "reasoningEffortVariants" + >, +): ModelCapabilities { + const reported = model?.variants !== undefined && model.variants !== "unknown"; + const efforts = (reported ? model.variants : FALLBACK_EFFORTS).filter(isMuseReasoningEffort); + const preferredDefault = reported ? model?.defaultReasoningEffort : FALLBACK_DEFAULT_EFFORT; + const defaultValue = efforts.find((tier) => tier === preferredDefault) ?? efforts[0]; + if (!defaultValue) return createModelCapabilities({ optionDescriptors: [] }); + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + currentValue: defaultValue, + options: efforts.map((id) => { + const description = model?.reasoningEffortVariants?.find( + (variant) => variant.tier === id, + )?.description; + return { + id, + label: REASONING_EFFORT_LABELS[id], + ...(id === defaultValue ? { isDefault: true } : {}), + ...(description ? { description } : {}), + }; + }), + }, + ], + }); +} + +/** Apply the advertised model choices to saved, remembered and implicit efforts at dispatch. */ +export function resolveMuseReasoningEffort( + capabilities: ModelCapabilities | null | undefined, + effort: string | undefined, +): string | undefined { + if (!capabilities) return effort; + const descriptor = getProviderOptionDescriptors({ + caps: capabilities, + selections: effort ? [{ id: "reasoningEffort", value: effort }] : undefined, + }).find((descriptor) => descriptor.id === "reasoningEffort"); + if (descriptor?.type !== "select") return undefined; + return descriptor.options.find((option) => option.id === descriptor.currentValue)?.id; +} + +class MuseCatalogError extends Schema.TaggedError()("MuseCatalogError", { + detail: Schema.String, +}) {} + +export const discoverMuseModels = Effect.fn("discoverMuseModels")(function* ( + settings: MuseSettings, + environment: NodeJS.ProcessEnv | undefined, + cwd?: string, + createHost: typeof createMuseSdkHost = createMuseSdkHost, +) { + const host = yield* Effect.acquireRelease( + createMuseSdkHostEffect( + { + binaryPath: settings.binaryPath, + ...(environment ? { environment } : {}), + ...(cwd ? { cwd } : {}), + readOnly: true, + startupTimeoutMs: 8_000, + }, + createHost, + ), + (host: MuseSdkHost) => Effect.promise(() => host.close()), + { interruptible: true }, + ); + const result = yield* Effect.tryPromise(() => host.connection.request("model/list", {})); + const catalog = yield* decodeModelList(result); + if (catalog.providerId !== "meta") { + return yield* new MuseCatalogError({ detail: "Muse returned a catalog for another provider." }); + } + const seen = new Set(); + return catalog.models.flatMap((model): ServerProviderModel[] => { + if (model.providerId !== "meta" || seen.has(model.modelId)) return []; + seen.add(model.modelId); + return [ + { + slug: model.modelId, + name: model.displayLabel.trim() || model.modelId, + isCustom: false, + isDefault: model.isDefault, + capabilities: museModelCapabilities(model), + }, + ]; + }); +}); diff --git a/packages/provider-muse/src/server/protocol.test.ts b/packages/provider-muse/src/server/protocol.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d583d84de4953709c56e0b80f858246e5bda94b5 --- /dev/null +++ b/packages/provider-muse/src/server/protocol.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { museApprovalChoices } from "./protocol.ts"; + +// Muse 1.4.3 shell approvals offer only `abort`; the muse_permission replay covers that case. +describe("Muse approval choices", () => { + it("keeps denied and abort separate when Muse offers both", () => { + const choices = museApprovalChoices({ + availableChoices: [ + { choiceId: "deny", label: "Deny", decision: "denied", scope: "once" }, + { choiceId: "abort", label: "Stop", decision: "abort", scope: "once" }, + ], + }); + expect(choices.get("decline")?.choiceId).toBe("deny"); + expect(choices.get("cancel")?.choiceId).toBe("abort"); + }); +}); diff --git a/packages/provider-muse/src/server/protocol.ts b/packages/provider-muse/src/server/protocol.ts new file mode 100644 index 0000000000000000000000000000000000000000..91befa21e0485d2e3ed2d5125b9d03c0ae7498db --- /dev/null +++ b/packages/provider-muse/src/server/protocol.ts @@ -0,0 +1,228 @@ +import type { ProviderApprovalDecision } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const NonEmptyString = Schema.String.check(Schema.isMinLength(1)); + +const MuseUsage = Schema.Struct({ + inputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + outputTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cachedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + reasoningTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cacheReadTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + cacheWriteTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), +}); + +export const MuseItem = Schema.Struct({ + /** `workflow`: the launched entry's name. */ + entryId: Schema.optional(Schema.String), + /** `workflow`: each child agent's latest attempt, re-sent whole on every change. */ + children: Schema.optional( + Schema.Array( + Schema.Struct({ + childId: NonEmptyString, + attempt: Schema.Int, + status: Schema.String, + label: Schema.optional(Schema.String), + terminal: Schema.optional(Schema.String), + durationMs: Schema.optional(Schema.Finite), + failureReason: Schema.optional(Schema.String), + }), + ), + ), + itemId: NonEmptyString, + kind: NonEmptyString, + revision: Schema.Int.check(Schema.isGreaterThan(0)), + status: NonEmptyString, + turnId: Schema.optional(Schema.NullOr(Schema.String)), + text: Schema.optional(Schema.String), + summary: Schema.optional(Schema.Array(Schema.String)), + tool: Schema.optional(Schema.String), + args: Schema.optional(Schema.String), + visibleOutput: Schema.optional(Schema.String), + truncated: Schema.optional(Schema.Boolean), + failureReason: Schema.optional(Schema.String), + fallbackText: Schema.optional(Schema.String), + commandText: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Int), + durationMs: Schema.optional(Schema.Finite), + subagentId: Schema.optional(Schema.String), + childSessionId: Schema.optional(Schema.String), + objective: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + controlStatus: Schema.optional(Schema.String), + result: Schema.optional( + Schema.Struct({ summary: Schema.String, text: Schema.optional(Schema.String) }), + ), + usage: Schema.optional(MuseUsage), + outcome: Schema.optional(Schema.String), + trigger: Schema.optional(Schema.String), + reason: Schema.optional(Schema.String), +}); +export type MuseItem = typeof MuseItem.Type; + +export const MuseSessionResult = Schema.Struct({ + session: Schema.Struct({ + sessionId: NonEmptyString, + modelId: Schema.optional(Schema.NullOr(Schema.String)), + activeTurnId: Schema.optional(Schema.NullOr(Schema.String)), + }), + history: Schema.optional( + Schema.Struct({ + items: Schema.NullOr(Schema.Array(MuseItem)), + snapshot: Schema.optional( + Schema.NullOr( + Schema.Struct({ + schemaVersion: Schema.optional(Schema.Int), + state: Schema.Struct({ items: Schema.Array(MuseItem) }), + }), + ), + ), + }), + ), +}); +export const MuseTurnStartResult = Schema.Struct({ turnId: NonEmptyString }); + +export const MuseApproval = Schema.Struct({ + approvalId: NonEmptyString, + protectedWrite: Schema.optional(Schema.Boolean), + judgeEscalated: Schema.optional(Schema.Boolean), + sessionId: NonEmptyString, + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + toolName: Schema.optional(Schema.String), + rawArgs: Schema.optional(Schema.String), + /** Set when a workflow child asks; `turnId` is then the child's run, not a session turn. */ + subagentOrigin: Schema.optional(Schema.Struct({ subagentId: NonEmptyString })), + currentRequirementId: Schema.Struct({ approvalId: NonEmptyString, sourceIndex: Schema.Int }), + availableChoices: Schema.Array( + Schema.Struct({ + choiceId: NonEmptyString, + label: Schema.String, + decision: NonEmptyString, + scope: Schema.String, + }), + ), + subject: Schema.Struct({ + kind: Schema.String, + access: Schema.optional(Schema.String), + command: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), + host: Schema.optional(Schema.String), + target: Schema.optional(Schema.String), + }), +}); +export type MuseApproval = typeof MuseApproval.Type; + +export const MuseUserInput = Schema.Struct({ + userInputId: NonEmptyString, + sessionId: NonEmptyString, + turnId: NonEmptyString, + questions: Schema.Array( + Schema.Struct({ + id: NonEmptyString, + header: Schema.String, + question: Schema.String, + options: Schema.Array( + Schema.Struct({ label: Schema.String, description: Schema.optional(Schema.String) }), + ), + selection: Schema.Struct({ + mode: Schema.Literals(["single", "multiple"]), + minSelections: Schema.optional(Schema.Int), + maxSelections: Schema.optional(Schema.Int), + }), + }), + ), +}); +export type MuseUserInput = typeof MuseUserInput.Type; + +export const MuseTokenUsageEvent = Schema.Struct({ + turnId: NonEmptyString, + promptTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + usage: MuseUsage, +}); +export const MuseContextUsage = Schema.Struct({ + usedTokens: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + windowTokens: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), +}); +export const MuseTodoList = Schema.Struct({ + items: Schema.Array(Schema.Struct({ text: Schema.String, status: Schema.String })), +}); +export const MuseTurnCompleted = Schema.Struct({ + turnId: NonEmptyString, + terminal: NonEmptyString, + reason: Schema.optional(Schema.String), + error: Schema.optional(Schema.Struct({ message: Schema.String })), + usage: Schema.optional(MuseUsage), +}); +export const MuseTurnRetryScheduled = Schema.Struct({ + turnId: NonEmptyString, + nextAttempt: Schema.Int.check(Schema.isGreaterThan(0)), + maxAttempts: Schema.Int.check(Schema.isGreaterThan(0)), + reason: NonEmptyString, + retryDelayMs: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), +}); +export const MuseCompactResult = Schema.Struct({ + status: Schema.String, + reason: Schema.optional(Schema.String), +}); + +export const MuseItemEvent = Schema.Struct({ item: MuseItem }); +export const MuseDelta = Schema.Struct({ + itemId: NonEmptyString, + delta: Schema.String, + field: Schema.optional(Schema.String), +}); + +/** The T3 decision for one native Muse choice, or none when T3 has no equivalent. */ +export function museApprovalDecision( + choice: Pick, +): ProviderApprovalDecision | undefined { + switch (choice.decision) { + case "approved": + return "accept"; + case "approvedForSession": + return "acceptForSession"; + case "approvedPolicyAmendment": + return choice.scope === "session" + ? "acceptForSession" + : choice.scope === "localPersistent" + ? "acceptAlways" + : undefined; + case "denied": + case "deniedPolicyAmendment": + return "decline"; + case "abort": + return "cancel"; + default: + return undefined; + } +} + +/** + * Maps each canonical decision to one native choice. Muse shell approvals offer + * no `denied` choice, only `abort` ("Reject"), so `decline` falls back to it. + */ +export function museApprovalChoices(approval: Pick) { + const choices = new Map(); + const rank = (choice: MuseApproval["availableChoices"][number]) => + (choice.scope === "once" ? 0 : choice.scope === "session" ? 2 : 4) + + (choice.decision.endsWith("PolicyAmendment") ? 1 : 0); + for (const choice of approval.availableChoices) { + const decision = museApprovalDecision(choice); + if (!decision) continue; + const previous = choices.get(decision); + if (!previous || rank(choice) < rank(previous)) choices.set(decision, choice); + } + const abort = choices.get("cancel"); + if (!choices.has("decline") && abort) choices.set("decline", abort); + return choices; +} + +/** The approval buttons to show: one per native choice, preferring `decline` over `cancel`. */ +export function museApprovalOptions(approval: Pick) { + const choices = museApprovalChoices(approval); + const declineChoiceId = choices.get("decline")?.choiceId; + return [...choices] + .filter(([decision, choice]) => decision !== "cancel" || choice.choiceId !== declineChoiceId) + .map(([decision, choice]) => ({ decision, label: choice.label })); +} diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c92498ca5170f10839e2e65e3e624ad75e3eb6f --- /dev/null +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -0,0 +1,266 @@ +import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { afterEach, describe, expect, vi } from "vite-plus/test"; +import { + createMuseSdkHost, + createMuseSdkHostEffect, + makeMuseEnvironment, + museApprovalMode, + type MuseSdkHost, +} from "./sdk.ts"; + +vi.mock("@muse-code/sdk", () => ({ spawnMspConnection: vi.fn() })); + +function pending() { + let resolve = (_value: A) => {}; + let reject = (_error: unknown) => {}; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function mockSpawn() { + const startup = pending(); + const shutdown = pending<{ code: number; signal: null }>(); + const closing = pending(); + const initializing = pending(); + const initialize = vi.fn(() => { + initializing.resolve(); + return startup.promise; + }); + const close = vi.fn(() => { + closing.resolve(); + return shutdown.promise; + }); + const handshake = { initialize, close } as unknown as MspHandshake; + vi.mocked(spawnMspConnection).mockReturnValue(handshake); + const ready = { + connection: {}, + initializeResult: { grantedCapabilities: [], schema: { version: 1 } }, + exited: shutdown.promise, + fingerprintWarning: { warning: "additive optional" }, + } as unknown as SpawnedMspConnection; + return { startup, shutdown, closing, initializing, initialize, close, ready }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("Muse SDK host", () => { + it.each(["META_API_KEY", "meta_api_key", "Meta_Api_Key"])( + "excludes %s without reading its value or mutating the supplied environment", + (key) => { + const source: NodeJS.ProcessEnv = { PATH: "/bin", MUSE_TEST: "yes" }; + const read = vi.fn(() => { + throw new Error("The API override must not be read."); + }); + Object.defineProperty(source, key, { enumerable: true, get: read }); + expect(makeMuseEnvironment(source)).toEqual({ + PATH: "/bin", + MUSE_TEST: "yes", + MUSE_NO_AUTO_UPDATE: "1", + }); + expect(read).not.toHaveBeenCalled(); + expect(Object.keys(source)).toContain(key); + }, + ); + + it("selects full access explicitly and keeps other runtime modes sandboxed", async () => { + for (const mode of ["approval-required", "auto-accept-edits", "auto", "full-access"] as const) { + const fake = mockSpawn(); + const running = createMuseSdkHost({ + binaryPath: "/custom/muse", + cwd: "/workspace", + runtimeMode: mode, + environment: { PATH: "/bin" }, + }); + expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0]).toMatchObject({ + command: "/custom/muse", + cwd: "/workspace", + args: + mode === "full-access" + ? ["serve", "--trust-workspace", "--disable-sandbox"] + : ["serve", "--trust-workspace"], + }); + expect(museApprovalMode(mode)).toBe(mode === "full-access" ? "allowAll" : "promptUnmatched"); + fake.startup.resolve(fake.ready); + const host = await running; + fake.shutdown.resolve({ code: 0, signal: null }); + await host.close(); + await host.close(); + expect(fake.close).toHaveBeenCalledOnce(); + } + }); + + it("bounds startup and waits for native shutdown after timeout", async () => { + vi.useFakeTimers(); + const fake = mockSpawn(); + const startup = createMuseSdkHost({ binaryPath: "muse", startupTimeoutMs: 30 }); + let settled = false; + const outcome = startup.then( + () => { + settled = true; + }, + (error: unknown) => { + settled = true; + return error; + }, + ); + await vi.advanceTimersByTimeAsync(31); + expect(fake.close).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + fake.shutdown.resolve({ code: 0, signal: null }); + expect(await outcome).toMatchObject({ message: "Muse SDK initialization timed out." }); + }); + + it("closes the pre-handshake host on abort and rejects only after shutdown", async () => { + const fake = mockSpawn(); + const abort = new AbortController(); + const started = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal }); + const reason = new Error("Cancelled by caller"); + const outcome = started.catch((error: unknown) => error); + abort.abort(reason); + expect(fake.close).toHaveBeenCalledOnce(); + fake.shutdown.resolve({ code: 0, signal: null }); + expect(await outcome).toBe(reason); + }); + + it("closes rejected initialization and keeps successful schema advisories usable", async () => { + const failed = mockSpawn(); + const started = createMuseSdkHost({ binaryPath: "muse" }); + const outcome = started.catch((error: unknown) => error); + failed.startup.reject(new Error("Handshake failed")); + failed.shutdown.resolve({ code: 0, signal: null }); + expect(await outcome).toMatchObject({ message: "Handshake failed" }); + expect(failed.close).toHaveBeenCalledOnce(); + const success = mockSpawn(); + const ready = createMuseSdkHost({ binaryPath: "muse", readOnly: true }); + success.startup.resolve(success.ready); + const host = await ready; + expect(host.initializeResult.grantedCapabilities).toEqual([]); + expect(vi.mocked(spawnMspConnection).mock.lastCall?.[0].args).toEqual([ + "serve", + "--disable-shell", + "--disable-write", + "--no-session-log", + ]); + success.shutdown.resolve({ code: 0, signal: null }); + await host.close(); + }); + + it.each([undefined, 2])( + "rejects unsupported envelope version %s and awaits shutdown", + async (version) => { + const fake = mockSpawn(); + const started = createMuseSdkHost({ binaryPath: "muse" }); + let settled = false; + const outcome = started.catch((error: unknown) => { + settled = true; + return error; + }); + fake.startup.resolve({ + ...fake.ready, + initializeResult: { + ...fake.ready.initializeResult, + schema: { fingerprint: "test", version }, + }, + } as unknown as SpawnedMspConnection); + await fake.closing.promise; + expect(fake.close).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + fake.shutdown.resolve({ code: 0, signal: null }); + expect(await outcome).toMatchObject({ + message: "Muse SDK returned an unsupported protocol envelope version.", + }); + }, + ); + + it("does not spawn when already aborted and closes a ready host on later abort", async () => { + const aborted = new AbortController(); + aborted.abort(); + await expect( + createMuseSdkHost({ binaryPath: "muse", signal: aborted.signal }), + ).rejects.toBeDefined(); + expect(spawnMspConnection).not.toHaveBeenCalled(); + const fake = mockSpawn(); + const abort = new AbortController(); + const running = createMuseSdkHost({ binaryPath: "muse", signal: abort.signal }); + fake.startup.resolve(fake.ready); + const host = await running; + abort.abort(); + fake.shutdown.resolve({ code: 0, signal: null }); + await host.close(); + expect(fake.close).toHaveBeenCalledOnce(); + }); + + it.effect( + "keeps Effect startup resources alive until interrupted initialization shuts down", + () => + Effect.gen(function* () { + const fake = mockSpawn(); + let released = false; + const fiber = yield* Effect.forkChild( + createMuseSdkHostEffect({ binaryPath: "muse" }).pipe( + Effect.ensuring( + Effect.sync(() => { + released = true; + }), + ), + ), + ); + yield* Effect.promise(() => fake.initializing.promise); + const interrupted = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild); + yield* Effect.promise(() => fake.closing.promise); + expect(released).toBe(false); + fake.shutdown.resolve({ code: 0, signal: null }); + yield* Fiber.join(interrupted); + expect(released).toBe(true); + expect(fake.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("closes a fulfilled host when interruption wins before acquisition is delivered", () => + Effect.gen(function* () { + const started = pending(); + const startup = pending(); + const closing = pending(); + const shutdown = pending(); + const host = { + close: vi.fn(() => { + closing.resolve(); + return shutdown.promise; + }), + } as unknown as MuseSdkHost; + let released = false; + const fiber = yield* Effect.forkChild( + createMuseSdkHostEffect({ binaryPath: "muse" }, () => { + started.resolve(); + return startup.promise; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + released = true; + }), + ), + ), + ); + yield* Effect.promise(() => started.promise); + startup.resolve(host); + const interrupted = yield* Fiber.interrupt(fiber).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => closing.promise); + expect(released).toBe(false); + shutdown.resolve(); + yield* Fiber.join(interrupted); + expect(released).toBe(true); + expect(host.close).toHaveBeenCalledOnce(); + }), + ); +}); diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcc778d4f77973d25832451b7bcd47f6390342cb --- /dev/null +++ b/packages/provider-muse/src/server/sdk.ts @@ -0,0 +1,163 @@ +// @effect-diagnostics globalTimers:off +// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists. +import { + spawnMspConnection, + type Connection, + type ProcessExit, + type SpawnedMspConnection, +} from "@muse-code/sdk"; +import type { RuntimeMode } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export interface MuseSdkHost { + readonly connection: Pick< + Connection, + | "command" + | "request" + | "mintCommandId" + | "onNotification" + | "onServerRequest" + | "onProtocolError" + | "closed" + >; + readonly initializeResult: Pick; + readonly exited: Promise; + /** The last lines Muse wrote to stderr, for explaining an unexpected exit. */ + readonly stderrTail?: () => ReadonlyArray; + readonly close: () => Promise; +} + +export interface MuseSdkHostOptions { + readonly binaryPath: string; + readonly cwd?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly runtimeMode?: RuntimeMode; + /** Disables shell and filesystem writes and workspace trust. */ + readonly readOnly?: boolean; + /** Read-only generation needs durable logging for turn/item notifications (verified through 1.1.1). */ + readonly sessionLogging?: boolean; + readonly signal?: AbortSignal; + readonly startupTimeoutMs?: number; +} + +/** Preserve the host's CLI login while excluding the API-key override before copying values. */ +export function makeMuseEnvironment( + environment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { + ...Object.fromEntries( + Object.keys(environment) + .filter((key) => key.toUpperCase() !== "META_API_KEY") + .map((key) => [key, environment[key]]), + ), + MUSE_NO_AUTO_UPDATE: "1", + }; +} + +export function museApprovalMode(runtimeMode: RuntimeMode) { + return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched"; +} + +/** The `muse serve` arguments for a host: read-only hosts get no shell, writes, or workspace trust. */ +export function museServeArgs( + options: Pick, +) { + const args = ["serve"]; + if (options.readOnly) { + args.push("--disable-shell", "--disable-write"); + if (options.sessionLogging !== true) args.push("--no-session-log"); + } else { + args.push("--trust-workspace"); + if (options.runtimeMode === "full-access") args.push("--disable-sandbox"); + } + return args; +} + +/** What T3 sends in MSP `initialize`; only full hosts ask for session MCP servers. */ +export function museInitializeParams(readOnly = false) { + return { + clientInfo: { name: "t3_code", title: "T3 Code", version: "1" }, + capabilities: { requestedCapabilities: readOnly ? [] : ["sessionMcp"] }, + }; +} + +/** One SDK-owned process. The pre-handshake handle owns cleanup even when initialize never replies. */ +export async function createMuseSdkHost( + options: MuseSdkHostOptions, + spawn: typeof spawnMspConnection = spawnMspConnection, +): Promise { + options.signal?.throwIfAborted(); + const handshake = spawn({ + command: options.binaryPath, + args: museServeArgs(options), + ...(options.cwd ? { cwd: options.cwd } : {}), + // Callers pass an environment already built with makeMuseEnvironment. + env: options.environment ?? makeMuseEnvironment(), + // A healthy host exits at once; this only bounds a hung one on close. + shutdownTimeoutMs: 10_000, + }); + let closePromise: Promise | undefined; + const close = () => { + options.signal?.removeEventListener("abort", onAbort); + return (closePromise ??= handshake.close().then(() => undefined)); + }; + let rejectStartup: (reason: unknown) => void = () => {}; + const onAbort = () => { + rejectStartup(options.signal?.reason ?? new Error("Muse SDK startup aborted.")); + void close().catch(() => {}); + }; + const interrupted = new Promise((_resolve, reject) => { + rejectStartup = reject; + }); + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + const timer = setTimeout( + () => rejectStartup(new Error("Muse SDK initialization timed out.")), + options.startupTimeoutMs ?? 20_000, + ); + timer.unref(); + try { + const host = await Promise.race([ + handshake.initialize(museInitializeParams(options.readOnly)), + interrupted, + ]); + if (host.initializeResult.schema?.version !== 1) { + throw new Error("Muse SDK returned an unsupported protocol envelope version."); + } + // The SDK's fingerprint warning permits additive optional schema changes. + // Successful initialization, not fingerprint identity, determines readiness. + return { + connection: host.connection, + initializeResult: host.initializeResult, + exited: host.exited, + stderrTail: () => handshake.child.stderrTail, + close, + }; + } catch (error) { + await close(); + throw error; + } finally { + clearTimeout(timer); + } +} + +/** Cancellation waits for native shutdown before callers release startup resources. */ +export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(function* ( + options: Omit, + createHost: typeof createMuseSdkHost = createMuseSdkHost, +) { + let startup: Promise | undefined; + return yield* Effect.tryPromise((signal) => { + startup = createHost({ ...options, signal }); + return startup; + }).pipe( + Effect.onInterrupt(() => + Effect.promise(async () => { + await startup?.then( + (host) => host.close(), + () => {}, + ); + }), + ), + ); +}); diff --git a/packages/provider-muse/src/server/status.test.ts b/packages/provider-muse/src/server/status.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f7031de0ad7b6b20fe061603edf0ac43bae0045 --- /dev/null +++ b/packages/provider-muse/src/server/status.test.ts @@ -0,0 +1,167 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { MuseSettings } from "../settings.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; + +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; +import type { MuseSdkHost } from "./sdk.ts"; +import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe"; +import { checkMuseProviderStatus } from "./status.ts"; + +const settings = Schema.decodeSync(MuseSettings); +const makeHost = (catalog: Record) => { + const host: MuseSdkHost = { + initializeResult: { grantedCapabilities: [] }, + connection: { + request: vi.fn(async () => catalog), + command: vi.fn(async () => ({})), + mintCommandId: () => "test-command", + onNotification: () => {}, + onServerRequest: () => {}, + onProtocolError: () => {}, + closed: new Promise(() => {}), + }, + exited: new Promise(() => {}), + close: vi.fn(async () => {}), + }; + return host; +}; +const metaCatalog = { + providerId: "meta", + models: [ + { + modelId: "muse-discovered", + displayLabel: "Muse Discovered", + providerId: "meta", + isDefault: true, + }, + { + modelId: "foreign-model", + displayLabel: "Another provider", + providerId: "another", + isDefault: false, + }, + ], +}; + +const fakeCli = (source = 'process.stdout.write("muse 1.0.3-R2198.1\\n");') => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "muse-status-test-" }); + return writeFakeCli({ directory, name: "muse", source }); + }); + +it.layer(NodeServices.layer)("Muse status", (it) => { + it.effect("does not start a host when disabled", () => + Effect.gen(function* () { + const createHost = vi.fn(async () => makeHost(metaCatalog)); + const snapshot = yield* checkMuseProviderStatus(settings({}), {}, undefined, createHost); + expect(snapshot.status).toBe("disabled"); + expect(createHost).not.toHaveBeenCalled(); + }), + ); + + it.effect("gives host-local install and login guidance for a missing binary", () => + Effect.gen(function* () { + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath: "/definitely-missing/muse" }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("muse login"); + expect(snapshot.message).toContain("this T3 server host"); + }), + ); + + it.effect("does not expose CLI stderr or start an SDK host after a failed version check", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeCli( + 'process.stderr.write("secret-test-value");process.exit(2);', + ); + const createHost = vi.fn(async () => makeHost(metaCatalog)); + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath }), + undefined, + undefined, + createHost, + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).not.toContain("secret-test-value"); + expect(createHost).not.toHaveBeenCalled(); + }), + ), + ); + + it.effect("discovers the Meta catalog without treating it as authentication proof", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeCli(); + const host = makeHost(metaCatalog); + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath, customModels: ["muse-custom"] }), + undefined, + undefined, + async () => host, + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.message).toBeUndefined(); + expect(snapshot.slashCommands).toEqual([COMPACT_SLASH_COMMAND]); + expect(snapshot.auth).toEqual({ status: "unknown" }); + expect(snapshot.version).toBe("1.0.3-R2198.1"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "muse-discovered", + "muse-custom", + ]); + const descriptor = snapshot.models[0]?.capabilities?.optionDescriptors?.[0]; + expect( + descriptor?.type === "select" && descriptor.options.map((option) => option.id), + ).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(host.connection.request).toHaveBeenCalledWith("model/list", {}); + expect(host.connection.command).not.toHaveBeenCalled(); + expect(host.close).toHaveBeenCalledOnce(); + }), + ), + ); + + it.effect("closes the host after malformed metadata and reports unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeCli(); + const host = makeHost({ providerId: "meta", models: [{ modelId: 1 }] }); + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath }), + undefined, + undefined, + async () => host, + ); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.models).toEqual([]); + expect(host.close).toHaveBeenCalledOnce(); + }), + ), + ); + + it.effect("keeps an empty discovered catalog empty instead of advertising a fallback model", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeCli(); + const host = makeHost({ providerId: "meta", models: [] }); + const snapshot = yield* checkMuseProviderStatus( + settings({ enabled: true, binaryPath }), + undefined, + undefined, + async () => host, + ); + expect(snapshot.status).toBe("warning"); + expect(snapshot.models).toEqual([]); + expect(host.close).toHaveBeenCalledOnce(); + }), + ), + ); +}); diff --git a/packages/provider-muse/src/server/status.ts b/packages/provider-muse/src/server/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cb4e5360ff2572ffe97755c5b39f326c6af2a91 --- /dev/null +++ b/packages/provider-muse/src/server/status.ts @@ -0,0 +1,154 @@ +import { ServerProviderModel } from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess } from "effect/process"; + +import { createMuseSdkHost, makeMuseEnvironment } from "./sdk.ts"; +import { parseMuseVersion } from "./maintenance.ts"; +import { discoverMuseModels, museModelCapabilities } from "./modelCatalog.ts"; +import { + buildServerProvider, + COMPACT_SLASH_COMMAND, + DEFAULT_TIMEOUT_MS, + isCommandMissingCause, + providerModelsFromSettings, + spawnAndCollect, + type ProviderProbeResult, +} from "@t3tools/provider-core/server/snapshotProbe"; + +const MUSE_PRESENTATION = { + displayName: "Muse Code", + showInteractionModeToggle: false, + reportsContextWindow: true, + // Muse has no native "accept edits" or reviewer-backed mode, so T3 offers only + // the two it maps directly: promptUnmatched and allowAll. + supportedRuntimeModes: ["approval-required", "full-access"], +} as const; + +const FALLBACK_CAPABILITIES = museModelCapabilities(); + +export const makePendingMuseProvider = Effect.fn("makePendingMuseProvider")(function* ( + settings: MuseSettings, +) { + return buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: settings.enabled, + checkedAt: DateTime.formatIso(yield* DateTime.now), + models: providerModelsFromSettings([], settings.customModels, FALLBACK_CAPABILITIES), + slashCommands: settings.enabled ? [COMPACT_SLASH_COMMAND] : [], + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: settings.enabled + ? "Checking Muse Code CLI availability..." + : "Muse Code is disabled in T3 Code settings.", + }, + }); +}); + +export const checkMuseProviderStatus = Effect.fn("checkMuseProviderStatus")(function* ( + settings: MuseSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, + createHost: typeof createMuseSdkHost = createMuseSdkHost, +) { + if (!settings.enabled) return yield* makePendingMuseProvider(settings); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + // The driver passes a prepared environment; direct callers get the host's without META_API_KEY. + const museEnvironment = environment ?? makeMuseEnvironment(); + const snapshot = (probe: ProviderProbeResult, models: ReadonlyArray = []) => + buildServerProvider({ + presentation: MUSE_PRESENTATION, + enabled: true, + checkedAt, + models: providerModelsFromSettings(models, settings.customModels, FALLBACK_CAPABILITIES), + slashCommands: [COMPACT_SLASH_COMMAND], + probe, + }); + const versionResult = yield* Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(settings.binaryPath, ["--version"], { + env: museEnvironment, + }); + return yield* spawnAndCollect( + settings.binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: museEnvironment, + shell: spawnCommand.shell, + ...(cwd ? { cwd } : {}), + }), + ); + }).pipe(Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(versionResult)) { + const missing = isCommandMissingCause(versionResult.failure); + return snapshot({ + installed: !missing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missing + ? "Muse Code CLI (`muse`) was not found. Install Muse Code and run `muse login` on this T3 server host." + : "Failed to execute Muse Code CLI. Check its binary path on this T3 server host.", + }); + } + if (Option.isNone(versionResult.success)) { + return snapshot({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Muse Code CLI version check timed out.", + }); + } + const versionOutput = versionResult.success.value; + const version = parseMuseVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return snapshot({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Muse Code CLI is installed but failed to run.", + }); + } + + const catalog = yield* discoverMuseModels(settings, museEnvironment, cwd, createHost).pipe( + Effect.scoped, + Effect.timeoutOption(12_000), + Effect.result, + ); + if (Result.isFailure(catalog) || Option.isNone(catalog.success)) { + return snapshot({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: + "Muse Code SDK could not read the model catalog. Check your Muse installation and run `muse login` on this T3 server host.", + }); + } + const models = catalog.success.value; + // MSP has no auth signal, and model/list answers from Muse's cache even when logged out. + // Auth failures surface on the first turn instead. + return snapshot( + { + installed: true, + version, + auth: { status: "unknown" }, + ...(models.length > 0 + ? { status: "ready" } + : { + status: "warning", + message: + "Muse Code returned no models. Run `muse login` on this T3 server host and refresh its status.", + }), + }, + models, + ); +}); diff --git a/packages/provider-muse/src/server/textGeneration.test.ts b/packages/provider-muse/src/server/textGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..41e84dae23ecc6e64bca809ccfcf5b36d5b85419 --- /dev/null +++ b/packages/provider-muse/src/server/textGeneration.test.ts @@ -0,0 +1,372 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import type { NotificationHandler } from "@muse-code/sdk"; +import { + MUSE_DEFAULT_MODEL, + ProviderInstanceId, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { MuseSettings } from "../settings.ts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; + +import type { MuseSdkHost } from "./sdk.ts"; +import { museModelCapabilities } from "./modelCatalog.ts"; +import { makeMuseTextGeneration } from "./textGeneration.ts"; + +const settings = Schema.decodeSync(MuseSettings)({ enabled: true }); +const modelSelection = createModelSelection( + ProviderInstanceId.make("muse"), + "muse-spark-1.3-contributor", + [{ id: "reasoningEffort", value: "high" }], +); +const titleInput = { + cwd: "/project/should-not-be-used", + message: "Fix the login form", + modelSelection, +}; + +function fixture( + onTurn?: (emit: (method: string, params: Record) => void) => void, + acknowledgeTurn = true, + modelCatalog?: Effect.Effect>, +) { + let handler: NotificationHandler = () => {}; + let commandSequence = 0; + let activeTurnId: string | undefined; + const mintCommandId = () => `turn-${++commandSequence}`; + const started = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const emit = (method: string, params: Record) => + handler({ + jsonrpc: "2.0", + method, + params: { + sessionId: "session-1", + turnId: activeTurnId, + ...params, + ...(params.item + ? { + item: { + revision: 1, + turnId: activeTurnId, + ...(params.item as Record), + }, + } + : {}), + }, + }); + const host: MuseSdkHost = { + initializeResult: { grantedCapabilities: [] }, + connection: { + command: vi.fn(async (method, _params, options) => { + // Connection.command overwrites params.commandId unless supplied in options. + const commandId = options?.commandId ?? mintCommandId(); + if (method === "session/start") return { session: { sessionId: "session-1" } }; + if (method === "turn/start") { + activeTurnId = commandId; + started.resolve(); + onTurn?.(emit); + if (!acknowledgeTurn) return await new Promise>(() => {}); + return { turnId: commandId }; + } + return {}; + }), + request: vi.fn(async () => ({})), + mintCommandId, + onNotification: (nextHandler) => { + handler = nextHandler; + }, + onProtocolError: () => {}, + onServerRequest: () => {}, + closed: closed.promise, + }, + close: vi.fn(async () => { + closed.resolve(); + }), + exited: new Promise(() => {}), + }; + const createHost = vi.fn(async () => host); + const make = makeMuseTextGeneration(settings, { + createHost, + ...(modelCatalog ? { modelCatalog } : {}), + }); + return { + host, + createHost, + started: started.promise, + closeConnection: closed.resolve, + emit, + make, + }; +} + +const finish = + (text: string) => (emit: (method: string, params: Record) => void) => { + emit("item/updated", { item: { itemId: "answer", kind: "agentMessage", text: "partial" } }); + emit("item/completed", { + item: { itemId: "answer", kind: "agentMessage", revision: 2, text }, + }); + emit("turn/completed", { terminal: "completed" }); + }; + +it.layer(NodeServices.layer)("Muse text generation", (it) => { + it.effect("validates the final item once and closes an isolated restrictive host", () => + Effect.gen(function* () { + const test = fixture(finish('{"title":"Fix login form"}')); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput)).toEqual({ + title: "Fix login form", + }); + expect(test.createHost).toHaveBeenCalledWith( + expect.objectContaining({ readOnly: true, sessionLogging: true }), + ); + expect(test.createHost).not.toHaveBeenCalledWith( + expect.objectContaining({ cwd: titleInput.cwd }), + ); + expect(test.host.connection.command).toHaveBeenCalledWith( + "session/start", + expect.objectContaining({ + providerId: "meta", + modelId: modelSelection.model, + approvalMode: "denyUnmatched", + }), + ); + expect(test.host.connection.command).toHaveBeenCalledWith( + "turn/start", + expect.objectContaining({ + reasoningEffort: "high", + }), + { commandId: "turn-1" }, + ); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("uses low effort by default and normalizes stale efforts against the model", () => + Effect.gen(function* () { + for (const { saved, variants, expected } of [ + { saved: undefined, variants: undefined, expected: "low" }, + { saved: undefined, variants: ["medium", "xhigh"], expected: "medium" }, + { saved: "max", variants: ["minimal", "max"], expected: "max" }, + { saved: "ultra", variants: ["medium", "xhigh"], expected: "medium" }, + { saved: "high", variants: [], expected: undefined }, + ]) { + const test = fixture( + finish('{"title":"Fix login form"}'), + true, + Effect.succeed([ + { + slug: modelSelection.model, + name: modelSelection.model, + isCustom: false, + capabilities: museModelCapabilities(variants ? { variants } : undefined), + }, + ]), + ); + const service = yield* test.make; + yield* service.generateThreadTitle({ + ...titleInput, + modelSelection: createModelSelection( + ProviderInstanceId.make("muse"), + modelSelection.model, + saved !== undefined ? [{ id: "reasoningEffort", value: saved }] : undefined, + ), + }); + const turn = vi + .mocked(test.host.connection.command) + .mock.calls.find(([method]) => method === "turn/start"); + expect(turn?.[1].reasoningEffort).toBe(expected); + expect(Object.hasOwn(turn?.[1] ?? {}, "reasoningEffort")).toBe(expected !== undefined); + } + }), + ); + + it.effect("lets Muse choose the model when none is selected", () => + Effect.gen(function* () { + for (const model of [MUSE_DEFAULT_MODEL, " "]) { + const test = fixture(finish('{"title":"Fix login form"}')); + const service = yield* test.make; + yield* service.generateThreadTitle({ + ...titleInput, + modelSelection: createModelSelection(ProviderInstanceId.make("muse"), model), + }); + const start = vi + .mocked(test.host.connection.command) + .mock.calls.find(([method]) => method === "session/start"); + expect(start?.[1]).not.toHaveProperty("modelId"); + } + }), + ); + + it.effect("rejects invalid structured output and releases the host", () => + Effect.gen(function* () { + const test = fixture(finish('{"title":42}')); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ + _tag: "TextGenerationError", + detail: "Muse returned invalid structured output.", + }); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("ignores output and terminal notifications for another turn", () => + Effect.gen(function* () { + const test = fixture((emit) => { + emit("item/completed", { + item: { + itemId: "other-answer", + kind: "agentMessage", + turnId: "another-turn", + text: '{"title":"Wrong title"}', + }, + }); + emit("turn/completed", { turnId: "another-turn", terminal: "completed" }); + finish('{"title":"Fix login form"}')(emit); + }); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); + expect(test.host.connection.command).toHaveBeenCalledWith( + "turn/start", + expect.objectContaining({ sessionId: "session-1" }), + { commandId: "turn-1" }, + ); + }), + ); + + it.effect("rejects a truncated response even when its prefix contains valid JSON", () => + Effect.gen(function* () { + const test = fixture((emit) => { + emit("item/completed", { + item: { + itemId: "answer", + kind: "agentMessage", + text: '{"title":"Incomplete answer"}', + truncated: true, + }, + }); + emit("turn/completed", { terminal: "completed" }); + }); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ + _tag: "TextGenerationError", + }); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("settles a completed turn even when its start acknowledgement never arrives", () => + Effect.gen(function* () { + const test = fixture(finish('{"title":"Fix login form"}'), false); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput)).toEqual({ title: "Fix login form" }); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("reports a failed turn without trying another provider", () => + Effect.gen(function* () { + const test = fixture((emit) => + emit("turn/completed", { + terminal: "failed", + error: { message: "Authentication required. Run muse login." }, + }), + ); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ + _tag: "TextGenerationError", + }); + expect(test.createHost).toHaveBeenCalledOnce(); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("settles when the transport closes before completion", () => + Effect.gen(function* () { + const test = fixture(); + const service = yield* test.make; + const pending = yield* service + .generateThreadTitle(titleInput) + .pipe(Effect.flip, Effect.forkChild); + yield* Effect.promise(() => test.started); + test.closeConnection(); + expect(yield* Fiber.join(pending)).toMatchObject({ _tag: "TextGenerationError" }); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("releases the host when interrupted after the turn starts", () => + Effect.gen(function* () { + const test = fixture(); + const service = yield* test.make; + const fiber = yield* service.generateThreadTitle(titleInput).pipe(Effect.forkChild); + yield* Effect.promise(() => test.started); + yield* Fiber.interrupt(fiber); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("denies approval requests and fails the noninteractive request", () => + Effect.gen(function* () { + const test = fixture((emit) => + emit("approval/requested", { + approvalId: "approval-1", + currentRequirementId: "requirement-1", + availableChoices: [{ choiceId: "deny-1", decision: "denied" }], + }), + ); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ + _tag: "TextGenerationError", + }); + expect(test.host.connection.command).toHaveBeenCalledWith("approval/decide", { + sessionId: "session-1", + approvalId: "approval-1", + requirementId: "requirement-1", + choiceId: "deny-1", + }); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("cancels user input requests", () => + Effect.gen(function* () { + const test = fixture((emit) => emit("userInput/requested", { userInputId: "input-1" })); + const service = yield* test.make; + expect(yield* service.generateThreadTitle(titleInput).pipe(Effect.flip)).toMatchObject({ + _tag: "TextGenerationError", + }); + expect(test.host.connection.command).toHaveBeenCalledWith( + "userInput/cancel", + expect.objectContaining({ + userInputId: "input-1", + }), + ); + expect(test.host.close).toHaveBeenCalledOnce(); + }), + ); + + it.effect("generates and sanitizes commit content through Muse", () => + Effect.gen(function* () { + const test = fixture( + finish('{"subject":"Fix login.","body":" Details ","branch":"fix-login"}'), + ); + const service = yield* test.make; + const generated = yield* service.generateCommitMessage({ + cwd: titleInput.cwd, + branch: "main", + stagedSummary: "login.ts", + stagedPatch: "login change", + includeBranch: true, + modelSelection, + }); + expect(generated.subject).toBe("Fix login"); + expect(generated.body).toBe("Details"); + expect(generated.branch).toContain("fix-login"); + }), + ); +}); diff --git a/packages/provider-muse/src/server/textGeneration.ts b/packages/provider-muse/src/server/textGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a13cf30260c83c5f16e9ff71e9017acc45b8886 --- /dev/null +++ b/packages/provider-muse/src/server/textGeneration.ts @@ -0,0 +1,350 @@ +import { + DEFAULT_TEXT_GENERATION_REASONING_EFFORT, + MUSE_DEFAULT_MODEL, + type ModelSelection, + type ServerProviderModel, + TextGenerationError, +} from "@t3tools/contracts"; +import type { MuseSettings } from "../settings.ts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { createMuseSdkHost, createMuseSdkHostEffect, type MuseSdkHost } from "./sdk.ts"; +import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "@t3tools/provider-core/server/textGenerationPrompts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "@t3tools/provider-core/server/textGenerationUtils"; + +const SessionStarted = Schema.Struct({ session: Schema.Struct({ sessionId: Schema.String }) }); +const ItemNotification = Schema.Struct({ + sessionId: Schema.String, + item: Schema.Struct({ + itemId: Schema.String, + kind: Schema.String, + revision: Schema.Int, + turnId: Schema.optionalKey(Schema.String), + text: Schema.optionalKey(Schema.String), + retracted: Schema.optionalKey(Schema.Boolean), + truncated: Schema.optionalKey(Schema.Boolean), + }), +}); +const TurnCompleted = Schema.Struct({ + sessionId: Schema.String, + turnId: Schema.String, + terminal: Schema.String, + error: Schema.optionalKey(Schema.Struct({ message: Schema.String })), +}); +const ApprovalRequested = Schema.Struct({ + sessionId: Schema.String, + approvalId: Schema.String, + currentRequirementId: Schema.Unknown, + availableChoices: Schema.Array( + Schema.Struct({ choiceId: Schema.String, decision: Schema.String }), + ), +}); +const UserInputRequested = Schema.Struct({ + sessionId: Schema.String, + userInputId: Schema.String, +}); +const decodeSession = Schema.decodeUnknownSync(SessionStarted); +const decodeItem = Schema.decodeUnknownSync(ItemNotification); +const decodeCompleted = Schema.decodeUnknownSync(TurnCompleted); +const decodeApproval = Schema.decodeUnknownSync(ApprovalRequested); +const decodeUserInput = Schema.decodeUnknownSync(UserInputRequested); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +type Operation = keyof ProviderTextGeneration; + +async function generateMuseText( + host: MuseSdkHost, + workspaceRoot: string, + prompt: string, + modelId: string | undefined, + reasoningEffort: string | undefined, + signal: AbortSignal, +) { + let sessionId: string | undefined; + const turnId = host.connection.mintCommandId(); + const messages = new Map(); + let resolveCompletion: (text: string) => void = () => {}; + let rejectCompletion: (error: unknown) => void = () => {}; + const completion = new Promise((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + // Notifications may settle the turn before the turn/start acknowledgement arrives. + void completion.catch(() => {}); + const rejectInteractive = async (method: string, params: Record) => { + if (method === "approval/requested") { + const approval = decodeApproval(params); + if (approval.sessionId !== sessionId) return; + rejectCompletion(new Error("Muse requested an interactive action during text generation.")); + const deny = approval.availableChoices.find((choice) => choice.decision === "denied"); + if (deny) { + await host.connection.command("approval/decide", { + sessionId, + approvalId: approval.approvalId, + requirementId: approval.currentRequirementId, + choiceId: deny.choiceId, + }); + } + } else { + const request = decodeUserInput(params); + if (request.sessionId !== sessionId) return; + rejectCompletion(new Error("Muse requested an interactive action during text generation.")); + await host.connection.command("userInput/cancel", { + sessionId, + userInputId: request.userInputId, + reason: "T3 Code text generation cannot request user input.", + }); + } + }; + host.connection.onNotification((notification) => { + try { + if (notification.method === "item/completed" || notification.method === "item/updated") { + const { item, sessionId: itemSessionId } = decodeItem(notification.params); + if (itemSessionId !== sessionId || item.turnId !== turnId || item.kind !== "agentMessage") + return; + const previous = messages.get(item.itemId); + if (!previous || item.revision > previous.revision) messages.set(item.itemId, item); + } else if (notification.method === "turn/completed") { + const completed = decodeCompleted(notification.params); + if (completed.sessionId !== sessionId || completed.turnId !== turnId) return; + if (completed.terminal === "completed") { + // A turn may include commentary before its final structured answer. + const answer = [...messages.values()].findLast((item) => !item.retracted); + if (answer?.truncated) { + rejectCompletion(new Error("Muse text generation returned a truncated response.")); + } else { + resolveCompletion(answer?.text ?? ""); + } + } else { + rejectCompletion( + new Error(completed.error?.message ?? `Muse turn ${completed.terminal}.`), + ); + } + } else if ( + notification.method === "approval/requested" || + notification.method === "userInput/requested" + ) { + void rejectInteractive(notification.method, notification.params ?? {}).catch( + rejectCompletion, + ); + } else if (notification.method === "view/gap") { + rejectCompletion(new Error("Muse text generation lost part of its response.")); + } + } catch (error) { + rejectCompletion(error); + } + }); + host.connection.onProtocolError(rejectCompletion); + host.connection.onServerRequest(async () => { + const error = new Error("T3 Code text generation cannot approve interactive requests."); + rejectCompletion(error); + throw error; + }); + void host.connection.closed.then(() => { + rejectCompletion(new Error("Muse connection closed before text generation completed.")); + }); + void host.exited.then(() => { + rejectCompletion(new Error("Muse exited before text generation completed.")); + }); + const onAbort = () => rejectCompletion(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + try { + signal.throwIfAborted(); + const started = decodeSession( + await host.connection.command("session/start", { + workspaceRoot, + providerId: "meta", + ...(modelId ? { modelId } : {}), + approvalMode: "denyUnmatched", + }), + ); + sessionId = started.session.sessionId; + const acknowledged = host.connection.command( + "turn/start", + { + sessionId, + input: [{ type: "text", text: prompt }], + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + }, + { commandId: turnId }, + ); + return await Promise.race([acknowledged.then(() => completion), completion]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +export const makeMuseTextGeneration = Effect.fn("makeMuseTextGeneration")(function* ( + settings: MuseSettings, + options: { + readonly environment?: NodeJS.ProcessEnv; + readonly modelCatalog?: Effect.Effect>; + /** Test hook. */ + readonly createHost?: typeof createMuseSdkHost; + } = {}, +) { + const { environment, modelCatalog = Effect.succeed([]), createHost } = options; + const fileSystem = yield* FileSystem.FileSystem; + const runMuseJson = Effect.fn("runMuseJson")(function* (input: { + operation: Operation; + prompt: string; + outputSchema: S; + modelSelection: ModelSelection; + }): Effect.fn.Return { + const { operation } = input; + if (!settings.enabled) { + return yield* new TextGenerationError({ operation, detail: "Muse Code is disabled." }); + } + const selectedModel = input.modelSelection.model.trim(); + const models = yield* modelCatalog; + // "default" is the catalog's default model, as in chat. Without one, Muse picks. + const catalogModel = + selectedModel && selectedModel !== MUSE_DEFAULT_MODEL + ? models.find((model) => model.slug === selectedModel) + : models.find((model) => model.isDefault && !model.isCustom); + const modelId = + selectedModel && selectedModel !== MUSE_DEFAULT_MODEL ? selectedModel : catalogModel?.slug; + const reasoningEffort = resolveMuseReasoningEffort( + catalogModel?.capabilities ?? museModelCapabilities(), + getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") ?? + DEFAULT_TEXT_GENERATION_REASONING_EFFORT, + ); + const jsonSchema = yield* encodeJson(toJsonSchemaObject(input.outputSchema)).pipe( + Effect.mapError( + (cause) => new TextGenerationError({ operation, detail: "Invalid output schema.", cause }), + ), + ); + const response = yield* Effect.gen(function* () { + // Metadata requests already include their context and do not need checkout configuration. + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-muse-text-" }); + const host = yield* Effect.acquireRelease( + createMuseSdkHostEffect( + { + binaryPath: settings.binaryPath, + cwd, + ...(environment ? { environment } : {}), + readOnly: true, + // Muse derives SDK turn events from its session log, including completion. + sessionLogging: true, + }, + createHost, + ), + (host: MuseSdkHost) => Effect.promise(() => host.close()), + { interruptible: true }, + ); + return yield* Effect.tryPromise((signal) => + generateMuseText( + host, + cwd, + `${input.prompt}\n\nReturn only a JSON object matching this schema, with no markdown fences. Do not use tools or ask questions.\n${jsonSchema}`, + modelId, + reasoningEffort, + signal, + ), + ); + }).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: + "Muse Code text generation failed. Check Muse login and availability on this T3 server host.", + cause, + }), + ), + Effect.timeoutOption(180_000), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new TextGenerationError({ operation, detail: "Muse request timed out." })), + onSome: Effect.succeed, + }), + ), + ); + const decodeOutput = Schema.decodeEffect(input.outputSchema); + return yield* decodeJson(extractJsonObject(response)).pipe( + Effect.flatMap(decodeOutput), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Muse returned invalid structured output.", + cause, + }), + ), + ); + }); + + const generateCommitMessage: ProviderTextGeneration["generateCommitMessage"] = Effect.fn( + "MuseTextGeneration.generateCommitMessage", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generateCommitMessage", + ...buildCommitMessagePrompt({ ...input, includeBranch: input.includeBranch === true }), + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + const generatePrContent: ProviderTextGeneration["generatePrContent"] = Effect.fn( + "MuseTextGeneration.generatePrContent", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generatePrContent", + ...buildPrContentPrompt(input), + modelSelection: input.modelSelection, + }); + return { title: sanitizePrTitle(generated.title), body: generated.body.trim() }; + }); + const generateBranchName: ProviderTextGeneration["generateBranchName"] = Effect.fn( + "MuseTextGeneration.generateBranchName", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generateBranchName", + ...buildBranchNamePrompt(input), + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + const generateThreadTitle: ProviderTextGeneration["generateThreadTitle"] = Effect.fn( + "MuseTextGeneration.generateThreadTitle", + )(function* (input) { + const generated = yield* runMuseJson({ + operation: "generateThreadTitle", + ...buildThreadTitlePrompt(input), + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies ProviderTextGeneration; +}); diff --git a/packages/provider-muse/src/settings.ts b/packages/provider-muse/src/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..85a781dc660897ef3a3dab87a94c9fb98fc28847 --- /dev/null +++ b/packages/provider-muse/src/settings.ts @@ -0,0 +1,36 @@ +/** + * Muse Code instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-muse/settings + */ +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const MuseSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("muse").pipe( + Schema.annotateKey({ + title: "Binary path", + description: + "Path to the Muse Code binary on this environment. Sign in with muse login on that host.", + providerSettingsForm: { placeholder: "muse", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath"] }, +); +export type MuseSettings = typeof MuseSettings.Type; diff --git a/packages/provider-muse/src/testing.ts b/packages/provider-muse/src/testing.ts new file mode 100644 index 0000000000000000000000000000000000000000..810388c5afe984c7d44898409fc9878e998bae7f --- /dev/null +++ b/packages/provider-muse/src/testing.ts @@ -0,0 +1,14 @@ +/** + * Muse Code internals for replay tests and the fixture recorder: the MSP + * launch arguments, initialize parameters, environment, and version parser. + * + * @module provider-muse/testing + */ +export { + makeMuseEnvironment, + museInitializeParams, + museServeArgs, + type MuseSdkHost, + type MuseSdkHostOptions, +} from "./server/sdk.ts"; +export { parseMuseVersion } from "./server/maintenance.ts"; diff --git a/packages/provider-muse/tsconfig.json b/packages/provider-muse/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/provider-muse/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-testing/package.json b/packages/provider-testing/package.json index 35e3941ba87d70c7a70727a64ae577e958cb92f7..39c6c1f644aa29f192ccdc99016011b5585084d1 100644 --- a/packages/provider-testing/package.json +++ b/packages/provider-testing/package.json @@ -18,6 +18,10 @@ "./host": { "types": "./src/host.ts", "import": "./src/host.ts" + }, + "./fakeCli": { + "types": "./src/fakeCli.ts", + "import": "./src/fakeCli.ts" } }, "scripts": { @@ -28,6 +32,7 @@ "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/provider-testing/src/fakeCli.ts b/packages/provider-testing/src/fakeCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..68ab157aed7aecb1a0419a896f67ed92cc17db7f --- /dev/null +++ b/packages/provider-testing/src/fakeCli.ts @@ -0,0 +1,106 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off - synchronous fixture writer used from plain and Effect tests alike. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +export interface FakeCliOptions { + /** Directory the launcher and its stub are written into. */ + readonly directory: string; + /** Command name as callers spawn it, e.g. `codex`. */ + readonly name: string; + /** ES module source the launcher runs with `node`. `process.argv.slice(2)` carries the CLI args. */ + readonly source: string; + /** Environment set for the stub before it runs, on top of the inherited environment. */ + readonly env?: Readonly>; + /** + * Platform the launcher is shaped for. Effect callers should pass the value + * they read from `HostProcessPlatform` so an injected override is honoured; + * defaults to the real host for plain tests. + */ + readonly platform?: NodeJS.Platform; +} + +/** + * Writes a fake CLI whose behaviour lives in a Node stub. On posix the + * launcher is a `#!/bin/sh` script; on Windows it is a `.cmd` shim, since a + * shebang file is not executable there and `resolveSpawnCommand` routes + * `.cmd` through a shell. Returns the launcher path to hand to the code under + * test, which on Windows carries the `.cmd` extension. + */ +export function writeFakeCli(options: FakeCliOptions): string { + NodeFS.mkdirSync(options.directory, { recursive: true }); + const stubPath = NodePath.join(options.directory, `${options.name}-stub.mjs`); + // The environment rides in a JSON sidecar the stub applies to itself, since + // neither sh nor cmd.exe can quote every value (newlines, quotes, `%`) safely. + const envPath = NodePath.join(options.directory, `${options.name}-env.json`); + NodeFS.writeFileSync(envPath, JSON.stringify(options.env ?? {}), "utf8"); + NodeFS.writeFileSync( + stubPath, + [ + 'import { readFileSync as readFakeCliEnv } from "node:fs";', + `Object.assign(process.env, JSON.parse(readFakeCliEnv(${JSON.stringify(envPath)}, "utf8")));`, + options.source, + ].join("\n"), + "utf8", + ); + + if ((options.platform ?? HostProcessPlatform.defaultValue()) === "win32") { + const launcherPath = NodePath.join(options.directory, `${options.name}.cmd`); + NodeFS.writeFileSync( + launcherPath, + ["@echo off", `node "%~dp0${options.name}-stub.mjs" %*`, "exit /b %ERRORLEVEL%", ""].join( + "\r\n", + ), + "utf8", + ); + return launcherPath; + } + + const launcherPath = NodePath.join(options.directory, options.name); + NodeFS.writeFileSync( + launcherPath, + ["#!/bin/sh", `exec node "$(dirname "$0")/${options.name}-stub.mjs" "$@"`, ""].join("\n"), + "utf8", + ); + NodeFS.chmodSync(launcherPath, 0o755); + return launcherPath; +} + +/** + * Stub source that becomes `scriptPath`: after optionally requiring a leading + * argv prefix, it imports the script into its own process so the stub is the + * agent. Nothing sits between the code under test and the mock, so a kill or + * a closed stdin reaches it directly and its exit log is faithful on every + * host. The script must not read `process.argv`; the mock agents are driven + * by environment and stdin. + */ +export function execScriptSource(options: { + readonly scriptPath: string; + readonly expectedArgs?: ReadonlyArray; + /** Tab-separated argv appended here per invocation, for launch-flag assertions. */ + readonly argvLogPath?: string; + /** Wait before handing over, for tests that race a slow startup. */ + readonly delayMs?: number; +}): string { + return [ + 'import { appendFileSync } from "node:fs";', + 'import { pathToFileURL } from "node:url";', + "const args = process.argv.slice(2);", + ...(options.argvLogPath === undefined + ? [] + : [ + `appendFileSync(${JSON.stringify(options.argvLogPath)}, args.join(${JSON.stringify("\t")}) + ${JSON.stringify("\n")});`, + ]), + ...(options.delayMs === undefined + ? [] + : [`Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${options.delayMs});`]), + `const expected = ${JSON.stringify(options.expectedArgs ?? [])};`, + "if (expected.some((value, index) => args[index] !== value)) {", + ' process.stderr.write(`unexpected args: ${args.join(" ")}\\n`);', + " process.exit(11);", + "}", + `await import(pathToFileURL(${JSON.stringify(options.scriptPath)}).href);`, + "", + ].join("\n"); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edc6c3e9a99a562a9dec54c447f3db04179ddf4b..0a4d57f3c47a96e3d76f751df82313856f95f337 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-muse': + specifier: workspace:* + version: link:../../packages/provider-muse '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -603,6 +606,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-muse': + specifier: workspace:* + version: link:../../packages/provider-muse '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -733,6 +739,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-muse': + specifier: workspace:* + version: link:../../packages/provider-muse '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi @@ -1099,6 +1108,40 @@ importers: specifier: 1.0.0 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) + packages/provider-muse: + dependencies: + '@muse-code/sdk': + specifier: 1.4.2 + version: 1.4.2(@types/node@24.12.4) + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + 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) + '@effect/vitest': + specifier: 4.0.1 + 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) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + 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) + packages/provider-pi: dependencies: '@t3tools/contracts': @@ -1141,6 +1184,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.1 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 8fb9ab883995e474cc7f8fe404a2da8114ca8dac..2f39d7a61e93727a4fdde815fc2a0eb7313f6b13 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -31,6 +31,7 @@ const workspaceFiles = [ "packages/provider-core/package.json", "packages/provider-testing/package.json", "packages/provider-pi/package.json", + "packages/provider-muse/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json",