From d826d8109f2a37c50d2ff49d2cba39ba488f1962 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:12:12 +0100 Subject: [PATCH] fix(muse): load models whose effort tiers carry descriptions (#6021) * fix(muse): load models whose effort tiers carry descriptions Muse 1.4.2 sends reasoningEffortVariants as described tiers ({ tier, description? }) and the complete effort list on variants, as the bundled MSP types declare. The catalog schema had the two shapes swapped, so any model with a described tier failed the whole model/list parse: the model picker stayed empty and agent creation failed. Parse both fields as declared, offer the efforts from variants, and show Muse's tier descriptions on the matching options. * fix(muse): leave effort descriptions out until the pickers show them * fix(muse): keep unused catalog metadata outside validation Described effort tiers are presentation metadata, not selectable efforts. Validating their shape blocks both discovery and session startup even though Paseo never consumes them. Normalize the complete variants list at the wire boundary and cover session readiness and selecting max. * fix(muse): align consumed session and event wire fields --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- plugins/muse-provider/server/catalog.ts | 5 +- plugins/muse-provider/server/connection.ts | 6 + plugins/muse-provider/server/session.ts | 28 +-- plugins/muse-provider/server/sessions.ts | 5 +- plugins/muse-provider/server/wire.ts | 22 ++- plugins/muse-provider/test/fake-muse.mjs | 25 +++ plugins/muse-provider/test/provider.test.ts | 204 +++++++++++++++++++- 7 files changed, 265 insertions(+), 30 deletions(-) diff --git a/plugins/muse-provider/server/catalog.ts b/plugins/muse-provider/server/catalog.ts index d4e483ed79..5e603a9eda 100644 --- a/plugins/muse-provider/server/catalog.ts +++ b/plugins/muse-provider/server/catalog.ts @@ -63,10 +63,7 @@ export class Catalog { export function presentCatalog(response: z.infer): ProviderCatalog { const models = response.models.map((model) => { - let supported: z.infer[] = efforts; - if (Array.isArray(model.reasoningEffortVariants) && model.reasoningEffortVariants.length > 0) - supported = model.reasoningEffortVariants; - else if (model.variants.length > 0) supported = model.variants; + const supported = model.variants.length > 0 ? model.variants : efforts; return { id: model.modelId, label: model.displayLabel, diff --git a/plugins/muse-provider/server/connection.ts b/plugins/muse-provider/server/connection.ts index 100bd101c7..f502705f8f 100644 --- a/plugins/muse-provider/server/connection.ts +++ b/plugins/muse-provider/server/connection.ts @@ -170,6 +170,12 @@ export class MspConnection { return; } if (frame.id === undefined) throw new MuseError("invalidFrame", "MSP response has no id"); + if (frame.id === null) { + if (!frame.error) throw new MuseError("invalidFrame", "MSP result has a null id"); + this.fail(new MuseError(frame.error.data?.kind ?? "rpc", frame.error.message)); + this.child.kill(); + return; + } const pending = this.pending.get(frame.id); if (!pending) return; clearTimeout(pending.timer); diff --git a/plugins/muse-provider/server/session.ts b/plugins/muse-provider/server/session.ts index b152361060..7d367fd1ac 100644 --- a/plugins/muse-provider/server/session.ts +++ b/plugins/muse-provider/server/session.ts @@ -66,7 +66,7 @@ interface Live { export class Session { private readonly host: MspConnection; private liveState: Live | undefined; - private providerId = "meta"; + private providerId: string | undefined; private cursor: string | undefined; private config: ProviderSessionConfig; private catalog: ProviderCatalog = { models: [], modes }; @@ -167,11 +167,11 @@ export class Session { }, ), }; - this.providerId = response.session.providerId; + this.providerId = response.session.providerId ?? undefined; this.cursor = response.viewCursor; this.config = { ...this.config, - model: response.session.modelId, + model: response.session.modelId ?? this.config.model, mode: response.session.approvalMode?.mode ?? this.config.mode ?? "onRequest", }; this.options.emit({ @@ -194,14 +194,7 @@ export class Session { for (const notification of this.buffered) this.enqueue(notification); this.buffered.length = 0; await this.notifications; - this.catalog = presentCatalog( - await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), - ); - const selected = this.catalog.models.find((model) => model.id === this.config.model); - this.config = { - ...this.config, - thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, - }; + await this.readCatalog(); await this.live().commands.refresh(); this.publishPersistence(); this.publishConfig(); @@ -229,6 +222,19 @@ export class Session { for (const approval of pending.approvals) await this.permission(approval); for (const question of pending.userInputs) this.live().questions.requested(question); } + private async readCatalog(): Promise { + this.catalog = presentCatalog( + await this.host.request("model/list", { sessionId: this.live().nativeId }, catalogSchema), + ); + const model = this.config.model ?? this.catalog.defaultModel; + const selected = this.catalog.models.find((entry) => entry.id === model); + this.config = { + ...this.config, + model, + thinkingOption: this.config.thinkingOption ?? selected?.defaultThinkingOptionId, + }; + } + async prompt(prompt: ProviderPrompt): Promise { if (await this.live().commands.compact(prompt)) return; const id = commandId(); diff --git a/plugins/muse-provider/server/sessions.ts b/plugins/muse-provider/server/sessions.ts index 93a22cd54b..a10879e248 100644 --- a/plugins/muse-provider/server/sessions.ts +++ b/plugins/muse-provider/server/sessions.ts @@ -38,7 +38,10 @@ export class Sessions { updatedAt: session.updatedAt, persistence: { version: 1, - data: { sessionId: session.sessionId, model: session.modelId }, + data: { + sessionId: session.sessionId, + ...(session.modelId === null ? {} : { model: session.modelId }), + }, }, }); if (input.limit && summaries.length >= input.limit) return summaries; diff --git a/plugins/muse-provider/server/wire.ts b/plugins/muse-provider/server/wire.ts index 4955acdaa5..3513cabfd1 100644 --- a/plugins/muse-provider/server/wire.ts +++ b/plugins/muse-provider/server/wire.ts @@ -87,7 +87,7 @@ export const notificationSchema = z.object({ }); export const frameSchema = z.object({ jsonrpc: z.literal("2.0"), - id: z.union([z.string(), z.number()]).optional(), + id: z.union([z.string(), z.number()]).nullable().optional(), method: z.string().optional(), params: z.unknown().optional(), result: z.unknown().optional(), @@ -115,16 +115,20 @@ export const catalogSchema = z.object({ contextLimit: z.number().nullable(), isDefault: z.boolean(), defaultReasoningEffort: effortSchema.nullable().optional(), - reasoningEffortVariants: z.union([z.array(effortSchema), z.literal("unknown")]).default([]), - variants: z.array(effortSchema).default([]), + // Validate consumed fields only. Muse's described subset (reasoningEffortVariants) + // is presentation metadata; its shape must not gate catalog discovery or session startup. + variants: z + .union([z.array(effortSchema), z.literal("unknown")]) + .transform((variants) => (variants === "unknown" ? [] : variants)) + .default([]), }), ), }); export const sessionSchema = z.object({ session: z.object({ sessionId: z.string(), - modelId: z.string(), - providerId: z.string(), + modelId: z.string().nullable(), + providerId: z.string().nullable(), approvalMode: z.object({ mode: approvalModeSchema }).optional(), }), viewCursor: z.string(), @@ -143,7 +147,11 @@ export const persistenceSchema = z.object({ model: z.string().optional(), thinkingOption: effortSchema.optional(), }); -export const deltaSchema = z.object({ itemId: z.string(), field: z.string(), delta: z.string() }); +export const deltaSchema = z.object({ + itemId: z.string(), + field: z.string().default("text"), + delta: z.string(), +}); export const turnSchema = z.object({ turnId: z.string(), commandId: z.string().optional(), @@ -241,7 +249,7 @@ export const sessionListSchema = z.object({ workspaceRoot: z.string().nullable(), title: z.string().optional(), updatedAt: z.string(), - modelId: z.string(), + modelId: z.string().nullable(), }), ), }); diff --git a/plugins/muse-provider/test/fake-muse.mjs b/plugins/muse-provider/test/fake-muse.mjs index fffca0cc89..65e1b88fcf 100644 --- a/plugins/muse-provider/test/fake-muse.mjs +++ b/plugins/muse-provider/test/fake-muse.mjs @@ -66,6 +66,14 @@ function controlResponse(frame) { process.stderr.write("diagnostic tail, not protocol\n"); process.exit(Number(process.env.MUSE_TEST_EXIT)); } + if (frame.method === "initialize" && process.env.MUSE_TEST_NULL_ERROR_ID) { + send({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Cannot recover request id", data: { kind: "parseError" } }, + }); + return true; + } if (process.env.MUSE_TEST_HANG === frame.method) return true; if (frame.method === "session/setReasoningEffort") { respond(frame, { status: "accepted", commandId: frame.params.commandId }); @@ -138,6 +146,9 @@ function parityResponse(frame) { if (frame.method === "session/list") { const result = responseFor(catalogRows, "session/list"); result.nextCursor = null; + if (process.env.MUSE_TEST_NULL_MODEL) { + for (const session of result.sessions) session.modelId = null; + } respond(frame, result); return true; } @@ -262,6 +273,7 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { return; if (frame.method === "session/start" && message.result?.session?.approvalMode) message.result.session.approvalMode.mode = frame.params.approvalMode; + applyWireVariants(message); if (message.result?.viewCursor && process.env.MUSE_TEST_EMPTY_CURSOR) message.result.viewCursor = ""; applyTestVariants(message); @@ -282,6 +294,19 @@ function emitFixtureMessage(recordedMessage, recorded, frame) { }); } } +function applyWireVariants(message) { + if ( + message.method === "item/delta" && + message.params?.field === "text" && + process.env.MUSE_TEST_IMPLICIT_TEXT_DELTA + ) { + delete message.params.field; + } + if (message.result?.session && process.env.MUSE_TEST_NULL_MODEL) { + message.result.session.modelId = null; + message.result.session.providerId = null; + } +} function parityDelivery(message) { if (process.env.MUSE_TEST_WORKFLOW && message.method === "turn/completed") send({ diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index ffb7c0468e..d19415d544 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -84,6 +84,7 @@ async function harness( async function open( persistence?: Extract["persistence"], mode = "onRequest", + model: string | null = "meta/muse-spark-1.3", ) { await send({ type: "session.open", @@ -97,7 +98,7 @@ async function harness( mcpServers: {}, persist: true, mode, - model: "meta/muse-spark-1.3", + model: model ?? undefined, thinkingOption: "high", systemPrompt: "Test instructions", }, @@ -610,12 +611,15 @@ test("ordinary subagent tool calls render sub_agent details without inventing ch }), ); }); -for (const [variants, reasoningEffortVariants, expected] of [ - [["low", "high"], ["medium"], ["medium"]], - [["low", "high"], "unknown", ["low", "high"]], - [[], [], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], +for (const [variants, expected] of [ + [ + ["low", "high"], + ["low", "high"], + ], + ["unknown", ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], + [[], ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]], ] as const) { - test(`catalogue effort discovery ${JSON.stringify(reasoningEffortVariants)} with variants ${JSON.stringify(variants)}`, async () => { + test(`catalogue effort discovery with variants ${JSON.stringify(variants)}`, async () => { const model = { modelId: "test-model", providerId: "meta", @@ -623,7 +627,6 @@ for (const [variants, reasoningEffortVariants, expected] of [ contextLimit: 123456, isDefault: true, variants, - reasoningEffortVariants, defaultReasoningEffort: "high", }; const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); @@ -639,6 +642,153 @@ for (const [variants, reasoningEffortVariants, expected] of [ }); }); } +test("catalogue loads models whose effort tiers carry descriptions", async () => { + const deepest = "Use this for deepest analysis and complex fixes."; + const row = (modelId: string, isDefault: boolean) => ({ + modelId, + providerId: "meta", + displayLabel: modelId, + contextLimit: 1000000, + isDefault, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: isDefault ? [] : [{ tier: "xhigh", description: deepest }], + }); + const models = [ + row("muse-spark-1.3", true), + row("muse-spark-1.3-contributor", false), + row("muse-spark-1.2", false), + row("muse-spark-1.2-contributor", false), + ]; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify(models) }); + await h.send({ type: "catalog", requestId: "models" }); + const result = await h.wait( + (event) => event.type === "catalog" || event.type === "request.failed", + ); + const offered = [ + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "high" }, + { id: "xhigh" }, + { id: "max", isDefault: true }, + ]; + expect(result).toMatchObject({ + type: "catalog", + catalog: { + defaultModel: "muse-spark-1.3", + models: [ + { id: "muse-spark-1.3", defaultThinkingOptionId: "max" }, + { id: "muse-spark-1.3-contributor", thinkingOptions: offered }, + { id: "muse-spark-1.2", thinkingOptions: offered }, + { id: "muse-spark-1.2-contributor", thinkingOptions: offered }, + ], + }, + }); +}); +for (const [configuredModel, catalogModels, expectedModel] of [ + ["configured-model", [], "configured-model"], + [ + "", + [ + { + modelId: "catalog-model", + providerId: "meta", + displayLabel: "Catalog model", + contextLimit: null, + isDefault: true, + variants: ["high"], + }, + ], + "catalog-model", + ], + ["", [], undefined], +] as const) { + test(`null session model preserves ${expectedModel ?? "unset model"}`, async () => { + const h = await harness("text-reasoning", { + MUSE_TEST_NULL_MODEL: "1", + MUSE_TEST_MODELS: JSON.stringify(catalogModels), + }); + expect(await h.open(undefined, "onRequest", configuredModel || null)).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + const config = await h.wait((event) => event.type === "session.config"); + if (config.type !== "session.config") throw new Error("Expected session config"); + expect(config.config.model).toBe(expectedModel); + const saved = h.events.findLast((event) => event.type === "session.persistence"); + if (!saved) throw new Error("Expected persistence"); + expect(saved.persistence.data.model).toBe(expectedModel); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + }); +} +test("session startup accepts described catalog efforts and publishes the complete list", async () => { + const model = { + modelId: "meta/muse-spark-1.3", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "max", + variants: ["minimal", "low", "medium", "high", "xhigh", "max"], + reasoningEffortVariants: [{ tier: "xhigh", description: "Deepest analysis" }], + }; + const h = await harness("text-reasoning", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + expect(await h.open()).toEqual({ + type: "session.ready", + requestId: "open", + sessionId: "paseo-session", + }); + expect(await h.wait((event) => event.type === "session.config")).toMatchObject({ + config: { + models: [{ id: model.modelId, defaultThinkingOptionId: "max" }], + thinkingOptions: model.variants.map((id) => ({ id, isDefault: id === "max" })), + }, + }); + await h.send({ + type: "session.configure", + requestId: "choose-max", + sessionId: "paseo-session", + changes: { thinkingOption: "max" }, + }); + await h.wait((event) => event.type === "request.completed" && event.requestId === "choose-max"); + await h.prompt(); + await h.wait((event) => event.type === "session.turn" && event.state === "completed"); + expect( + (await h.recorded()).find((frame) => frame.method === "turn/start").params.reasoningEffort, + ).toBe("max"); +}); +for (const metadata of [ + "unknown", + [{ tier: "future-tier", description: { text: "Future metadata" } }], +]) { + test(`catalog ignores unused effort descriptions ${JSON.stringify(metadata)}`, async () => { + const model = { + modelId: "test-model", + providerId: "meta", + displayLabel: "Muse Spark", + contextLimit: null, + isDefault: true, + defaultReasoningEffort: "high", + variants: ["low", "high"], + reasoningEffortVariants: metadata, + }; + const h = await harness("catalog-controls", { MUSE_TEST_MODELS: JSON.stringify([model]) }); + await h.send({ type: "catalog", requestId: "models" }); + expect( + await h.wait((event) => event.type === "catalog" || event.type === "request.failed"), + ).toMatchObject({ + type: "catalog", + catalog: { + models: [ + { id: model.modelId, thinkingOptions: [{ id: "low" }, { id: "high", isDefault: true }] }, + ], + }, + }); + }); +} test("status accepts future credential states and drains its maintenance host", async () => { const h = await harness("catalog-controls", { MUSE_TEST_ACCOUNT: "futureCredential" }); expect(await h.provider.status!({ launch: h.launch })).toEqual({ available: true }); @@ -1073,6 +1223,46 @@ test("resuming a session reapplies its provider options to the new host", async ]); expect(frames.some((f) => f.method === "session/resume")).toBe(true); }); +test("an uncorrelated MSP parse error keeps the provider diagnostic", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_NULL_ERROR_ID: "1" }); + expect(await h.open()).toMatchObject({ + type: "request.failed", + error: { code: "parseError", message: "Cannot recover request id" }, + }); +}); +test("text deltas with an omitted field stream as text", async () => { + const h = await harness("text-reasoning", { MUSE_TEST_IMPLICIT_TEXT_DELTA: "1" }); + await h.open(); + await h.wait((e) => e.type === "session.ready"); + await h.prompt(); + await h.wait((e) => e.type === "session.turn" && e.state === "completed"); + expect( + h.events.some( + (e) => + e.type === "timeline.item" && + e.item.type === "assistant_message" && + e.item.text === "Sum with", + ), + ).toBe(true); +}); +test("sessions with a null model import and resume without a null persistence model", async () => { + const h = await harness("resume-without-cursor", { MUSE_TEST_NULL_MODEL: "1" }); + await h.send({ + type: "sessions", + requestId: "null-list", + cwd: "/tmp/muse-phase0/repo", + limit: 2, + }); + const listed = await h.wait((e) => e.type === "sessions"); + if (listed.type !== "sessions") throw new Error("Expected sessions"); + expect(listed.sessions).toHaveLength(2); + expect(listed.sessions[0]!.persistence.data.model).toBeUndefined(); + await h.open(listed.sessions[0]!.persistence); + await h.wait((e) => e.type === "session.ready"); + expect( + h.events.some((e) => e.type === "session.config" && e.config.model === "meta/muse-spark-1.3"), + ).toBe(true); +}); test("sessions list filters workspace and imported persistence opens via resume", async () => { const h = await harness("resume-without-cursor"); await h.send({