diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d508289 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: set this to true or false +onlyBuiltDependencies: + - esbuild diff --git a/src/config/options.ts b/src/config/options.ts index a5beb5d..a29e394 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -181,6 +181,19 @@ export async function buildModes( }; } +/** + * Canonical display order for thought-level tokens across models + * (GLM-5.3: low/high/max; GLM-5-Turbo: enabled/off; others may differ). + * Unknown tokens keep their config order after the known ones. + */ +const THOUGHT_ORDER = ["low", "medium", "high", "xhigh", "max", "ultra", "enabled", "disabled", "off"]; + +export function orderThoughtVariants(variants: string[]): Array<{ value: string; name: string }> { + const known = THOUGHT_ORDER.filter((t) => variants.includes(t)); + const extra = variants.filter((t) => !THOUGHT_ORDER.includes(t)); + return [...known, ...extra].map((t) => ({ value: t, name: t })); +} + /** Build the ACP configOptions array (3 items: model/mode/thought). * zcodeSid null = pending session — skip the backend read and use defaults; * mode defaults to "yolo" (the mode session/create hardcodes) so the dropdown @@ -194,6 +207,43 @@ export async function buildConfigOptions( let currentMode = zcodeSid === null ? "yolo" : "build"; let currentThought = "high"; let thoughtOptions: Array<{ value: string; name: string }> | null = null; + if (zcodeSid === null) { + // Pending session — no backend to read yet, but the thought vocabulary + // is per model and the runtime's own source of truth is the enabled + // provider's models[].reasoning.variants in the local config. Advertise + // THAT for the default model instead of a hardcoded list: a client that + // relays the options into a picker (Multica's effort selector) would + // otherwise offer tokens the runtime rejects ("nothink" was fiction, + // "low" was missing). + const cur = loadAllModels()[0]; + if (cur) { + // The advertised current model follows the dropdown's leading entry + // (the enabled provider's first model — what the runtime actually + // starts sessions with) rather than the legacy hardcoded "GLM-5.2". + // ACP clients skip a requested model switch when it equals the + // advertised current value, so a stale fiction silently pinned the + // wrong model whenever the requested id happened to match it. + currentProviderId = cur.providerId; + currentModelId = cur.modelId; + try { + const cfg = readConfig() as ConfigShape; + const m = (cfg.provider?.[cur.providerId]?.models as + | Record< + string, + { reasoning?: { enabled?: boolean; variants?: string[]; defaultVariant?: string } } + > + | undefined)?.[cur.modelId]; + const reasoning = m?.reasoning; + const variants = reasoning?.variants; + if (reasoning?.enabled !== false && variants && variants.length > 0) { + thoughtOptions = orderThoughtVariants(variants); + currentThought = reasoning.defaultVariant ?? variants[0]; + } + } catch { + // unreadable config — the static fallback below applies + } + } + } if (zcodeSid !== null) { try { @@ -261,7 +311,12 @@ export async function buildConfigOptions( { id: "thought", name: CONFIG_META.thought.name, - category: "thought" as acp.SessionConfigOptionCategory, + // Category thought_level (not "thought") so ACP clients recognise the + // option as the reasoning-effort selector: the shared matchers in + // editors and orchestrators (e.g. Multica's acpEffortOptionIDs) key on + // id/category "effort"/"thought_level". The id stays "thought" — it is + // what session/set_config_option addresses. + category: "thought_level" as acp.SessionConfigOptionCategory, type: "select", currentValue: currentThought, options: thoughtOptions, diff --git a/src/utils.ts b/src/utils.ts index c44017b..5c0813e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -93,10 +93,14 @@ export const CONFIG_META = { thought: { name: "Thought Level", category: "thought_level", + // Fallback only — the real vocabulary is per model (read from the + // enabled provider's models[].reasoning.variants). These values match + // the default coding-plan model (GLM-5.3): low/high/max, verified + // against the runtime's own session/read. options: [ - { value: "max", name: "max" }, + { value: "low", name: "low" }, { value: "high", name: "high" }, - { value: "nothink", name: "nothink" }, + { value: "max", name: "max" }, ], }, } as const; diff --git a/tests/bugfixes.test.ts b/tests/bugfixes.test.ts index 3a9d25f..721442d 100644 --- a/tests/bugfixes.test.ts +++ b/tests/bugfixes.test.ts @@ -14,7 +14,14 @@ import { EventStreamListener } from "../src/backend/listener.js"; import { ZcodeBackend } from "../src/backend/client.js"; import { ProjectionDiffer } from "../src/translators/projection-differ.js"; import { flattenTodos } from "../src/handlers/session.js"; +import { + buildConfigOptions, + formatModelValue, + loadAllModels, + orderThoughtVariants, +} from "../src/config/options.js"; import { CONFIG_META } from "../src/utils.js"; +import { ZcodeAcpServer } from "../src/server.js"; import type { ZcodeEvent, ZcodeResponse } from "../src/backend/types.js"; /** Build a listener over a fake backend (no subprocess; we drive handleEvent). */ @@ -236,8 +243,11 @@ describe("Bug 3: thought configOption metadata matches Python", () => { it("uses thought_level category, Thought Level name, lowercase option names", () => { expect(CONFIG_META.thought.category).toBe("thought_level"); expect(CONFIG_META.thought.name).toBe("Thought Level"); + // The static fallback matches the default coding-plan model's real + // vocabulary (runtime-verified); the live per-model list comes from the + // enabled provider's reasoning.variants instead of this constant. const names = CONFIG_META.thought.options.map((o) => o.name); - expect(names).toEqual(["max", "high", "nothink"]); + expect(names).toEqual(["low", "high", "max"]); }); it("uses lowercase mode option names", () => { @@ -246,6 +256,50 @@ describe("Bug 3: thought configOption metadata matches Python", () => { }); }); +describe("Bug 6: thought option is discoverable and honest", () => { + it("advertises the spec category thought_level on the pending session", async () => { + // Regression: the category was a bare "thought", which is not one of the + // ACP spec's reserved SessionConfigOptionCategory names (mode/model/ + // model_config/thought_level) — clients keying on the standard tokens + // (effort pickers in editors and orchestrators) could not find the + // reasoning selector at all. + const server = new ZcodeAcpServer(); + const options = await buildConfigOptions(server, null); + const thought = options.find((o) => o.id === "thought"); + expect(thought?.category).toBe("thought_level"); + expect(thought?.options.length).toBeGreaterThan(0); + }); + + it("orders thought variants canonically and keeps unknown tokens last", () => { + expect(orderThoughtVariants(["high", "nothink", "low", "max"])).toEqual([ + { value: "low", name: "low" }, + { value: "high", name: "high" }, + { value: "max", name: "max" }, + { value: "nothink", name: "nothink" }, + ]); + expect(orderThoughtVariants(["turbo", "low"])).toEqual([ + { value: "low", name: "low" }, + { value: "turbo", name: "turbo" }, + ]); + }); + + it("advertises the enabled provider's leading model as the pending current value", async () => { + // Regression: the pending session hardcoded currentValue "GLM-5.2", + // which can disagree with the model the runtime actually starts with + // (the enabled provider's leading model). ACP clients skip a requested + // switch when it matches the advertised current value, so the stale + // fiction silently pinned the wrong model whenever the requested id + // happened to equal it (observed with Paseo's --model GLM-5.2). + const server = new ZcodeAcpServer(); + const options = await buildConfigOptions(server, null); + const model = options.find((o) => o.id === "model"); + const leading = loadAllModels()[0]; + expect(model?.currentValue).toBe(formatModelValue(leading.providerId, leading.modelId)); + // The advertised current value must also be selectable in the dropdown. + expect(model?.options.map((o) => o.value)).toContain(model?.currentValue); + }); +}); + describe("Bug 5: usage fallback treats contextUsed=0 as falsy", () => { it("ProjectionDiffer falls back to totalTokenCount when contextUsed is 0", () => { const d = new ProjectionDiffer(); diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts index b6c398a..1a113cc 100644 --- a/tests/dispatch.test.ts +++ b/tests/dispatch.test.ts @@ -306,7 +306,10 @@ describe("dispatchEvent", () => { configOptions: [ { id: "model", currentValue: "anthropic\\GLM-5.2" }, { id: "mode", currentValue: "plan" }, - { id: "thought", currentValue: "high" }, + // The default thought value is config-derived (per-model + // reasoning.variants of the enabled provider), so it legitimately + // varies with the machine the test runs on. + { id: "thought", currentValue: expect.any(String) }, ], }); expect(sent[1]).toEqual({