Skip to content
Merged
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
129 changes: 99 additions & 30 deletions packages/webui/server/lib/mcode-acp.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import { loadSessions, saveSessions } from "./sessions.js";
// mcode-exec (which honours --permission ask/full/auto/off).

/**
* Push the recorded pre-session model pick to a brand-new engine session.
* Push the recorded pre-session model pick (and, if recorded, the
* matching thinking-effort level) to a brand-new engine session.
*
* Called from `runMcodeAcp` immediately after `session/new` returns, while
* the new `McodeAcpClient` is still in scope but not yet registered as the
Expand All @@ -61,44 +62,97 @@ import { loadSessions, saveSessions } from "./sessions.js";
* A successful apply updates `cs.configOptions` with the new currentValue
* so the next `/api/models` reads the same model the engine is running.
*
* Engine contract: the `thinkingEffort` config option is rejected when
* no model is selected (`Select a Session model before changing
* thinking effort.`, agent.ts#1003). We push the model first, then the
* effort, in that order — and only when a level was recorded
* (`cs.model.thinking` non-empty). The level is otherwise accepted
* as-is: the engine validates it against the selected model's
* effortOptions and answers invalidParams on a mismatch.
*
* Errors are swallowed: a fresh session with the engine's default is
* better than a failed session start; the user can re-pick on the chip.
*/
async function applyRecordedModel(client, sid, cs, cid) {
const recorded = cs && cs.model && typeof cs.model.name === "string"
? cs.model.name.trim()
: "";
if (!recorded) return;
const modelOption = findModelOption(cs);
if (!modelOption) return; // engine hasn't reported its model option yet
const engineCurrent = modelOption.currentValue;
if (matchesModelId(recorded, engineCurrent, modelOption)) return;
const recordedThinking = cs && cs.model && typeof cs.model.thinking === "string"
? cs.model.thinking.trim()
: "";

const resolved = resolveModelId(recorded, modelOption);
if (!resolved) {
console.warn(
`[webui] applyRecordedModel: recorded id "${recorded}" does not match any engine option; skipping`,
);
return;
let modelApplied = false;
if (recorded) {
const modelOption = findModelOption(cs);
if (!modelOption) {
// Engine hasn't reported its model option yet — neither apply
// can fire (the engine rejects effort before a model is selected).
// Bail; both will get re-attempted on the next session event that
// carries a fresh configOptions list.
return;
}
const engineCurrent = modelOption.currentValue;
if (!matchesModelId(recorded, engineCurrent, modelOption)) {
const resolved = resolveModelId(recorded, modelOption);
if (!resolved) {
console.warn(
`[webui] applyRecordedModel: recorded id "${recorded}" does not match any engine option; skipping`,
);
} else {
await client.request("session/set_config_option", {
sessionId: sid,
configId: "model",
value: resolved,
});
// Reflect the apply on the local config-options snapshot so a
// follow-up /api/models reads the engine's new currentValue
// instead of the session-boot default. The engine pushes a
// `config_option_update` notification when it processes the
// apply; this local update is the synchronous mirror that keeps
// the chip and the engine in lockstep before the next SSE flush
// lands.
const opts = Array.isArray(cs.configOptions) ? cs.configOptions : [];
for (const o of opts) {
if (o && o.id === "model" && typeof o === "object") {
o.currentValue = resolved;
}
}
modelApplied = true;
}
}
}
await client.request("session/set_config_option", {
sessionId: sid,
configId: "model",
value: resolved,
});
// Reflect the apply on the local config-options snapshot so a follow-up
// /api/models reads the engine's new currentValue instead of the
// session-boot default. The engine pushes a `config_option_update`
// notification when it processes the apply; this local update is the
// synchronous mirror that keeps the chip and the engine in lockstep
// before the next SSE flush lands.
const opts = Array.isArray(cs.configOptions) ? cs.configOptions : [];
for (const o of opts) {
if (o && o.id === "model" && typeof o === "object") {
o.currentValue = resolved;

// Thinking-effort apply (ticket 04): only when a level was recorded
// AND a model is selected (recorded or just applied). The engine
// validates the level against the selected model's effortOptions; a
// rejection is logged and otherwise ignored — the engine's default
// stands, and the next /api/models reflects that.
if (recordedThinking) {
if (!recorded && !modelApplied) {
// No recorded model and the engine's current model is unknown to
// us; we have no anchor for the effort. Skip.
return;
}
try {
await client.request("session/set_config_option", {
sessionId: sid,
configId: "thinkingEffort",
value: recordedThinking,
});
const opts = Array.isArray(cs.configOptions) ? cs.configOptions : [];
for (const o of opts) {
if (o && o.id === "thinkingEffort" && typeof o === "object") {
o.currentValue = recordedThinking;
}
}
} catch (e) {
console.warn(
`[webui] applyRecordedModel: thinking effort "${recordedThinking}" rejected: ${e.message}`,
);
}
}
if (cid) pushStateFor(cid);

if ((modelApplied || recordedThinking) && cid) pushStateFor(cid);
}

/** Locate the engine's `model` config option, or null if none was reported yet. */
Expand Down Expand Up @@ -346,8 +400,9 @@ export function buildEmptyTurnNote(stopReason, answer) {
// applyConfigOptionUpdate — handle the engine's `config_option_update`
// session event. Replaces `cs.configOptions` wholesale (the engine sends
// the whole list), propagates `permissionMode.currentValue` through
// `mcodePermissionToWebui`, and propagates `model.currentValue` into
// `cs.model.name`. The model field is read with the same
// `mcodePermissionToWebui`, propagates `model.currentValue` into
// `cs.model.name`, and propagates `thinkingEffort.currentValue` into
// `cs.model.thinking`. The model field is read with the same
// `option.currentValue` contract that `routes/model.js#handleGetModels`
// uses, so the two cannot disagree about which holds the encoded id.
// When the model option is absent or its currentValue is empty,
Expand All @@ -366,6 +421,20 @@ export function applyConfigOptionUpdate(cs, update) {
if (model && model.currentValue) {
cs.model = { ...(cs.model || {}), name: model.currentValue };
}
const thinking = opts.find((o) => o && o.id === "thinkingEffort");
if (thinking) {
// currentValue can legitimately be empty (engine's default or no
// override); reflect that exactly so the picker shows "off" rather
// than a stale level. The field is dropped when the option is
// missing altogether (model without an effort dimension).
if (typeof thinking.currentValue === "string" && thinking.currentValue) {
cs.model = { ...(cs.model || {}), thinking: thinking.currentValue };
} else if (cs.model && "thinking" in cs.model) {
const { thinking: _drop, ...rest } = cs.model;
void _drop;
cs.model = rest;
}
}
}

// applyToolUpdate — handle a `tool_update` (a.k.a. `tool_call_update`)
Expand Down
2 changes: 1 addition & 1 deletion packages/webui/server/lib/state-bus.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function makeClientState() {
return {
version: "1.0", // 顶栏显示 "v" + version
workspace: { dir: DEFAULT_WORKSPACE, branch: null, tree: null },
model: { name: DEFAULT_MODEL, thinking: "On", ctx: "512k" },
model: { name: DEFAULT_MODEL, thinking: "", ctx: "512k" },
sessionId: null, // webui 侧边栏 session id (randomUUID)
mcodeSessionId: null, // mcode acp/exec 自己的 session id (mvs_xxx)
sessionTitle: "Untitled",
Expand Down
96 changes: 89 additions & 7 deletions packages/webui/server/routes/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,22 @@ export function handleGetModels(_req, res, ctx) {
currentName ||
null;

// Current thinking-effort level: read the engine's `thinkingEffort`
// option when present; otherwise fall back to `cs.model.thinking`,
// which `handleSetModel` writes (pre-session record) and which the
// engine's `config_option_update` notification refreshes via
// `applyConfigOptionUpdate` (see lib/mcode-acp.js). The selector
// reads this to highlight the active level and to skip the picker
// when the active model has no `thinkingLevels`.
const thinkingEffortOption =
Array.isArray(cs && cs.configOptions) ? cs.configOptions.find((o) => o && o.id === "thinkingEffort") : null;
const currentThinking =
(thinkingEffortOption && typeof thinkingEffortOption.currentValue === "string"
? thinkingEffortOption.currentValue
: null) ||
(cs && cs.model && typeof cs.model.thinking === "string" && cs.model.thinking) ||
null;

const source =
option && Array.isArray(option.options) && option.options.length > 0
? "acp-session-config"
Expand All @@ -270,6 +286,7 @@ export function handleGetModels(_req, res, ctx) {
models: list,
groups,
current,
currentThinking,
source,
// Backwards-compat: surface the same soft-failure marker the older
// engine-only build did when nothing could be sourced. With the
Expand All @@ -285,32 +302,97 @@ export function handleGetModels(_req, res, ctx) {

// POST /api/set-model — only updates cs.model; with a session the same value
// is also pushed to the engine via session/set_config_option.
//
// Body: `{ model: string, thinking?: string }`. `thinking` is the
// reasoning-effort level the engine accepts on its `thinkingEffort`
// config option (`low` / `medium` / `high`, plus `off` / `none` for
// models that disable reasoning — see the engine's control-state.ts
// `thinkingEffortOption`). Per the engine's contract, the
// `thinkingEffort` set is rejected when no model is selected
// (`Select a Session model before changing thinking effort.`,
// agent.ts#1003), so a thinking-only update routes the same way the
// set_config_option engine path expects: model first, then effort.
//
// `thinking` is OPTIONAL: a model-only update leaves the recorded
// effort intact (it gets re-applied on the next session boot via
// `applyRecordedModel`); an effort-only update leaves the model alone.
// An empty string clears the recorded effort, signalling "no override
// — let the engine's default stand".
export async function handleSetModel(req, res, ctx) {
const cs = ctx.cs;
const cid = ctx.cid;
const payload = await readJson(req);
const modelId = (payload.model || "").trim();
if (!modelId) {
const modelId = typeof payload.model === "string" ? payload.model.trim() : "";
const rawThinking =
typeof payload.thinking === "string" ? payload.thinking.trim() : undefined;
// "no field" → keep the existing cs.model.thinking; "empty string" →
// clear it (no override). Both arrive as falsy here, but the
// distinction is encoded by `thinkingWasProvided`.
const thinkingWasProvided = Object.prototype.hasOwnProperty.call(payload, "thinking");
const thinking = thinkingWasProvided ? (rawThinking || "") : undefined;
if (!modelId && !thinkingWasProvided) {
res.writeHead(400, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: false, error: "model required" }));
}
cs.model = cs.model || {};
cs.model.name = modelId;
if (modelId) cs.model.name = modelId;
if (thinkingWasProvided) {
cs.model.thinking = thinking;
}
const sid = cs.mcodeSessionId;
let mcodeSynced = false;
let thinkingSynced = false;
let warning = sid ? null : "no mcode session yet — recorded for the next one";
// Engine contract: model first, then thinkingEffort (the engine
// rejects a thinkingEffort set when no model is selected). Only push
// when BOTH the recorded model and the new (or unchanged) thinking
// are concrete — the engine will validate the level against the
// selected model's effortOptions and reject unknown values.
if (sid) {
const r = await setConfigOption(sid, "model", modelId, ctx.cid);
mcodeSynced = r.ok;
if (!r.ok) warning = r.error;
if (modelId) {
const r = await setConfigOption(sid, "model", modelId, ctx.cid);
mcodeSynced = r.ok;
if (!r.ok) warning = r.error;
}
if (thinkingWasProvided && thinking) {
const r = await setConfigOption(sid, "thinkingEffort", thinking, ctx.cid);
thinkingSynced = r.ok;
if (!r.ok && (!warning || warning === null || warning === "no mcode session yet — recorded for the next one")) {
warning = r.error;
}
if (r.ok) {
// Mirror the apply on the local configOptions snapshot so a
// follow-up /api/models reads the engine's new currentValue
// before the SSE flush lands (same reason as
// applyRecordedModel's cs.configOptions write).
const opts = Array.isArray(cs.configOptions) ? cs.configOptions : [];
for (const o of opts) {
if (o && o.id === "thinkingEffort") {
o.currentValue = thinking;
}
}
}
} else if (thinkingWasProvided && !thinking && modelId) {
// Model changed AND effort cleared. The engine picks its own
// default for the new model; we drop the local mirror so a
// subsequent /api/models doesn't keep showing the cleared value.
const opts = Array.isArray(cs.configOptions) ? cs.configOptions : [];
for (const o of opts) {
if (o && o.id === "thinkingEffort") {
delete o.currentValue;
}
}
}
}
pushStateFor(cid);
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
return res.end(
JSON.stringify({
ok: true,
model: modelId,
...(modelId ? { model: modelId } : {}),
...(thinkingWasProvided ? { thinking } : {}),
mcodeSynced,
thinkingSynced,
...(warning ? { warning } : {}),
}),
);
Expand Down
Loading
Loading