mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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 <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
62f79e88bb
commit
d826d8109f
@@ -63,10 +63,7 @@ export class Catalog {
|
||||
|
||||
export function presentCatalog(response: z.infer<typeof catalogSchema>): ProviderCatalog {
|
||||
const models = response.models.map((model) => {
|
||||
let supported: z.infer<typeof effortSchema>[] = 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (await this.live().commands.compact(prompt)) return;
|
||||
const id = commandId();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -84,6 +84,7 @@ async function harness(
|
||||
async function open(
|
||||
persistence?: Extract<ProviderInput, { type: "session.open" }>["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({
|
||||
|
||||
Reference in New Issue
Block a user