deer-flow/frontend/tests/unit/core/settings/preferences-sync.test.ts
Nan Gao bcaf6db36a
feat(models): add declarative reasoning capability contract (#5780)
* feat(models): add declarative reasoning capability contract (#5073)

Introduce an optional per-model `reasoning:` block beside the legacy
`supports_thinking` / `supports_reasoning_effort` booleans. It declares
thinking availability (unsupported | optional | required), the accepted
effort values with aliases, default, and serialization path, the payload
dialect, and the reasoning-history requirement. When present the booleans
are derived from it and contradictory profiles fail at config load.

`deerflow.models.reasoning` normalizes legacy and declared profiles into
one immutable contract and resolves every caller's generic request against
it. `create_chat_model` is the single enforcement point: required-thinking
models never receive a synthesized disable payload, unsupported effort
values never reach the provider, and the dialect synthesizes the on/off
payload when no template exists. Legacy profiles keep the historical path
byte-for-byte. The lead agent and subagent descriptor resolve the same
policy first so run metadata reports effective values.

`/api/models` and `DeerFlowClient` project the contract as `reasoning`;
the frontend derives mode clamping and effort choices from it through
`core/models/reasoning.ts`, and user preferences accept provider-specific
effort tokens. The setup wizard's Z.AI GLM-5.3-Flash profile migrates to
the contract, restoring its low/high/max effort control.

* docs(models): move the reasoning contract guidance into models/AGENTS.md

backend/AGENTS.md sits within 46 bytes of the 96 KiB effective-chain hard
limit for the middlewares, runtime, sandbox, and subagents guides, so the
new section pushed scripts/check_agent_guidance.py over it. The models
guide is the nearest file for the factory and the contract module, and
its chain has ample room.

* fix(models): address review on the reasoning contract

- vLLM dialect: the enable path now mirrors whichever chat_template_kwargs
  switch the template declares, like the disable path already did, so a
  template using the older `thinking` key no longer also grows
  `enable_thinking`.
- Validate operator-supplied effort values at `effort.path` inside
  `when_thinking_enabled`, `when_thinking_disabled`, and the `thinking`
  shortcut against the contract, not only the profile-level value; they are
  forwarded when the caller chooses nothing, so the "unknown values never
  reach the provider" guarantee now holds for them too.
- Constrain `effort.path` to dotted identifiers and reject single-segment
  paths that would replace a whole mapping (`extra_body`, `thinking`,
  `model_kwargs`, `default_headers`, `default_query`).
- Z.AI GLM-5.3-Flash profile: `default: high` instead of `max`, because the
  default also governs summarization, title generation, and subagent calls
  that never choose an effort; `max` stays selectable. Documented in the
  config example, the configuration manual, and the design note.

* fix(models): close reasoning contract review gaps

* fix(models): preserve native Ollama reasoning config

* fix(models): keep native reasoning strings and project reasoning into the descriptor

- `ModelConfig.reasoning` also accepts the level string ChatOllama takes
  for gpt-oss style models (`low` / `medium` / `high`); the factory forwards
  both the boolean and the string on the legacy path, so profiles that
  passed `reasoning: high` through before the contract field existed keep
  loading and keep sending it.
- The assembly descriptor no longer excludes `reasoning` from
  `model_parameters`: the native ChatOllama value and a declared contract's
  dialect/history/effort change the request payload, so they now move the
  assembly fingerprint.
- e2e: the sidecar spec pinned the pre-contract body where a model without
  effort control still received `reasoning_effort: minimal`; the preset is
  now dropped for such models (the Gateway discarded it anyway), so the
  assertion expects the key to be absent.
2026-09-24 20:39:30 +08:00

118 lines
3.3 KiB
TypeScript

import { describe, expect, it } from "@rstest/core";
import {
PreferencesSync,
type Preferences,
} from "@/core/settings/preferences-sync";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function setup(
read: () => Promise<Preferences>,
patch: (value: Preferences) => Promise<void>,
) {
let visible: Preferences = {};
let pending: Preferences = {};
const sync = new PreferencesSync(
{
read,
patch,
apply: (value) => {
visible = value;
},
savePending: (value) => {
pending = value;
},
},
{},
{},
);
return { sync, visible: () => visible, pending: () => pending };
}
describe("account preference synchronization", () => {
it("retries a failed bootstrap and preserves edits made while loading", async () => {
let attempts = 0;
const writes: Preferences[] = [];
const state = setup(
async () => {
if (++attempts === 1) throw new Error("offline");
return { mode: "pro", notification_enabled: true };
},
async (value) => {
writes.push(value);
},
);
await expect(state.sync.flush()).rejects.toThrow("offline");
state.sync.edit({ notification_enabled: false });
await state.sync.flush();
expect(writes).toEqual([{ notification_enabled: false }]);
expect(state.visible()).toEqual({
mode: "pro",
notification_enabled: false,
});
expect(state.pending()).toEqual({});
});
it("does not let an older acknowledgement erase a newer edit", async () => {
const ack = deferred<void>();
const writes: Preferences[] = [];
const state = setup(
async () => ({}),
async (value) => {
writes.push(value);
if (writes.length === 1) await ack.promise;
},
);
await state.sync.flush();
state.sync.edit({ mode: "pro" });
const flushing = state.sync.flush();
await Promise.resolve();
state.sync.edit({ mode: "flash" });
ack.resolve();
await flushing;
expect(writes).toEqual([{ mode: "pro" }, { mode: "flash" }]);
expect(state.visible().mode).toBe("flash");
expect(state.pending()).toEqual({});
});
it("ignores a late read after stopping an account", async () => {
const read = deferred<Preferences>();
const state = setup(
() => read.promise,
async () => undefined,
);
const loading = state.sync.flush();
state.sync.stop();
read.resolve({ model_name: "alice-only" });
await loading;
expect(state.visible()).toEqual({});
});
});
describe("parsePreferences reasoning_effort", () => {
it("accepts provider-specific effort tokens such as max", async () => {
const { parsePreferences } =
await import("@/core/settings/preferences-sync");
expect(parsePreferences({ reasoning_effort: "max" })).toEqual({
reasoning_effort: "max",
});
expect(parsePreferences({ reasoning_effort: "xhigh" })).toEqual({
reasoning_effort: "xhigh",
});
});
it("drops effort values that are not plain tokens", async () => {
const { parsePreferences } =
await import("@/core/settings/preferences-sync");
expect(parsePreferences({ reasoning_effort: "Not Valid!" })).toEqual({});
expect(parsePreferences({ reasoning_effort: 42 })).toEqual({});
});
});