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
3 changes: 2 additions & 1 deletion src/backend/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { readFileSync } from "node:fs";
import process from "node:process";

import { DEFAULT_MODEL_ID } from "../config/options.js";
import { log, ZCODE_CREDS_PATH } from "../utils.js";

/** Parsed provider entry in config.json. */
Expand Down Expand Up @@ -40,7 +41,7 @@ export function loadZcodeCredentials(): ZcodeCredentials {
const opts = p.options ?? {};
const models = p.models ?? {};
return {
ZCODE_MODEL: Object.keys(models)[0] ?? "GLM-5.2",
ZCODE_MODEL: Object.keys(models)[0] ?? DEFAULT_MODEL_ID,
ZCODE_BASE_URL: opts.baseURL ?? "",
ANTHROPIC_API_KEY: opts.apiKey ?? "",
};
Expand Down
13 changes: 10 additions & 3 deletions src/config/model-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
import type * as acp from "@agentclientprotocol/sdk";

import type { ZcodeReadResult } from "../backend/types.js";
import { formatModelValue, loadAllModels, modelContextWindow, parseModelValue } from "./options.js";
import {
DEFAULT_MODEL_ID,
DEFAULT_PROVIDER_ID,
formatModelValue,
loadAllModels,
modelContextWindow,
parseModelValue,
} from "./options.js";
import { log } from "../utils.js";
import type { ZcodeAcpServer } from "../server.js";
import { dispatchEvent } from "../handlers/dispatch.js";
Expand All @@ -28,7 +35,7 @@ export async function currentModelCached(
const cached = server.modelCache.get(zcodeSid);
if (cached) return cached;
let providerId = "";
let modelId = "GLM-5.2";
let modelId = DEFAULT_MODEL_ID;
try {
const read = await sessionRead(server, zcodeSid);
const settings = (read.settings ?? {}) as Record<string, unknown>;
Expand All @@ -41,7 +48,7 @@ export async function currentModelCached(
}
if (!providerId) {
// Legacy session without a providerId — resolve to the first enabled provider.
providerId = loadAllModels()[0]?.providerId ?? "builtin:bigmodel-coding-plan";
providerId = loadAllModels()[0]?.providerId ?? DEFAULT_PROVIDER_ID;
}
const encoded = formatModelValue(providerId, modelId);
server.modelCache.set(zcodeSid, encoded);
Expand Down
36 changes: 27 additions & 9 deletions src/config/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ interface ConfigShape {
provider?: Record<string, ProviderEntry>;
}

/**
* Fallback defaults for when config.json is unreadable or has no enabled
* provider — keeps a freshly-installed editor functional. Must stay in sync
* with the model the app ships first in its provider list.
*/
export const DEFAULT_PROVIDER_ID = "builtin:bigmodel-coding-plan";
export const DEFAULT_PROVIDER_NAME = "BigModel";
export const DEFAULT_MODEL_ID = "GLM-5.3";

/** A model selectable in the dropdown, with its owning provider. */
export interface ModelRef {
providerId: string;
Expand Down Expand Up @@ -77,16 +86,20 @@ export function loadAllModels(): ModelRef[] {
// default so a freshly-installed editor still shows something.
return [
{
providerId: "builtin:bigmodel-coding-plan",
providerName: "BigModel",
modelId: "GLM-5.2",
providerId: DEFAULT_PROVIDER_ID,
providerName: DEFAULT_PROVIDER_NAME,
modelId: DEFAULT_MODEL_ID,
},
];
}
return out;
} catch {
return [
{ providerId: "builtin:bigmodel-coding-plan", providerName: "BigModel", modelId: "GLM-5.2" },
{
providerId: DEFAULT_PROVIDER_ID,
providerName: DEFAULT_PROVIDER_NAME,
modelId: DEFAULT_MODEL_ID,
},
];
}
}
Expand Down Expand Up @@ -144,7 +157,7 @@ export function parseModelValue(value: string): { providerId: string; modelId: s
// (falling back to the legacy default if none configured).
const firstBuiltin = loadAllModels().find((m) => isBuiltinProvider(m.providerId));
return {
providerId: firstBuiltin?.providerId ?? "builtin:bigmodel-coding-plan",
providerId: firstBuiltin?.providerId ?? DEFAULT_PROVIDER_ID,
modelId: value,
};
}
Expand Down Expand Up @@ -203,9 +216,12 @@ export async function buildConfigOptions(
zcodeSid: string | null,
): Promise<acp.SessionConfigOption[]> {
let currentProviderId = "";
let currentModelId = "GLM-5.2";
let currentModelId = DEFAULT_MODEL_ID;
let currentMode = zcodeSid === null ? "yolo" : "build";
let currentThought = "high";
// Matches the enabled provider's default reasoning variants (GLM-5.3:
// max/high/low, default max). Pending sessions show this until the real
// session/read thoughtLevel arrives.
let currentThought = "max";
let thoughtOptions: Array<{ value: string; name: string }> | null = null;
if (zcodeSid === null) {
// Pending session — no backend to read yet, but the thought vocabulary
Expand Down Expand Up @@ -258,7 +274,9 @@ export async function buildConfigOptions(
if (cur.providerId) currentProviderId = cur.providerId;
if (cur.modelId) currentModelId = cur.modelId;
const tlSet = (settings.thoughtLevel as Record<string, unknown>) ?? {};
currentThought = (tlSet.current as string) ?? currentThought;
// `current` is absent right after session/create — fall back to the
// backend's defaultLevel (the level the session actually runs at).
currentThought = (tlSet.current as string) ?? (tlSet.defaultLevel as string) ?? currentThought;
const tlAvail = (tlSet.available as Array<Record<string, string>>) ?? [];
if (tlAvail.length > 0) {
thoughtOptions = tlAvail.map((a) => ({ value: a.value, name: a.label ?? a.value }));
Expand All @@ -272,7 +290,7 @@ export async function buildConfigOptions(
// right provider (and its apiKey). Fall back to the first enabled provider
// when settings omits providerId (legacy sessions).
const currentModel = formatModelValue(
currentProviderId || loadAllModels()[0]?.providerId || "builtin:bigmodel-coding-plan",
currentProviderId || loadAllModels()[0]?.providerId || DEFAULT_PROVIDER_ID,
currentModelId,
);

Expand Down
54 changes: 47 additions & 7 deletions src/config/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,21 @@ import { readFileSync } from "node:fs";

import { ZCODE_CREDS_PATH, log } from "../utils.js";

/** A model entry in config.json (`provider.<id>.models.<modelId>`). */
export interface ModelEntry {
name?: string;
limit?: { context?: number; output?: number };
reasoning?: { enabled?: boolean; variants?: string[]; defaultVariant?: string };
}

/** A provider's raw entry in config.json (`provider.<providerId>`). */
interface ProviderEntry {
name?: string;
kind?: string;
enabled?: boolean;
source?: string;
options?: { baseURL?: string; apiKey?: string; apiKeyRequired?: boolean };
models?: Record<string, unknown>;
models?: Record<string, ModelEntry | undefined>;
}

interface ConfigShape {
Expand All @@ -48,12 +55,45 @@ function apiFormatForKind(kind: string | undefined): string | undefined {
return undefined;
}

/**
* Build a single model element from a config.json model entry.
*
* Beyond `{modelId}`, the backend's schema accepts label / contextWindow /
* maxOutputTokens / reasoning. `reasoning` is the important one: without it
* the backend falls back to the apiFormat's default thought levels (a 2-state
* enabled/disabled for anthropic-messages), losing the provider's real
* variants (e.g. max/high/low) — the session then shows a wrong thought-level
* dropdown. config.json's `variants`/`defaultVariant` map to the protocol's
* `levels`/`defaultLevel`.
*
* Exported: runtime-model.ts reuses it for the `runtimeModel` overlay — both
* paths must carry identical model definitions or the overlay (resume /
* setModel) silently downgrades the session back to the 2-state default.
*/
export function buildModelElement(modelId: string, m: ModelEntry): Record<string, unknown> {
const el: Record<string, unknown> = { modelId };
if (m.name) el.label = m.name;
if (m.limit?.context) el.contextWindow = m.limit.context;
if (m.limit?.output) el.maxOutputTokens = m.limit.output;
const variants = m.reasoning?.variants ?? [];
if (m.reasoning?.enabled && variants.length > 0) {
const reasoning: Record<string, unknown> = {
enabled: true,
levels: variants.map((v) => ({ value: v, label: v })),
};
if (m.reasoning.defaultVariant) reasoning.defaultLevel = m.reasoning.defaultVariant;
el.reasoning = reasoning;
}
return el;
}

/** Build a single provider element from a config.json entry. */
function buildProviderElement(providerId: string, p: ProviderEntry): Record<string, unknown> {
// models MUST be an array of {modelId} — the backend's strict schema rejects
// the object form ({modelId: {...}}) that config.json uses. Only the id is
// required; context limits live in the backend's own model catalog.
const models = Object.keys(p.models ?? {}).map((modelId) => ({ modelId }));
// models MUST be an array — the backend's strict schema rejects the object
// form ({modelId: {...}}) that config.json uses.
const models = Object.entries(p.models ?? {}).map(([modelId, m]) =>
buildModelElement(modelId, m ?? {}),
);
const el: Record<string, unknown> = {
providerId,
kind: p.kind,
Expand Down Expand Up @@ -104,10 +144,10 @@ export function buildProviderRegistry(): ProviderRegistryPayload {
return { providers, generatedAt, revision };
}

/** Stable short hash over provider ids + kind + baseURL (revision gate). */
/** Stable short hash over provider ids + kind + baseURL + models (revision gate). */
function hashRevision(providers: ReadonlyArray<Record<string, unknown>>): string {
const sig = providers
.map((p) => `${p.providerId}|${p.kind ?? ""}|${p.baseURL ?? ""}`)
.map((p) => `${p.providerId}|${p.kind ?? ""}|${p.baseURL ?? ""}|${JSON.stringify(p.models ?? [])}`)
.sort()
.join("\n");
// FNV-1a 32-bit → hex; cheap, dependency-free, stable.
Expand Down
13 changes: 9 additions & 4 deletions src/config/runtime-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
* See provider-registry.ts.
*/

import { buildModelElement, type ModelEntry } from "./provider-registry.js";
import {
findProviderConfig,
formatModelValue,
Expand Down Expand Up @@ -63,10 +64,14 @@ export function buildRuntimeModel(ref: ModelRef, revision = "bridge"): unknown |
return null;
}
const baseURL = p.options?.baseURL ?? DEFAULT_BASE_URL;
const models =
Object.keys(p.models ?? {}).length > 0
? Object.keys(p.models ?? {}).map((m) => ({ modelId: m }))
: [{ modelId: ref.modelId }];
// Model elements must carry the full definition (reasoning variants /
// contextWindow / label) — a bare {modelId} overlay makes the backend fall
// back to the apiFormat's default 2-state thought levels (enabled/disabled),
// silently resetting the session's max/high/low dropdown on resume/switch.
const models = Object.entries(p.models ?? {}).map(([modelId, m]) =>
buildModelElement(modelId, (m ?? {}) as ModelEntry),
);
if (models.length === 0) models.push({ modelId: ref.modelId });
const provider: Record<string, unknown> = {
providerId: ref.providerId,
kind: p.kind ?? DEFAULT_KIND,
Expand Down
66 changes: 61 additions & 5 deletions src/handlers/server-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ export async function handleServerRequests(
const mySid = turn?.zcodeSid;

for (;;) {
// Turn cancelled: drain + decline this session's requests directly instead
// of forwarding each to the editor (which races a 100ms cancel-poll). The
// backend can keep re-emitting permission/elicitation requests after a stop
// while it finalises; forwarding them creates a tight
// forward→cancel-abort→decline→re-emit loop that starves the event loop
// and freezes the UI (observed ~2/s sustained, accelerating until hang).
// Declining inline breaks the cycle: the backend gets an immediate answer
// per request and stops re-emitting once its stop finalisation completes.
// Non-busy errors and multi-attempt transient retries are unaffected —
// those exit the turn loop before re-entering here.
if (mySid !== undefined && turn?.cancelled) {
let declined = false;
const drained = backend.pollServerRequests();
for (const req of drained) {
const sid = (req.params as { sessionId?: string }).sessionId;
if (sid === undefined || sid === mySid) {
sendZcodeReply(backend, req.id, {
action: "decline",
reason: "turn cancelled",
});
declined = true;
} else {
backend.requeueServerRequests([req]); // not ours — leave for owner
}
}
return handled || declined;
}
const all = backend.pollServerRequests();
if (all.length === 0) return handled;
// Without a session filter (no turn), process everything — legacy path.
Expand Down Expand Up @@ -561,6 +588,14 @@ async function requestWithTimeout(
timeoutMs = INTERACTION_TIMEOUT_MS,
turn?: PendingTurn,
): Promise<InteractionResult> {
// Each racer may register timers / listeners that outlive the race. Collect
// disposers so we can tear them all down once ANY racer wins — otherwise the
// turn-cancel setInterval keeps firing its `warn + resolve` every 100ms for
// the rest of the process lifetime once turn.cancelled sticks true, producing
// an unbounded `aborted (turn cancelled)` storm that starves the event loop.
const disposers: Array<() => void> = [];
const settled = { done: false };

// Build the racers. The primary is the client request itself.
const racers: Array<Promise<InteractionResult>> = [
cx.request(method, params as never).catch((e: unknown) => {
Expand All @@ -577,15 +612,15 @@ async function requestWithTimeout(
if (signal || closed) {
racers.push(
new Promise<typeof INTERRUPTED>((resolve) => {
let done = false;
const fire = () => {
if (done) return;
done = true;
if (settled.done) return;
settled.done = true;
warn(` ⚠ ${label} aborted (client connection closed)`);
resolve(INTERRUPTED);
};
signal?.addEventListener("abort", fire);
closed?.then(fire).catch(() => {});
if (signal) disposers.push(() => signal.removeEventListener("abort", fire));
if (closed) closed.then(fire).catch(() => {});
}),
);
}
Expand All @@ -595,10 +630,13 @@ async function requestWithTimeout(
racers.push(
new Promise<typeof INTERRUPTED>((resolve) => {
const t = setTimeout(() => {
if (settled.done) return;
settled.done = true;
warn(` ⚠ ${label} timed out after ${timeoutMs}ms`);
resolve(INTERRUPTED);
}, timeoutMs);
t.unref?.();
disposers.push(() => clearTimeout(t));
}),
);
}
Expand All @@ -609,17 +647,35 @@ async function requestWithTimeout(
new Promise<typeof INTERRUPTED>((resolve) => {
const cancelTimer = setInterval(() => {
if (turn.cancelled) {
if (settled.done) return;
settled.done = true;
warn(` ⚠ ${label} aborted (turn cancelled)`);
resolve(INTERRUPTED);
}
}, 100);
// unref so this polling interval cannot keep the event loop alive.
cancelTimer.unref?.();
disposers.push(() => clearInterval(cancelTimer));
}),
);
}

return Promise.race(racers);
const winner = await Promise.race(racers);
// Mark settled BEFORE disposing: the primary racer (the client request)
// resolves without touching settled.done, so a later `closed.then(fire)`
// would otherwise pass its guard and emit a spurious "aborted (client
// connection closed)" warn long after a normal completion.
settled.done = true;
// Tear down every racer's timer/listener so the losers don't leak. The
// turn-cancel interval is the critical one: without this it fires forever.
for (const dispose of disposers) {
try {
dispose();
} catch {
/* best-effort cleanup */
}
}
return winner;
}

/**
Expand Down
Loading
Loading