Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: set this to true or false
onlyBuiltDependencies:
- esbuild
49 changes: 48 additions & 1 deletion src/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -261,7 +303,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,
Expand Down
8 changes: 6 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 34 additions & 1 deletion tests/bugfixes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -236,8 +238,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", () => {
Expand All @@ -246,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();
Expand Down
5 changes: 4 additions & 1 deletion tests/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down