The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of any pick, while composer and settings UIs agreed with each other and lied about the run. Root cause: a fossil default-settings record (settings.json chat section, frozen pre-slots-wave) was stamped verbatim into every new chat at birth by the new-session flow, and resolveConversationSpec prefers the per-chat record over the strong slot forever. Owner redesign — models are per session, always; the strong slot is the new-session default: - POST /api/chats stamps the newborn verbatim from the strong slot (model + thinking level); no client settings PUT on create anymore - the composer control is dual-bound: in-session it reads/writes the session's own record (GET /api/chats/:id, PUT .../settings); on the draft surfaces it binds the strong slot (picking there = changing the default) - thinking levels follow the identical mechanics (confirmed by owner) - the zombie default-settings system dies whole: useDefaultSettings, /api/settings/defaults, src/store/default-settings.ts, the chat section of UserSettings (sanitizer strips fossil copies), the legacy default-settings.json fold, and DEFAULT_SETTINGS_FILE - catalogDefaultsFor collapses to the strong slot alone - thinking display for a legacy unstamped session resolves exactly like the engine (session ?? strong ?? model ladder) Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot, the unset level, and the copy-not-live-view regression; defaults pins rewritten strong-only; legacy-migration suite deleted.
109 lines
4.3 KiB
TypeScript
109 lines
4.3 KiB
TypeScript
/**
|
|
* The BIRTH STAMP (per-session models, owner ruling 2026-09-28): every
|
|
* session owns its model + thinking level from birth — POST /api/chats
|
|
* stamps the newborn chat verbatim from the settings record's STRONG
|
|
* slot, the new-session default. From then on the strong slot never
|
|
* reaches the session again (in-session picks write the session's own
|
|
* record; resolveConversationSpec prefers chat.settings.model).
|
|
*
|
|
* This pin drives the actual route handler (framework-free
|
|
* Request/Response) under an isolated LH_DATA_DIR — the seam must be set
|
|
* before the first import that transitively loads config.ts, so every
|
|
* app-module import is dynamic (the prompt-overrides discipline).
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { after, describe, it } from "node:test";
|
|
|
|
// ── the seam, BEFORE any config-loading import ──────────────────────────────
|
|
const TMP_DATA = fs.mkdtempSync(path.join(os.tmpdir(), "lh-birth-stamp-"));
|
|
process.env.LH_DATA_DIR = TMP_DATA;
|
|
|
|
const { POST } = await import("../app/api/chats/route.ts");
|
|
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
|
const { chatStore } = await import("../src/store/chats.ts");
|
|
|
|
after(() => {
|
|
fs.rmSync(TMP_DATA, { recursive: true, force: true });
|
|
});
|
|
|
|
function post(): Promise<Response> {
|
|
return POST(new Request("http://test/api/chats", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: "teach me the chain rule" }),
|
|
}));
|
|
}
|
|
|
|
describe("POST /api/chats — the birth stamp", () => {
|
|
it("stamps the newborn verbatim from the strong slot (model + thinking level)", async () => {
|
|
writeSettings({
|
|
version: 1,
|
|
models: {
|
|
strong: {
|
|
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
|
|
thinkingLevel: "low",
|
|
},
|
|
},
|
|
});
|
|
const res = await post();
|
|
assert.equal(res.status, 200);
|
|
const { id } = (await res.json()) as { id: string };
|
|
const record = chatStore.getChat(id);
|
|
assert.ok(record);
|
|
assert.deepEqual(record.settings, {
|
|
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
|
|
thinkingLevel: "low",
|
|
});
|
|
});
|
|
|
|
it("no strong slot configured (fresh BYOK boot) → NO stamp; the turn gate refuses downstream", async () => {
|
|
fs.rmSync(path.join(TMP_DATA, "settings.json"), { force: true });
|
|
assert.equal(readSettings(), null);
|
|
const res = await post();
|
|
assert.equal(res.status, 200);
|
|
const { id } = (await res.json()) as { id: string };
|
|
const record = chatStore.getChat(id);
|
|
assert.ok(record);
|
|
assert.equal(record.settings, undefined, "nothing stamped — nothing to lie about");
|
|
});
|
|
|
|
it("an unset thinking level rides as-is — the session's level resolves (strong's, then the model's ladder)", async () => {
|
|
writeSettings({
|
|
version: 1,
|
|
models: { strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } } },
|
|
});
|
|
const res = await post();
|
|
const { id } = (await res.json()) as { id: string };
|
|
const record = chatStore.getChat(id);
|
|
assert.ok(record);
|
|
assert.equal(record.settings?.thinkingLevel, undefined);
|
|
assert.deepEqual(record.settings, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } });
|
|
});
|
|
|
|
it("changing the strong slot afterward NEVER touches existing sessions — the stamp is a copy, not a live view", async () => {
|
|
writeSettings({
|
|
version: 1,
|
|
models: { strong: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low" } },
|
|
});
|
|
const first = (await (await post()).json()) as { id: string };
|
|
writeSettings({
|
|
version: 1,
|
|
models: { strong: { model: { provider: "openai", id: "gpt-6-luna" }, thinkingLevel: "low" } },
|
|
});
|
|
const second = (await (await post()).json()) as { id: string };
|
|
// The pre-change session keeps its stamp; the post-change session
|
|
// carries the new default. This is the regression pin for the
|
|
// reported bug (2026-09-28): both UIs showed the strong slot while
|
|
// every chat ran a fossil stamp.
|
|
const firstRecord = chatStore.getChat(first.id);
|
|
const secondRecord = chatStore.getChat(second.id);
|
|
assert.ok(firstRecord && secondRecord);
|
|
assert.equal(firstRecord.settings?.model?.id, "z-ai/glm-5.3-flash");
|
|
assert.equal(secondRecord.settings?.model?.id, "gpt-6-luna");
|
|
});
|
|
});
|