From 5e92d0e73eae8a20c85f425e28ea66712f50caa2 Mon Sep 17 00:00:00 2001
From: amosblomqvist
Date: Tue, 22 Sep 2026 22:05:27 +0300
Subject: [PATCH] Fix wave chunk 2: thinking levels auto-populate everywhere;
legacy records show the resolved level
- thinking-default.ts: defaultOrderLevel (the ordering's first supported,
the client twin of the server's defaultThinkingLevel) + populateLevel
(an explicit level always rides; the WRITE clamps server-side, bd68486,
and the cross-surface signal re-reads the clamped truth; only absence
populates from the ordering) + THINKING_LABEL shared.
- Setup dialog (item 5): per-slot thinking selectors pre-populated with
the default-order level per picked model, editable; disabled until the
model pick; prefillLevelFromPin keeps a stored level as-is and populates
legacy pins from the ordering.
- Composer + agent section (item 6): fresh model picks always write a
level: the stored one rides, absence populates; the unset state can
never re-enter. Item 4: records with a model but no stored level display
the RESOLVED level (ordering's first supported) instead of a dash, in
the composer pill and the settings slot triggers/menus.
- Item 7 verified live end-to-end: an explicit level rides into the PUT,
the server clamps, the signal re-reads, the pill shows the clamped truth.
---
components/model-picker.tsx | 33 ++---
components/settings/agent-section.tsx | 16 ++-
components/setup-dialog.tsx | 175 +++++++++++++++++++-------
components/thinking-default.ts | 51 ++++++++
tests/byok-refusal.test.ts | 20 ++-
tests/thinking-default.test.ts | 59 +++++++++
6 files changed, 292 insertions(+), 62 deletions(-)
create mode 100644 components/thinking-default.ts
create mode 100644 tests/thinking-default.test.ts
diff --git a/components/model-picker.tsx b/components/model-picker.tsx
index e2f6f34..5819ff9 100644
--- a/components/model-picker.tsx
+++ b/components/model-picker.tsx
@@ -8,6 +8,7 @@ import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-men
import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
+import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
/** GET /api/models is cached per page load for instant menu opens, but
* the federation is LIVE per call — keys saved mid-session widen the
@@ -63,16 +64,6 @@ function useModelCatalog(): { catalog: ModelCatalogResponse | undefined; refresh
return { catalog, refresh };
}
-const LEVEL_LABEL: Record = {
- off: "Off",
- minimal: "Minimal",
- low: "Low",
- medium: "Medium",
- high: "High",
- xhigh: "Xhigh",
- max: "Max",
-};
-
/**
* THE STRONG-SLOT CONTROL (fix wave, owner ruling: "one and the same").
* The composer's model + thinking pills ARE the strong slot — the tier
@@ -120,11 +111,16 @@ export function ModelControl() {
const override = models?.strong;
const effectiveId = override?.model?.id;
const effectiveProvider = override?.model?.provider;
- const effectiveLevel = override?.thinkingLevel;
+ const storedLevel = override?.thinkingLevel;
const entry = catalog?.models.find(
(m) => m.id === effectiveId && m.provider === effectiveProvider,
);
const levels = entry?.supportedLevels ?? [];
+ // Legacy display (chunk 2 item 4, owner): a record with a model but no
+ // stored level shows the RESOLVED level — the ordering's first supported
+ // — because that is exactly what the engine build would run. Display-
+ // only: the record stays unset until a fresh pick populates it.
+ const effectiveLevel = storedLevel ?? (entry ? defaultOrderLevel(levels) : undefined);
// The intuitive dropdown contract (owner, stats-control round 1): any
// click outside the open menu — or an Esc keypress — closes it. The
@@ -216,9 +212,16 @@ export function ModelControl() {
// thinking level only when the new model supports it — otherwise
// drop it (a carry-over would 400 at the server's catalog gate).
const pickModel = (m: ModelCatalogEntry) => {
- const nextLevel =
- effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel) ? effectiveLevel : undefined;
- void save(composeSlotModel(models, "strong", m.provider, m.id, nextLevel ?? ""));
+ // Auto-populate (chunk 2, owner: "populate everywhere"): a fresh pick
+ // ALWAYS writes a level — the STORED one rides as-is (the write clamps
+ // server-side when the new model doesn't support it; the signal
+ // re-reads and the clamped truth displays), and a record with no
+ // stored level populates the ordering's level. The unset state can
+ // never re-enter through this door. (The DERIVED display level never
+ // rides — it belongs to the OLD model; the fresh pick's level comes
+ // from the fresh model's own ladder.)
+ const nextLevel = populateLevel(storedLevel, m.supportedLevels);
+ void save(composeSlotModel(models, "strong", m.provider, m.id, nextLevel));
};
// Stale-while-revalidate on manual open too (⌘K covers the keyboard
@@ -259,7 +262,7 @@ export function ModelControl() {
{/* Just the level — the button reads like the model button reads
the model name (owner, stats-control round 1). */}
- {effectiveLevel === undefined ? "—" : (LEVEL_LABEL[effectiveLevel] ?? effectiveLevel)}
+ {effectiveLevel === undefined ? "—" : (THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
▾
diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx
index da5bca0..e81f8e1 100644
--- a/components/settings/agent-section.tsx
+++ b/components/settings/agent-section.tsx
@@ -44,6 +44,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { MenuShell } from "@/components/menu-shell";
import { useSettingsSaved } from "@/components/settings-signal";
+import { defaultOrderLevel, populateLevel } from "@/components/thinking-default";
import type React from "react";
import {
MODEL_SLOTS,
@@ -506,7 +507,10 @@ export function AgentSection({
widthClass="min-w-20"
/>
{
- onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, thinking));
+ // Auto-populate (chunk 2, owner: "populate everywhere"): the
+ // fresh pick writes a level — the stored one rides as-is
+ // (the write clamps server-side), else the ordering's level.
+ const next = populateLevel(thinking || undefined, entry.supportedLevels);
+ onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next));
setOpenMenu(null);
}}
footer={
@@ -546,7 +554,9 @@ export function AgentSection({
>
{
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
setOpenMenu(null);
diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx
index e1a4f7d..9aafd6a 100644
--- a/components/setup-dialog.tsx
+++ b/components/setup-dialog.tsx
@@ -37,6 +37,9 @@ import { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
+import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
+import { ThinkingMenuList } from "@/components/settings/model-menu";
+import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
/** The pickers' slot identity — same roster language as the Agent
* section's slot rows. */
@@ -54,7 +57,7 @@ interface ModelsBody {
/** The three tiers' RESOLVED pins (BYOK: NULL per unset tier) — the
* PREFILL + "needed" marks (owner, 2026-09-22): pinned tiers open
* with their pick, unpinned tiers carry the mark. */
- slots?: Record;
+ slots?: Record;
}
/** Pure: which stage the flow opens at — no keyed provider = keys first
@@ -86,6 +89,18 @@ export function prefillFromSlots(
return pre;
}
+/** Level prefill (chunk 2): a pinned tier opens with its STORED level
+ * as-is (Save clamps server-side if the ladder lapsed), else the
+ * ordering's level for that model — the level a fresh pick would
+ * populate (owner: the ordering's only purpose is first assignment).
+ * Exported pure for tests. */
+export function prefillLevelFromPin(
+ entry: ModelCatalogEntry,
+ pin: { thinkingLevel?: ThinkingLevel } | null | undefined,
+): ThinkingLevel {
+ return populateLevel(pin?.thinkingLevel, entry.supportedLevels);
+}
+
export function SetupDialog({
open,
onClose,
@@ -103,10 +118,13 @@ export function SetupDialog({
const [body, setBody] = useState(null);
const [failed, setFailed] = useState(false);
const [picked, setPicked] = useState>>({});
+ /** Per-slot thinking level (chunk 2): populated from the ordering on
+ * every fresh model pick, editable — an explicit pick always wins. */
+ const [levels, setLevels] = useState>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
- const [openSlot, setOpenSlot] = useState(null);
- /** The open slot's trigger wrapper — the shared shell's anchor (fix
+ const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
+ /** The open menu's trigger wrapper — the shared shell's anchor (fix
* wave chunk B): one menu open at a time, so one ref suffices, and
* the menu PORTALS out of this dialog's overflow-hidden frame — it
* extends beyond the dialog exactly like the settings menus do. */
@@ -117,6 +135,7 @@ export function SetupDialog({
useEffect(() => {
if (!open) return;
setPicked({});
+ setLevels({});
setError(null);
fetch("/api/models")
.then((r) => r.json())
@@ -127,7 +146,16 @@ export function SetupDialog({
// pick (matched in the FEDERATED catalog — a lapsed key's pin
// does not prefill); unpinned tiers stay empty and carry the
// "needed" mark. First-run = nothing pinned → all three marked.
- setPicked(prefillFromSlots(b.models, b.slots));
+ const pre = prefillFromSlots(b.models, b.slots);
+ setPicked(pre);
+ // Levels prefill with the pins (chunk 2): stored level if still
+ // supported, else the ordering's level for the pinned model.
+ const preLevels: Partial> = {};
+ for (const slot of SLOT_ORDER) {
+ const entry = pre[slot];
+ if (entry) preLevels[slot] = prefillLevelFromPin(entry, b.slots?.[slot]);
+ }
+ setLevels(preLevels);
})
.catch(() => setFailed(true));
}, [open]);
@@ -140,14 +168,16 @@ export function SetupDialog({
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
- if (openSlot !== null) setOpenSlot(null);
+ if (openMenu !== null) setOpenMenu(null);
else onClose();
};
const onPointerDown = (e: PointerEvent) => {
- if (openSlot === null) return;
+ if (openMenu === null) return;
const t = e.target as Element;
- if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="setup-trigger-${openSlot}"]`)) return;
- setOpenSlot(null);
+ const triggerTestid =
+ openMenu.kind === "model" ? `setup-trigger-${openMenu.slot}` : `setup-thinking-${openMenu.slot}`;
+ if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return;
+ setOpenMenu(null);
};
window.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onPointerDown);
@@ -155,7 +185,7 @@ export function SetupDialog({
window.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDown);
};
- }, [open, onClose, openSlot]);
+ }, [open, onClose, openMenu]);
if (!open) return null;
@@ -167,7 +197,13 @@ export function SetupDialog({
setSaving(true);
setError(null);
const models: ModelsSettings = {};
- for (const slot of SLOT_ORDER) models[slot] = { model: { provider: picked[slot]!.provider, id: picked[slot]!.id } };
+ for (const slot of SLOT_ORDER) {
+ const level = levels[slot];
+ models[slot] = {
+ model: { provider: picked[slot]!.provider, id: picked[slot]!.id },
+ ...(level ? { thinkingLevel: level } : {}),
+ };
+ }
const res = await fetch("/api/settings/models", {
method: "PUT",
headers: { "content-type": "application/json" },
@@ -235,6 +271,8 @@ export function SetupDialog({
);
diff --git a/components/thinking-default.ts b/components/thinking-default.ts
new file mode 100644
index 0000000..d6841df
--- /dev/null
+++ b/components/thinking-default.ts
@@ -0,0 +1,51 @@
+"use client";
+
+/**
+ * The default-order resolution, CLIENT-SIDE (chunk 2, Platform interlude
+ * bd68486): the UI mirrors defaultThinkingLevel from src/agent/models.ts
+ * EXACTLY — first DEFAULT_THINKING_ORDER entry the model supports, else
+ * the first supported level, else "off". One source of truth for the
+ * ordering (the core barrel); this helper is its only client
+ * interpretation, so a fresh model pick can auto-populate the SAME
+ * level the engine would run for an unset record (owner ruling: the
+ * ordering's ONLY purpose is first assignment — an explicit pick always
+ * wins, and after a swap the WRITE clamps server-side, the signal
+ * re-reads, and the clamped truth displays).
+ */
+import { DEFAULT_THINKING_ORDER } from "@/src/core";
+import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
+
+/** Display labels for the levels (shared by every trigger that shows one). */
+export const THINKING_LABEL: Record = {
+ off: "Off",
+ minimal: "Minimal",
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ xhigh: "Xhigh",
+ max: "Max",
+};
+
+/** The level a fresh pick of `supported` populates with. Empty support
+ * (shouldn't happen — the catalog always reports at least ["off"])
+ * resolves "off", matching the server's fallback. */
+export function defaultOrderLevel(supported: readonly ThinkingLevel[]): ThinkingLevel {
+ for (const level of DEFAULT_THINKING_ORDER) {
+ if (supported.includes(level)) return level;
+ }
+ return supported[0] ?? "off";
+}
+
+/** An EXPLICIT level always rides — even when the fresh pick's model
+ * doesn't support it: the WRITE clamps it to the nearest supported level
+ * server-side (Platform's interlude bd68486 — the owner's swap-400 case
+ * now just works), and the cross-surface signal re-reads so the clamped
+ * truth displays. The ordering only assigns FIRST: when no explicit
+ * level exists, a fresh model pick populates the ordering's level for
+ * that model (chunk 2: the unset state can never re-enter). */
+export function populateLevel(
+ explicit: ThinkingLevel | undefined,
+ supported: readonly ThinkingLevel[],
+): ThinkingLevel {
+ return explicit ?? defaultOrderLevel(supported);
+}
\ No newline at end of file
diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts
index 5c9c4dd..7055728 100644
--- a/tests/byok-refusal.test.ts
+++ b/tests/byok-refusal.test.ts
@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseModelsRefusal } from "../components/chat.tsx";
-import { prefillFromSlots, setupStage } from "../components/setup-dialog.tsx";
+import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx";
import { isModelsRefusal, modelsNotConfigured } from "../src/core/settings.ts";
import type { ModelCatalogEntry, ModelSlotName } from "../src/core/index.ts";
@@ -90,6 +90,24 @@ describe("prefillFromSlots — the SetupDialog prefill (BYOK wave)", () => {
});
});
+describe("prefillLevelFromPin — the setup dialog's level prefill (chunk 2)", () => {
+ const entry = (levels: string[]) =>
+ ({ id: "m", name: "M", provider: "p", reasoning: true, input: ["text"], supportedLevels: levels, cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 1000 }) as never;
+
+ it("a stored level the pinned model still supports prefills as-is", () => {
+ assert.equal(prefillLevelFromPin(entry(["low", "high"]), { thinkingLevel: "high" }), "high");
+ });
+
+ it("a stored level the model LOST rides as-is — Save clamps server-side (bd68486)", () => {
+ assert.equal(prefillLevelFromPin(entry(["minimal", "medium"]), { thinkingLevel: "xhigh" }), "xhigh");
+ });
+
+ it("a legacy pin with no stored level prefills the ordering's level", () => {
+ assert.equal(prefillLevelFromPin(entry(["low", "medium"]), {}), "medium");
+ assert.equal(prefillLevelFromPin(entry(["low", "medium"]), null), "medium");
+ });
+});
+
describe("setupStage — unchanged by the BYOK wave", () => {
it("keys first (owner ordering), pickers when a provider is keyed", () => {
assert.equal(setupStage(null), "keys");
diff --git a/tests/thinking-default.test.ts b/tests/thinking-default.test.ts
new file mode 100644
index 0000000..ae7b09d
--- /dev/null
+++ b/tests/thinking-default.test.ts
@@ -0,0 +1,59 @@
+/**
+ * The default-order resolution, client-side (chunk 2): the UI populates a
+ * fresh model pick's thinking level with the SAME level the engine build
+ * runs for an unset record — first DEFAULT_THINKING_ORDER entry the model
+ * supports (Platform's interlude bd68486 moved the ordering into the core
+ * barrel exactly for this). These tests pin the helper against the
+ * server's defaultThinkingLevel semantics (src/agent/models.ts).
+ */
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { DEFAULT_THINKING_ORDER } from "../src/core/settings.ts";
+import { defaultOrderLevel, populateLevel } from "../components/thinking-default.ts";
+import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
+
+const tl = (levels: string[]) => levels as ThinkingLevel[];
+
+describe("defaultOrderLevel — the ordering's first supported", () => {
+ it("a reasoning model taking the ladder's head (medium) resolves medium", () => {
+ assert.equal(defaultOrderLevel(tl(["minimal", "low", "medium", "high"])), "medium");
+ });
+
+ it("a model without medium falls to the NEXT order entry it supports", () => {
+ // order: medium, low, high, minimal, xhigh, max — low wins here
+ assert.equal(defaultOrderLevel(tl(["minimal", "low", "high"])), "low");
+ assert.equal(defaultOrderLevel(tl(["high", "xhigh", "max"])), "high");
+ });
+
+ it("a non-reasoning model (pi reports exactly [off]) resolves off", () => {
+ assert.equal(defaultOrderLevel(tl(["off"])), "off");
+ });
+
+ it("empty support resolves off (the server's fallback), never crashes", () => {
+ assert.equal(defaultOrderLevel(tl([])), "off");
+ });
+
+ it("the full ladder resolves its head", () => {
+ const full = tl(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
+ assert.equal(defaultOrderLevel(full), DEFAULT_THINKING_ORDER[0]);
+ });
+});
+
+describe("populateLevel — an explicit pick always rides; absence populates", () => {
+ it("a supported explicit level rides unchanged", () => {
+ assert.equal(populateLevel("high", tl(["low", "high"])), "high");
+ });
+
+ it("an UNSUPPORTED explicit level STILL rides — the write clamps server-side (bd68486), the signal re-reads the clamped truth", () => {
+ // xhigh rides even though unsupported here; never re-populated client-side
+ assert.equal(populateLevel("xhigh", tl(["minimal", "medium", "high"])), "xhigh");
+ });
+
+ it("no explicit level populates the ordering's level (the fresh-pick case)", () => {
+ assert.equal(populateLevel(undefined, tl(["low", "high"])), "low");
+ });
+
+ it("off as an explicit level rides when supported", () => {
+ assert.equal(populateLevel("off", tl(["off"])), "off");
+ });
+});