From 7dc8833957039e8776ad7cc9bb3138a7adcffe95 Mon Sep 17 00:00:00 2001 From: lianxin255 <39765770+lianxin255@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:42:09 +0800 Subject: [PATCH 1/3] fix: advertise the thought option under category thought_level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thought option carried category "thought", which no client recognises as the reasoning-effort selector — the ACP ecosystem keys on id/category "effort" / "thought_level" (CodeBuddy, jcode, reasonix all use thought_level). Multica's shared matcher is representative: it selects the effort option by those tokens, so the zcode thinking toggle was invisible to effort pickers and a per-agent reasoning level had nowhere to land. The id stays "thought" (that is what session/set_config_option addresses); only the category changes, which is display/marking metadata. Editors that group config options by category now file it with the other thinking controls instead of a bespoke group. Verified through Multica: with the category aligned, the per-agent thinking picker appears (values enabled/disabled verbatim from the advertised options) and set_config_option dispatches to the runtime's session/setThoughtLevel. --- src/config/options.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/config/options.ts b/src/config/options.ts index a5beb5d..49a05d6 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -261,7 +261,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, From 7320e0b43e1488fb41c1672577507dfe017643f4 Mon Sep 17 00:00:00 2001 From: lianxin255 <39765770+lianxin255@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:40:50 +0800 Subject: [PATCH 2/3] fix: derive the pending-session thought vocabulary from the model's reasoning.variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy session/new advertised a hardcoded max/high/nothink list, which was fiction on both ends for the default coding-plan model: the runtime's own session/read for GLM-5.3 offers low/high/max (default max), and it REJECTS nothink with 'Unsupported reasoning effort'. Clients that relay the advertised options into a picker (Multica's effort selector) then offered tokens the runtime refuses and hid ones it accepts. The pending-session path now reads the enabled provider's models[].reasoning.variants for the default model — the same source the runtime itself resolves — with the static fallback corrected to the verified low/high/max. The value advertised for a live session was and remains the backend's session/read. Verified end-to-end through Multica: the per-agent picker now mirrors the real vocabulary (low/high/max for GLM-5.3) and dispatches tokens the runtime accepts; a behavioural A/B on the same task produced 3 reasoning chunks at low vs 18 at max. --- pnpm-workspace.yaml | 4 ++++ src/config/options.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 8 ++++++-- tests/bugfixes.test.ts | 5 ++++- tests/dispatch.test.ts | 5 ++++- 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 pnpm-workspace.yaml 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 49a05d6..1eefefd 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"]; + +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,35 @@ 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) { + 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 { 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..51b1441 100644 --- a/tests/bugfixes.test.ts +++ b/tests/bugfixes.test.ts @@ -236,8 +236,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", () => { 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({ From 88f7f02becfc36a06b929b032fa43d17d61e3bd9 Mon Sep 17 00:00:00 2001 From: lianxin255 <39765770+lianxin255@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:20:49 +0800 Subject: [PATCH 3/3] test: regression coverage for the thought option's discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pending-session buildConfigOptions must advertise the thought option under the spec's thought_level category (the regression the category fix addresses: a bare "thought" category is not one of the reserved SessionConfigOptionCategory names, so effort pickers never found it) - orderThoughtVariants unit tests: canonical ordering with unknown tokens preserved at the end (exported for testability) 461 → 463 tests, all green. --- src/config/options.ts | 2 +- tests/bugfixes.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/config/options.ts b/src/config/options.ts index 1eefefd..0538936 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -188,7 +188,7 @@ export async function buildModes( */ const THOUGHT_ORDER = ["low", "medium", "high", "xhigh", "max", "ultra", "enabled", "disabled", "off"]; -function orderThoughtVariants(variants: string[]): Array<{ value: string; name: string }> { +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 })); diff --git a/tests/bugfixes.test.ts b/tests/bugfixes.test.ts index 51b1441..b69a760 100644 --- a/tests/bugfixes.test.ts +++ b/tests/bugfixes.test.ts @@ -14,7 +14,9 @@ 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, 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). */ @@ -249,6 +251,34 @@ 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" }, + ]); + }); +}); + describe("Bug 5: usage fallback treats contextUsed=0 as falsy", () => { it("ProjectionDiffer falls back to totalTokenCount when contextUsed is 0", () => { const d = new ProjectionDiffer();