diff --git a/src/backend/credentials.ts b/src/backend/credentials.ts index 1508343..8953e06 100644 --- a/src/backend/credentials.ts +++ b/src/backend/credentials.ts @@ -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. */ @@ -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 ?? "", }; diff --git a/src/config/model-cache.ts b/src/config/model-cache.ts index 5187a36..4f1bbe6 100644 --- a/src/config/model-cache.ts +++ b/src/config/model-cache.ts @@ -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"; @@ -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; @@ -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); diff --git a/src/config/options.ts b/src/config/options.ts index a29e394..32010a2 100644 --- a/src/config/options.ts +++ b/src/config/options.ts @@ -40,6 +40,15 @@ interface ConfigShape { provider?: Record; } +/** + * 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; @@ -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, + }, ]; } } @@ -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, }; } @@ -203,9 +216,12 @@ export async function buildConfigOptions( zcodeSid: string | null, ): Promise { 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 @@ -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) ?? {}; - 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>) ?? []; if (tlAvail.length > 0) { thoughtOptions = tlAvail.map((a) => ({ value: a.value, name: a.label ?? a.value })); @@ -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, ); diff --git a/src/config/provider-registry.ts b/src/config/provider-registry.ts index 1aee0f3..f441acb 100644 --- a/src/config/provider-registry.ts +++ b/src/config/provider-registry.ts @@ -19,6 +19,13 @@ import { readFileSync } from "node:fs"; import { ZCODE_CREDS_PATH, log } from "../utils.js"; +/** A model entry in config.json (`provider..models.`). */ +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.`). */ interface ProviderEntry { name?: string; @@ -26,7 +33,7 @@ interface ProviderEntry { enabled?: boolean; source?: string; options?: { baseURL?: string; apiKey?: string; apiKeyRequired?: boolean }; - models?: Record; + models?: Record; } interface ConfigShape { @@ -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 { + const el: Record = { 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 = { + 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 { - // 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 = { providerId, kind: p.kind, @@ -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>): 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. diff --git a/src/config/runtime-model.ts b/src/config/runtime-model.ts index 4c36452..a89ef97 100644 --- a/src/config/runtime-model.ts +++ b/src/config/runtime-model.ts @@ -26,6 +26,7 @@ * See provider-registry.ts. */ +import { buildModelElement, type ModelEntry } from "./provider-registry.js"; import { findProviderConfig, formatModelValue, @@ -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 = { providerId: ref.providerId, kind: p.kind ?? DEFAULT_KIND, diff --git a/src/handlers/server-requests.ts b/src/handlers/server-requests.ts index 80a9f08..b205022 100644 --- a/src/handlers/server-requests.ts +++ b/src/handlers/server-requests.ts @@ -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. @@ -561,6 +588,14 @@ async function requestWithTimeout( timeoutMs = INTERACTION_TIMEOUT_MS, turn?: PendingTurn, ): Promise { + // 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> = [ cx.request(method, params as never).catch((e: unknown) => { @@ -577,15 +612,15 @@ async function requestWithTimeout( if (signal || closed) { racers.push( new Promise((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(() => {}); }), ); } @@ -595,10 +630,13 @@ async function requestWithTimeout( racers.push( new Promise((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)); }), ); } @@ -609,17 +647,35 @@ async function requestWithTimeout( new Promise((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; } /** diff --git a/src/handlers/session.ts b/src/handlers/session.ts index f4f233c..0579f17 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -156,6 +156,14 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): // promise is stored before any concurrent caller can observe the entry. const creating = (async () => { const backend = server.ensureBackend(); + // Push the provider registry BEFORE session/create: the backend resolves + // the session's default model against the registry, and without the + // provider's reasoning/model definitions it falls back to the bare + // anthropic channel (2-state thought: enabled/disabled) instead of the + // real provider (max/high/low). Also covers third-party providers for + // later model switches (provider_not_configured). Best-effort — a failed + // push logs and continues, the session still works over the fallback. + await syncProviderRegistry(server, pending.cwd); // Client-provided MCP servers (ACP session/new mcpServers) ride along // when the lazy session materializes. The backend accepts the ACP array // shape verbatim; the verified merge behaviour is additive (client @@ -169,12 +177,7 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): createParams.mcpServers = pending.mcpServers; log(`session/create carrying ${pending.mcpServers.length} client MCP server(s)`); } - const resp = await backend.request( - server.nextId(), - "session/create", - createParams, - 15000, - ); + const resp = await backend.request(server.nextId(), "session/create", createParams, 15000); if (resp.error) { throw new Error(`zcode create failed: ${resp.error.message ?? ""}`); } @@ -191,11 +194,6 @@ export async function ensureRealSession(server: ZcodeAcpServer, acpSid: string): log(`session/new ${acpSid} → created ${sid} (lazy, on first use)`); server.ensureBackgroundListener(sid); - // Push the provider registry so third-party providers in config.json are - // recognised by this isolated backend subprocess. Must happen before any - // model switch / turn that targets a non-builtin provider. - await syncProviderRegistry(server, pending.cwd); - // Sync to the App's tasks-index.sqlite so the App UI shows this session. // Best-effort; failures are logged inside upsertSessionTask and swallowed. const { upsertSessionTask } = await import("../tasks-index.js"); @@ -484,9 +482,15 @@ export async function prompt( zcodeSid, cancelled: false, }; - await withPreemptLock(server, zcodeSid, () => { + // True when this send cancelled another in-flight prompt (preempt/stop). + // Drives the turn-attribution gate: only a preempted prompt can see leftover + // events from a prior turn in its listener queue; without preemption any + // events before this turn's turn.started belong to a backend-owned turn + // (e.g. auto-resumed after compaction) that this send was steered into. + let preempted = false; + await withPreemptLock(server, zcodeSid, async () => { server.pendingTurns.set(requestId, turn); - return preemptInFlightTurn(server, zcodeSid, requestId); + preempted = preemptInFlightTurn(server, zcodeSid, requestId); }); const listener = new EventStreamListener(backend, zcodeSid); @@ -556,22 +560,69 @@ export async function prompt( } const chunkMsgId = randomUUID(); - const sendResp = await backend.request( - server.nextId(), - "session/send", + + // Send the prompt, retrying while the backend reports it's still busy. + // The backend's prompt lock is the single authoritative readiness signal: + // a rejected send (code 1308 "prompt is running") means a previous turn + // (cancelled, preempted, or still finalising) hasn't released the lock + // yet. Rather than guessing when the backend is ready — or blocking on a + // local shadow flag — we retry with a fixed delay until the backend + // accepts. This covers the preempt path (new prompt interrupting an + // in-flight one) and the stop-recovery window after a manual cancel. + const SEND_RETRY_INTERVAL_MS = 500; + const SEND_RETRY_TIMEOUT_MS = 30_000; + const sendParams = attachments.length > 0 ? { sessionId: zcodeSid, content: text, attachments } - : { sessionId: zcodeSid, content: text }, - 15000, - ); - if (sendResp.error) { - // send failed/timeout. Don't fire stop here: a send failure usually - // means the turn never started (no lock to leak). Mirrors Python which - // just returns the error without stopping. - throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`); + : { sessionId: zcodeSid, content: text }; + const sendT0 = Date.now(); + let sendAttempt = 0; + while (true) { + if (turn.cancelled) { + stopBackendTurn(server, zcodeSid); + return { stopReason: "cancelled" }; + } + sendAttempt++; + // Wait before sending when a recent cancel/preempt makes a busy reject + // likely — right after stop the backend is in its recovery window and + // will reject an immediate send. On the first attempt with no recent + // cancel, send immediately so normal prompts aren't delayed. + const recentCancel = server.lastCancelledAt.get(zcodeSid); + const expectBusy = + sendAttempt > 1 || + (recentCancel !== undefined && Date.now() - recentCancel < SEND_RETRY_TIMEOUT_MS); + if (expectBusy) { + await sleep(SEND_RETRY_INTERVAL_MS); + if (turn.cancelled) { + stopBackendTurn(server, zcodeSid); + return { stopReason: "cancelled" }; + } + } + const sendResp = await backend.request(server.nextId(), "session/send", sendParams, 15000); + if (!sendResp.error) { + const accepted = (sendResp.result ?? {}) as { accepted?: boolean }; + if (accepted.accepted) break; // backend took it → turn starts + throw new Error("zcode send not accepted"); + } + const sendErrCode = sendResp.error.code; + const sendErrMsg = (sendResp.error.message ?? "").toLowerCase(); + const isBusy = + sendErrCode === 1308 || + sendErrMsg.includes("prompt is running") || + sendErrMsg.includes("already running"); + if (!isBusy) { + // Non-busy error (auth, malformed, etc.) — don't retry, surface it. + throw new Error(`zcode send failed: ${sendResp.error.message ?? ""}`); + } + if (Date.now() - sendT0 > SEND_RETRY_TIMEOUT_MS) { + throw new Error( + `zcode send failed: backend still busy after ${Math.round(SEND_RETRY_TIMEOUT_MS / 1000)}s (${sendResp.error.message ?? ""})`, + ); + } + log( + ` [send] backend busy (${sendResp.error.message ?? ""}), retrying in ${SEND_RETRY_INTERVAL_MS}ms`, + ); } - const accepted = (sendResp.result ?? {}) as { accepted?: boolean }; - if (!accepted.accepted) throw new Error("zcode send not accepted"); try { // Event-driven turn loop: translate events via EventTranslator + dispatch. @@ -584,6 +635,7 @@ export async function prompt( params.sessionId, chunkMsgId, turn, + preempted, ); // Session title: set once on the first end_turn, but ONLY for freshly @@ -713,10 +765,10 @@ export async function cancel( ): Promise { const zcodeSid = server.resolveSid(params.sessionId); if (!zcodeSid) return; - // Cancel ALL matching turns for this session (not just the first). During a - // preempt-wait, pendingTurns holds both the old turn (already cancelled by - // preempt) and the new queued prompt; breaking on the first match would - // leave the queued prompt running. The stopSent guard dedupes the backend + // Cancel ALL matching turns for this session (not just the first). While a + // prior turn is still finalising, pendingTurns holds both it and any newer + // prompt waiting on the backend's prompt lock; breaking on the first match + // could leave the live one running. The stopSent guard dedupes the backend // stop call across turns and repeated cancels. for (const [, turn] of server.pendingTurns) { if (turn.zcodeSid === zcodeSid) { @@ -754,9 +806,9 @@ class TurnFailedError extends Error { * presence), never wait for a response, never throw. * * The turn-loop cancel site calls this once (guarded by turn.stopSent), then - * keeps looping until the backend emits turn.completed/turn.failed. preempt - * waits on pendingTurns deletion — which only happens after that backend - * completion event — so it transitively waits for the backend to be done. + * keeps looping until the backend emits turn.completed/turn.failed. The + * backend's prompt lock releases when ITS finalisation completes — that, + * not any bridge-side signal, is what the next prompt's send-retry waits on. */ function stopBackendTurn(server: ZcodeAcpServer, zcodeSid: string): void { try { @@ -778,9 +830,9 @@ function stopBackendTurn(server: ZcodeAcpServer, zcodeSid: string): void { * entering its section sees this turn in its preempt scan. Without this lock, * two near-simultaneous prompts could both scan before either registers. * - * The body may be async and long-running (preempt waits up to 35s for the old - * turn to exit); that is acceptable because the turn loop itself runs OUTSIDE - * this lock — only registration + preempt-in-wait are serialized. + * The body is async only to satisfy the lock chain (registration is + * synchronous; preempt no longer waits). The turn loop itself runs OUTSIDE + * this lock — only registration + preempt are serialized. */ function withPreemptLock( server: ZcodeAcpServer, @@ -808,65 +860,52 @@ function withPreemptLock( } /** - * Cancel any other in-flight turn for this zcodeSid and wait for it to fully - * exit (pendingTurns cleaned) before returning. - * - * Must be called from inside a preempt lock section (the caller has already - * registered itself in pendingTurns), so a concurrent prompt entering its own - * section is guaranteed to see this caller's turn and cancel it. + * Cancel any other in-flight turn for this zcodeSid: fire `session/stop` and + * signal the old turn to stop retrying, then return immediately. * - * `session/stop` is fired here, immediately, for the same reason the cancel - * handler fires it eagerly: the old turn loop is blocked behind long awaits - * (permission popups, per-event dispatch, tool-result backend calls), so a - * deferred stop would lag by the remaining await window and the old turn - * would visibly keep running. The loop's `stopSent` guard skips a second - * send. See `cancel()` for the idempotency rationale. + * We do NOT wait for the old turn's runEventTurn to exit. Previously this spun + * on `pendingTurns` deletion (the old turn's finally), but that signal only + * proves "the old turn's loop returned" — NOT "the backend is ready for a new + * turn". Waiting on it blocked the new prompt in a long loading state while + * the backend's stop-recovery window elapsed, and it still didn't prevent the + * next send from racing the backend. The backend's prompt lock is the only + * authoritative readiness signal: the new prompt's `session/send` retries + * until the lock releases, so there is nothing useful to wait for here. * - * We then wait on pendingTurns deletion (the old turn's prompt() finally - * block) — that only runs after runEventTurn returns, which only happens once - * the backend emits turn.completed/turn.failed. With stop already sent, the - * backend aborts in milliseconds, so this wait is short. + * The old turn's runEventTurn ends on its own once it sees a terminal event + * from the backend (turn.completed/turn.failed after stop). Until then it + * keeps dispatching whatever the backend sends for this session — which is + * correct, because within a single session the backend is the single source + * of truth and its events should reach the client. * - * Best-effort: never throws. On timeout, continues anyway. + * Exported for unit tests (multi-turn pendingTurns scenarios). */ -async function preemptInFlightTurn( +export function preemptInFlightTurn( server: ZcodeAcpServer, zcodeSid: string, selfRequestId: number, -): Promise { - // Find any in-flight turn for this session that isn't this request. - let oldRequestId: number | undefined; +): boolean { + // Cancel ALL matching turns (mirrors cancel()): pendingTurns can hold more + // than one entry for this session — e.g. an already-cancelled turn still + // finalising plus the live one. Breaking on the first match could hit the + // stale entry and leave the live turn running, so the new prompt's send + // would retry against a busy backend for 30s and fail. The stopSent guard + // dedupes the backend stop call across turns. + let found = false; for (const [reqId, turn] of server.pendingTurns) { - if (turn.zcodeSid === zcodeSid && reqId !== selfRequestId) { - oldRequestId = reqId; - turn.cancelled = true; // signal the old turn loop to silent-drain - if (!turn.stopSent) { - stopBackendTurn(server, zcodeSid); - turn.stopSent = true; - } - // Record cancel time (same recovery-window rationale as cancel()). - server.lastCancelledAt.set(zcodeSid, Date.now()); - break; - } - } - if (oldRequestId === undefined) return; // no in-flight turn, proceed - - log(` [preempt] in-flight turn ${oldRequestId} found, cancelling`); - - // Wait for the old turn to fully exit. With stop already fired above, the - // backend aborts quickly and emits turn.completed; the old turn loop sees - // translator.turnDone and returns, then prompt()'s finally deletes the - // pendingTurns entry — which is what we are waiting on here. - const PREEMPT_TIMEOUT_MS = 120_000; - const t0 = Date.now(); - while (server.pendingTurns.has(oldRequestId)) { - if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) { - warn(` [preempt] timed out waiting for old turn ${oldRequestId} to exit`); - return; + if (turn.zcodeSid !== zcodeSid || reqId === selfRequestId) continue; + turn.cancelled = true; // signal the old turn to stop its retry loops + if (!turn.stopSent) { + stopBackendTurn(server, zcodeSid); + turn.stopSent = true; } - await sleep(200); + // Record cancel time so the prompt()'s send-retry can use the recovery + // window as a hint (see session/send retry loop). + server.lastCancelledAt.set(zcodeSid, Date.now()); + log(` [preempt] in-flight turn ${reqId} cancelled, proceeding without waiting`); + found = true; } - log(` [preempt] old turn ${oldRequestId} exited, proceeding`); + return found; } // ---------- internals ---------- @@ -1090,15 +1129,12 @@ async function runEventTurn( acpSid: string, chunkMsgId: string, turn: PendingTurn, + preempted: boolean, ): Promise { const backend = server.ensureBackend(); const translator = new EventTranslator(); differ.resetTurn(); const NO_PROGRESS_MS = 120_000; - // Backend's GLM API connection cleanup window after a mid-stream abort. - // Measured: a prompt sent <18s after cancel stalls 80-120s; ≥20s recovers - // to normal. 25s covers the tail of the recovery distribution. - const CANCEL_RECOVERY_WINDOW_MS = 25_000; let lastProgress = Date.now(); let lastStallCheck = Date.now(); let emittedText = false; @@ -1123,27 +1159,18 @@ async function runEventTurn( } if (turn.cancelled) { - // Send stop ONCE, then keep looping to wait for the backend's turn - // completion event. Returning immediately here would let the old - // turn's prompt() exit (deleting pendingTurns) BEFORE the backend - // finishes processing stop — the next prompt's subscribe/send then - // collides with the still-finalizing backend (observed 18-41s - // recovery window). By continuing the loop we block until the - // backend emits turn.completed/turn.failed (translator.turnDone - // below), the real "backend done" signal. pendingTurns stays until - // then, so the next prompt's preempt waits on it. - // - // But continuing the loop must NOT keep pushing output to the UI: - // session/cancel is a notification, so the editor unlocks the input - // box the instant it is sent. Once cancelled we switch to a silent - // drain — events are still translated (to detect turnDone) but every - // dispatch point below is skipped, so the user's stop takes effect on - // screen immediately while we still wait for the backend to truly end. + // Cancel requested: ensure stop was fired (cancel()/preempt normally do + // this, but guard anyway). We do NOT silence subsequent events here — if + // the backend ignored the stop and kept producing, that content is still + // valuable to the user and should be displayed (the backend is the single + // source of truth within a session). Cross-turn contamination is handled + // separately by the turn-attribution gate below, which discards this + // turn's leftover events from the *next* turn's queue. The loop exits + // normally on the terminal event (translator.turnDone below). if (!turn.stopSent) { stopBackendTurn(server, turn.zcodeSid); turn.stopSent = true; } - // Fall through to pollEvent (silently). } const ev = await listener.pollEvent(500); @@ -1170,8 +1197,9 @@ async function runEventTurn( } // Stall reconciliation: probe authoritative status after 15s of silence. // Skipped while cancelled: we've already fired stop, so the backend will - // emit its own completion event, and the reconciliation branch's - // fetchLastReply/sendTextChunk would push output after the user stopped. + // emit its own completion event, and this branch would otherwise push + // stale output or return a wrong stopReason (end_turn / throw) after the + // user stopped. if ( !turn.cancelled && translator.turnStarted && @@ -1198,7 +1226,8 @@ async function runEventTurn( if (!emittedText) { const reply = await fetchLastReply(server, turn.zcodeSid, differ); if (reply) { - await sendTextChunk(cx, acpSid, reply, chunkMsgId); + registerFetchedReply(translator, reply); + await sendTextChunk(cx, acpSid, reply.text, chunkMsgId); } else if (!emittedOutput) { // No text and no output → suspected failure. stopBackendTurn(server, turn.zcodeSid); @@ -1221,26 +1250,10 @@ async function runEventTurn( continue; } if (proj?.status === "running") { - // Cancel-recovery fast-fail: if this session was cancelled recently - // and the new turn has stalled (turn.started emitted, then silence), - // the backend is in its model-connection recovery window — the model - // request is queued but won't produce output for tens of seconds. - // Rather than hanging 80-120s, surface a recovery hint, stop the - // stalled turn, then switch to silent drain (keep looping until the - // backend emits turn.completed) so pendingTurns isn't cleaned up - // while the backend is still finalizing — preserving the invariant - // that preemptInFlightTurn relies on. - const lastCancel = server.lastCancelledAt.get(turn.zcodeSid); - if (lastCancel && Date.now() - lastCancel < CANCEL_RECOVERY_WINDOW_MS) { - await sendTextChunk(cx, acpSid, "[后端正在从停止中恢复,请稍后重试。]", randomUUID()); - stopBackendTurn(server, turn.zcodeSid); - turn.cancelled = true; - turn.stopSent = true; - // Fall through: the cancelled branch at the top of the next - // iteration + silent drain below will wait for the backend's - // completion event before returning. - continue; - } + // Backend still working (or recovering from a stop) — keep waiting. + // The send-retry loop in prompt() already covers the recovery window + // for the NEXT turn; for this in-flight turn we just resubscribe and + // let the backend emit its terminal event when ready. lastProgress = Date.now(); await listener.resubscribe(() => server.nextId()); } @@ -1249,6 +1262,26 @@ async function runEventTurn( } lastProgress = Date.now(); + // Turn-attribution gate: before this turn's own turn.started arrives, any + // event is leftover from a prior turn (cancelled/preempted but still + // finalising) that landed in the queue while send was retrying on a busy + // backend. Discard it — including a prior turn's turn.completed, which + // would otherwise make this turn exit (cancelled) before it even begins. + // + // The gate must run BEFORE translate(): translator flags (turnDone / + // turnFailed / turnResultType) are sticky, so translating a prior turn's + // terminal event here would flip them and make THIS turn exit prematurely + // at the first check after its own turn.started passes the gate. + // + // The gate is armed ONLY when this send preempted another prompt. Without + // preemption no prior-turn residue can exist: the queue can only contain + // events of a backend-owned turn that was already active at send time + // (e.g. the main-branch turn auto-resumed after a compaction) — this send + // was steered into it and produces NO new turn.started, so dropping those + // events would silently swallow the entire turn's output in the UI. + if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, preempted)) { + continue; + } const internalEvents = translator.translate(ev); // Capture the turn-start timestamp for the thinking-phase hint above. // Done after translate so the flag flip on the turn.started event is @@ -1256,15 +1289,6 @@ async function runEventTurn( if (turnStartedAt === null && translator.turnStarted) { turnStartedAt = Date.now(); } - if (turn.cancelled) { - // Silent drain: translate advances the state machine (needed to detect - // turnDone below) but we discard every internal event. No text, reasoning, - // tool, or usage is dispatched after the user stopped. - if (translator.turnDone) { - return { stopReason: "cancelled" }; - } - continue; - } for (const iev of internalEvents) { if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") emittedText = true; if (iev.kind === "ToolCallNew" || iev.kind === "ToolCallUpdate") emittedOutput = true; @@ -1323,9 +1347,10 @@ async function runEventTurn( } if (translator.turnDone) { - // Cancel signalled via turn.completed(resultType:"cancelled"). The - // backend turn has already ended and released the lock — no stop needed. - if (translator.turnResultType === "cancelled") { + // User requested cancel (via cancel()/preempt). Whatever the backend's + // terminal resultType (cancelled / success / failed), honour the user's + // intent and report cancelled. + if (turn.cancelled || translator.turnResultType === "cancelled") { return { stopReason: "cancelled" }; } if (translator.turnFailed) { @@ -1339,23 +1364,38 @@ async function runEventTurn( // Fallback: if no text streamed, surface the last assistant reply. if (!emittedText) { const reply = await fetchLastReply(server, turn.zcodeSid, differ); - if (reply) await sendTextChunk(cx, acpSid, reply, chunkMsgId); + if (reply) { + registerFetchedReply(translator, reply); + await sendTextChunk(cx, acpSid, reply.text, chunkMsgId); + } } // Turn-completion diff: emits PlanUpdate (todos) + final usage_update, - // reconciles any snapshot-only tool events. + // reconciles any snapshot-only tool events, and replays assistant text + // that never reached the live event stream. // - // TextDelta and ReasoningDelta are deliberately filtered out here: the - // event path already streamed the assistant reply and reasoning via - // model.streaming (chunkMsgId). The differ's seenMessageIds dedup cannot - // bridge the two paths because they use different id spaces — the - // streaming path uses a client-generated chunkMsgId while the differ - // keys on the backend's message info.id. Without this filter the whole - // reply and reasoning are dispatched a second time. `fetchLastReply` - // above already covers the case where the event path delivered no text. + // TextDelta/ReasoningDelta are filtered only when the same message was + // ALREADY streamed live (dedup by backend message id — `translator` + // records `assistantMessageId` per streamed delta, the differ tags its + // replay with the same id). The differ's seenMessageIds dedup cannot + // bridge the two paths because the streaming path uses a client-generated + // chunkMsgId while the differ keys on the backend's message info.id. + // + // Without this per-message dedup the whole reply would be dispatched a + // second time; without the replay, a backend turn resumed while no + // listener was attached (e.g. the main-branch turn auto-resumed after + // compaction, before the user's next send) would leave its entire output + // invisible in the UI. `fetchLastReply` above only covers the last + // assistant message, not the whole missing span. const snapshot = await buildSnapshot(server, turn.zcodeSid); const completionEvents = differ.diff(snapshot); for (const iev of completionEvents) { - if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") continue; + if ( + (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") && + iev.messageId && + translator.deliveredMessageIds.has(iev.messageId) + ) { + continue; + } await dispatchEvent(server, cx, acpSid, iev, chunkMsgId); } // Mode reconciliation: an in-turn tool (EnterPlanMode/ExitPlanMode) can @@ -1373,6 +1413,40 @@ async function runEventTurn( return { stopReason: "max_turn_requests" }; } +/** + * Turn-attribution gate decision (pure, exported for tests): whether an event + * observed before this turn's own `turn.started` should be dropped as leftover + * residue of a prior turn. + * + * Residue only exists when this send preempted/cancelled another prompt (its + * finalising events land in the new listener's queue). Without preemption the + * queue can only carry events of a backend-owned turn already active at send + * time — e.g. the main-branch turn auto-resumed after a compaction — which + * this send was steered into and which emits no new `turn.started`; dropping + * those events would silently swallow the whole turn's output in the UI. + */ +export function shouldDropEventForTurnAttribution( + ev: { type: string }, + turnStarted: boolean, + preempted: boolean, +): boolean { + return !turnStarted && preempted && ev.type !== "turn.started"; +} + +/** + * Register a fetchLastReply-delivered message as text-delivered so the + * turn-completion diff replay doesn't dispatch the same text a second time + * (the differ never saw this message — its live events were lost — so its + * diff would re-emit the TextDelta). Reasoning is NOT registered: it was + * never streamed either, so the replay dispatching it is pure gain. + */ +function registerFetchedReply( + translator: EventTranslator, + reply: { messageId: string | null }, +): void { + if (reply.messageId) translator.deliveredMessageIds.add(reply.messageId); +} + /** * Fetch the last assistant message text as a fallback for lost text events. * @@ -1385,7 +1459,7 @@ async function fetchLastReply( server: ZcodeAcpServer, zcodeSid: string, differ: ProjectionDiffer, -): Promise { +): Promise<{ text: string; messageId: string | null } | null> { for (let attempt = 0; attempt < 4; attempt++) { const messages = await fetchMessages(server, zcodeSid); for (let i = messages.length - 1; i >= 0; i--) { @@ -1398,7 +1472,7 @@ async function fetchLastReply( const p = m.parts[j]; if (p && typeof p === "object" && (p as { type?: string }).type === "text") { const text = (p as { text?: string }).text ?? ""; - if (text.trim()) return text; + if (text.trim()) return { text, messageId: m.info?.id ?? null }; } } } diff --git a/src/tasks-index.ts b/src/tasks-index.ts index 03dd22f..ecd26fc 100644 --- a/src/tasks-index.ts +++ b/src/tasks-index.ts @@ -19,6 +19,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; +import { DEFAULT_MODEL_ID } from "./config/options.js"; import { warn, ZCODE_CREDS_PATH } from "./utils.js"; // Precise DatabaseSync constructor type from @types/node, captured without a @@ -112,7 +113,7 @@ async function withSqliteRetry( * Read provider id + model ref from config.json. * * The App stores `model` as the full `providerKey/modelId` path (e.g. - * `builtin:bigmodel-coding-plan/GLM-5.2`) — the provider map's KEY is the + * `builtin:bigmodel-coding-plan/GLM-5.3`) — the provider map's KEY is the * provider id, not the short label. We mirror that format so App-side * filtering/grouping by model treats bridge-created rows identically. * @@ -127,14 +128,14 @@ function resolveProviderModel(): { providerId: string; modelRef: string } { for (const [providerKey, p] of Object.entries(cfg.provider ?? {})) { if (p?.enabled) { const models = p.models ?? {}; - const modelId = Object.keys(models)[0] ?? "GLM-5.2"; + const modelId = Object.keys(models)[0] ?? DEFAULT_MODEL_ID; return { providerId: "glm", modelRef: `${providerKey}/${modelId}` }; } } } catch { // fall through to defaults } - return { providerId: "glm", modelRef: "GLM-5.2" }; + return { providerId: "glm", modelRef: DEFAULT_MODEL_ID }; } /** diff --git a/src/translators/event-translator.ts b/src/translators/event-translator.ts index e825458..1ee4c79 100644 --- a/src/translators/event-translator.ts +++ b/src/translators/event-translator.ts @@ -52,6 +52,15 @@ export class EventTranslator { * drops all events — that turn is owned by BackgroundTaskListener. */ private skippingBackgroundTurn = false; + /** + * Backend message ids (`assistantMessageId`) whose content reached this + * translator via the live event stream. Used by the turn loop to dedup the + * turn-completion fallback replay: a message already streamed live must not + * be re-emitted by `ProjectionDiffer.diff()`, while messages produced while + * no listener was attached (e.g. a backend turn resumed after compaction) + * have no live deltas and must be replayed. + */ + readonly deliveredMessageIds = new Set(); /** Tool call ids we've already emitted a ToolCallNew for. */ readonly seenToolIds = new Set(); @@ -159,6 +168,11 @@ export class EventTranslator { const results: InternalEvent[] = []; const kind = (payload["kind"] as string) ?? ""; const delta = (payload["delta"] as string) ?? ""; + // Record the owning assistant message so the turn loop can distinguish + // "already streamed live" from "produced while no listener was attached" + // when replaying missing content at turn completion. + const msgId = payload["assistantMessageId"]; + if (typeof msgId === "string" && msgId) this.deliveredMessageIds.add(msgId); if (kind === "text_delta") { if (delta) results.push({ kind: "TextDelta", text: delta }); diff --git a/src/translators/projection-differ.ts b/src/translators/projection-differ.ts index b6b65c4..d5c0ea3 100644 --- a/src/translators/projection-differ.ts +++ b/src/translators/projection-differ.ts @@ -115,12 +115,12 @@ export class ProjectionDiffer { } else if (ptype === "text" && role === "assistant") { const text = (p as { text?: string }).text ?? ""; if (text.trim()) { - events.push({ kind: "TextDelta", text }); + events.push({ kind: "TextDelta", text, messageId: m.info.id }); this.emittedTextThisTurn = true; } } else if (ptype === "reasoning") { const text = (p as { text?: string }).text ?? ""; - if (text.trim()) events.push({ kind: "ReasoningDelta", text }); + if (text.trim()) events.push({ kind: "ReasoningDelta", text, messageId: m.info.id }); } else if (ptype === "patch") { const ph = (p as { hash?: string }).hash; if (ph && !this.seenPatchHashes.has(ph)) { diff --git a/src/translators/types.ts b/src/translators/types.ts index 58fbcfa..315da36 100644 --- a/src/translators/types.ts +++ b/src/translators/types.ts @@ -66,11 +66,19 @@ export interface UsageDeltaEvent { export interface TextDeltaEvent { kind: "TextDelta"; text: string; + /** + * Backend message id (assistantMessageId). Set by the projection-differ's + * turn-completion fallback replay so the turn loop can dedup against + * content already streamed via events this turn; absent on live stream deltas. + */ + messageId?: string; } export interface ReasoningDeltaEvent { kind: "ReasoningDelta"; text: string; + /** See TextDeltaEvent.messageId. */ + messageId?: string; } export interface PlanUpdateEvent { diff --git a/src/utils.ts b/src/utils.ts index 5c0813e..294b4ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -60,12 +60,12 @@ export const SLASH_COMMANDS = [ { name: "model", description: "Switch the session model", - input: { hint: "GLM-5.2|GLM-5-Turbo" }, + input: { hint: "GLM-5.3|GLM-5.2|GLM-5-Turbo" }, }, { name: "thought", description: "Set the reasoning effort", - input: { hint: "max|high|nothink" }, + input: { hint: "low|high|max" }, }, { name: "quota", description: "Show remaining usage quota (5h / weekly / MCP)" }, { name: "mcp", description: "List available MCP servers" }, diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 6590d98..73a9b0e 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -36,7 +36,13 @@ const FAKE_CONFIG = { baseURL: "https://example.test/one", apiKeyRequired: true, }, - models: { "custom-model-1": { limit: { context: 128000 } } }, + models: { + "custom-model-1": { + name: "Custom Model One", + limit: { context: 128000, output: 4096 }, + reasoning: { enabled: true, variants: ["high", "max"], defaultVariant: "max" }, + }, + }, }, "custom-anthropic-kind": { name: "Custom Two", @@ -108,7 +114,44 @@ describe("buildProviderRegistry", () => { expect(p?.label).toBe("Custom One"); expect(p?.source).toBe("custom"); expect(p?.apiKeyRequired).toBe(true); - expect(p?.models).toEqual([{ modelId: "custom-model-1" }]); + expect(p?.models).toEqual([ + { + modelId: "custom-model-1", + label: "Custom Model One", + contextWindow: 128000, + maxOutputTokens: 4096, + reasoning: { + enabled: true, + levels: [ + { value: "high", label: "high" }, + { value: "max", label: "max" }, + ], + defaultLevel: "max", + }, + }, + ]); + }); + + it("maps config.json reasoning variants/defaultVariant to levels/defaultLevel", () => { + // Without reasoning the backend falls back to the apiFormat's 2-state + // thought levels (enabled/disabled) — this is the thought-dropdown bug. + const reg = buildProviderRegistry(); + const p = providerById(reg, "custom-openai-kind"); + const model = (p?.models as Array>)[0]; + expect(model.reasoning).toEqual({ + enabled: true, + levels: [ + { value: "high", label: "high" }, + { value: "max", label: "max" }, + ], + defaultLevel: "max", + }); + }); + + it("omits reasoning for models without one (plain models stay plain)", () => { + const reg = buildProviderRegistry(); + const p = providerById(reg, "builtin:primary"); + expect(p?.models).toEqual([{ modelId: "model-a", contextWindow: 128000 }]); }); it("serialises models as an array of {modelId}, NOT the config.json object form", () => { diff --git a/tests/turn-attribution.test.ts b/tests/turn-attribution.test.ts new file mode 100644 index 0000000..c3c4438 --- /dev/null +++ b/tests/turn-attribution.test.ts @@ -0,0 +1,243 @@ +/** + * Regression tests for the "compaction → UI shows nothing" bug. + * + * Root cause (sess_b3249788 incident, 2026-08-12): after a compaction the + * backend auto-resumes the main-branch turn. A user send made while that turn + * is still active is steered into it and produces NO new `turn.started`. The + * old turn-attribution gate (commit f49eb91) dropped every event until a + * `turn.started` arrived, silently swallowing the whole turn's output in the + * UI. The gate now only fires when this send preempted another prompt (the + * only case leftover residue exists), and the turn-completion replay re-emits + * assistant text that never reached the live event stream (deduped per message + * id so live-streamed content is not duplicated). + */ + +import { describe, expect, it } from "vitest"; + +import { preemptInFlightTurn, shouldDropEventForTurnAttribution } from "../src/handlers/session.js"; +import { ProjectionDiffer } from "../src/translators/projection-differ.js"; +import { EventTranslator } from "../src/translators/event-translator.js"; +import type { ZcodeMessage } from "../src/backend/types.js"; + +function ev(type: string) { + return { type }; +} + +describe("turn-attribution gate: steer into a backend-owned turn must NOT be dropped", () => { + it("keeps events when the send did not preempt anything (steer into resumed turn)", () => { + // Compression just finished; the backend resumed its main-branch turn and + // this send was steered into it — no new turn.started will ever arrive. + expect( + shouldDropEventForTurnAttribution( + ev("model.streaming"), + /* turnStarted */ false, + /* preempted */ false, + ), + ).toBe(false); + expect( + shouldDropEventForTurnAttribution( + ev("tool.updated"), + /* turnStarted */ false, + /* preempted */ false, + ), + ).toBe(false); + // Its natural completion must also pass so the loop terminates normally. + expect( + shouldDropEventForTurnAttribution( + ev("turn.completed"), + /* turnStarted */ false, + /* preempted */ false, + ), + ).toBe(false); + }); + + it("drops preempt residue only when this send cancelled another prompt", () => { + // User interrupted an in-flight prompt: leftover events of the cancelled + // turn land in the new listener's queue and must not contaminate it. + expect( + shouldDropEventForTurnAttribution( + ev("model.streaming"), + /* turnStarted */ false, + /* preempted */ true, + ), + ).toBe(true); + expect( + shouldDropEventForTurnAttribution( + ev("turn.completed"), + /* turnStarted */ false, + /* preempted */ true, + ), + ).toBe(true); + }); + + it("never drops anything once this turn's own turn.started arrived", () => { + expect( + shouldDropEventForTurnAttribution( + ev("model.streaming"), + /* turnStarted */ true, + /* preempted */ true, + ), + ).toBe(false); + expect( + shouldDropEventForTurnAttribution( + ev("turn.completed"), + /* turnStarted */ true, + /* preempted */ true, + ), + ).toBe(false); + }); + + it("never drops a turn.started event itself", () => { + expect( + shouldDropEventForTurnAttribution( + ev("turn.started"), + /* turnStarted */ false, + /* preempted */ true, + ), + ).toBe(false); + }); +}); + +describe("turn-completion replay: re-emit text never streamed live, dedup by message id", () => { + function assistantMsg(id: string, text: string, reasoning?: string): ZcodeMessage { + const parts: Array> = [{ type: "text", text }]; + if (reasoning) parts.push({ type: "reasoning", text: reasoning }); + return { info: { id, role: "assistant" }, parts }; + } + + it("tags replayed TextDelta/ReasoningDelta with the backend message id", () => { + const differ = new ProjectionDiffer(); + const events = differ.diff({ + projection: { status: "idle" }, + messages: [assistantMsg("msg_missing_1", "output produced while no listener was attached")], + }); + const text = events.find((e) => e.kind === "TextDelta"); + expect(text).toEqual({ + kind: "TextDelta", + text: "output produced while no listener was attached", + messageId: "msg_missing_1", + }); + }); + + it("translator records assistantMessageId from the live stream", () => { + const t = new EventTranslator(); + t.translate({ + type: "model.streaming", + payload: { kind: "text_delta", delta: "live text", assistantMessageId: "msg_live_1" }, + }); + expect(t.deliveredMessageIds.has("msg_live_1")).toBe(true); + }); + + it("streamed-then-replayed messages are skipped, missing ones are kept", () => { + // Baseline: a message the differ has seen is never re-emitted. + const differ = new ProjectionDiffer(); + differ.markSeen([assistantMsg("msg_old", "previous turn")]); + + const events = differ.diff({ + projection: { status: "idle" }, + messages: [ + assistantMsg("msg_live_1", "streamed live"), + assistantMsg("msg_missing_2", "produced while no listener attached"), + ], + }); + + // Turn-loop side of the dedup (mirrors runEventTurn): skip deltas whose + // message id was already delivered via the event stream. + const translator = new EventTranslator(); + translator.deliveredMessageIds.add("msg_live_1"); + const replayed = events.filter( + (e) => + !( + (e.kind === "TextDelta" || e.kind === "ReasoningDelta") && + e.messageId && + translator.deliveredMessageIds.has(e.messageId) + ), + ); + const texts = replayed.filter((e) => e.kind === "TextDelta"); + expect(texts).toHaveLength(1); + expect(texts[0]).toMatchObject({ kind: "TextDelta", messageId: "msg_missing_2" }); + }); +}); + +describe("gate placement: residue must be dropped BEFORE translate", () => { + it("prior turn's turn.completed must not flip sticky turnDone on this turn's translator", () => { + // Regression: the gate used to run AFTER translate(), so a preempted + // turn's residue turn.completed flipped the fresh translator's sticky + // turnDone flag. This turn's own turn.started then passed the gate and + // the very next turnDone check exited the loop immediately — the new + // prompt returned "cancelled"/zero output at its own start event. + const translator = new EventTranslator(); + const preempted = true; + const events = [ + { type: "turn.completed", payload: { resultType: "cancelled" } }, // prior turn's residue + { type: "turn.started" }, // this turn's own start + { type: "model.streaming", payload: { kind: "text_delta", delta: "hi" } }, + { type: "turn.completed", payload: { resultType: "success" } }, // this turn's real end + ]; + let exitResultType: string | null = null; + let delivered = 0; + for (const ev of events) { + // Mirrors runEventTurn ordering: gate BEFORE translate, turnDone check + // after dispatch. + if (shouldDropEventForTurnAttribution(ev, translator.turnStarted, preempted)) continue; + translator.translate(ev as never); + delivered++; + if (translator.turnDone) { + exitResultType = translator.turnResultType; + break; + } + } + // Must terminate on ITS OWN success event (3 events translated), not on + // the residue (which with the old ordering exited after 2 with + // resultType="cancelled"). + expect(delivered).toBe(3); + expect(exitResultType).toBe("success"); + }); +}); + +describe("preemptInFlightTurn cancels ALL matching turns", () => { + it("skips the stale entry and stops the live one too (not just the first match)", () => { + // Regression: breaking on the first pendingTurns match could hit an + // already-cancelled-but-still-finalising turn and leave the LIVE turn + // running — the new prompt then retried against a busy backend for 30s. + const mkTurn = (cancelled: boolean, stopSent: boolean) => ({ + zcodeSid: "zs_1", + cancelled, + stopSent, + }); + const pendingTurns = new Map>([ + [101, mkTurn(true, true)], // stale: cancelled by an earlier preempt, finalising + [102, mkTurn(false, false)], // live: the one that MUST be stopped + ]); + const sends: Array<{ method: string; sid: string }> = []; + const server = { + pendingTurns, + lastCancelledAt: new Map(), + ensureBackend: () => ({ + send: (method: string, params: { sessionId: string }) => + sends.push({ method, sid: params.sessionId }), + }), + }; + const preempted = preemptInFlightTurn(server as never, "zs_1", 103); + expect(preempted).toBe(true); + expect(pendingTurns.get(101)?.cancelled).toBe(true); + expect(pendingTurns.get(102)?.cancelled).toBe(true); + expect(pendingTurns.get(102)?.stopSent).toBe(true); + // stopBackendTurn fires once (stopSent guard dedupes across both turns). + expect(sends).toEqual([{ method: "session/stop", sid: "zs_1" }]); + }); + + it("returns false when no other turn exists for the session", () => { + const pendingTurns = new Map< + number, + { zcodeSid: string; cancelled: boolean; stopSent: boolean } + >([[201, { zcodeSid: "zs_other", cancelled: false, stopSent: false }]]); + const server = { + pendingTurns, + lastCancelledAt: new Map(), + ensureBackend: () => ({ send: () => {} }), + }; + expect(preemptInFlightTurn(server as never, "zs_1", 202)).toBe(false); + expect(pendingTurns.get(201)?.cancelled).toBe(false); + }); +});