From 05e54d270fbd9de86bd73f082467303ea82b1aa3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 08:33:04 -0700 Subject: [PATCH 01/19] fix: offer Altimate Base to every user on an implicit free Zen default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1301. The 0.11.0 migration to Altimate Base only fired when `opencode/big-pickle` was in persisted `recent`, which only a picker writes. 1,027 of 1,031 Big Pickle machines were implicit-default users with no `recent`, so on 0.11.x they were silently rerouted to the next public free Zen model (`nemotron-3.5-lightning-free`) and never offered Base. TUI (`packages/tui`) - `usesImplicitFreeDefault()`: eligibility is now "launch default resolves to a free public OpenCode Zen model (Big Pickle included), no explicit selection, no project provider allowlist", judged on `fallbackModel()` so a restored session model is never mistaken for the default. - One startup decision in `app.tsx` replaces the two racing effects: prior decline → skip; registered Base → migrate silently; returning user (prompt history at startup, project sessions, or a recent model) → migration disclosure; a brand-new user keeps the first-run welcome picker. A `dialog.replace()` that loses a race falls through instead of latching. - Escape / Ctrl-C on the migration disclosure now routes through the close guard to the same path as explicit No: the decline is persisted AND the provider picker opens, instead of stranding the user on the retired model. - Migration copy names the actual free model being moved. - Telemetry: `altimate_base_confirm_shown` / `altimate_base_choice` / `altimate_base_register_result` now fire for the migration origin (they were gated on the first-run funnel, so the migration was invisible), with an `origin` field. - `PromptHistory` exposes `loaded()` and a one-time `hadHistoryAtStartup()`. Server (`packages/opencode`) - `Provider.defaultModel()` and the ACP default scan: a registered Altimate Base now outranks the keyless public Zen tier (`options.apiKey === "public"`) and nothing else; a keyed Zen account or any BYOK provider still wins. Headless hosts (`serve` behind the VS Code extension) therefore land on Base once the install is registered. Tests: `test/context/local.test.ts` (free-Zen detection, eligibility, recents rewrite), `test/cli/tui/dialog-altimate-base.test.tsx` (migration telemetry without first-run, Escape → decline + picker, generalized copy), `test/provider/provider.test.ts` and `test/acp/default-model.test.ts` (public tier vs keyed Zen vs registered Base, recents precedence unchanged, Big Pickle never migrated headlessly). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012uzYijGHdhxvpUzhgWxayo --- docs/docs/configure/providers.md | 1 + docs/docs/reference/telemetry.md | 4 +- packages/opencode/src/acp/service.ts | 49 +++- .../opencode/src/altimate/telemetry/index.ts | 4 +- packages/opencode/src/provider/provider.ts | 38 ++- .../opencode/test/acp/default-model.test.ts | 161 ++++++++++- .../opencode/test/provider/provider.test.ts | 82 +++++- packages/tui/src/app.tsx | 195 ++++++++----- .../tui/src/component/altimate-onboarding.tsx | 123 ++++++-- packages/tui/src/context/local.tsx | 258 ++++++++++++++++- .../tui/src/context/onboarding-telemetry.tsx | 9 +- packages/tui/src/prompt/history.tsx | 31 +- packages/tui/src/ui/dialog.tsx | 61 ++-- .../cli/tui/dialog-altimate-base.test.tsx | 269 ++++++++++++++++-- .../tui/test/component/welcome-panel.test.tsx | 7 +- packages/tui/test/context/local.test.ts | 165 +++++++++++ 16 files changed, 1273 insertions(+), 184 deletions(-) diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index 67fd570822..47d2f8bd77 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -68,6 +68,7 @@ registration request; **No** is selected by default. After registration, the mod explicit model is selected. Big Pickle is retired as a new selection — it no longer appears in the picker or the full model catalog for users choosing a model for the first time. Users already on Big Pickle are still detected on launch and offered Altimate Base through the same consent gate. +If you decline the default switch, `declinedManagedBaseDefault: true` in the state directory's `model.json` keeps public Zen ahead of registered Base for headless and ACP defaults, with Base used only as a last resort; accepting migration or explicitly selecting Base clears the flag. Official release binaries embed the current gateway endpoint at build time. Operators and local development can override it without changing code: diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index df5c491e35..5f1dad6e69 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -57,8 +57,8 @@ We collect the following categories of events: | `onboarding_started` | The first-run setup gate opened (fresh launch with no usable model). | | `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Altimate Base, and from the prompt gate. | | `provider_selected` | A provider row was chosen — `altimate_gateway`, `altimate_base`, `anthropic`, `openai`, `google`, `search_all`, or `other` for anything outside the curated five. `provider_id` carries the raw id only for publicly-known providers, so a provider you named yourself in config is reported as `other` with no name attached. `via_search` marks a pick made inside the full catalogue after choosing "Search all providers…". **Choosing search emits this event twice for one user** — once as `search_all`, then again with the provider actually chosen — so count distinct users or filter on `via_search`, not raw event count. Recorded at the moment of choice, so a sign-in that is then cancelled still counts. | -| `altimate_base_confirm_shown` / `altimate_base_choice` | The Altimate Base disclosure was shown (`welcome` or `model` origin), and what the user decided (`accept`/`cancel`). | -| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. No credential or gateway response body is included. | +| `altimate_base_confirm_shown` / `altimate_base_choice` | The Altimate Base disclosure was shown, and what the user decided (`accept`/`cancel`). `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base on launch); required for the disclosure event and optional for the choice event. | +| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. Optional `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base on launch). No credential or gateway response body is included. | | `gateway_device_code_issued` | The Altimate Gateway authorize URL was built and the browser open attempted. **Name note:** the flow is a browser loopback OAuth — there is no device code. The name follows the original event spec. | | `gateway_auth_completed` / `gateway_auth_failed` | Gateway sign-in outcome. `reason` is `timeout`, `denied`, or `error` — never the underlying message, which can contain the instance name. An unrecognised callback state does not reject the pending attempt, so a CSRF mismatch surfaces as `timeout`. | | `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. No instance or tenant name is sent. | diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index b69c30eeac..5a4a0ff1e2 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -811,10 +811,14 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { // `model: "altimate-free/altimate-base"` alongside any `provider` allowlist end up with a // `defaultModel` pointing at a provider this snapshot had just excluded — ACP would still // select and route the managed model even though it was hidden from `modelOptions`. + // The async snapshot loader also honors the TUI's persisted recents and default-switch decline. + const { recent, declinedManagedBaseDefault } = await Provider.readDefaultModelState() const defaultModel = defaultModelFromConfig( config?.model, snapshotProviders, config?.provider as Record | undefined, + declinedManagedBaseDefault, + recent, ) // altimate_change end ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) @@ -856,6 +860,10 @@ export function defaultModelFromConfig( configuredModel: string | undefined, providers: Record, providerFilter?: Record, + // altimate_change start — persisted recents and default-switch consent, normalized by the shared state reader + declinedManagedBaseDefault = false, + recent: Awaited>["recent"] = [], + // altimate_change end ): Directory.DefaultModel | undefined { // altimate_change start — fork Provider ids are branded ProviderID/ModelID; re-brand to core ProviderV2.ID/ModelV2.ID (identity at runtime) const configured = configuredModel @@ -869,6 +877,15 @@ export function defaultModelFromConfig( const configuredProviderEntries = Object.keys(providerFilter ?? {}) const hasProviderAllowlist = configuredProviderEntries.length > 0 + for (const entry of recent) { + const providerID = ProviderV2.ID.make(entry.providerID) + const modelID = ModelV2.ID.make(entry.modelID) + if (!providers[providerID]?.models[modelID]) continue + // Match Provider.defaultModel(): only managed Base recents are restricted by an allowlist. + if (entry.providerID === "altimate-free" && hasProviderAllowlist) continue + return { providerID, modelID } + } + // Prefer altimate-backend/altimate-default when the fork's backend is available and the user // hasn't pinned a model — restores dropped fork behavior (the merge fell straight through to the // opencode provider, routing ACP clients away from altimate's backend). Honors an explicit @@ -882,11 +899,21 @@ export function defaultModelFromConfig( return { providerID: ProviderV2.ID.make("altimate-backend"), modelID: ModelV2.ID.make("altimate-default") } } - // First-session ACP startup must not scan historical sessions just to infer - // a default. Configured model, opencode provider, then sorted best model keep - // the protocol response deterministic without extra session/message reads. + // First-session ACP startup must not scan historical sessions just to infer a default. + // Recents above come from model.json, not session storage. After configured/recent choices + // and the backend preference, use the opencode provider, then the sorted best model, + // without extra session/message reads. + const baseProvider = providers[ProviderV2.ID.make("altimate-free")] + const registeredBaseAvailable = Boolean(baseProvider?.models[ModelV2.ID.make("altimate-base")]) && !hasProviderAllowlist const providerAllowed = (id: string) => - id !== "altimate-free" && (!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id)) + id !== "altimate-free" && + (!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id)) && + !( + registeredBaseAvailable && + !declinedManagedBaseDefault && + id === "opencode" && + providers[ProviderV2.ID.make(id)]?.options.apiKey === "public" + ) const opencodeProvider = providerAllowed("opencode") ? providers[ProviderV2.ID.make("opencode")] : undefined const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models)).find((model) => model.id !== "big-pickle") @@ -901,12 +928,14 @@ export function defaultModelFromConfig( ).find((model) => !(model.providerID === "opencode" && model.id === "big-pickle")) if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) } - // Altimate Base replaces Big Pickle as the free fallback, but only as a LAST resort and only - // after the user consented and registered (which is why it is present in `providers`). Anything - // else connected outranks the request-logging tier. A project provider block cannot force the - // managed model; an explicit configured model above remains authoritative. - const baseProvider = providers[ProviderV2.ID.make("altimate-free")] - if (!hasProviderAllowlist && baseProvider?.models[ModelV2.ID.make("altimate-base")]) { + // Altimate Base replaces Big Pickle as the free fallback only after the user consented and + // registered (which is why it is present in `providers`). Anything the user actually connected + // outranks the request-logging tier, except the keyless public Zen tier, which ranks below + // registered Base unless the user declined the default switch in model.json. After a decline, + // public Zen stays in both scans and Base is only the last resort. A keyed Zen account still + // wins. A project provider block cannot force the managed model; an explicit configured model + // above remains authoritative. + if (registeredBaseAvailable) { return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") } } return undefined diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 2b9cc25c32..eccd2a3c49 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -1027,18 +1027,20 @@ export namespace Telemetry { type: "altimate_base_confirm_shown" timestamp: number session_id: string - origin: "welcome" | "model" + origin: "welcome" | "model" | "migration" } | { type: "altimate_base_choice" timestamp: number session_id: string + origin?: "welcome" | "model" | "migration" choice: "accept" | "cancel" } | { type: "altimate_base_register_result" timestamp: number session_id: string + origin?: "welcome" | "model" | "migration" result: "success" | "rate_limited" | "unavailable" | "network" | "error" } | { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9aee4337d0..b07bbf45cc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2193,12 +2193,26 @@ export namespace Provider { ) } - // altimate_change start — discard malformed persisted model references before use + // altimate_change start — normalize persisted model references and default-switch consent function isModelReference(model: unknown): model is { providerID: ProviderID; modelID: ModelID } { if (!model || typeof model !== "object") return false const value = model as Record return typeof value.providerID === "string" && typeof value.modelID === "string" } + + // Share the TUI's persisted default-switch consent with headless and ACP selection. + // Missing, unreadable, or malformed state preserves the existing default behavior. + export async function readDefaultModelState() { + return Filesystem.readJson<{ + recent?: { providerID: ProviderID; modelID: ModelID }[] + declinedManagedBaseDefault?: boolean + }>(path.join(Global.Path.state, "model.json")) + .then((state) => ({ + recent: Array.isArray(state?.recent) ? state.recent.filter(isModelReference) : [], + declinedManagedBaseDefault: state?.declinedManagedBaseDefault === true, + })) + .catch(() => ({ recent: [], declinedManagedBaseDefault: false })) + } // altimate_change end export async function defaultModel() { @@ -2218,11 +2232,7 @@ export namespace Provider { const baseModelID = ModelID.make(FreeTier.MODEL_ID) const baseProvider = providers[baseProviderID] const registeredBaseAvailable = Boolean(baseProvider?.models[baseModelID]) && !hasProviderAllowlist - const recent = (await Filesystem.readJson<{ recent?: { providerID: ProviderID; modelID: ModelID }[] }>( - path.join(Global.Path.state, "model.json"), - ) - .then((x) => (Array.isArray(x.recent) ? x.recent.filter(isModelReference) : [])) - .catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[] + const { recent, declinedManagedBaseDefault } = await readDefaultModelState() for (const entry of recent) { // A recent entry is the user's own last pick, so it is never rewritten here — not even a // legacy Big Pickle one. The TUI owns the migration because it owns the disclosure, and @@ -2258,9 +2268,12 @@ export namespace Provider { // altimate_change end // altimate_change start — select registered Altimate Base and never select Big Pickle implicitly - // Altimate Base owns the free fallback role that used to belong to Big Pickle, but only as a - // LAST resort. Anything the user has actually connected outranks the request-logging tier, so - // adding a paid key never silently routes prompts to the free gateway. A project provider + // Altimate Base owns the free fallback role that used to belong to Big Pickle. Anything the + // user has actually connected outranks the request-logging tier; the keyless public Zen tier + // ranks below registered Base unless the user declined the default switch in model.json. + // After a decline, public Zen stays in the scan and Base is only the last resort. + // A keyed Zen account still wins, so adding a paid key never silently routes prompts to the + // free gateway. A project provider // block cannot force the managed model; an explicit `model` setting above remains // authoritative. // Base is excluded from the ordinary scan so it can only be reached by the last-resort branch @@ -2270,6 +2283,13 @@ export namespace Provider { ) if (candidates.length === 0 && !registeredBaseAvailable) throw new Error("no providers found") for (const provider of candidates) { + if ( + registeredBaseAvailable && + !declinedManagedBaseDefault && + provider.id === "opencode" && + provider.options.apiKey === "public" + ) + continue const model = sort(Object.values(provider.models)).find( (candidate) => !(provider.id === "opencode" && candidate.id === "big-pickle"), ) diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 701628f5ee..b926ffebd9 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -2,6 +2,9 @@ // rewrote defaultModelFromConfig and dropped the fork's "prefer altimate-backend/altimate-default" // behavior, routing ACP clients (Zed/editors) to the opencode provider instead of altimate's backend. import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "@/global" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -82,6 +85,158 @@ describe("ACP defaultModelFromConfig", () => { }) }) + test("registered Altimate Base outranks public Zen in both implicit scans", () => { + const zen = provider("opencode", ["big-pickle", "nemotron-3-super-free"]) + zen.options.apiKey = "public" + const result = ACPService.defaultModelFromConfig( + undefined, + providers(zen, provider("altimate-free", ["altimate-base"])), + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + }) + + test.each([ + { + name: "public Zen recent outranks registered Base", + recent: ["opencode/nemotron-3-super-free"], + expected: "opencode/nemotron-3-super-free", + }, + { name: "unloaded provider recent is ignored", recent: ["missing/model"], expected: "altimate-free/altimate-base" }, + { name: "missing model recent is ignored", recent: ["opencode/missing"], expected: "altimate-free/altimate-base" }, + { + name: "first available recent wins", + recent: ["missing/model", "opencode/missing", "opencode/nemotron-3-super-free", "altimate-free/altimate-base"], + expected: "opencode/nemotron-3-super-free", + }, + { + name: "Base recent is skipped with an allowlist even when included", + recent: ["altimate-free/altimate-base", "opencode/nemotron-3-super-free"], + filter: { "altimate-free": {}, opencode: {} }, + expected: "opencode/nemotron-3-super-free", + }, + { + name: "non-managed recent retains precedence outside the allowlist", + recent: ["opencode/nemotron-3-super-free"], + filter: { "altimate-backend": {} }, + expected: "opencode/nemotron-3-super-free", + }, + { + name: "configured model outranks recents", + configured: "altimate-free/altimate-base", + recent: ["opencode/nemotron-3-super-free"], + expected: "altimate-free/altimate-base", + }, + { name: "no recents preserves the Base fallback", recent: [], expected: "altimate-free/altimate-base" }, + ])("$name", ({ recent, filter, configured, expected }) => { + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.options.apiKey = "public" + const expectedModel = Provider.parseModel(expected) + expect( + ACPService.defaultModelFromConfig( + configured, + providers(zen, provider("altimate-free", ["altimate-base"])), + filter, + false, + recent.map(Provider.parseModel), + ), + ).toEqual({ + providerID: ProviderV2.ID.make(expectedModel.providerID), + modelID: ModelV2.ID.make(expectedModel.modelID), + }) + }) + + test.each([ + { flag: true, providerID: "opencode", modelID: "nemotron-3-super-free" }, + { flag: false, providerID: "altimate-free", modelID: "altimate-base" }, + { flag: undefined, providerID: "altimate-free", modelID: "altimate-base" }, + { flag: "yes", providerID: "altimate-free", modelID: "altimate-base" }, + ])("honors persisted default-switch decline flag $flag", async ({ flag, providerID, modelID }) => { + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(stateFile, JSON.stringify({ recent: [], declinedManagedBaseDefault: flag })) + const zen = provider("opencode", ["big-pickle", "nemotron-3-super-free"]) + zen.options.apiKey = "public" + const state = await Provider.readDefaultModelState() + const result = ACPService.defaultModelFromConfig( + undefined, + providers(zen, provider("altimate-free", ["altimate-base"])), + undefined, + state.declinedManagedBaseDefault, + state.recent, + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make(providerID), + modelID: ModelV2.ID.make(modelID), + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + } + }) + + test("a decline still permits Base as the last resort and as an explicit choice", () => { + const available = providers(provider("altimate-free", ["altimate-base"]), provider("opencode", ["big-pickle"])) + for (const configured of [undefined, "altimate-free/altimate-base"]) { + expect(ACPService.defaultModelFromConfig(configured, available, undefined, true)).toEqual({ + providerID: ProviderV2.ID.make("altimate-free"), + modelID: ModelV2.ID.make("altimate-base"), + }) + } + }) + + test("a keyed Zen account outranks registered Altimate Base even with zero-cost models", () => { + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.key = "test-zen-key" + const result = ACPService.defaultModelFromConfig( + undefined, + providers(zen, provider("altimate-free", ["altimate-base"])), + ) + expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) + }) + + test("a self-hosted provider with zero-cost metadata still outranks registered Base", () => { + const local = provider("local-llm", ["llama-3"]) + local.options = { apiKey: "public", baseURL: "http://localhost:11434/v1" } + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.options.apiKey = "public" + const result = ACPService.defaultModelFromConfig( + undefined, + providers(zen, provider("altimate-free", ["altimate-base"]), local), + ) + expect(result?.providerID).toBe(ProviderV2.ID.make("local-llm")) + }) + + test("public Zen stays available without registered Base or when a provider allowlist excludes Base", () => { + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.options.apiKey = "public" + expect(ACPService.defaultModelFromConfig(undefined, providers(zen))?.providerID).toBe(ProviderV2.ID.make("opencode")) + expect( + ACPService.defaultModelFromConfig( + undefined, + providers(zen, provider("altimate-free", ["altimate-base"])), + { opencode: {} }, + )?.providerID, + ).toBe(ProviderV2.ID.make("opencode")) + }) + + test("an explicitly configured public Zen model still outranks registered Base", () => { + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.options.apiKey = "public" + const result = ACPService.defaultModelFromConfig( + "opencode/nemotron-3-super-free", + providers(zen, provider("altimate-free", ["altimate-base"])), + ) + expect(result).toEqual({ + providerID: ProviderV2.ID.make("opencode"), + modelID: ModelV2.ID.make("nemotron-3-super-free"), + }) + }) + test("never chooses Big Pickle implicitly", () => { expect( ACPService.defaultModelFromConfig(undefined, providers(provider("opencode", ["big-pickle"]))), @@ -145,9 +300,9 @@ describe("ACP defaultModelFromConfig", () => { }) test("a connected paid provider outranks registered Altimate Base", () => { - // Base logs requests, so it must never win over something the user actually connected. ACP has - // no recent-model list, so without this ordering a registered user with an Anthropic key would - // silently route every new session to the free logging tier. + // Base logs requests, so absent a configured model or persisted recent, it must never win over + // something the user actually connected. Otherwise a registered user with an Anthropic key + // would silently route every new session to the free logging tier. const result = ACPService.defaultModelFromConfig( undefined, providers(provider("altimate-free", ["altimate-base"]), provider("anthropic", ["claude-sonnet-4"])), diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 96bc76b6f1..729a20f19b 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -189,6 +189,7 @@ test("an Altimate Base-only provider block cannot select an unrelated provider", } }) +// altimate_change start — registered Base outranks only public Zen, preserving connected and recent choices test("a connected provider outranks registered Altimate Base as the implicit default", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", @@ -199,9 +200,13 @@ test("a connected provider outranks registered Altimate Base as the implicit def await using tmp = await tmpdir({ config: { provider: {} } }) await provideProviderTestInstance({ directory: tmp.path, + init: async () => Env.set("OPENCODE_API_KEY", "test-zen-key"), fn: async () => { - // Altimate Base logs requests, so it is only ever the LAST resort. Anything the user has - // actually connected wins, and `provider: {}` still does not act as an allowlist. + // A keyed Zen account outranks Base, and `provider: {}` is not an allowlist. + const providers = await Provider.list() + expect(providers.opencode.key).toBe("test-zen-key") + expect(providers.opencode.options.apiKey).not.toBe("public") + expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() const model = await Provider.defaultModel() expect(model).not.toEqual({ providerID: ProviderID.make(FreeTier.PROVIDER_ID), @@ -215,6 +220,79 @@ test("a connected provider outranks registered Altimate Base as the implicit def } }) +test.each([ + { flag: true, providerID: "opencode", modelID: "gpt-5-nano" }, + { flag: false, providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, + { flag: undefined, providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, + { flag: "yes", providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }, +])("public Zen versus registered Base with persisted decline flag $flag", async ({ flag, providerID, modelID }) => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(stateFile, JSON.stringify({ recent: [], declinedManagedBaseDefault: flag })) + await using tmp = await tmpdir({ + config: { provider: {}, enabled_providers: ["opencode", FreeTier.PROVIDER_ID] }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.remove("OPENCODE_API_KEY"), + fn: async () => { + const providers = await Provider.list() + expect(Object.keys(providers).sort()).toEqual([FreeTier.PROVIDER_ID, "opencode"]) + expect(providers.opencode.options.apiKey).toBe("public") + expect(providers.opencode.models["nemotron-3-super-free"]).toBeDefined() + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) + +test("a persisted public Zen recent outranks registered Altimate Base", async () => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "nemotron-3-super-free" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.remove("OPENCODE_API_KEY"), + fn: async () => { + const providers = await Provider.list() + expect(providers.opencode.options.apiKey).toBe("public") + expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("nemotron-3-super-free"), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } +}) +// altimate_change end + test("a persisted Big Pickle default is not silently migrated headlessly", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3bb029b426..afa81dd93d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -58,7 +58,9 @@ import { SDKProvider, useSDK } from "./context/sdk" import { StartupLoading } from "./component/startup-loading" import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" -import { LocalProvider, useLocal } from "./context/local" +// altimate_change — fixes #1301 (Codex review, P2): `ALTIMATE_BASE_MIGRATION_DECLINED_KEY` moved +// to local.tsx so `local.model.hasUsableFreeDefault()` can read the same kv key. +import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY } from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" @@ -74,7 +76,9 @@ import { DialogConsoleOrg } from "./component/dialog-console-org" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" import { Session } from "./routes/session" -import { PromptHistoryProvider } from "./component/prompt/history" +// altimate_change — fixes #1301: `usePromptHistory` also carries a "returning user" signal +// (`hadHistoryAtStartup`) that the startup migration decision below consults. +import { PromptHistoryProvider, usePromptHistory } from "./component/prompt/history" import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" @@ -115,10 +119,6 @@ import { cliErrorMessage, errorFormat } from "./util/error" import { detectModeFromCOLORFGBG } from "./terminal-detection" // altimate_change end -// altimate_change start — remember an explicit migration decline without suppressing later manual setup -const ALTIMATE_BASE_MIGRATION_DECLINED_KEY = "altimate_base_big_pickle_migration_declined_v1" -// altimate_change end - const appGlobalBindingCommands = [ "session.list", "session.new", @@ -436,6 +436,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() + // altimate_change start — fixes #1301: "returning user" signal for the startup migration + // decision below; see prompt/history.tsx. + const promptHistory = usePromptHistory() + // altimate_change end const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const clipboard = useClipboard() @@ -607,82 +611,113 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const trackOnboarding = useOnboardingTelemetry() // altimate_change end - // altimate_change start — move the retired Big Pickle default to Altimate Base - // Already-registered users migrate immediately. Everyone else sees the existing logging - // disclosure first; an explicit No is remembered and leaves their model untouched. - let legacyModelMigrationHandled = false + // altimate_change start — fixes #1301: move the retired Big Pickle default — and more broadly + // any implicit free OpenCode Zen default — to Altimate Base. Already-registered users migrate + // immediately. Returning users who are not yet registered see the existing logging disclosure + // first; an explicit No is remembered and leaves their model untouched. A brand-new user (no + // history anywhere) falls straight through to the ordinary first-run picker below: the + // migration disclosure reads as "your existing default changed," which is meaningless on a + // first launch. + // + // SINGLE DECISION: this used to be two independent `createEffect`s — one deciding migration, + // one deciding the first-run picker — each guarded only by its own one-shot latch. That missed + // every path where migration exits WITHOUT showing a dialog (silent migration, a prior + // decline, no consent operation available), and separately a `dialog.replace()` that loses a + // race to another dialog and returns `false`: latching "handled" before checking the replace + // result would suppress the first-run picker without migration ever actually being shown. + // Merging both into one decision, made once, closes both gaps: there is exactly one launch-time + // verdict — migrate silently, show the migration disclosure, or fall through to today's + // first-run logic — and only ONE of those branches is allowed to latch "handled". + let startupDecisionHandled = false + // Armed only when THIS launch starts genuinely un-onboarded (so the Part 2 scan + // gate below fires after the user completes first-run setup — not for a returning + // user whose onboardingReady merely flips false→true once sync loads providers). + let armScanGate = false createEffect(() => { - if (legacyModelMigrationHandled) return - if (!ready() || sync.status !== "complete" || !local.model.ready) return - if (!local.model.usesLegacyDefault()) { - legacyModelMigrationHandled = true - return - } + if (startupDecisionHandled) return + // Decide only once the plugin host has started, sync has finished loading providers, the + // persisted model selection has loaded, AND prompt history has loaded. `ready()` alone is + // plugin-host startup, which can settle before sync populates `sync.data.provider` — + // deciding then would transiently see a returning (connected) user as un-onboarded and + // re-show the picker + scan gate (see the regression this effect guards against, above). + // `sync.status` is the provider-load signal (same one used for continue/fork above). + // `local.model.ready` guards a parallel race: `model.json`'s read is async, and if provider + // sync finishes first, the legacy/returning checks below would see an empty recent list and + // misclassify a returning user as fresh. `promptHistory.loaded()` guards the same race for + // the "returning user" signal immediately below. + if (!ready() || sync.status !== "complete" || !local.model.ready || !promptHistory.loaded()) return - // A previous decline is checked FIRST, before registration state. Registering Altimate Base - // for one task is not consent to move a Big Pickle default that the user already refused to - // move; without this the decline is silently overridden on every later launch. - if (kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false)) { - legacyModelMigrationHandled = true - return - } + // altimate_change — fixes #1301: a user is "returning" if there is any sign of prior use + // anywhere this TUI persists it: prompt history (independent of the current project's + // session list, and not windowed to the last 30 days the way session sync is), the current + // project's own session list, or a picker-written recent model. `hadHistoryAtStartup()` is a + // one-time snapshot — a prompt sent during THIS launch must not retroactively make the launch + // look like a return visit. + const returning = + promptHistory.hadHistoryAtStartup() || sync.data.session.length > 0 || local.model.recent().length > 0 - const altimateBaseAvailable = sync.data.provider.some( - (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), - ) - if (altimateBaseAvailable) { - legacyModelMigrationHandled = true - local.model.migrateLegacyDefault() - return - } + // ---- Migration ---- + // A previous decline is checked FIRST, before registration state or eligibility. Registering + // Altimate Base for one task is not consent to move a free default that the user already + // refused to move; without this the decline is silently overridden on every later launch. + const previouslyDeclined = kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) + if (!previouslyDeclined && local.model.usesLegacyDefault()) { + const altimateBaseAvailable = sync.data.provider.some( + (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), + ) + // altimate_change — fixes #1301 (Codex review round 2, P1): an older picker-written Zen + // recent (predating the `explicitDefault` marker) is still the user's own past pick, not a + // truly implicit default — silently sweeping it into Base when registered skips the + // disclosure entirely. Big Pickle keeps today's behavior (always silent when registered); + // see `hasOwnPickOfImplicitDefault`'s declaration in local.tsx. + if (altimateBaseAvailable && !local.model.hasOwnPickOfImplicitDefault()) { + startupDecisionHandled = true + local.model.migrateLegacyDefault() + return + } - // altimate_change — the registration operation lives in its own dedicated context now, not on - // `sdk`; see context/altimate-base-consent.tsx. - if (!altimateBaseConsent) { - legacyModelMigrationHandled = true - return + // altimate_change — the registration operation lives in its own dedicated context now, not + // on `sdk`; see context/altimate-base-consent.tsx. A brand-new (non-returning) user never + // sees this disclosure — see the block comment above. + if (altimateBaseConsent && returning) { + const shown = dialog.replace(() => ( + { + kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true) + // altimate_change — fixes #1301 (Codex review, P1): the kv key alone is invisible + // to headless/server default selection (`Provider.defaultModel()`, ACP). Persist + // the same refusal into `model.json`, which the server already reads, so a decline + // made in the TUI is honored there too. + local.model.declineManagedBaseDefault() + }} + /> + )) + if (shown) { + startupDecisionHandled = true + return + } + // `dialog.replace` lost a race to another dialog and returned false without opening + // anything — fall through to first-run logic below instead of latching "handled" on a + // dialog nobody actually saw. + } + // Not registered, no consent operation available, or a brand-new user: no migration + // dialog this launch. Fall through to the ordinary first-run logic below. } - legacyModelMigrationHandled = true - dialog.replace(() => ( - kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true)} - /> - )) - }) - // altimate_change end - - // altimate_change start — AI-7774: first-run onboarding gate. On a fresh launch - // with no usable model, open the curated provider picker as the entry point (chat - // input stays visible; submit is gated in the prompt until setup completes). Fire - // EXACTLY once, and only after startup has settled (`ready()` = plugin host + - // sync bootstrap done), so a returning user with valid credentials never sees it. - let firstRunPickerHandled = false - // Armed only when THIS launch starts genuinely un-onboarded (so the Part 2 scan - // gate below fires after the user completes first-run setup — not for a returning - // user whose onboardingReady merely flips false→true once sync loads providers). - let armScanGate = false - createEffect(() => { - if (firstRunPickerHandled) return - // Decide only once the plugin host has started, sync has finished loading providers, AND the - // persisted model selection has loaded. `ready()` alone is plugin-host startup, which can - // settle before sync populates `sync.data.provider` — deciding then would transiently see a - // returning (connected) user as un-onboarded and re-show the picker + scan gate (see the - // regression this effect guards against, above). `sync.status` is the provider-load signal - // (same one used for continue/fork above). `local.model.ready` guards the same race the - // migration effect above already does: `model.json`'s read is async, and if provider sync - // finishes first, `hasExistingLegacySelection` below would see an empty recent list and - // misclassify a returning Big Pickle user as fresh. - if (!ready() || sync.status !== "complete" || !local.model.ready) return - // A Big Pickle selection proves this is an existing user, even though that zero-cost - // provider does not satisfy useConnected(). The migration effect above owns any consent - // prompt; never overwrite it with the first-run picker. - if (local.model.hasExistingLegacySelection()) { - firstRunPickerHandled = true + // ---- First-run onboarding gate ---- + // On a fresh launch with no usable model, open the curated provider picker as the entry + // point (chat input stays visible; submit is gated in the prompt until setup completes). + // A Big Pickle (or other legacy implicit) selection proves this is an existing user, even + // though that zero-cost provider does not satisfy useConnected(). The migration branch above + // owns any consent prompt for that case; never overwrite it with the first-run picker. + // altimate_change — fixes #1301 (Codex review, P2): `hasUsableFreeDefault()` covers the + // broader case — a free Zen model the user explicitly picked, or already declined migrating + // away from — the same way `hasExistingLegacySelection()` always covered Big Pickle. + if (local.model.hasExistingLegacySelection() || local.model.hasUsableFreeDefault()) { + startupDecisionHandled = true return } - firstRunPickerHandled = true if (onboardingReady()) { // Not necessarily a returning user. The prompt gate (component/prompt/index.tsx) opens the // same picker as soon as the user tries to submit, which can happen BEFORE sync finishes @@ -691,6 +726,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // exactly the impatient-user case. setupComplete() is the discriminator: it starts false // every launch and is only set by a setup the user completed during THIS one, so a genuine // returning user never trips this branch. + startupDecisionHandled = true if (setupComplete()) { // Deliberately NOT markFirstRunActive(): its only clear is markSetupComplete(), which has // already run on this branch and will not run again, so setting it here would latch the @@ -705,12 +741,21 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } return } + // altimate_change start — fixes #1301 (Codex review, P2): latch (and arm the scan gate) only + // AFTER a successful replacement, not before. `dialog.replace()` can lose a race to another + // dialog and return `false` without opening anything; latching first left the decision + // "handled" and the scan gate armed for a picker nobody ever saw. Telemetry and + // `markFirstRunActive()` move with it — emitting "the first-run flow started" for a picker + // that never opened would be equally wrong. + const shown = dialog.replace(() => ) + if (!shown) return armScanGate = true markFirstRunActive() // altimate_change — funnel: top of the first-run flow. Emitted only on the branch that // actually opens the gate, so returning users never enter the funnel. trackOnboarding({ name: "onboarding_started" }) - dialog.replace(() => ) + startupDecisionHandled = true + // altimate_change end }) // altimate_change end diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index ca23d24a58..7474280fea 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -4,7 +4,7 @@ // Base disclosure. Imports back into dialog-model are runtime-only (used inside // callbacks/JSX), so the circular reference is safe. import { createEffect, createMemo, createSignal, For, Show, onMount, onCleanup } from "solid-js" -import { useLocal } from "../context/local" +import { useLocal, isLegacyBigPickleModel } from "../context/local" import { useDialog } from "../ui/dialog" import { useTheme, selectedForeground } from "../context/theme" import { TextAttributes, RGBA } from "@opentui/core" @@ -68,7 +68,17 @@ export function resetSetupComplete() { } export function useReady() { const connected = useConnected() - return createMemo(() => connected() || setupComplete()) + // altimate_change start — fixes #1301 (Codex review, P2): a free public Zen model the user + // either chose on purpose or already declined migrating away from is a legitimate way to use + // the product, not "un-onboarded." Without this term, a returning free-default user who is + // explicit or already said No gets treated as not-ready on every relaunch — the first-run + // welcome picker reopens (see the first-run effect in app.tsx) and prompt submission itself + // reopens the picker and discards whatever was typed (see `useReady()`'s callers in + // component/prompt/index.tsx). `LocalProvider` wraps the whole app above `DialogProvider` (see + // app.tsx), so `useLocal()` is always available to every caller of `useReady()`. + const local = useLocal() + return createMemo(() => connected() || setupComplete() || local.model.hasUsableFreeDefault()) + // altimate_change end } /** @@ -407,29 +417,79 @@ export function DialogAltimateBaseConfirm(props: { let decided = false let choiceRecorded = false let disposed = false - const releaseCloseGuard = dialog.guardClose(() => !busy()) + // altimate_change start — fixes #1301: the migration origin never entered the first-run funnel + // at all (it was gated on `firstRunActive()`, which migration never sets), so the disclosure + // that matters most for measuring the fix was invisible to telemetry. Migration is still not + // FIRST-RUN onboarding, so it stays out of the `firstRunActive()`-gated events below, but it + // gets its own unconditional emission with `origin: "migration"` on every event. + // + // `lastCloseReason` remembers which kind of close the guard most recently saw (`"dismiss"` for + // Escape/Ctrl+C AND the backdrop click — `dialog.tsx`'s `dismiss()`, wired to the backdrop + // specifically; `"programmatic"` for this dialog's own `clear()`/`replace()` or an unrelated + // feature's — see `ui/dialog.tsx`) so the `onCleanup` fallback below can tell them apart too. + let lastCloseReason: "dismiss" | "programmatic" | undefined + const releaseCloseGuard = dialog.guardClose((reason) => { + lastCloseReason = reason + // Escape/Ctrl-C close through `DialogProvider`'s keymap binding (`closeTop()`), which calls + // this guard BEFORE the dialog's own `useKeyboard` below ever sees the key — so intercepting + // in `useKeyboard` alone would be too late; the dialog would already be gone. The backdrop + // click reaches here the same way, via `dialog.tsx`'s `dismiss()` (fixes #1301, Codex review + // round 2, P2: it used to call `clear()`, i.e. "programmatic", so clicking outside the dialog + // silently skipped both the decline AND the picker that keyboard Escape gets). This dialog's + // own visible "esc" label calls `no()` directly instead of going through the guard at all — + // see its `onMouseUp` below. For a migration DISMISSAL from any of these, veto the close and + // run the same routing `no()` does (persist the decline, open the picker) on a microtask + // instead of a bare dismissal, which the retired Big Pickle model cannot silently fall back + // to. `no()` sets `decided = true` before its own `dialog.replace`, so that replace passes + // this same guard on its re-check (reason "programmatic", by then decided) and this queued + // call cannot double-fire. + // + // A PROGRAMMATIC close (this dialog's own `clear()`/`replace()`, or an unrelated feature — + // command palette, session list — replacing the dialog stack out from under this one) is left + // alone here. Neither is the user declining Altimate Base, so forcing `no()` for them turned + // harmless UI navigation into a persisted refusal plus an unwanted picker takeover. The + // `onCleanup` fallback below only persists a decline for the reasons this guard could not + // itself resolve into a decision. + if (reason === "dismiss" && props.origin === "migration" && !decided && !busy()) { + queueMicrotask(no) + return false + } + return !busy() + }) + // altimate_change end function recordChoice(choice: "accept" | "cancel") { if (choiceRecorded) return choiceRecorded = true - if (firstRunActive() && props.origin !== "migration") { - trackOnboarding({ name: "altimate_base_choice", choice }) + // altimate_change — fixes #1301: see the block comment on `releaseCloseGuard` above + if (props.origin === "migration" || firstRunActive()) { + trackOnboarding({ name: "altimate_base_choice", choice, origin: props.origin }) } } onMount(() => { - // Migration is not first-run onboarding and must not enter that funnel. - if (firstRunActive() && props.origin !== "migration") { + // altimate_change — fixes #1301: see the block comment on `releaseCloseGuard` above + if (props.origin === "migration" || firstRunActive()) { trackOnboarding({ name: "altimate_base_confirm_shown", origin: props.origin }) } }) onCleanup(() => { releaseCloseGuard() disposed = true - // Escape and click-away are handled by DialogProvider and never reach no(), but they are just - // as much a refusal. Persisting the decline here too keeps a dismissed migration prompt from - // reappearing on every launch forever. - if (!decided && props.origin === "migration") props.onDecline?.() + // altimate_change start — fixes #1301 (Codex review round 2, P2): every genuine user + // DISMISSAL is now fully handled above via `queueMicrotask(no)`, which sets `decided` before + // this ever runs — keyboard Escape/Ctrl+C AND the backdrop click, which `dialog.tsx` now + // reports as `dismiss()` (reason "dismiss"), same as this dialog's own visible "esc" label + // (see its `onMouseUp` above, which calls `no()` directly). This branch therefore never + // doubles any of those. What's left reaching here with `decided` still false is a genuinely + // PROGRAMMATIC close: this dialog's own `clear()`/`replace()` (already `decided` by then) or + // an unrelated feature (command palette, session list) replacing the dialog stack out from + // under this one. Neither is the user declining Altimate Base — an unrelated navigation must + // not silently count as a refusal (see the guard comment above) — so this does not persist + // for them. Only a close this component never saw a reason for at all (some other, untracked + // unmount path) still falls back to persisting, as a safety net. + if (!decided && props.origin === "migration" && lastCloseReason !== "programmatic") props.onDecline?.() + // altimate_change end decided = true recordChoice("cancel") }) @@ -458,10 +518,12 @@ export function DialogAltimateBaseConfirm(props: { setError(undefined) const outcome = await registerAltimateBase(altimateBaseConsent) if (disposed) return - if (firstRunActive() && props.origin !== "migration") { + // altimate_change — fixes #1301: see the block comment on `releaseCloseGuard` above + if (props.origin === "migration" || firstRunActive()) { trackOnboarding({ name: "altimate_base_register_result", result: outcome.ok ? "success" : outcome.result, + origin: props.origin, }) } if (!outcome.ok) { @@ -555,15 +617,44 @@ export function DialogAltimateBaseConfirm(props: { Use Altimate Base? - !busy() && dialog.clear()}> + {/* altimate_change start — fixes #1301 (Codex review round 2, P2): this visible label is + a user dismissal too, exactly like the keyboard key and the backdrop click — for + migration it must route through `no()` (persist the decline, open the picker), not a + bare `dialog.clear()`, or clicking it silently leaves the next server launch free to + pick Base again after a partial registration. */} + { + if (busy()) return + if (props.origin === "migration") { + no() + return + } + dialog.clear() + }} + > esc + {/* altimate_change end */} + {/* altimate_change start — fixes #1301: migration now also covers implicit free OpenCode + Zen defaults besides the retired Big Pickle id, so the copy must name whichever model + is actually being moved rather than always naming Big Pickle specifically. */} - - Big Pickle has been retired. - + + {`Your default model, ${local.model.parsed().model}, is a public free model. Altimate Base is the free model Altimate hosts for data work.`} + + } + > + + Big Pickle has been retired. + + + {/* altimate_change end */} {ALTIMATE_BASE_DISCLOSURE} diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index f7eea6237a..c94760ec65 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -12,6 +12,12 @@ import { readJson, writeJsonAtomic } from "../util/persistence" import { useTheme } from "./theme" import { useToast } from "../ui/toast" import { useRoute } from "./route" +// altimate_change — reuse the same free-tier marker `isAnyProviderConnected` uses so the two +// checks cannot silently diverge; see `isFreeZenModel` below. +import type { ConnectedProviderShape } from "../util/connected" +// altimate_change — fixes #1301 (Codex review, P2): `hasUsableFreeDefault` below needs to see the +// same migration-decline kv key app.tsx writes. +import { useKV } from "./kv" export type LocalTheme = { secondary: RGBA @@ -44,6 +50,10 @@ export const ALTIMATE_BASE_MODEL = { modelID: "altimate-base", } as const satisfies ModelRef +// altimate_change — remember an explicit migration decline without suppressing later manual +// setup. Moved here (from app.tsx) so `hasUsableFreeDefault` below can read the same key. +export const ALTIMATE_BASE_MIGRATION_DECLINED_KEY = "altimate_base_big_pickle_migration_declined_v1" + export function isModelRef(model: unknown): model is ModelRef { if (!model || typeof model !== "object") return false const value = model as Record @@ -91,6 +101,90 @@ export function isConfirmedExplicitSelection(current: unknown, explicitDefault: } // altimate_change end +// altimate_change start — fixes #1301: offer Altimate Base to every user riding an implicit free +// OpenCode Zen default, not only the retired Big Pickle id. `shouldMigrateLegacyDefault` above +// required Big Pickle in `recent`, but only a picker-driven pick ever writes `recent` — the vast +// majority of implicit-default users never touched a picker, so they were never offered Base. +export function isFreeZenModel(model: ModelRef | undefined, providers: readonly ConnectedProviderShape[]): boolean { + if (!model || model.providerID !== "opencode") return false + const provider = providers.find((item) => item.id === model.providerID) + const info = provider?.models[model.modelID] + if (!info) return false + // Same free-tier marker `util/connected.ts`'s `isAnyProviderConnected` uses: a missing cost or + // an explicit zero on the built-in `opencode` provider both mean the public free tier. + const cost = info.cost?.input + return cost == null || cost === 0 +} + +export function shouldOfferManagedBaseDefault( + current: ModelRef | undefined, + explicit: boolean, + providerConfig: unknown, + isFree: (model: ModelRef) => boolean, +): boolean { + if (explicit || !allowsManagedBaseDefault(providerConfig)) return false + if (current == null) return false + return isFree(current) +} +// altimate_change end + +// altimate_change start — fixes #1301 (Codex review, P2): pure predicate for "is the CURRENT +// model a free public Zen model the user is fine staying on" — explicitly chosen, or already +// declined migrating away from. A free default the user picked on purpose or already said No to +// moving is a legitimate way to use the product, not "un-onboarded"; without this, a returning +// free-default user who is explicit or already declined gets treated as not-ready on every +// relaunch (see `hasUsableFreeDefault`'s call site for what that breaks). +export function isUsableFreeDefault( + current: ModelRef | undefined, + isValid: (model: ModelRef) => boolean, + isFree: (model: ModelRef) => boolean, + explicit: boolean, + declined: boolean, +): boolean { + if (!current || !isValid(current)) return false + if (!isFree(current)) return false + return explicit || declined +} +// altimate_change end + +// altimate_change start — fixes #1301 (Codex review round 2, P1): an older picker-written Zen +// recent that predates the `explicitDefault` marker (see that field's declaration comment) is +// still the user's OWN past pick, not a truly implicit default — `recentModels()` only ever adds +// an entry through a deliberate `/model` pick, session restore, or this migration itself. Silent +// migration (when Base is already registered) must not sweep that up without asking; the +// disclosure stays declinable for it. Big Pickle is deliberately excluded: recents written before +// this whole distinction existed were always silently migrated, and that stays unchanged. +export function isOwnPastPickOfFreeDefault(current: ModelRef | undefined, recent: readonly ModelRef[]): boolean { + if (!current) return false + // Destructured BEFORE the `isLegacyBigPickleModel` check, not after: it is itself a type + // predicate over `ModelRef`, and TS (still, even through a `const` alias — "control flow + // analysis of aliased conditions") narrows `current` on its false branch by subtracting that + // asserted type from `current`'s already-`ModelRef` type, which collapses straight to `never` + // and breaks any later property access on `current` (same hazard `migrateLegacyRecentModels` + // documents above). + const { providerID, modelID } = current + if (isLegacyBigPickleModel(current)) return false + return recent.some((item) => item.providerID === providerID && item.modelID === modelID) +} +// altimate_change end + +// altimate_change start — fixes #1301 (Codex review round 2, P1): migration is a decision about +// the DEFAULT, not about an already-open conversation. `current` is `currentModel()` (can be a +// session-restored model, `restoreSession`/`--continue`); `previous` is the `fallbackModel()` +// captured before migration mutates anything — the implicit default actually being migrated +// away from. Only move the active agent's model when it is STILL that default (or there simply +// is no current model to preserve); a restored conversation on some other model must be left +// alone — migrating the default must not silently rewrite an unrelated open thread onto Base. +export function shouldMoveAgentModelDuringMigration( + current: ModelRef | undefined, + previous: ModelRef | undefined, +): boolean { + if (!current) return true + if (!previous) return false + return current.providerID === previous.providerID && current.modelID === previous.modelID +} +// altimate_change end + export function recentModels( model: { providerID: string; modelID: string }, recent: { providerID: string; modelID: string }[], @@ -107,11 +201,22 @@ export function recentModels( .map((item) => ({ providerID: item.providerID, modelID: item.modelID })) } -// altimate_change start — remove Big Pickle from migrated recents without touching other models -export function migrateLegacyRecentModels(recent: readonly unknown[]) { +// altimate_change start — remove Big Pickle from migrated recents without touching other models. +// `previous` additionally drops the free Zen model just migrated away from (any implicit free +// default now, not only Big Pickle) so `cycle()` does not bounce straight back onto it. +export function migrateLegacyRecentModels(recent: readonly unknown[], previous?: ModelRef) { return recentModels( ALTIMATE_BASE_MODEL, - recent.filter((model): model is ModelRef => isModelRef(model) && !isLegacyBigPickleModel(model)), + recent.filter( + // `isLegacyBigPickleModel` is checked LAST: it is itself a type predicate over `ModelRef`, + // and TS narrows `model` on its false branch by subtracting that asserted type from + // `model`'s current (already-`ModelRef`) type — which collapses straight to `never` and + // breaks the `previous` field access below if that access comes after this call instead. + (model): model is ModelRef => + isModelRef(model) && + !(previous && model.providerID === previous.providerID && model.modelID === previous.modelID) && + !isLegacyBigPickleModel(model), + ), ) } // altimate_change end @@ -125,6 +230,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const theme = useTheme().theme const route = useRoute() const paths = useTuiPaths() + // altimate_change — fixes #1301 (Codex review, P2): `hasUsableFreeDefault` reads the + // migration-decline kv key here too. `KVProvider` wraps `LocalProvider` in app.tsx, so this + // is always available. + const kv = useKV() function isModelValid(model: { providerID: string; modelID: string }) { const provider = sync.data.provider.find((item) => item.id === model.providerID) @@ -225,6 +334,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // silently overwrites it. See `hasExplicitModel` / `shouldMigrateLegacyDefault` below. explicitDefault: ModelRef | undefined // altimate_change end + // altimate_change start — fixes #1301 (Codex review, P1): a migration decline used to + // live ONLY in the TUI's kv store (app.tsx's `ALTIMATE_BASE_MIGRATION_DECLINED_KEY`), + // which headless/server default selection (`Provider.defaultModel()`, ACP) cannot see. + // Persisting it here too, alongside the rest of the model state the server already reads + // from `model.json`, lets the server-side consent gate honor the same refusal. + declinedManagedBaseDefault: boolean + // altimate_change end }>({ ready: false, model: {}, @@ -234,6 +350,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // altimate_change start — see the `explicitDefault` field declaration above explicitDefault: undefined, // altimate_change end + // altimate_change start — see the `declinedManagedBaseDefault` field declaration above + declinedManagedBaseDefault: false, + // altimate_change end }) const filePath = path.join(paths.state, "model.json") @@ -254,6 +373,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // altimate_change start — persist the last explicitly-picked model across launches explicitDefault: modelStore.explicitDefault, // altimate_change end + // altimate_change start — see the `declinedManagedBaseDefault` field declaration above + declinedManagedBaseDefault: modelStore.declinedManagedBaseDefault, + // altimate_change end }) } @@ -270,6 +392,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // altimate_change start — restore the last explicitly-picked model if (isModelRef(value.explicitDefault)) setModelStore("explicitDefault", value.explicitDefault) // altimate_change end + // altimate_change start — restore a persisted Base-migration decline + if (typeof value.declinedManagedBaseDefault === "boolean") + setModelStore("declinedManagedBaseDefault", value.declinedManagedBaseDefault) + // altimate_change end }) .catch(() => {}) .finally(() => { @@ -292,6 +418,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return isConfirmedExplicitSelection(currentModel(), modelStore.explicitDefault) } + // altimate_change start — fixes #1301 (Codex review, P1): `usesImplicitFreeDefault` below + // judges eligibility against `fallbackModel()` (the LAUNCH default), so explicitness must be + // judged against that SAME model — not `currentModel()`, which `hasExplicitModel` above + // uses and which can be a session-restored model (`restoreSession`, `--continue`) unrelated + // to what this launch would actually fall back to. Using `hasExplicitModel()` there let an + // explicit Nemotron pick read as "implicit" whenever a different conversation happened to be + // open, and `migrateLegacyDefault()` then overwrote that restored conversation's model. + // Older picker-written recents without an `explicitDefault` marker remain eligible here by + // design (see that field's declaration comment) — those users still see one declinable + // migration prompt rather than being silently exempted forever. + function hasExplicitDefault() { + if (args.model || sync.data.config.model) return true + if (agent.current()?.model) return true + return isConfirmedExplicitSelection(fallbackModel(), modelStore.explicitDefault) + } + // altimate_change end + function hasExplicitLegacyModel() { const configured = [args.model, sync.data.config.model] .filter((model): model is string => Boolean(model)) @@ -391,26 +534,76 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // A picker-driven selection, as opposed to session restore or programmatic migration — // see `hasExplicitModel` above for why this needs its own persisted marker. if (options?.explicit) setModelStore("explicitDefault", { providerID: model.providerID, modelID: model.modelID }) + // altimate_change start — fixes #1301 (Codex review round 2, P2): ANY deliberate, + // interactive explicit selection of Altimate Base clears an earlier migration decline — + // not only `/connect`'s `set()`. `cycleFavorite` below calls `selectModel` directly, so + // the clearing has to live HERE, in the one place every explicit selection funnels + // through, or favorite-cycling to Base left `declinedManagedBaseDefault` (and the + // mirrored kv key) stuck `true`, which a later headless/ACP launch still reads as a + // refusal even though the user just picked Base on purpose. Both flags are cleared + // together — see `declineManagedBaseDefault()` below for where both are SET together. + if ( + options?.explicit && + model.providerID === ALTIMATE_BASE_MODEL.providerID && + model.modelID === ALTIMATE_BASE_MODEL.modelID + ) { + setModelStore("declinedManagedBaseDefault", false) + kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) + } + // altimate_change end if (options?.recent || options?.explicit) save() selected = true }) return selected } - function usesLegacyDefault() { - return shouldMigrateLegacyDefault( - currentModel(), - modelStore.recent, - hasExplicitModel(), + // fixes #1301: evaluated against `fallbackModel()` (the LAUNCH default), not + // `currentModel()`. `currentModel()` can resolve to a session-restored model + // (`restoreSession`, `--continue`), which was never a deliberate choice either way and must + // not be mistaken for "this launch's implicit default" — see `restoreSession` below. + function usesImplicitFreeDefault() { + return shouldOfferManagedBaseDefault( + fallbackModel(), + hasExplicitDefault(), sync.data.config.provider, + (candidate) => isLegacyBigPickleModel(candidate) || isFreeZenModel(candidate, sync.data.provider), ) } + // Alias kept so existing call sites (app.tsx's migration effect, `migrateLegacyDefault` + // below) do not need to change. + const usesLegacyDefault = usesImplicitFreeDefault + + // altimate_change — fixes #1301 (Codex review round 2, P1): see `isOwnPastPickOfFreeDefault` + // above. Evaluated against the same launch default (`fallbackModel()`) eligibility is + // judged on, and app.tsx's silent-migration branch (Base already registered) consults it + // to fall back to the declinable disclosure instead. + function hasOwnPickOfImplicitDefault() { + return isOwnPastPickOfFreeDefault(fallbackModel(), modelStore.recent) + } function hasExistingLegacySelection() { return isExistingBigPickleSelection(currentModel(), modelStore.recent, hasExplicitLegacyModel()) } // altimate_change end + // altimate_change start — fixes #1301 (Codex review, P2): a free public Zen model the user + // either chose on purpose or already said No to migrating away from is a legitimate way to + // use the product, not "un-onboarded." Without this, a returning Nemotron user who + // explicitly selected it (or already declined once) sees the first-run welcome picker on + // every relaunch, re-enters the first-run funnel, and has prompt submission itself reopen + // the picker and discard whatever they typed (see `useReady()`'s callers in + // component/prompt/index.tsx). + function hasUsableFreeDefault() { + return isUsableFreeDefault( + currentModel(), + isModelValid, + (candidate) => isLegacyBigPickleModel(candidate) || isFreeZenModel(candidate, sync.data.provider), + hasExplicitDefault(), + kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) || modelStore.declinedManagedBaseDefault, + ) + } + // altimate_change end + return { current: currentModel, get ready() { @@ -496,12 +689,57 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // altimate_change start — migrate Big Pickle defaults after managed-model consent usesLegacyDefault, hasExistingLegacySelection, + // altimate_change — fixes #1301 (Codex review, P2): see `hasUsableFreeDefault`'s + // declaration above + hasUsableFreeDefault, + // altimate_change — fixes #1301 (Codex review round 2, P1): see + // `hasOwnPickOfImplicitDefault`'s declaration above + hasOwnPickOfImplicitDefault, + // altimate_change — fixes #1301 (Codex review round 2, P2/D): read-only accessor so + // callers (and tests) can check the persisted decline state directly, rather than only + // its downstream effects. + declinedManagedBaseDefault() { + return modelStore.declinedManagedBaseDefault + }, + // altimate_change start — fixes #1301 (Codex review, P1): see the + // `declinedManagedBaseDefault` field declaration above. Called from app.tsx's migration + // `onDecline`, alongside (not instead of) the existing kv-key write. + declineManagedBaseDefault() { + batch(() => { + setModelStore("declinedManagedBaseDefault", true) + save() + }) + }, + // altimate_change end migrateLegacyDefault() { if (!usesLegacyDefault() || !isModelValid(ALTIMATE_BASE_MODEL)) return false + // Capture the model being migrated away from BEFORE mutating: reading it after + // `setModelStore("model", ...)` below would see Base, not the free default being + // dropped, so `migrateLegacyRecentModels` could never actually remove it from `recent`. + // Use the LAUNCH default (`fallbackModel`), the same value eligibility was judged on: + // `currentModel()` can be a session-restored model, which must not be dropped from + // `recent` just because the implicit default moved. + const previous = fallbackModel() batch(() => { const a = agent.current() - if (a) setModelStore("model", a.name, { ...ALTIMATE_BASE_MODEL }) - setModelStore("recent", migrateLegacyRecentModels(modelStore.recent)) + // altimate_change start — fixes #1301 (Codex review round 2, P1): migration is a + // decision about the DEFAULT, not about an already-open conversation. A restored + // session (`restoreSession`, `--continue`) can be on a DIFFERENT model than the + // implicit default this migration is about — unconditionally reassigning the active + // agent's model overwrote that conversation with Base. `shouldMoveAgentModelDuringMigration` + // (a pure, directly-tested predicate — see its declaration) decides whether THIS + // conversation is still actually on the default being migrated away from. The recents + // rewrite and decline-clear below still always happen regardless — those are about + // the DEFAULT going forward, independent of what this one conversation is showing. + if (a && shouldMoveAgentModelDuringMigration(currentModel(), previous)) + setModelStore("model", a.name, { ...ALTIMATE_BASE_MODEL }) + // altimate_change end + setModelStore("recent", migrateLegacyRecentModels(modelStore.recent, previous)) + // altimate_change — fixes #1301 (Codex review round 2, P2): an explicit accept via + // migration clears any earlier decline the same way `selectModel` does for every + // other explicit Base selection (`/connect`, favorite-cycling) — both flags together. + setModelStore("declinedManagedBaseDefault", false) + kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) save() }) return true diff --git a/packages/tui/src/context/onboarding-telemetry.tsx b/packages/tui/src/context/onboarding-telemetry.tsx index e4b60eb25e..5c6f570e7d 100644 --- a/packages/tui/src/context/onboarding-telemetry.tsx +++ b/packages/tui/src/context/onboarding-telemetry.tsx @@ -34,11 +34,16 @@ export type OnboardingTelemetryEvent = /** Set when the pick came from the full catalogue, i.e. after `searchAll`. */ via_search?: boolean } - | { name: "altimate_base_confirm_shown"; origin: "welcome" | "model" } - | { name: "altimate_base_choice"; choice: "accept" | "cancel" } + // altimate_change — fixes #1301: "migration" covers a returning user whose implicit free + // default (not only the retired Big Pickle id) is offered Altimate Base on relaunch. It is + // emitted unconditionally, unlike "welcome"/"model" which stay gated behind `firstRunActive()` + // — see `component/altimate-onboarding.tsx`. + | { name: "altimate_base_confirm_shown"; origin: "welcome" | "model" | "migration" } + | { name: "altimate_base_choice"; choice: "accept" | "cancel"; origin?: "welcome" | "model" | "migration" } | { name: "altimate_base_register_result" result: "success" | "rate_limited" | "unavailable" | "network" | "error" + origin?: "welcome" | "model" | "migration" } | { name: "scan_gate_shown" } | { name: "scan_gate_choice"; choice: "scan" | "skip" | "dismissed" } diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 2dc4b2c0e6..89fb215861 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -1,5 +1,5 @@ import path from "path" -import { onMount } from "solid-js" +import { createSignal, onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "../context/helper" @@ -92,14 +92,27 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create init: () => { const paths = useTuiPaths() const historyPath = path.join(paths.state, "prompt-history.jsonl") + // altimate_change start — fixes #1301: a "returning user" signal for the startup migration + // decision in app.tsx, independent of the current project's (30-day-windowed) session list. + // `loaded()` settles (true) once this read finishes either way; `hadHistoryAtStartup()` is a + // ONE-TIME snapshot taken at that moment, not a live "history is non-empty" memo — a prompt + // sent during THIS launch must not retroactively make the launch look like a return visit. + const [loaded, setLoaded] = createSignal(false) + let hadHistoryAtStartup = false onMount(async () => { - const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) - setStore("history", lines) + try { + const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) + setStore("history", lines) + hadHistoryAtStartup = lines.length > 0 - // Rewrite valid retained entries to self-heal corruption and enforce the limit. - if (lines.length > 0) - writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) + // Rewrite valid retained entries to self-heal corruption and enforce the limit. + if (lines.length > 0) + writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) + } finally { + setLoaded(true) + } }) + // altimate_change end const [store, setStore] = createStore({ index: 0, @@ -110,6 +123,12 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }) return { + // altimate_change start — fixes #1301: see the signal declarations above + loaded, + hadHistoryAtStartup() { + return hadHistoryAtStartup + }, + // altimate_change end // altimate_change start — preserve in-progress prompt while browsing history move(direction: 1 | -1, prompt: PromptInfo) { const result = movePromptHistory({ index: store.index, draft: store.draft }, store.history, direction, prompt) diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 5ef1d5d451..c4a8c784b0 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -74,11 +74,16 @@ function init() { const renderer = useRenderer() const modeStack = useOpencodeModeStack() - // altimate_change start — allow a modal to veto every dialog replacement/close path - let closeGuard: (() => boolean) | undefined + // altimate_change start — allow a modal to veto every dialog replacement/close path. `reason` + // distinguishes a user dismissal (Escape/Ctrl+C, via `closeTop()`) from a programmatic close + // (`clear()`/`replace()`, whether that is this same dialog closing itself, a click-away, or an + // unrelated feature — command palette, session list — taking over the dialog stack). A guard + // that reacted identically to both could not tell "the user asked to leave THIS dialog" from + // "something else is happening to the dialog stack" (fixes #1301, Codex review, P2). + let closeGuard: ((reason: "dismiss" | "programmatic") => boolean) | undefined - function canClose() { - return closeGuard?.() ?? true + function canClose(reason: "dismiss" | "programmatic") { + return closeGuard?.(reason) ?? true } // altimate_change end @@ -108,7 +113,7 @@ function init() { // altimate_change start — centralize guarded single-dialog close behavior function closeTop() { - if (!canClose()) return false + if (!canClose("dismiss")) return false const current = store.stack.at(-1) current?.onClose?.() setStore("stack", store.stack.slice(0, -1)) @@ -149,24 +154,36 @@ function init() { ], })) + // altimate_change start — fixes #1301 (Codex review round 2, P2): shared body for `clear()` + // (a "programmatic" close — used all over the codebase, including a dialog closing itself) and + // `dismiss()` (a "dismiss" close — the ONE caller is the backdrop click, which is just as much + // a user dismissal as Escape/Ctrl+C and must be reported to the guard the same way). + function clearAll(reason: "dismiss" | "programmatic") { + if (!canClose(reason)) return false + for (const item of store.stack) { + if (item.onClose) item.onClose() + } + batch(() => { + setStore("size", "medium") + setStore("stack", []) + }) + refocus() + return true + } + // altimate_change end + return { clear() { - // altimate_change start — guard and report bulk dialog closure - if (!canClose()) return false - for (const item of store.stack) { - if (item.onClose) item.onClose() - } - batch(() => { - setStore("size", "medium") - setStore("stack", []) - }) - refocus() - return true - // altimate_change end + return clearAll("programmatic") + }, + // altimate_change — fixes #1301 (Codex review round 2, P2): backdrop click only, wired in + // `DialogProvider`'s `` below — see `clearAll` above. + dismiss() { + return clearAll("dismiss") }, replace(input: any, onClose?: () => void) { // altimate_change start — replacement is a close path and must obey the same guard - if (!canClose()) return false + if (!canClose("programmatic")) return false if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable focus?.blur() @@ -194,7 +211,7 @@ function init() { setStore("size", size) }, // altimate_change start — install and safely dispose the active close guard - guardClose(guard: () => boolean) { + guardClose(guard: (reason: "dismiss" | "programmatic") => boolean) { closeGuard = guard return () => { if (closeGuard === guard) closeGuard = undefined @@ -242,7 +259,11 @@ export function DialogProvider(props: ParentProps) { onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined} > - value.clear()} size={value.size}> + {/* altimate_change — fixes #1301 (Codex review round 2, P2): backdrop click is a USER + dismissal, same as Escape/Ctrl+C — `dismiss()` reports "dismiss" to the guard, + unlike every other `clear()`/`replace()` call site (self-close, or an unrelated + feature taking over the stack), which stays "programmatic". */} + value.dismiss()} size={value.size}> {value.stack.at(-1)!.element} diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index c9dd241713..77c8331e8e 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -3,9 +3,17 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { testRender, useRenderer } from "@opentui/solid" import { expect, test } from "bun:test" import { onCleanup, onMount } from "solid-js" +import { mkdir } from "node:fs/promises" +import path from "node:path" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" +// altimate_change — fixes #1301 (Codex review round 2, D): the harness now performs REAL +// kv/model.json writes (see `declinedInKv`/`declinedInModel` below), so it needs a per-mount +// isolated state directory — `TestTuiContexts`'s default `state` path is a single fixed +// `/tmp/opencode/state` shared by every test in the process (see `dialog-scan-gate.test.tsx` for +// the same pattern with a real DialogProvider + kv fixture). +import { tmpdir } from "../../fixture/fixture" import type { OnboardingTelemetryEvent } from "../../../src/context/onboarding-telemetry" async function waitUntil(predicate: () => boolean, timeout = 2_000) { @@ -26,6 +34,16 @@ async function mountConfirm( >) modelAvailable?: boolean origin?: "welcome" | "migration" + // altimate_change start — fixes #1301: broadened migration eligibility test support + // Whether the harness marks first-run active before mounting. Every prior test relied on this + // always being true; migration's telemetry must now also fire when it is NOT (migration is + // reachable on a returning launch, which is never "first run"). + markFirstRun?: boolean + // The free OpenCode Zen model presented as the (sole, when `modelAvailable: false`) opencode + // provider model — defaults to the retired Big Pickle id so every existing test is unaffected. + // Swap it to prove the migration copy names whichever free model is actually current. + zenModel?: { id: string; name: string; family?: string } + // altimate_change end } = {}, ) { const [ @@ -39,7 +57,7 @@ async function mountConfirm( }, { OnboardingTelemetryProvider }, { ArgsProvider }, - { KVProvider }, + { KVProvider, useKV }, { ThemeProvider }, { TuiConfigProvider }, { ToastProvider }, @@ -47,7 +65,10 @@ async function mountConfirm( { AltimateBaseConsentProvider }, { ProjectProvider }, { SyncProvider }, - { LocalProvider }, + // altimate_change — fixes #1301 (Codex review round 2, D): `useLocal`/`ALTIMATE_BASE_MIGRATION_DECLINED_KEY` + // let the harness assert the ACTUAL persisted decline state (kv + model.json) an app.tsx + // `onDecline` would produce, instead of only whether a mock callback was invoked. + { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY }, { OpencodeKeymapProvider, registerOpencodeKeymap }, { ExitProvider }, { RouteProvider }, @@ -72,12 +93,30 @@ async function mountConfirm( import("../../../src/context/route"), ]) + // altimate_change start — fixes #1301 (Codex review round 2, D): isolated per-mount state dir + // — see the `tmpdir` import comment above. `kv.json` is pre-seeded (matching + // `dialog-scan-gate.test.tsx`) purely to avoid the harmless-but-noisy "Failed to read KV state" + // console error `kv.tsx` logs on a missing file; `model.json`'s reader doesn't log at all, so + // it isn't pre-seeded. + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + // altimate_change end + resetSetupComplete() - markFirstRunActive() + // altimate_change — fixes #1301: default preserved (every prior test relies on it), but a test + // can now mount without first-run active to prove migration telemetry fires regardless. + if (input.markFirstRun ?? true) markFirstRunActive() const events: OnboardingTelemetryEvent[] = [] const registrations: true[] = [] const declines: true[] = [] let replaceDialog = () => false + // altimate_change — fixes #1301 (Codex review round 2, D): populated inside `OpenConfirm` below + // (rendered inside `KVProvider`/`LocalProvider`), so the harness can assert the actual + // persisted decline state, not only whether a mock callback fired. + let declinedInKv = () => false + let declinedInModel = () => false const model = { id: "altimate-base", providerID: "altimate-free", @@ -89,14 +128,17 @@ async function mountConfirm( limit: { context: 65_536, output: 4_096 }, } const provider = { id: "altimate-free", name: "Altimate", models: { "altimate-base": model }, env: [] } + // altimate_change — fixes #1301: the opencode-provider free model defaults to the retired Big + // Pickle id (unchanged for every existing test) but can be swapped to any other free Zen model. + const zenModel = input.zenModel ?? { id: "big-pickle", name: "Big Pickle", family: "glm" } const bigPickle = { ...model, - id: "big-pickle", + id: zenModel.id, providerID: "opencode", - name: "Big Pickle", - family: "glm", + name: zenModel.name, + family: zenModel.family ?? "opencode", } - const openCodeProvider = { id: "opencode", name: "Legacy Zen", models: { "big-pickle": bigPickle }, env: [] } + const openCodeProvider = { id: "opencode", name: "Legacy Zen", models: { [zenModel.id]: bigPickle }, env: [] } const inner = createFetch((url) => { if (url.pathname === "/instance/dispose") return json({}) if (url.pathname === "/config/providers") { @@ -125,17 +167,33 @@ async function mountConfirm( function OpenConfirm() { const dialog = useDialog() + // altimate_change start — fixes #1301 (Codex review round 2, D): mirror app.tsx's REAL + // migration `onDecline` (kv.set + local.model.declineManagedBaseDefault()) instead of only + // recording that the callback fired, so tests can assert the actual persisted state a real + // launch would see — not just that a mock array grew. + const kv = useKV() + const local = useLocal() + declinedInKv = () => kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) + declinedInModel = () => local.model.declinedManagedBaseDefault() replaceDialog = () => dialog.replace(() => Session list replacement) onMount(() => dialog.replace(() => ( - declines.push(true)} /> + { + declines.push(true) + kv.set(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, true) + local.model.declineManagedBaseDefault() + }} + /> )), ) + // altimate_change end return null } return ( - + {}}> @@ -193,10 +251,22 @@ async function mountConfirm( setupComplete: useSetupComplete(), registrations: () => registrations, declines: () => declines, + // altimate_change — fixes #1301 (Codex review round 2, D): actual persisted decline state. + declinedInKv: () => declinedInKv(), + declinedInModel: () => declinedInModel(), replaceDialog: () => replaceDialog(), - cleanup() { + async cleanup() { app.renderer.destroy() resetSetupComplete() + // altimate_change — fixes #1301 (Codex review round 2, D): `local.model`'s `save()` (and + // `kv.tsx`'s `set()`) fire-and-forget their disk write (`void writeJsonAtomic(...)`, never + // awaited by the caller). A decline persisted just before this runs can still have its + // write in flight; disposing the tmp dir immediately raced the atomic-write temp file + // against the directory removal (an EINVAL/ENOENT from `writeJsonAtomic`, surfacing as an + // unhandled rejection misattributed to whichever test happened to be running when it + // resolved). A short buffer lets any in-flight write actually land first. + await Bun.sleep(20) + await tmp[Symbol.asyncDispose]() }, } } @@ -223,7 +293,7 @@ test.serial("Altimate Base shows the privacy disclosure before registration and expect(confirm.registrations()).toHaveLength(0) expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "welcome" }]) } finally { - confirm.cleanup() + await confirm.cleanup() } }) @@ -235,37 +305,170 @@ test.serial("Return declines, because No is the default — it must never regist // else in this suite exercises it, so this path was previously unverified in either direction. confirm.app.mockInput.pressKey("RETURN") await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel" }) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel", origin: "welcome" }) // The property that matters: an unread Return cannot opt the installation into request logging. expect(confirm.registrations()).toHaveLength(0) } finally { - confirm.cleanup() + await confirm.cleanup() } }) test.serial( - "the Big Pickle migration reuses consent, stays out of first-run telemetry, and routes No to the picker", + // altimate_change — fixes #1301: migration telemetry is no longer suppressed — see the "even + // when first-run is not active" variant below for why that matters. + "the migration disclosure reuses consent, reports its own telemetry, and routes explicit No to the picker", async () => { const confirm = await mountConfirm({ origin: "migration" }) try { const frame = confirm.app.captureCharFrame() expect(frame).toContain("No — pick something else") expect(frame.replace(/\s+/g, " ")).toContain("Requests and responses may be logged and used") - expect(confirm.events).toEqual([]) + expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "migration" }]) confirm.app.mockInput.pressKey("n") await waitUntil(() => confirm.declines().length === 1) expect(confirm.registrations()).toHaveLength(0) + // altimate_change — fixes #1301 (Codex review round 2, D): assert the ACTUAL persisted + // state (kv + model.json, both through the real `local.model.declineManagedBaseDefault()`), + // not only that a mock callback was invoked. + expect(confirm.declinedInKv()).toBe(true) + expect(confirm.declinedInModel()).toBe(true) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel", origin: "migration" }) // altimate_change — "No — pick something else" must actually route somewhere: Big Pickle is // retired, so declining the migration prompt lands the user in the curated picker instead of // silently leaving the dialog cleared (the label used to promise a re-pick that never // happened). await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) - expect(confirm.events).toEqual([{ name: "model_picker_shown", trigger: "altimate_base_back" }]) + expect(confirm.events).toContainEqual({ name: "model_picker_shown", trigger: "altimate_base_back" }) await confirm.app.renderOnce() expect(confirm.app.captureCharFrame()).toContain("Altimate LLM Gateway") } finally { - confirm.cleanup() + await confirm.cleanup() + } + }, +) + +test.serial( + "migration telemetry fires even when first-run is not active, unlike welcome/model", + async () => { + // altimate_change — fixes #1301: migration is reachable on a returning (non-first-run) + // launch — the whole point of the fix — so its telemetry must not depend on + // `firstRunActive()` the way "welcome"/"model" origins' does. + const confirm = await mountConfirm({ origin: "migration", markFirstRun: false }) + try { + expect(confirm.events).toEqual([{ name: "altimate_base_confirm_shown", origin: "migration" }]) + } finally { + await confirm.cleanup() + } + }, +) + +test.serial( + "Escape on the migration disclosure persists the decline and opens the welcome picker, not a bare dismissal", + async () => { + // altimate_change — fixes #1301: DialogProvider's keymap binding closes the dialog BEFORE the + // component's own `useKeyboard` ever sees Escape/Ctrl-C, so this must route through the close + // guard — see `releaseCloseGuard` in altimate-onboarding.tsx. + const confirm = await mountConfirm({ origin: "migration" }) + try { + // `pressKey("escape")` (lowercase) types the literal LETTERS e-s-c-a-p-e — it is not the + // Escape key (see `KeyCodes.ESCAPE`/`resolveKeyInput` in @opentui/core's mock-keys helper). + // `pressEscape()` sends the actual key. + confirm.app.mockInput.pressEscape() + await waitUntil(() => confirm.declines().length === 1) + expect(confirm.registrations()).toHaveLength(0) + // altimate_change — fixes #1301 (Codex review round 2, D): the actual persisted state a + // real launch's `Provider.defaultModel()`/ACP would read, not only the mock callback. + expect(confirm.declinedInKv()).toBe(true) + expect(confirm.declinedInModel()).toBe(true) + await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) + await confirm.app.renderOnce() + const frame = confirm.app.captureCharFrame() + expect(frame).toContain("Select a provider") + expect(frame).toContain("Altimate LLM Gateway") + } finally { + await confirm.cleanup() + } + }, +) + +test.serial( + "the visible mouse esc label on the migration disclosure persists the decline and opens the picker, same as keyboard Escape", + async () => { + // altimate_change — fixes #1301 (Codex review round 2, P2): this visible label used to call a + // bare `dialog.clear()` for every origin, including migration — so clicking it silently + // skipped both the decline persistence AND the picker takeover that keyboard Escape produces, + // leaving a later headless/server launch free to pick Base again after a partial + // registration. It must now behave exactly like Escape for `origin === "migration"`. + const confirm = await mountConfirm({ origin: "migration" }) + try { + const frame = confirm.app.captureCharFrame() + expect(frame).toContain("esc") + // The "esc" label sits on the same row as the dialog title, near its right edge. + const escRow = frame.split("\n").findIndex((line) => line.includes("Use Altimate Base?")) + expect(escRow).toBeGreaterThanOrEqual(0) + const escColumn = frame.split("\n")[escRow].indexOf("esc") + await confirm.app.mockMouse.click(escColumn, escRow) + await waitUntil(() => confirm.declines().length === 1) + expect(confirm.registrations()).toHaveLength(0) + expect(confirm.declinedInKv()).toBe(true) + expect(confirm.declinedInModel()).toBe(true) + await waitUntil(() => confirm.events.some((event) => event.name === "model_picker_shown")) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Select a provider") + } finally { + await confirm.cleanup() + } + }, +) + +test.serial( + "a programmatic replace of the migration dialog succeeds and does not persist a decline", + async () => { + // altimate_change — fixes #1301 (Codex review round 2, P2): an unrelated feature (command + // palette, session list) replacing the dialog stack while the migration disclosure is open is + // not the user declining Altimate Base — it never dismissed THIS dialog, unlike keyboard + // Escape/Ctrl+C, the backdrop click (`dialog.tsx`'s `dismiss()`), or the visible mouse "esc" + // label, all of which now route through `no()` (see the tests above). Before the original + // fix, the close guard queued `no()` for every guarded close, including this one. + const confirm = await mountConfirm({ origin: "migration" }) + try { + expect(confirm.replaceDialog()).toBe(true) + await confirm.app.renderOnce() + expect(confirm.app.captureCharFrame()).toContain("Session list replacement") + expect(confirm.declines()).toHaveLength(0) + // altimate_change — fixes #1301 (Codex review round 2, D): the actual persisted state, + // which is what a real headless/server launch would read — not only the mock callback. + expect(confirm.declinedInKv()).toBe(false) + expect(confirm.declinedInModel()).toBe(false) + expect(confirm.registrations()).toHaveLength(0) + expect(confirm.events.some((event) => event.name === "model_picker_shown")).toBe(false) + } finally { + await confirm.cleanup() + } + }, +) + +test.serial( + "the migration copy names the current free model instead of always naming Big Pickle", + async () => { + // altimate_change — fixes #1301: migration now also covers implicit free OpenCode Zen + // defaults besides Big Pickle, so the copy must say which model is actually being moved. + // `modelAvailable: false` makes this swapped-in model the ONLY (hence current) provider + // entry, sidestepping any ambiguity in which provider the fallback picks first. + const confirm = await mountConfirm({ + origin: "migration", + modelAvailable: false, + zenModel: { id: "nemotron-3.5-lightning-free", name: "Nemotron 3.5 Lightning (Free)" }, + }) + try { + const flat = confirm.app.captureCharFrame().replace(/\s+/g, " ") + expect(flat).toContain( + "Your default model, Nemotron 3.5 Lightning (Free), is a public free model. Altimate Base is the free model Altimate hosts for data work.", + ) + expect(flat).not.toContain("Big Pickle has been retired.") + } finally { + await confirm.cleanup() } }, ) @@ -275,7 +478,7 @@ test.serial("declining Altimate Base makes no registration request, and Big Pick try { confirm.app.mockInput.pressKey("n") await waitUntil(() => confirm.events.some((event) => event.name === "altimate_base_choice")) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel" }) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "cancel", origin: "welcome" }) expect(confirm.registrations()).toHaveLength(0) confirm.app.mockInput.pressKey("/") await confirm.app.renderOnce() @@ -285,7 +488,7 @@ test.serial("declining Altimate Base makes no registration request, and Big Pick expect(confirm.app.captureCharFrame()).not.toContain("Big Pickle") expect(confirm.registrations()).toHaveLength(0) } finally { - confirm.cleanup() + await confirm.cleanup() } }) @@ -295,11 +498,15 @@ test.serial("accepting registers once through the private host operation and com confirm.app.mockInput.pressKey("y") await waitUntil(() => confirm.setupComplete()) expect(confirm.registrations()).toHaveLength(1) - expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept" }) - expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "success" }) + expect(confirm.events).toContainEqual({ name: "altimate_base_choice", choice: "accept", origin: "welcome" }) + expect(confirm.events).toContainEqual({ + name: "altimate_base_register_result", + result: "success", + origin: "welcome", + }) expect(confirm.events.filter((event) => event.name === "altimate_base_choice")).toHaveLength(1) } finally { - confirm.cleanup() + await confirm.cleanup() } }) @@ -313,7 +520,7 @@ test.serial("registration without a usable model remains incomplete and visibly expect(confirm.setupComplete()).toBe(false) expect(confirm.app.captureCharFrame()).toContain("ready yet. Try again") } finally { - confirm.cleanup() + await confirm.cleanup() } }) @@ -328,10 +535,14 @@ test.serial("rate-limited registration stays recoverable and reports a typed out await confirm.app.renderOnce() expect(confirm.setupComplete()).toBe(false) expect(confirm.registrations()).toHaveLength(1) - expect(confirm.events).toContainEqual({ name: "altimate_base_register_result", result: "rate_limited" }) + expect(confirm.events).toContainEqual({ + name: "altimate_base_register_result", + result: "rate_limited", + origin: "welcome", + }) expect(confirm.app.captureCharFrame()).toContain("Too many Altimate Base") } finally { - confirm.cleanup() + await confirm.cleanup() } }) @@ -356,7 +567,11 @@ test.serial("dismissal keys and backdrop clicks are ignored while registration i expect(confirm.replaceDialog()).toBe(false) await confirm.app.renderOnce() expect(confirm.app.captureCharFrame()).not.toContain("Session list replacement") - confirm.app.mockInput.pressKey("escape") + // altimate_change — fixes #1301: `pressKey("escape")` (lowercase) sends the literal letters + // e-s-c-a-p-e, not the Escape key (see the comment on the migration Escape test below); this + // assertion happened to hold either way since typing those letters while busy is also a + // no-op, but `pressEscape()` is what actually exercises the key this test is named for. + confirm.app.mockInput.pressEscape() await confirm.app.renderOnce() expect(confirm.app.captureCharFrame()).toContain("Setting up…") confirm.app.mockInput.pressKey("c", { ctrl: true }) @@ -369,6 +584,6 @@ test.serial("dismissal keys and backdrop clicks are ignored while registration i finish({ ok: true }) await waitUntil(() => confirm.setupComplete()) } finally { - confirm.cleanup() + await confirm.cleanup() } }) diff --git a/packages/tui/test/component/welcome-panel.test.tsx b/packages/tui/test/component/welcome-panel.test.tsx index 3909255325..75f8118d80 100644 --- a/packages/tui/test/component/welcome-panel.test.tsx +++ b/packages/tui/test/component/welcome-panel.test.tsx @@ -8,6 +8,9 @@ import { FULL_MIN_HEIGHT, FULL_MIN_WIDTH, MEDIUM_MIN_WIDTH } from "../../src/com import { ArgsProvider } from "../../src/context/args" import { ExitProvider } from "../../src/context/exit" import { KVProvider } from "../../src/context/kv" +// altimate_change — fixes #1301 (Codex review, P2): `useReady()` now also calls `useLocal()` +// (`hasUsableFreeDefault`), so `WelcomePanel` needs `LocalProvider` in its tree like the real app. +import { LocalProvider } from "../../src/context/local" import { ProjectProvider } from "../../src/context/project" import { RouteProvider } from "../../src/context/route" import { SDKProvider } from "../../src/context/sdk" @@ -40,7 +43,9 @@ async function renderPanel(availableWidth: number, availableHeight: number) { - + + + diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index e3c2bb7cd3..4daedd82af 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -4,12 +4,24 @@ import { ALTIMATE_BASE_MODEL, isConfirmedExplicitSelection, isExistingBigPickleSelection, + // altimate_change start — fixes #1301: broaden legacy-default migration eligibility + isFreeZenModel, + shouldOfferManagedBaseDefault, + // altimate_change end + // altimate_change start — fixes #1301 (Codex review, P2): usable-free-default predicate + isUsableFreeDefault, + // altimate_change end + // altimate_change start — fixes #1301 (Codex review round 2, P1): migration correctness + isOwnPastPickOfFreeDefault, + shouldMoveAgentModelDuringMigration, + // altimate_change end LEGACY_BIG_PICKLE_MODEL, migrateLegacyRecentModels, parseModel, recentModels, shouldMigrateLegacyDefault, } from "../../src/context/local" +import type { ConnectedProviderShape } from "../../src/util/connected" test("parses model IDs containing slashes", () => { expect(parseModel("provider/family/model")).toEqual({ @@ -93,3 +105,156 @@ test("replaces Big Pickle recents while preserving every unrelated model and ord { providerID: "openai", modelID: "gpt-5" }, ]) }) + +// altimate_change start — fixes #1301: offer Altimate Base to every user riding an implicit free +// OpenCode Zen default, not only the retired Big Pickle id. +const NEMOTRON = { providerID: "opencode", modelID: "nemotron-3.5-lightning-free" } as const +const ZEN_PAID = { providerID: "opencode", modelID: "zen-paid" } as const + +function providersFixture(): ConnectedProviderShape[] { + return [ + { + id: "opencode", + models: { + "nemotron-3.5-lightning-free": { cost: undefined }, + "big-pickle": { cost: { input: 0 } }, + "zen-paid": { cost: { input: 3 } }, + }, + }, + { + id: "anthropic", + models: { + "claude-sonnet": { cost: { input: 3 } }, + }, + }, + ] +} + +test("identifies a free OpenCode Zen model regardless of cost being zero or absent", () => { + const providers = providersFixture() + expect(isFreeZenModel(NEMOTRON, providers)).toBe(true) + expect(isFreeZenModel(LEGACY_BIG_PICKLE_MODEL, providers)).toBe(true) +}) + +test("does not treat a paid Zen model, another provider, or a missing catalogue entry as free", () => { + const providers = providersFixture() + expect(isFreeZenModel(ZEN_PAID, providers)).toBe(false) + expect(isFreeZenModel({ providerID: "anthropic", modelID: "claude-sonnet" }, providers)).toBe(false) + expect(isFreeZenModel({ providerID: "opencode", modelID: "does-not-exist" }, providers)).toBe(false) + expect(isFreeZenModel(undefined, providers)).toBe(false) +}) + +test("offers Altimate Base for an implicit free default but never for an explicit one or an allowlisted project", () => { + const isFree = (model: { providerID: string; modelID: string }) => + isFreeZenModel(model, providersFixture()) || model.modelID === LEGACY_BIG_PICKLE_MODEL.modelID + + // Implicit free Zen default (the case the old Big-Pickle-only, recent-gated check missed). + expect(shouldOfferManagedBaseDefault(NEMOTRON, false, {}, isFree)).toBe(true) + // Implicit Big Pickle default still qualifies too. + expect(shouldOfferManagedBaseDefault(LEGACY_BIG_PICKLE_MODEL, false, {}, isFree)).toBe(true) + // A deliberate (picker-driven or CLI/config) choice is never overridden. + expect(shouldOfferManagedBaseDefault(NEMOTRON, true, {}, isFree)).toBe(false) + // A project provider allowlist that excludes the managed provider is respected. + expect(shouldOfferManagedBaseDefault(NEMOTRON, false, { anthropic: {} }, isFree)).toBe(false) + // No current model at all (e.g. no provider connected) has nothing to offer. + expect(shouldOfferManagedBaseDefault(undefined, false, {}, isFree)).toBe(false) +}) + +test("migrateLegacyRecentModels also drops the previous free default so cycling cannot bounce back onto it", () => { + expect( + migrateLegacyRecentModels( + [NEMOTRON, { providerID: "anthropic", modelID: "claude-sonnet" }, LEGACY_BIG_PICKLE_MODEL], + NEMOTRON, + ), + ).toEqual([ALTIMATE_BASE_MODEL, { providerID: "anthropic", modelID: "claude-sonnet" }]) + // Without a `previous`, behavior is unchanged from before (only Big Pickle is dropped). + expect(migrateLegacyRecentModels([NEMOTRON, LEGACY_BIG_PICKLE_MODEL])).toEqual([ALTIMATE_BASE_MODEL, NEMOTRON]) +}) +// altimate_change end + +// altimate_change start — fixes #1301 (Codex review, P1): explicitness must be judged against +// the SAME model eligibility is judged against (`fallbackModel()`, the launch default), not +// `currentModel()` — which can be a session-restored model unrelated to the launch default. +test("explicitness must be checked against the launch default, not a session-restored model", () => { + const isFree = (model: { providerID: string; modelID: string }) => isFreeZenModel(model, providersFixture()) + const explicitDefault = NEMOTRON + const fallbackModel = NEMOTRON // the launch default the user explicitly chose + const restoredSessionModel = { providerID: "anthropic", modelID: "claude-sonnet" } // an unrelated open conversation + + // Correct: explicitness checked against the SAME model eligibility evaluates (`fallbackModel`). + // `usesImplicitFreeDefault()` in local.tsx now does exactly this via `hasExplicitDefault()`. + const explicitAgainstFallback = isConfirmedExplicitSelection(fallbackModel, explicitDefault) + expect(explicitAgainstFallback).toBe(true) + expect(shouldOfferManagedBaseDefault(fallbackModel, explicitAgainstFallback, {}, isFree)).toBe(false) + + // The bug this guards against: checking explicitness against `currentModel()` — here standing + // in for a restored session on a different, unrelated conversation — finds no match, + // misclassifies the deliberate Nemotron pick as implicit, and `usesImplicitFreeDefault()` would + // wrongly become eligible to migrate, overwriting the restored conversation's model with Base. + const explicitAgainstRestoredSession = isConfirmedExplicitSelection(restoredSessionModel, explicitDefault) + expect(explicitAgainstRestoredSession).toBe(false) + expect(shouldOfferManagedBaseDefault(fallbackModel, explicitAgainstRestoredSession, {}, isFree)).toBe(true) +}) +// altimate_change end + +// altimate_change start — fixes #1301 (Codex review, P2): a free default the user chose on +// purpose, or already declined migrating away from, is usable — not "un-onboarded". +test("isUsableFreeDefault: usable when explicit or previously declined, never when neither", () => { + const isValid = () => true + const isFree = (model: { providerID: string; modelID: string }) => isFreeZenModel(model, providersFixture()) + + // Explicitly chosen, never declined: usable. + expect(isUsableFreeDefault(NEMOTRON, isValid, isFree, true, false)).toBe(true) + // Not explicit, but a previous decline is on record (kv key or model.json flag): usable. + expect(isUsableFreeDefault(NEMOTRON, isValid, isFree, false, true)).toBe(true) + // Neither explicit nor declined: still "un-onboarded" — not usable. + expect(isUsableFreeDefault(NEMOTRON, isValid, isFree, false, false)).toBe(false) + // A paid/non-free model is never usable via this path regardless of explicitness/decline. + expect(isUsableFreeDefault(ZEN_PAID, isValid, isFree, true, true)).toBe(false) + // An invalid (no longer offered) model is never usable. + expect(isUsableFreeDefault(NEMOTRON, () => false, isFree, true, true)).toBe(false) + // No current model at all. + expect(isUsableFreeDefault(undefined, isValid, isFree, true, true)).toBe(false) +}) +// altimate_change end + +// altimate_change start — fixes #1301 (Codex review round 2, P1): `migrateLegacyDefault()` in +// local.tsx is closure-internal and needs `LocalProvider`/`SyncProvider`/SDK mocks to exercise +// directly (the existing dialog test harness in dialog-altimate-base.test.tsx does not go through +// this function at all — it mounts the dialog with a stubbed `onDecline`, never accept). Per +// review guidance, this is the pure-function test standing in for that: it exercises the EXACT +// predicate `migrateLegacyDefault()` now calls (`shouldMoveAgentModelDuringMigration`), not a +// hand-rolled comparison, so a change to that predicate's logic is caught here even without a +// full-context test. +test("shouldMoveAgentModelDuringMigration: only moves a conversation still on the implicit default", () => { + const previous = NEMOTRON // the implicit free default being migrated away from + const restoredSession = { providerID: "anthropic", modelID: "claude-sonnet" } // an unrelated open conversation + + // Still on the implicit default (the common case: no session restored) — migrate it. + expect(shouldMoveAgentModelDuringMigration(previous, previous)).toBe(true) + // No current model at all (e.g. agent has no per-agent model set yet) — nothing to preserve. + expect(shouldMoveAgentModelDuringMigration(undefined, previous)).toBe(true) + // A restored conversation on a DIFFERENT model must be left alone — this is the regression: + // migration is a decision about the DEFAULT, not about overwriting an unrelated open thread. + expect(shouldMoveAgentModelDuringMigration(restoredSession, previous)).toBe(false) + // No captured `previous` at all (should not happen in practice — `usesLegacyDefault()` already + // requires a defined `fallbackModel()` — but fail closed rather than move an unrelated model). + expect(shouldMoveAgentModelDuringMigration(restoredSession, undefined)).toBe(false) +}) + +test("isOwnPastPickOfFreeDefault: an older picker-written recent is the user's own pick, not implicit", () => { + // An older Nemotron recent (predates the `explicitDefault` marker) is still the user's own past + // pick — silent migration when Base is registered must not sweep it up without asking. + expect(isOwnPastPickOfFreeDefault(NEMOTRON, [NEMOTRON, { providerID: "anthropic", modelID: "claude-sonnet" }])).toBe( + true, + ) + // Not in recents at all: a genuinely implicit default, silent migration proceeds as before. + expect(isOwnPastPickOfFreeDefault(NEMOTRON, [{ providerID: "anthropic", modelID: "claude-sonnet" }])).toBe(false) + expect(isOwnPastPickOfFreeDefault(NEMOTRON, [])).toBe(false) + // Big Pickle is deliberately excluded — recents written before this distinction existed were + // always silently migrated, and that stays unchanged ("today's behaviour"). + expect(isOwnPastPickOfFreeDefault(LEGACY_BIG_PICKLE_MODEL, [LEGACY_BIG_PICKLE_MODEL])).toBe(false) + // No current model at all. + expect(isOwnPastPickOfFreeDefault(undefined, [NEMOTRON])).toBe(false) +}) +// altimate_change end From 1f0e3591fd4912984a71809c03b4f9206f3c1dd1 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 08:47:16 -0700 Subject: [PATCH 02/19] fix: wrap two remaining hunks in altimate_change block markers The marker guard in CI (with the upstream remote present) flagged two spans that only had single-line marker comments over multi-line code. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/context/local.tsx | 3 ++- packages/tui/src/ui/dialog.tsx | 15 +++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index c94760ec65..dbf32946be 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -230,10 +230,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const theme = useTheme().theme const route = useRoute() const paths = useTuiPaths() - // altimate_change — fixes #1301 (Codex review, P2): `hasUsableFreeDefault` reads the + // altimate_change start — fixes #1301 (Codex review, P2): `hasUsableFreeDefault` reads the // migration-decline kv key here too. `KVProvider` wraps `LocalProvider` in app.tsx, so this // is always available. const kv = useKV() + // altimate_change end function isModelValid(model: { providerID: string; modelID: string }) { const provider = sync.data.provider.find((item) => item.id === model.providerID) diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index c4a8c784b0..ceddf25279 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -173,14 +173,16 @@ function init() { // altimate_change end return { + // altimate_change start — fixes #1301 (Codex review round 2, P2): `clear()` is the + // programmatic close path; `dismiss()` is the backdrop click only, wired in + // `DialogProvider`'s `` below — see `clearAll` above. clear() { return clearAll("programmatic") }, - // altimate_change — fixes #1301 (Codex review round 2, P2): backdrop click only, wired in - // `DialogProvider`'s `` below — see `clearAll` above. dismiss() { return clearAll("dismiss") }, + // altimate_change end replace(input: any, onClose?: () => void) { // altimate_change start — replacement is a close path and must obey the same guard if (!canClose("programmatic")) return false @@ -259,13 +261,14 @@ export function DialogProvider(props: ParentProps) { onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined} > - {/* altimate_change — fixes #1301 (Codex review round 2, P2): backdrop click is a USER - dismissal, same as Escape/Ctrl+C — `dismiss()` reports "dismiss" to the guard, - unlike every other `clear()`/`replace()` call site (self-close, or an unrelated - feature taking over the stack), which stays "programmatic". */} + {/* altimate_change start — fixes #1301: backdrop click is a USER dismissal, same as + Escape/Ctrl+C. `dismiss()` reports "dismiss" to the close guard, unlike every other + `clear()`/`replace()` call site (self-close, or an unrelated feature taking over + the stack), which stays "programmatic". */} value.dismiss()} size={value.size}> {value.stack.at(-1)!.element} + {/* altimate_change end */} From d49a24897b52a467ebb87e0fdcf713910f82aa31 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 08:53:55 -0700 Subject: [PATCH 03/19] chore: drop the upstream product name from five #1301 comments The branding leak audit in CI flags the upstream product name in a single-line-marked test comment; the other four were inside marker blocks but are reworded too so none can surface later. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/app.tsx | 2 +- packages/tui/src/component/altimate-onboarding.tsx | 2 +- packages/tui/src/context/local.tsx | 2 +- packages/tui/test/cli/tui/dialog-altimate-base.test.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index afa81dd93d..e131aaed1c 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -612,7 +612,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // altimate_change end // altimate_change start — fixes #1301: move the retired Big Pickle default — and more broadly - // any implicit free OpenCode Zen default — to Altimate Base. Already-registered users migrate + // any implicit free public Zen default — to Altimate Base. Already-registered users migrate // immediately. Returning users who are not yet registered see the existing logging disclosure // first; an explicit No is remembered and leaves their model untouched. A brand-new user (no // history anywhere) falls straight through to the ordinary first-run picker below: the diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 7474280fea..328e8e7801 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -637,7 +637,7 @@ export function DialogAltimateBaseConfirm(props: { {/* altimate_change end */} - {/* altimate_change start — fixes #1301: migration now also covers implicit free OpenCode + {/* altimate_change start — fixes #1301: migration now also covers implicit free public Zen defaults besides the retired Big Pickle id, so the copy must name whichever model is actually being moved rather than always naming Big Pickle specifically. */} diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index dbf32946be..b78e92636d 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -102,7 +102,7 @@ export function isConfirmedExplicitSelection(current: unknown, explicitDefault: // altimate_change end // altimate_change start — fixes #1301: offer Altimate Base to every user riding an implicit free -// OpenCode Zen default, not only the retired Big Pickle id. `shouldMigrateLegacyDefault` above +// public Zen default, not only the retired Big Pickle id. `shouldMigrateLegacyDefault` above // required Big Pickle in `recent`, but only a picker-driven pick ever writes `recent` — the vast // majority of implicit-default users never touched a picker, so they were never offered Base. export function isFreeZenModel(model: ModelRef | undefined, providers: readonly ConnectedProviderShape[]): boolean { diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index 77c8331e8e..ee9e4d4403 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -39,7 +39,7 @@ async function mountConfirm( // always being true; migration's telemetry must now also fire when it is NOT (migration is // reachable on a returning launch, which is never "first run"). markFirstRun?: boolean - // The free OpenCode Zen model presented as the (sole, when `modelAvailable: false`) opencode + // The free public Zen model presented as the (sole, when `modelAvailable: false`) opencode // provider model — defaults to the retired Big Pickle id so every existing test is unaffected. // Swap it to prove the migration copy names whichever free model is actually current. zenModel?: { id: string; name: string; family?: string } @@ -452,7 +452,7 @@ test.serial( test.serial( "the migration copy names the current free model instead of always naming Big Pickle", async () => { - // altimate_change — fixes #1301: migration now also covers implicit free OpenCode Zen + // altimate_change — fixes #1301: migration now also covers implicit free public Zen // defaults besides Big Pickle, so the copy must say which model is actually being moved. // `modelAvailable: false` makes this swapped-in model the ONLY (hence current) provider // entry, sidestepping any ambiguity in which provider the fallback picks first. From 0ec6bcd0698ea542c848528527c43d17d0d3bc61 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 10:42:52 -0700 Subject: [PATCH 04/19] fix: address review feedback on the Altimate Base default migration (#1302) Inline findings from Cursor Bugbot, CodeRabbit, cubic, and two Codex passes. TUI - startup decision waits for kv hydration and treats a refusal in either store (kv key or model.json flag) as a decline - disclosure copy and the Big Pickle predicate use the launch default, never a restored session's model - accept path captures the launch default on mount and passes it to migrateLegacyDefault({ from }); after registration the re-check keeps only the explicit/allowlist guards and requires the fallback to be unchanged or Base, so a registered provider sorting first cannot route to the picker and a paid provider appearing mid-dialog cannot be migrated - cleanup persists a decline only for an observed dismiss; Ctrl-C is an "interrupt" that closes without deciding; programmatic closes emit no cancel telemetry - usability (useReady / prompt gate) judges the current model with the current explicit marker, includes an older picker-written free recent, and recent-model cycling marks the pick explicit - prompt history excludes appends that race the startup read from the returning-user snapshot - persistence: every outstanding model.json write is tracked; tests await the real writes instead of sleeping Server - public Zen tier is demoted only when options.apiKey === "public" and no key is configured; fixtures discriminate the two cases - ACP re-reads model.json (recents + decline) at each default selection - own-property checks on recents so prototype-name ids cannot throw Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/acp/directory.ts | 6 + packages/opencode/src/acp/service.ts | 62 ++++--- packages/opencode/src/provider/provider.ts | 7 +- .../opencode/test/acp/default-model.test.ts | 34 ++-- .../opencode/test/acp/service-session.test.ts | 51 ++++++ .../opencode/test/provider/provider.test.ts | 39 ++++- packages/tui/src/app.tsx | 22 ++- .../tui/src/component/altimate-onboarding.tsx | 118 +++++++++---- packages/tui/src/context/kv.tsx | 9 + packages/tui/src/context/local.tsx | 163 ++++++++++++++---- packages/tui/src/prompt/history.tsx | 35 +++- packages/tui/src/ui/dialog.tsx | 32 ++-- .../cli/tui/dialog-altimate-base.test.tsx | 63 ++++++- packages/tui/test/context/local.test.ts | 57 ++++++ .../test/prompt/history-startup-race.test.tsx | 145 ++++++++++++++++ 15 files changed, 695 insertions(+), 148 deletions(-) create mode 100644 packages/tui/test/prompt/history-startup-race.test.tsx diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index 1be274f198..afd900e9c1 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -34,6 +34,10 @@ export type Snapshot = { readonly defaultModeID: string readonly availableCommands: readonly Command.Info[] readonly defaultModel?: DefaultModel + readonly defaultModelConfig?: { + readonly model?: string + readonly provider?: Record + } } export interface LoaderInterface { @@ -61,6 +65,7 @@ export const build = (input: { readonly defaultModeID: string readonly commands: readonly Command.Info[] readonly defaultModel?: DefaultModel + readonly defaultModelConfig?: Snapshot["defaultModelConfig"] }): Snapshot => { const modelOptions = Provider.sort( Object.values(input.providers).flatMap((provider) => @@ -110,6 +115,7 @@ export const build = (input: { : (input.modes[0]?.id ?? input.defaultModeID), availableCommands: input.commands, ...(input.defaultModel ? { defaultModel: input.defaultModel } : {}), + ...(input.defaultModelConfig ? { defaultModelConfig: input.defaultModelConfig } : {}), } } diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 5a4a0ff1e2..d4a4c4dec4 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -805,23 +805,6 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { // provider either, so it fails closed the same way an explicit allowlist without it does. const snapshotProviders = configLoaded && !hasProviderAllowlist ? providers : withoutManagedBase() // altimate_change end - const defaultModelStarted = performance.now() - // altimate_change start — resolve the default against the SAME filtered snapshot advertised to - // the client. Resolving against the unfiltered `providers` map let a project that sets - // `model: "altimate-free/altimate-base"` alongside any `provider` allowlist end up with a - // `defaultModel` pointing at a provider this snapshot had just excluded — ACP would still - // select and route the managed model even though it was hidden from `modelOptions`. - // The async snapshot loader also honors the TUI's persisted recents and default-switch decline. - const { recent, declinedManagedBaseDefault } = await Provider.readDefaultModelState() - const defaultModel = defaultModelFromConfig( - config?.model, - snapshotProviders, - config?.provider as Record | undefined, - declinedManagedBaseDefault, - recent, - ) - // altimate_change end - ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) const modes = agents .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) .map((agent) => ({ @@ -850,7 +833,8 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { modes, defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), - ...(defaultModel ? { defaultModel } : {}), + // Cache project config, but resolve mutable model.json state at each default selection. + defaultModelConfig: { model: config?.model, provider: config?.provider }, }) }) } @@ -880,7 +864,8 @@ export function defaultModelFromConfig( for (const entry of recent) { const providerID = ProviderV2.ID.make(entry.providerID) const modelID = ModelV2.ID.make(entry.modelID) - if (!providers[providerID]?.models[modelID]) continue + if (!Object.hasOwn(providers, providerID)) continue + if (!Object.hasOwn(providers[providerID].models, modelID)) continue // Match Provider.defaultModel(): only managed Base recents are restricted by an allowlist. if (entry.providerID === "altimate-free" && hasProviderAllowlist) continue return { providerID, modelID } @@ -912,7 +897,8 @@ export function defaultModelFromConfig( registeredBaseAvailable && !declinedManagedBaseDefault && id === "opencode" && - providers[ProviderV2.ID.make(id)]?.options.apiKey === "public" + providers[ProviderV2.ID.make(id)]?.options.apiKey === "public" && + !providers[ProviderV2.ID.make(id)]?.key ) const opencodeProvider = providerAllowed("opencode") ? providers[ProviderV2.ID.make("opencode")] : undefined const opencodeModel = opencodeProvider @@ -943,8 +929,21 @@ export function defaultModelFromConfig( } // altimate_change start — keep Big Pickle explicitly selectable but never choose it implicitly -export function selectDefaultModel(snapshot: Directory.Snapshot) { - if (snapshot.defaultModel) return snapshot.defaultModel +export async function selectDefaultModel(snapshot: Directory.Snapshot) { + if (snapshot.defaultModelConfig) { + const started = performance.now() + const { recent, declinedManagedBaseDefault } = await Provider.readDefaultModelState() + // Resolve against the filtered catalogue so an excluded managed provider cannot be selected. + const selected = defaultModelFromConfig( + snapshot.defaultModelConfig.model, + snapshot.providers, + snapshot.defaultModelConfig.provider, + declinedManagedBaseDefault, + recent, + ) + ACPProfile.duration("acp.directory.defaultModel.resolve", started, { configured: !!selected }) + if (selected) return selected + } else if (snapshot.defaultModel) return snapshot.defaultModel // Big Pickle remains explicitly selectable for existing users, but Altimate Base replaces it as // the free implicit choice. Do not silently route a new ACP session back to Big Pickle when it is // the first (or only) sorted catalogue entry and no usable default was resolved above. @@ -964,17 +963,14 @@ function availableModel(snapshot: Directory.Snapshot, model: Directory.DefaultMo : undefined } -function requireDefaultModel(snapshot: Directory.Snapshot) { - const selected = selectDefaultModel(snapshot) - return selected - ? Effect.succeed(selected) - : Effect.fail( - new ACPError.ServiceFailureError({ - safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", - service: "model", - }), - ) -} +const requireDefaultModel = Effect.fn("ACP.requireDefaultModel")(function* (snapshot: Directory.Snapshot) { + const selected = yield* request(() => selectDefaultModel(snapshot), "model") + if (selected) return selected + return yield* new ACPError.ServiceFailureError({ + safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.", + service: "model", + }) +}) // altimate_change end function detectSlashCommand(parts: ReturnType) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b07bbf45cc..f05cd2ec18 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2239,9 +2239,9 @@ export namespace Provider { // `migrateLegacyDefault()` rewrites model.json on accept, so headless follows on the next // launch. Migrating here instead would move a declining user to the request-logging tier // with no prompt and no way to refuse. + if (!Object.hasOwn(providers, entry.providerID)) continue const provider = providers[entry.providerID] - if (!provider) continue - if (!provider.models[entry.modelID]) continue + if (!Object.hasOwn(provider.models, entry.modelID)) continue // Keep legacy recent-model behavior unchanged for every other provider; // only the consent-gated managed provider must not bypass this project. if (entry.providerID === FreeTier.PROVIDER_ID && !providerAllowed(String(entry.providerID))) continue @@ -2287,7 +2287,8 @@ export namespace Provider { registeredBaseAvailable && !declinedManagedBaseDefault && provider.id === "opencode" && - provider.options.apiKey === "public" + provider.options.apiKey === "public" && + !provider.key ) continue const model = sort(Object.values(provider.models)).find( diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index b926ffebd9..4c7ad40a95 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -106,6 +106,10 @@ describe("ACP defaultModelFromConfig", () => { }, { name: "unloaded provider recent is ignored", recent: ["missing/model"], expected: "altimate-free/altimate-base" }, { name: "missing model recent is ignored", recent: ["opencode/missing"], expected: "altimate-free/altimate-base" }, + { name: "__proto__ provider is ignored", recent: ["__proto__/x"], expected: "altimate-free/altimate-base" }, + { name: "constructor provider is ignored", recent: ["constructor/x"], expected: "altimate-free/altimate-base" }, + { name: "__proto__ model is ignored", recent: ["opencode/__proto__"], expected: "altimate-free/altimate-base" }, + { name: "constructor model is ignored", recent: ["opencode/constructor"], expected: "altimate-free/altimate-base" }, { name: "first available recent wins", recent: ["missing/model", "opencode/missing", "opencode/nemotron-3-super-free", "altimate-free/altimate-base"], @@ -189,15 +193,19 @@ describe("ACP defaultModelFromConfig", () => { } }) - test("a keyed Zen account outranks registered Altimate Base even with zero-cost models", () => { - const zen = provider("opencode", ["nemotron-3-super-free"]) - zen.key = "test-zen-key" - const result = ACPService.defaultModelFromConfig( - undefined, - providers(zen, provider("altimate-free", ["altimate-base"])), - ) - expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) - }) + test.each([{}, { apiKey: "public" }])( + "a keyed Zen account outranks registered Altimate Base with options %j", + (options) => { + const zen = provider("opencode", ["nemotron-3-super-free"]) + zen.key = "test-zen-key" + zen.options = options + const result = ACPService.defaultModelFromConfig( + undefined, + providers(zen, provider("altimate-free", ["altimate-base"])), + ) + expect(result?.providerID).toBe(ProviderV2.ID.make("opencode")) + }, + ) test("a self-hosted provider with zero-cost metadata still outranks registered Base", () => { const local = provider("local-llm", ["llama-3"]) @@ -249,7 +257,7 @@ describe("ACP defaultModelFromConfig", () => { ).toBeUndefined() }) - test("does not reintroduce Big Pickle through the ACP snapshot fallback", () => { + test("does not reintroduce Big Pickle through the ACP snapshot fallback", async () => { const snapshot = { directory: "/tmp/acp-default-model-test", providers: {}, @@ -273,7 +281,7 @@ describe("ACP defaultModelFromConfig", () => { availableCommands: [], } satisfies Directory.Snapshot - expect(ACPService.selectDefaultModel(snapshot)).toEqual({ + expect(await ACPService.selectDefaultModel(snapshot)).toEqual({ providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-5"), }) @@ -364,7 +372,7 @@ describe("ACP defaultModelFromConfig", () => { }) }) - test("returns no snapshot fallback when Big Pickle is the only option", () => { + test("returns no snapshot fallback when Big Pickle is the only option", async () => { const snapshot = { directory: "/tmp/acp-big-pickle-only", providers: {}, @@ -382,7 +390,7 @@ describe("ACP defaultModelFromConfig", () => { availableCommands: [], } satisfies Directory.Snapshot - expect(ACPService.selectDefaultModel(snapshot)).toBeUndefined() + expect(await ACPService.selectDefaultModel(snapshot)).toBeUndefined() }) }) // altimate_change end diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index da9de3ed9a..2f80ed079d 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { Global } from "@/global" import type { AgentSideConnection, ForkSessionResponse, @@ -344,6 +347,54 @@ describe("ACP service sessions", () => { expect(creates).toHaveLength(0) }) + it.each([ + { recent: [{ providerID: "opencode", modelID: "nemotron-3-super-free" }] }, + { recent: [], declinedManagedBaseDefault: true }, + ])("re-reads model state for subsequent sessions in a cached directory: %j", async (state) => { + const zen = { + ...provider, + id: ProviderID.make("opencode"), + options: { apiKey: "public" }, + models: { + [ModelID.make("nemotron-3-super-free")]: { + ...provider.models[modelID], + id: ModelID.make("nemotron-3-super-free"), + providerID: ProviderID.make("opencode"), + }, + }, + } satisfies Provider.Info + const base = { + ...provider, + id: ProviderID.make("altimate-free"), + models: { + [ModelID.make("altimate-base")]: { + ...provider.models[modelID], + id: ModelID.make("altimate-base"), + providerID: ProviderID.make("altimate-free"), + }, + }, + } satisfies Provider.Info + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.writeFile(stateFile, JSON.stringify({ recent: [] })) + const { service } = makeService([], { providers: [zen, base] }) + const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + expect(select(first, "model")?.currentValue).toBe("altimate-free/altimate-base") + + await fs.writeFile(stateFile, JSON.stringify(state)) + const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + expect(select(second, "model")?.currentValue).toBe("opencode/nemotron-3-super-free") + + await fs.writeFile(stateFile, JSON.stringify({ recent: [] })) + const third = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + expect(select(third, "model")?.currentValue).toBe("altimate-free/altimate-base") + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + } + }) + it("fails before creating a session when the configured model is unavailable", async () => { const bigPickleProvider = { ...provider, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 729a20f19b..a8eb99aab1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -190,7 +190,7 @@ test("an Altimate Base-only provider block cannot select an unrelated provider", }) // altimate_change start — registered Base outranks only public Zen, preserving connected and recent choices -test("a connected provider outranks registered Altimate Base as the implicit default", async () => { +test.each([false, true])("a keyed Zen provider outranks registered Base with public marker %s", async (publicMarker) => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", baseURL: ALTIMATE_BASE_GATEWAY_URL, @@ -207,6 +207,8 @@ test("a connected provider outranks registered Altimate Base as the implicit def expect(providers.opencode.key).toBe("test-zen-key") expect(providers.opencode.options.apiKey).not.toBe("public") expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() + // A retained public marker must not override the key on the loaded provider. + if (publicMarker) providers.opencode.options.apiKey = "public" const model = await Provider.defaultModel() expect(model).not.toEqual({ providerID: ProviderID.make(FreeTier.PROVIDER_ID), @@ -246,6 +248,7 @@ test.each([ const providers = await Provider.list() expect(Object.keys(providers).sort()).toEqual([FreeTier.PROVIDER_ID, "opencode"]) expect(providers.opencode.options.apiKey).toBe("public") + expect(providers.opencode.key).toBeUndefined() expect(providers.opencode.models["nemotron-3-super-free"]).toBeDefined() expect(await Provider.defaultModel()).toEqual({ providerID: ProviderID.make(providerID), @@ -278,6 +281,7 @@ test("a persisted public Zen recent outranks registered Altimate Base", async () fn: async () => { const providers = await Provider.list() expect(providers.opencode.options.apiKey).toBe("public") + expect(providers.opencode.key).toBeUndefined() expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() expect(await Provider.defaultModel()).toEqual({ providerID: ProviderID.make("opencode"), @@ -293,6 +297,39 @@ test("a persisted public Zen recent outranks registered Altimate Base", async () }) // altimate_change end +test.each(["__proto__/x", "constructor/x", "opencode/__proto__", "opencode/constructor"])( + "ignores prototype-name recent %s and resolves the registered Base fallback", + async (recent) => { + const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ + apiKey: "sk-altimate-base", + baseURL: ALTIMATE_BASE_GATEWAY_URL, + installSecret: "install-secret", + }) + const stateFile = path.join(Global.Path.state, "model.json") + const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + try { + await fs.writeFile(stateFile, JSON.stringify({ recent: [Provider.parseModel(recent)] })) + await using tmp = await tmpdir({ + config: { provider: {}, enabled_providers: ["opencode", FreeTier.PROVIDER_ID] }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.remove("OPENCODE_API_KEY"), + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) + }, + }) + } finally { + if (previous === undefined) await fs.rm(stateFile, { force: true }) + else await fs.writeFile(stateFile, previous) + credentials.mockRestore() + } + }, +) + test("a persisted Big Pickle default is not silently migrated headlessly", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue({ apiKey: "sk-altimate-base", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index e131aaed1c..df97ad3321 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -644,8 +644,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // `local.model.ready` guards a parallel race: `model.json`'s read is async, and if provider // sync finishes first, the legacy/returning checks below would see an empty recent list and // misclassify a returning user as fresh. `promptHistory.loaded()` guards the same race for - // the "returning user" signal immediately below. - if (!ready() || sync.status !== "complete" || !local.model.ready || !promptHistory.loaded()) return + // the "returning user" signal immediately below. `kv.ready` guards the SAME race for the + // decline check immediately below (PR #1302 review, P1): without it, a launch where kv.json's + // read is still in flight sees an actually-declined user as un-declined and silently migrates + // to Base before kv hydration can ever re-run this effect. + if (!ready() || sync.status !== "complete" || !local.model.ready || !promptHistory.loaded() || !kv.ready) return // altimate_change — fixes #1301: a user is "returning" if there is any sign of prior use // anywhere this TUI persists it: prompt history (independent of the current project's @@ -660,7 +663,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // A previous decline is checked FIRST, before registration state or eligibility. Registering // Altimate Base for one task is not consent to move a free default that the user already // refused to move; without this the decline is silently overridden on every later launch. - const previouslyDeclined = kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) + // altimate_change — PR #1302 review, P1 (Cursor + cubic): also honor the model.json flag, not + // only the kv key. `hasUsableFreeDefault()` already ORs both; this gate must too, or a launch + // where only the model.json flag is set (kv unread, or written from a different code path) + // can migrate right past a refusal that's actually on record. + const previouslyDeclined = + kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) || local.model.declinedManagedBaseDefault() if (!previouslyDeclined && local.model.usesLegacyDefault()) { const altimateBaseAvailable = sync.data.provider.some( (provider) => provider.id === "altimate-free" && Boolean(provider.models?.["altimate-base"]), @@ -713,7 +721,13 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // owns any consent prompt for that case; never overwrite it with the first-run picker. // altimate_change — fixes #1301 (Codex review, P2): `hasUsableFreeDefault()` covers the // broader case — a free Zen model the user explicitly picked, or already declined migrating - // away from — the same way `hasExistingLegacySelection()` always covered Big Pickle. + // away from — the same way `hasExistingLegacySelection()` always covered Big Pickle. It also + // folds in `hasOwnPickOfImplicitDefault()` (an older picker-written free-Zen recent with no + // `explicitDefault` marker and no consent operation available) directly now — Codex review + // round 2, P2/P3: that case needs to be recognized everywhere `hasUsableFreeDefault()` is + // (in particular `useReady()`/the prompt gate), not only here at startup, or the SAME user + // hits the picker again on their next submit and loses whatever they typed. See + // `hasUsableFreeDefault`'s declaration in local.tsx for where the fold now lives. if (local.model.hasExistingLegacySelection() || local.model.hasUsableFreeDefault()) { startupDecisionHandled = true return diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 328e8e7801..9c4129096e 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -414,6 +414,14 @@ export function DialogAltimateBaseConfirm(props: { const [error, setError] = createSignal() const trackOnboarding = useOnboardingTelemetry() const firstRunActive = useFirstRunActive() + // altimate_change — PR #1302 review (Cursor "Accept can skip default rewrite", medium, real): + // captured HERE, ONCE, before `yes()` can run any registration — `local.model.launchDefault()` + // is a live memo (`fallbackModel()`), and registration + `sync.bootstrap()` can make + // `altimate-free/altimate-base` the first live provider, moving `fallbackModel()` to Base + // itself by the time `yes()` would otherwise re-read it. Passed to `migrateLegacyDefault({ + // from })` below so eligibility is re-checked against what the launch default WAS, not what it + // has since become. + const launchDefault = local.model.launchDefault() let decided = false let choiceRecorded = false let disposed = false @@ -427,29 +435,38 @@ export function DialogAltimateBaseConfirm(props: { // Escape/Ctrl+C AND the backdrop click — `dialog.tsx`'s `dismiss()`, wired to the backdrop // specifically; `"programmatic"` for this dialog's own `clear()`/`replace()` or an unrelated // feature's — see `ui/dialog.tsx`) so the `onCleanup` fallback below can tell them apart too. - let lastCloseReason: "dismiss" | "programmatic" | undefined + let lastCloseReason: "dismiss" | "interrupt" | "programmatic" | undefined const releaseCloseGuard = dialog.guardClose((reason) => { lastCloseReason = reason - // Escape/Ctrl-C close through `DialogProvider`'s keymap binding (`closeTop()`), which calls - // this guard BEFORE the dialog's own `useKeyboard` below ever sees the key — so intercepting - // in `useKeyboard` alone would be too late; the dialog would already be gone. The backdrop - // click reaches here the same way, via `dialog.tsx`'s `dismiss()` (fixes #1301, Codex review - // round 2, P2: it used to call `clear()`, i.e. "programmatic", so clicking outside the dialog - // silently skipped both the decline AND the picker that keyboard Escape gets). This dialog's - // own visible "esc" label calls `no()` directly instead of going through the guard at all — - // see its `onMouseUp` below. For a migration DISMISSAL from any of these, veto the close and - // run the same routing `no()` does (persist the decline, open the picker) on a microtask - // instead of a bare dismissal, which the retired Big Pickle model cannot silently fall back - // to. `no()` sets `decided = true` before its own `dialog.replace`, so that replace passes - // this same guard on its re-check (reason "programmatic", by then decided) and this queued - // call cannot double-fire. + // Escape closes through `DialogProvider`'s keymap binding (`closeTop("dismiss")`), which + // calls this guard BEFORE the dialog's own `useKeyboard` below ever sees the key — so + // intercepting in `useKeyboard` alone would be too late; the dialog would already be gone. + // The backdrop click reaches here the same way, via `dialog.tsx`'s `dismiss()` (fixes #1301, + // Codex review round 2, P2: it used to call `clear()`, i.e. "programmatic", so clicking + // outside the dialog silently skipped both the decline AND the picker that keyboard Escape + // gets). This dialog's own visible "esc" label calls `no()` directly instead of going through + // the guard at all — see its `onMouseUp` below. For a migration DISMISSAL from any of these, + // veto the close and run the same routing `no()` does (persist the decline, open the picker) + // on a microtask instead of a bare dismissal, which the retired Big Pickle model cannot + // silently fall back to. `no()` sets `decided = true` before its own `dialog.replace`, so + // that replace passes this same guard on its re-check (reason "programmatic", by then + // decided) and this queued call cannot double-fire. + // + // Ctrl+C closes through the same binding but with reason "interrupt" (PR review round 3): + // Ctrl+C is a "get me out" gesture (quitting the app, or backing out of whatever's on + // screen), not "I decline Altimate Base specifically" the way Escape on THIS dialog is. Before + // this distinction existed, quitting with Ctrl+C twice while the migration dialog was open + // queued `no()` on the FIRST Ctrl+C (persist + picker takeover) before the second one could + // quit — recording a refusal the user never made. "interrupt" is deliberately NOT matched + // below, so it falls through to the same handling as a PROGRAMMATIC close: the close + // succeeds, nothing is persisted, and the disclosure is simply offered again next launch. // // A PROGRAMMATIC close (this dialog's own `clear()`/`replace()`, or an unrelated feature — // command palette, session list — replacing the dialog stack out from under this one) is left - // alone here. Neither is the user declining Altimate Base, so forcing `no()` for them turned - // harmless UI navigation into a persisted refusal plus an unwanted picker takeover. The - // `onCleanup` fallback below only persists a decline for the reasons this guard could not - // itself resolve into a decision. + // alone here too. Neither it nor an interrupt is the user declining Altimate Base, so forcing + // `no()` for them turned harmless UI navigation (or quitting) into a persisted refusal plus an + // unwanted picker takeover. The `onCleanup` fallback below only persists a decline for the + // reasons this guard could not itself resolve into a decision. if (reason === "dismiss" && props.origin === "migration" && !decided && !busy()) { queueMicrotask(no) return false @@ -461,8 +478,21 @@ export function DialogAltimateBaseConfirm(props: { function recordChoice(choice: "accept" | "cancel") { if (choiceRecorded) return choiceRecorded = true - // altimate_change — fixes #1301: see the block comment on `releaseCloseGuard` above - if (props.origin === "migration" || firstRunActive()) { + // altimate_change — PR #1302 review (cubic P2; round 3: Ctrl+C interrupt). Neither a + // PROGRAMMATIC close of the migration dialog (an unrelated feature replacing the dialog + // stack — command palette, session list) nor an INTERRUPT (Ctrl+C — a "get me out" gesture, + // not a refusal) is the user declining anything — neither dismissed THIS dialog. Both reach + // `onCleanup`'s `recordChoice("cancel")` fallback below with `decided` still false, and both + // must stay out of telemetry, same as they're already kept out of the persisted-decline + // fallback. `lastCloseReason` is `undefined` for every OTHER path that calls `recordChoice` + // directly (explicit Yes/No, the visible "esc" label's own `no()` call) — the guard is never + // consulted for those, so excluding only the two known-uninformative reasons (not requiring + // `"dismiss"` specifically) is what keeps those explicit choices in telemetry. + if ( + props.origin === "migration" + ? lastCloseReason !== "programmatic" && lastCloseReason !== "interrupt" + : firstRunActive() + ) { trackOnboarding({ name: "altimate_base_choice", choice, origin: props.origin }) } } @@ -476,19 +506,24 @@ export function DialogAltimateBaseConfirm(props: { onCleanup(() => { releaseCloseGuard() disposed = true - // altimate_change start — fixes #1301 (Codex review round 2, P2): every genuine user - // DISMISSAL is now fully handled above via `queueMicrotask(no)`, which sets `decided` before - // this ever runs — keyboard Escape/Ctrl+C AND the backdrop click, which `dialog.tsx` now - // reports as `dismiss()` (reason "dismiss"), same as this dialog's own visible "esc" label - // (see its `onMouseUp` above, which calls `no()` directly). This branch therefore never - // doubles any of those. What's left reaching here with `decided` still false is a genuinely - // PROGRAMMATIC close: this dialog's own `clear()`/`replace()` (already `decided` by then) or - // an unrelated feature (command palette, session list) replacing the dialog stack out from - // under this one. Neither is the user declining Altimate Base — an unrelated navigation must - // not silently count as a refusal (see the guard comment above) — so this does not persist - // for them. Only a close this component never saw a reason for at all (some other, untracked - // unmount path) still falls back to persisting, as a safety net. - if (!decided && props.origin === "migration" && lastCloseReason !== "programmatic") props.onDecline?.() + // altimate_change start — PR #1302 review (CodeRabbit + cubic, both flagged this): every + // genuine user DISMISSAL is already fully handled above via `queueMicrotask(no)`, which sets + // `decided` before this ever runs — keyboard Escape/Ctrl+C AND the backdrop click, which + // `dialog.tsx` reports as `dismiss()` (reason "dismiss"), same as this dialog's own visible + // "esc" label (see its `onMouseUp` above, which calls `no()` directly). So this branch never + // doubles any of those, and requiring `lastCloseReason === "dismiss"` here is not actually + // reachable for them either — it exists purely as documentation of intent alongside the + // negative case below. + // + // The bug this fixes: renderer teardown (process exit, Ctrl+C-to-quit at the TOP level, not + // this dialog's own Ctrl+C binding) runs this cleanup WITHOUT the guard ever having been + // consulted, so `lastCloseReason` stays `undefined`. The previous `!== "programmatic"` check + // treated "no reason at all" the same as "dismissed", persisting a refusal the user never + // made just from quitting the app. Requiring the reason to be the observed, positive + // "dismiss" — not merely "not programmatic" — excludes both `undefined` and "programmatic" + // (this dialog's own `clear()`/`replace()`, or an unrelated feature replacing the dialog + // stack out from under this one — neither is the user declining Altimate Base either). + if (!decided && props.origin === "migration" && lastCloseReason === "dismiss") props.onDecline?.() // altimate_change end decided = true recordChoice("cancel") @@ -553,8 +588,10 @@ export function DialogAltimateBaseConfirm(props: { if (props.origin === "migration") { // A migration also removes the retired implicit model from recents. Re-check eligibility // after registration so a project allowlist or explicit model change made while the dialog - // was open cannot be overwritten by the returning-user migration. - const migrated = local.model.migrateLegacyDefault() + // was open cannot be overwritten by the returning-user migration. `from: launchDefault` + // (captured on mount, before registration) — see its declaration above — keeps this + // re-check from being defeated by `fallbackModel()` itself having moved to Base by now. + const migrated = local.model.migrateLegacyDefault({ from: launchDefault }) if (!migrated) { // Registration succeeded, but migration is no longer eligible — the user is still on the // retired Big Pickle model. Route to the picker instead of marking setup complete for a @@ -639,13 +676,18 @@ export function DialogAltimateBaseConfirm(props: { {/* altimate_change start — fixes #1301: migration now also covers implicit free public Zen defaults besides the retired Big Pickle id, so the copy must name whichever model - is actually being moved rather than always naming Big Pickle specifically. */} + is actually being moved rather than always naming Big Pickle specifically. + PR #1302 review (CodeRabbit + cubic, both flagged this): this must describe the LAUNCH + default (`local.model.launchDefault()`, = `fallbackModel()`) — the model migration + eligibility and `migrateLegacyDefault()` actually reason about — not + `local.model.current()`/`parsed()`, which can resolve to a session-restored model on + `restoreSession`/`--continue` and so name (or Big-Pickle-classify) the wrong model. */} - {`Your default model, ${local.model.parsed().model}, is a public free model. Altimate Base is the free model Altimate hosts for data work.`} + {`Your default model, ${local.model.launchDefaultDisplay().model}, is a public free model. Altimate Base is the free model Altimate hosts for data work.`} } > diff --git a/packages/tui/src/context/kv.tsx b/packages/tui/src/context/kv.tsx index 7b90c95f59..2b1e08cd37 100644 --- a/packages/tui/src/context/kv.tsx +++ b/packages/tui/src/context/kv.tsx @@ -60,6 +60,15 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ console.error("Failed to write KV state", { error }) }) }, + // altimate_change start — PR #1302 review (CodeRabbit + cubic "Await the atomic writes + // before disposing the state directory"): expose the queued-write chain so a caller (the + // dialog test harness's cleanup, primarily) can wait for everything set so far to actually + // land, instead of guessing with a fixed delay before removing the directory the write + // targets. + flush() { + return write + }, + // altimate_change end } return result }, diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index b78e92636d..397d639e72 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -185,6 +185,28 @@ export function shouldMoveAgentModelDuringMigration( } // altimate_change end +// altimate_change start — Codex review round 2, P2: `migrateLegacyDefault({ from })`'s captured +// `from` must not bypass free-model validation entirely — a provider refresh moving +// `fallbackModel()` off `from` onto some OTHER (in particular PAID) model while the dialog is +// open must not still let accept insert Base. Only the ONE transition this capture exists for is +// allowed: the launch default is either still exactly `from`, or registration itself already +// moved it to Base (the expected post-registration state `usesLegacyDefault()`'s own `isFree` +// check can no longer see, since Base is not a free model). +function sameModel(a: ModelRef | undefined, b: ModelRef): boolean { + return a !== undefined && a.providerID === b.providerID && a.modelID === b.modelID +} + +export function isMigrationStillEligibleAfterCapture( + current: ModelRef | undefined, + from: ModelRef, + explicit: boolean, + providerConfig: unknown, +): boolean { + if (explicit || !allowsManagedBaseDefault(providerConfig)) return false + return sameModel(current, from) || sameModel(current, ALTIMATE_BASE_MODEL) +} +// altimate_change end + export function recentModels( model: { providerID: string; modelID: string }, recent: { providerID: string; modelID: string }[], @@ -360,6 +382,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const state = { pending: false, } + // altimate_change start — PR #1302 review (CodeRabbit + cubic "Await the atomic writes + // before disposing the state directory"; Codex review round 2, P2: a single `pendingWrite` + // reassigned on every `save()` only let a caller wait for the LATEST write — an earlier one + // still in flight (rapid consecutive `save()` calls, e.g. `declineManagedBaseDefault()` + // immediately followed by another mutation) was silently dropped from what `persisted()` + // waited for). Track every outstanding write in a Set instead, each removing itself once + // settled; `persisted()` below awaits all of them, not just the newest. + const pendingWrites = new Set>() + // altimate_change end function save() { if (!modelStore.ready) { @@ -367,7 +398,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return } state.pending = false - void writeJsonAtomic(filePath, { + // altimate_change — see `pendingWrites`' declaration above + const write = writeJsonAtomic(filePath, { recent: modelStore.recent, favorite: modelStore.favorite, variant: modelStore.variant, @@ -378,6 +410,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ declinedManagedBaseDefault: modelStore.declinedManagedBaseDefault, // altimate_change end }) + pendingWrites.add(write) + // `.catch()` here keeps `write` itself from ever being an unhandled rejection (a handler + // is now attached directly to it); `persisted()`'s `Promise.allSettled` below tolerates + // either outcome regardless. + write.catch(() => {}).finally(() => pendingWrites.delete(write)) } readJson(filePath) @@ -595,16 +632,54 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // the picker and discard whatever they typed (see `useReady()`'s callers in // component/prompt/index.tsx). function hasUsableFreeDefault() { - return isUsableFreeDefault( - currentModel(), - isModelValid, - (candidate) => isLegacyBigPickleModel(candidate) || isFreeZenModel(candidate, sync.data.provider), - hasExplicitDefault(), - kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) || modelStore.declinedManagedBaseDefault, + // Codex review round 2, P1: usability is about the model + // ACTUALLY IN USE right now, so explicitness must be judged against `currentModel()` too + // — `hasExplicitModel()`, not `hasExplicitDefault()` (which judges against the LAUNCH + // default `fallbackModel()`, the right comparison for migration eligibility, but the + // wrong one here). Cycling from free model A (the launch default) to free model B writes + // `explicitDefault = B`; comparing that against A made this predicate go false right + // after a deliberate pick, flipping `useReady()` true→false and reopening the picker (and + // clearing the prompt) on the very next submit. + return ( + isUsableFreeDefault( + currentModel(), + isModelValid, + (candidate) => isLegacyBigPickleModel(candidate) || isFreeZenModel(candidate, sync.data.provider), + hasExplicitModel(), + kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) || modelStore.declinedManagedBaseDefault, + ) || + // Codex review round 2, P2: an older picker-written free recent with no explicit marker + // (`hasOwnPickOfImplicitDefault`, judged against the LAUNCH default) is exempted from + // the startup picker in app.tsx — folded in here too so the prompt gate (which reads + // `useReady()`, built on this predicate) agrees, instead of catching that same user on + // their next submit and discarding whatever they typed. + hasOwnPickOfImplicitDefault() ) } // altimate_change end + // altimate_change start — PR #1302 review (CodeRabbit): shared by `parsed` below and + // `launchDefaultDisplay` — the migration disclosure needs to resolve a display name for the + // LAUNCH default (`fallbackModel()`), not only the current selection, via the exact same + // provider/model lookup so the two can never drift. + function modelDisplayName(value: ModelRef | undefined) { + if (!value) { + return { + provider: "Connect a provider", + model: "No provider selected", + reasoning: false, + } + } + const provider = sync.data.provider.find((item) => item.id === value.providerID) + const info = provider?.models[value.modelID] + return { + provider: provider?.name ?? value.providerID, + model: info?.name ?? value.modelID, + reasoning: info?.capabilities?.reasoning ?? false, + } + } + // altimate_change end + return { current: currentModel, get ready() { @@ -616,22 +691,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ favorite() { return modelStore.favorite }, + // altimate_change start — PR #1302 review (CodeRabbit "Migration copy names the wrong + // model"): the migration disclosure and `migrateLegacyDefault()` both reason about the + // LAUNCH default, not whatever `currentModel()` happens to be (which can be a + // session-restored model on `restoreSession`/`--continue`). Expose it, and its resolved + // display name, directly rather than making every caller re-derive them. + launchDefault: fallbackModel, + launchDefaultDisplay: createMemo(() => modelDisplayName(fallbackModel())), + // altimate_change end parsed: createMemo(() => { - const value = currentModel() - if (!value) { - return { - provider: "Connect a provider", - model: "No provider selected", - reasoning: false, - } - } - const provider = sync.data.provider.find((item) => item.id === value.providerID) - const info = provider?.models[value.modelID] - return { - provider: provider?.name ?? value.providerID, - model: info?.name ?? value.modelID, - reasoning: info?.capabilities?.reasoning ?? false, - } + return modelDisplayName(currentModel()) }), cycle(direction: 1 | -1) { const current = currentModel() @@ -644,9 +713,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next >= recent.length) next = 0 const val = recent[next] if (!val) return - const a = agent.current() - if (!a) return - setModelStore("model", a.name, { ...val }) + // altimate_change start — PR #1302 review, cubic P2: route through `selectModel` (with + // `explicit: true`, no `recent: true` — this shortcut cycles WITHIN `recent`, it doesn't + // reorder it) so this deliberate keyboard pick also marks `explicitDefault`, mirroring + // `cycleFavorite` below. Without this, an all-free-tier user who cycles onto a free Zen + // model stays "implicit" per `hasExplicitDefault()`/`hasUsableFreeDefault()`, and the + // first-run picker reopens on every later launch even though they chose it on purpose. + selectModel(val, { explicit: true }) + // altimate_change end }, cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) @@ -702,6 +776,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ declinedManagedBaseDefault() { return modelStore.declinedManagedBaseDefault }, + // altimate_change — PR #1302 review (CodeRabbit + cubic): see `pendingWrite`'s + // declaration above. Awaiting this settles once the most recent `save()` has landed. + persisted() { + // altimate_change — see `pendingWrites`' declaration above. `allSettled` (not `all`) + // so one write's rejection can't stop the caller from also waiting out the others. + return Promise.allSettled([...pendingWrites]).then(() => undefined) + }, // altimate_change start — fixes #1301 (Codex review, P1): see the // `declinedManagedBaseDefault` field declaration above. Called from app.tsx's migration // `onDecline`, alongside (not instead of) the existing kv-key write. @@ -712,15 +793,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) }, // altimate_change end - migrateLegacyDefault() { - if (!usesLegacyDefault() || !isModelValid(ALTIMATE_BASE_MODEL)) return false + // altimate_change start — PR #1302 review (Cursor "Accept can skip default rewrite", + // medium, real): after registration, `yes()` calls `sdk.client.instance.dispose()` then + // `sync.bootstrap()`, which can make `altimate-free/altimate-base` the FIRST live + // provider — so a fresh `fallbackModel()`/`usesLegacyDefault()` re-check here resolves to + // Base itself, its `isFree(fallback)` term goes false, and a real accept looks + // ineligible: recents are never rewritten and the user is bounced to the welcome picker. + // `options.from` lets the caller (the migration dialog's `yes()`) pass the LAUNCH default + // it captured on mount, BEFORE registration ran. With `from` given, eligibility only + // re-checks the parts registration cannot invalidate — still not an explicit choice, + // still allowed by the project's provider allowlist — and skips re-deriving (and losing) + // the free-default check against a `fallbackModel()` that has since moved. The silent + // (already-registered) path in app.tsx keeps calling this with no `from`, unchanged. + migrateLegacyDefault(options?: { from?: ModelRef }) { + const from = options?.from + // altimate_change start — Codex review round 2, P2: see `isMigrationStillEligibleAfterCapture`'s + // declaration above for why `from` cannot just bypass eligibility entirely. + const eligible = from + ? isMigrationStillEligibleAfterCapture(fallbackModel(), from, hasExplicitDefault(), sync.data.config.provider) + : usesLegacyDefault() + if (!eligible || !isModelValid(ALTIMATE_BASE_MODEL)) return false + // altimate_change end // Capture the model being migrated away from BEFORE mutating: reading it after // `setModelStore("model", ...)` below would see Base, not the free default being // dropped, so `migrateLegacyRecentModels` could never actually remove it from `recent`. - // Use the LAUNCH default (`fallbackModel`), the same value eligibility was judged on: - // `currentModel()` can be a session-restored model, which must not be dropped from - // `recent` just because the implicit default moved. - const previous = fallbackModel() + // Use the LAUNCH default (`fallbackModel`, or the caller's captured `from` — see above), + // the same value eligibility was judged on: `currentModel()` can be a session-restored + // model, which must not be dropped from `recent` just because the implicit default moved. + const previous = from ?? fallbackModel() batch(() => { const a = agent.current() // altimate_change start — fixes #1301 (Codex review round 2, P1): migration is a @@ -745,6 +845,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) return true }, + // altimate_change end // Opening an old session restores the model that session was recorded with, verbatim. // Migration is a decision about the DEFAULT model and is owned by the disclosure flow in // app.tsx; applying it here rewrote historical threads onto the request-logging tier with diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 89fb215861..b2a5a7ca6f 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -103,13 +103,31 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create try { const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) setStore("history", lines) + // altimate_change — Codex review round 4: captured from the READ RESULT ALONE, before + // `append()` below can have merged anything else into `store.history`. Subtracting a + // count of races that happened DURING the read (the previous fix) was itself unsound: an + // early append's file write can land on disk AFTER the read started but BEFORE it + // resolves, in which case it's already counted in `lines.length` too — one pre-existing + // entry + one early, already-landed append could read as `lines.length === 2`, and + // subtracting the count of 1 wrongly gives `2 - 1 = 1 > 0`... but the reverse also + // happens: NO pre-existing entries + one early append whose write hadn't landed by read + // time gives `lines.length === 0`, and subtracting still gives a negative-clamped 0 — + // except when the write DOES land in between, giving `1 - 1 = 0` for a case that should + // read as "no prior history", by accident rather than by contract. Arithmetic against an + // unbounded race has no correct answer; fixed by construction below instead — `append()` + // defers its FILE write (never the in-memory update) until this read has fully resolved + // and this snapshot has already been taken, so nothing from this launch can reach + // `lines` in the first place. hadHistoryAtStartup = lines.length > 0 - - // Rewrite valid retained entries to self-heal corruption and enforce the limit. - if (lines.length > 0) - writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) } finally { setLoaded(true) + // altimate_change — flush: a rewrite is needed either to self-heal a corrupted/malformed + // file (whenever the read above found anything at all) or to persist any `append()` that + // deferred its write while this read was still in flight (see `append()` below) — by now + // `store.history` already reflects both, in-memory updates there are always immediate. + // One write covers both cases; `store.history.length > 0` is true for either. + if (store.history.length > 0) + writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) } }) // altimate_change end @@ -162,6 +180,15 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }), ) + // altimate_change — Codex review round 4: the IN-MEMORY update above always happens + // immediately (so the UI — history navigation, drafts — is unaffected either way); only + // the FILE write is deferred while the startup read is still in flight, so this launch's + // own write cannot land in `historyPath` before that read's `hadHistoryAtStartup` + // snapshot is taken from it (see onMount above). `onMount`'s `finally` flushes the + // merged `store.history` in one write once `loaded()` settles — writing here too could + // race that flush and get silently clobbered by it. + if (!loaded()) return + if (trimmed) { writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) return diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index ceddf25279..1d6ae8972f 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -75,14 +75,16 @@ function init() { const renderer = useRenderer() const modeStack = useOpencodeModeStack() // altimate_change start — allow a modal to veto every dialog replacement/close path. `reason` - // distinguishes a user dismissal (Escape/Ctrl+C, via `closeTop()`) from a programmatic close + // distinguishes a user dismissal (Escape, via `closeTop("dismiss")`) from a programmatic close // (`clear()`/`replace()`, whether that is this same dialog closing itself, a click-away, or an - // unrelated feature — command palette, session list — taking over the dialog stack). A guard - // that reacted identically to both could not tell "the user asked to leave THIS dialog" from - // "something else is happening to the dialog stack" (fixes #1301, Codex review, P2). - let closeGuard: ((reason: "dismiss" | "programmatic") => boolean) | undefined + // unrelated feature — command palette, session list — taking over the dialog stack) from a + // Ctrl+C interrupt (via `closeTop("interrupt")`, PR review round 3: Ctrl+C is a "get me out" + // gesture, distinct from Escape's "I decline this dialog specifically" — a guard that treated + // them the same made quitting with Ctrl+C twice while the migration dialog was open persist a + // refusal the user never made, since the guard queued the decline+picker on the FIRST Ctrl+C). + let closeGuard: ((reason: "dismiss" | "interrupt" | "programmatic") => boolean) | undefined - function canClose(reason: "dismiss" | "programmatic") { + function canClose(reason: "dismiss" | "interrupt" | "programmatic") { return closeGuard?.(reason) ?? true } // altimate_change end @@ -111,9 +113,11 @@ function init() { }, 1) } - // altimate_change start — centralize guarded single-dialog close behavior - function closeTop() { - if (!canClose("dismiss")) return false + // altimate_change start — centralize guarded single-dialog close behavior. `reason` defaults to + // "dismiss" (Escape's behavior before Ctrl+C got its own reason below) but every caller now + // passes explicitly. + function closeTop(reason: "dismiss" | "interrupt" = "dismiss") { + if (!canClose(reason)) return false const current = store.stack.at(-1) current?.onClose?.() setStore("stack", store.stack.slice(0, -1)) @@ -131,7 +135,7 @@ function init() { group: "Dialog", cmd: () => { // altimate_change start — preserve selection when the active close guard vetoes Escape - if (!closeTop()) return + if (!closeTop("dismiss")) return if (renderer.getSelection()) { renderer.clearSelection() } @@ -143,8 +147,10 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { - // altimate_change start — preserve selection when the active close guard vetoes Ctrl-C - if (!closeTop()) return + // altimate_change start — preserve selection when the active close guard vetoes Ctrl-C. + // PR review round 3: "interrupt", not "dismiss" — Ctrl+C is a "get me out" gesture, not + // a refusal of whatever dialog happens to be open (see the guard's declaration above). + if (!closeTop("interrupt")) return if (renderer.getSelection()) { renderer.clearSelection() } @@ -213,7 +219,7 @@ function init() { setStore("size", size) }, // altimate_change start — install and safely dispose the active close guard - guardClose(guard: (reason: "dismiss" | "programmatic") => boolean) { + guardClose(guard: (reason: "dismiss" | "interrupt" | "programmatic") => boolean) { closeGuard = guard return () => { if (closeGuard === guard) closeGuard = undefined diff --git a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx index ee9e4d4403..3772d2c23d 100644 --- a/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx +++ b/packages/tui/test/cli/tui/dialog-altimate-base.test.tsx @@ -117,6 +117,9 @@ async function mountConfirm( // persisted decline state, not only whether a mock callback fired. let declinedInKv = () => false let declinedInModel = () => false + // altimate_change — PR #1302 review (CodeRabbit + cubic "Await the atomic writes before + // disposing the state directory"): populated inside `OpenConfirm` below. + let waitForPersistence: () => Promise = () => Promise.resolve() const model = { id: "altimate-base", providerID: "altimate-free", @@ -175,6 +178,15 @@ async function mountConfirm( const local = useLocal() declinedInKv = () => kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false) declinedInModel = () => local.model.declinedManagedBaseDefault() + // altimate_change — PR #1302 review (CodeRabbit + cubic; Codex review round 2, P2): both + // real write queues, so `cleanup()` can await the actual persistence instead of a fixed + // delay before disposing the tmp state directory. Safe to `Promise.all` (rather than + // `allSettled`) here: `local.model.persisted()` now internally awaits `allSettled` over + // EVERY outstanding model write (not just the latest — a single reassigned promise + // previously dropped earlier in-flight writes from what this waited for), and `kv.flush()` + // returns kv.tsx's own queued write chain, which already swallows its own errors + // internally — neither can reject. + waitForPersistence = () => Promise.all([local.model.persisted(), kv.flush()]) replaceDialog = () => dialog.replace(() => Session list replacement) onMount(() => dialog.replace(() => ( @@ -258,14 +270,14 @@ async function mountConfirm( async cleanup() { app.renderer.destroy() resetSetupComplete() - // altimate_change — fixes #1301 (Codex review round 2, D): `local.model`'s `save()` (and - // `kv.tsx`'s `set()`) fire-and-forget their disk write (`void writeJsonAtomic(...)`, never - // awaited by the caller). A decline persisted just before this runs can still have its - // write in flight; disposing the tmp dir immediately raced the atomic-write temp file - // against the directory removal (an EINVAL/ENOENT from `writeJsonAtomic`, surfacing as an - // unhandled rejection misattributed to whichever test happened to be running when it - // resolved). A short buffer lets any in-flight write actually land first. - await Bun.sleep(20) + // altimate_change — PR #1302 review (CodeRabbit + cubic "Await the atomic writes before + // disposing the state directory"): `local.model`'s `save()` and `kv.tsx`'s `set()` each now + // expose their in-flight write (`persisted()`/`flush()` — see `waitForPersistence` above). + // A decline persisted just before this runs previously still had its write in flight when a + // fixed `Bun.sleep(20)` disposed the tmp dir out from under it (an EINVAL/ENOENT from + // `writeJsonAtomic`, surfacing as an unhandled rejection misattributed to whichever test + // happened to be running when it resolved). Actually awaiting the writes removes the guess. + await waitForPersistence().catch(() => {}) await tmp[Symbol.asyncDispose]() }, } @@ -392,6 +404,37 @@ test.serial( }, ) +test.serial( + "Ctrl+C on the migration disclosure closes it without deciding anything, unlike Escape", + async () => { + // altimate_change — PR review round 3: Ctrl+C is a "get me out" gesture (quitting the app), + // not "I decline Altimate Base specifically" the way Escape on THIS dialog is. Before + // `dialog.tsx` gave it its own "interrupt" reason, Ctrl+C was treated identically to Escape + // ("dismiss"), so quitting with Ctrl+C twice while this dialog was open queued `no()` (persist + // + picker takeover) on the FIRST Ctrl+C, recording a refusal the user never made. + const confirm = await mountConfirm({ origin: "migration" }) + try { + // The established way this suite sends a real Ctrl+C through the keymap (see the + // busy-state test below) — not `pressKey("c")` alone, which is just the letter "c". + confirm.app.mockInput.pressKey("c", { ctrl: true }) + await confirm.app.renderOnce() + // The dialog closes (it was the only entry on the stack) without being replaced by + // anything — no forced picker takeover, unlike Escape. + expect(confirm.app.captureCharFrame()).not.toContain("Use Altimate Base?") + expect(confirm.declines()).toHaveLength(0) + expect(confirm.registrations()).toHaveLength(0) + expect(confirm.events.some((event) => event.name === "altimate_base_choice")).toBe(false) + expect(confirm.events.some((event) => event.name === "model_picker_shown")).toBe(false) + // altimate_change — the actual persisted state, which is what a real headless/server + // launch's `Provider.defaultModel()`/ACP would read — not only the mock callback. + expect(confirm.declinedInKv()).toBe(false) + expect(confirm.declinedInModel()).toBe(false) + } finally { + await confirm.cleanup() + } + }, +) + test.serial( "the visible mouse esc label on the migration disclosure persists the decline and opens the picker, same as keyboard Escape", async () => { @@ -408,6 +451,10 @@ test.serial( const escRow = frame.split("\n").findIndex((line) => line.includes("Use Altimate Base?")) expect(escRow).toBeGreaterThanOrEqual(0) const escColumn = frame.split("\n")[escRow].indexOf("esc") + // altimate_change — PR #1302 review (cubic P3): if the label ever moves off this row, + // `indexOf` returns -1 and the click silently misses — fail here with the real cause + // instead of a generic "timed out waiting for condition" from the assertion below. + expect(escColumn).toBeGreaterThanOrEqual(0) await confirm.app.mockMouse.click(escColumn, escRow) await waitUntil(() => confirm.declines().length === 1) expect(confirm.registrations()).toHaveLength(0) diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index 4daedd82af..f17c5228ba 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -15,6 +15,10 @@ import { isOwnPastPickOfFreeDefault, shouldMoveAgentModelDuringMigration, // altimate_change end + // altimate_change start — PR #1302 Codex review round 2 + isLegacyBigPickleModel, + isMigrationStillEligibleAfterCapture, + // altimate_change end LEGACY_BIG_PICKLE_MODEL, migrateLegacyRecentModels, parseModel, @@ -258,3 +262,56 @@ test("isOwnPastPickOfFreeDefault: an older picker-written recent is the user's o expect(isOwnPastPickOfFreeDefault(undefined, [NEMOTRON])).toBe(false) }) // altimate_change end + +// altimate_change start — PR #1302 Codex review round 2, P1: `cycle()` (the recent-model +// shortcut) sets `explicitDefault` to whichever model was cycled TO, without reordering +// `recent` — so `fallbackModel()` (the LAUNCH default) can still resolve to the model cycled +// FROM. Usability (`hasUsableFreeDefault()`) must therefore compare explicitness against +// `currentModel()` (`hasExplicitModel()`'s comparison), not `fallbackModel()` +// (`hasExplicitDefault()`'s — the right comparison for MIGRATION eligibility, the wrong one for +// "is the model in use right now usable"). +test("readiness after cycling: explicitness must be judged against the model in use, not the launch default", () => { + const isFree = (model: { providerID: string; modelID: string }) => + isLegacyBigPickleModel(model) || isFreeZenModel(model, providersFixture()) + const launchDefault = NEMOTRON // A: what fallbackModel() still resolves to after cycling + const cycledTo = LEGACY_BIG_PICKLE_MODEL // B: the current model, and what explicitDefault now is + + // Correct: explicitness checked against the model actually in use sees the deliberate cycle + // and stays usable — this is `hasExplicitModel()`'s comparison. + const explicitAgainstCurrent = isConfirmedExplicitSelection(cycledTo, cycledTo) + expect(explicitAgainstCurrent).toBe(true) + expect(isUsableFreeDefault(cycledTo, () => true, isFree, explicitAgainstCurrent, false)).toBe(true) + + // The bug this guards against: checking explicitness against the LAUNCH default instead + // (`hasExplicitDefault()`'s comparison) finds no match — `explicitDefault` is B, not A — so + // usability wrongly flips false for a model the user just deliberately picked, flipping + // `useReady()` true→false and reopening the picker (clearing the prompt) on the next submit. + const explicitAgainstLaunchDefault = isConfirmedExplicitSelection(launchDefault, cycledTo) + expect(explicitAgainstLaunchDefault).toBe(false) + expect(isUsableFreeDefault(cycledTo, () => true, isFree, explicitAgainstLaunchDefault, false)).toBe(false) +}) +// altimate_change end + +// altimate_change start — PR #1302 Codex review round 2, P2: `migrateLegacyDefault({ from })`'s +// captured `from` must not bypass free-model validation entirely. +test("isMigrationStillEligibleAfterCapture: only the launch-default-unchanged or registration-induced-Base transitions stay eligible", () => { + const from = NEMOTRON + + // Still exactly `from`: the ordinary case (nothing changed while the dialog was open). + expect(isMigrationStillEligibleAfterCapture(from, from, false, {})).toBe(true) + // Registration itself moved the launch default to Base: the expected post-registration state. + expect(isMigrationStillEligibleAfterCapture(ALTIMATE_BASE_MODEL, from, false, {})).toBe(true) + // A provider refresh moved the launch default to some OTHER (in particular PAID) model for an + // unrelated reason — this must NOT stay eligible, or accept would insert Base on top of a + // default that changed out from under it. + expect(isMigrationStillEligibleAfterCapture(ZEN_PAID, from, false, {})).toBe(false) + expect( + isMigrationStillEligibleAfterCapture({ providerID: "anthropic", modelID: "claude-sonnet" }, from, false, {}), + ).toBe(false) + // Explicit or allowlist-excluded still block it regardless of which model `fallbackModel()` is. + expect(isMigrationStillEligibleAfterCapture(from, from, true, {})).toBe(false) + expect(isMigrationStillEligibleAfterCapture(from, from, false, { anthropic: {} })).toBe(false) + // No current fallback at all (e.g. no provider connected any more). + expect(isMigrationStillEligibleAfterCapture(undefined, from, false, {})).toBe(false) +}) +// altimate_change end diff --git a/packages/tui/test/prompt/history-startup-race.test.tsx b/packages/tui/test/prompt/history-startup-race.test.tsx new file mode 100644 index 0000000000..948c2d3636 --- /dev/null +++ b/packages/tui/test/prompt/history-startup-race.test.tsx @@ -0,0 +1,145 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — Codex review rounds 2 and 4: `hadHistoryAtStartup()` has no latency +// bound or ordering guarantee against `append()` — a prompt submitted before the startup read +// settles (`routes/home.tsx` can auto-submit without waiting for history; the prompt saves drafts +// with no readiness gate either) could otherwise write an entry that the SAME read then picks up, +// making a genuinely fresh launch look like a returning one (round 2's finding), OR corrupt the +// COUNT-based fix that round introduced — subtracting how many appends raced the read is unsound +// because an early append's write can land on disk either before or after the read resolves, +// with no bound either way (round 4's finding: one pre-existing entry + one early append whose +// write lands late reads as `lines.length === 1`, and subtracting 1 wrongly gives `0`). Fixed by +// construction instead: `append()` defers its FILE write (never the in-memory store update) while +// `!loaded()`, so nothing from this launch can reach the read's `lines` at all; `onMount`'s +// `finally` flushes the merged `store.history` in one write once the snapshot is captured. These +// tests cover both completion orders — a genuinely fresh launch, and a returning one — and assert +// the deferred write actually lands on disk once `loaded()` settles. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { TestTuiContexts } from "../fixture/tui-environment" +import { tmpdir } from "../fixture/fixture" +import { PromptHistoryProvider, usePromptHistory, parsePromptHistory } from "../../src/prompt/history" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +async function mountWithRacingAppend(existing?: string) { + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + const historyPath = path.join(state, "prompt-history.jsonl") + if (existing !== undefined) await Bun.write(historyPath, existing) + + let history: ReturnType | undefined + let appended = false + + function Capture() { + history = usePromptHistory() + // Called synchronously during the SAME initial render pass that mounts + // `PromptHistoryProvider` — necessarily before its `onMount`'s `await readText(...)` (real + // disk I/O) can possibly have resolved, reproducing the race deterministically rather than by + // timing luck. + history!.append({ input: "first prompt of this launch", parts: [] }) + appended = true + return null + } + + const app = await testRender(() => ( + + + + + + )) + await app.renderOnce() + expect(appended).toBe(true) + return { + historyPath, + history: history!, + async cleanup() { + app.renderer.destroy() + await tmp[Symbol.asyncDispose]() + }, + } +} + +test.serial( + "a prompt appended before the startup read settles does not count as pre-existing history (no prior history)", + async () => { + const mounted = await mountWithRacingAppend() + try { + await waitUntil(() => mounted.history.loaded()) + // The append's own write eventually lands (it's in `history()`), but it must not be + // mistaken for history that existed BEFORE this launch. + expect(mounted.history.hadHistoryAtStartup()).toBe(false) + // The deferred write actually reaches disk once `loaded()` settles — this is the fix: the + // write was deferred, not dropped. + const onDisk = parsePromptHistory(await Bun.file(mounted.historyPath).text()) + expect(onDisk).toEqual([{ input: "first prompt of this launch", parts: [] }]) + } finally { + await mounted.cleanup() + } + }, +) + +test.serial( + "a prompt appended before the startup read settles does not hide real pre-existing history", + async () => { + // Codex review round 4: the count-subtraction fix this replaces could turn THIS case into a + // false negative — one pre-existing entry plus one early append whose write happened to land + // before the read resolves reads as `lines.length === 1`, and subtracting the appended count + // (1) wrongly gave `0`, misclassifying a genuine returning user as fresh. + const existing = JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n" + const mounted = await mountWithRacingAppend(existing) + try { + await waitUntil(() => mounted.history.loaded()) + expect(mounted.history.hadHistoryAtStartup()).toBe(true) + // Both the pre-existing entry and the deferred append are on disk once `loaded()` settles. + const onDisk = parsePromptHistory(await Bun.file(mounted.historyPath).text()) + expect(onDisk).toEqual([ + { input: "from a previous launch", parts: [] }, + { input: "first prompt of this launch", parts: [] }, + ]) + } finally { + await mounted.cleanup() + } + }, +) + +test.serial("real pre-existing history is still recognized when nothing races ahead of the read", async () => { + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write( + path.join(state, "prompt-history.jsonl"), + JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n", + ) + + let history: ReturnType | undefined + function Capture() { + history = usePromptHistory() + return null + } + + const app = await testRender(() => ( + + + + + + )) + try { + await waitUntil(() => history!.loaded()) + expect(history!.hadHistoryAtStartup()).toBe(true) + } finally { + app.renderer.destroy() + await tmp[Symbol.asyncDispose]() + } +}) +// altimate_change end From e7736afca2c76cf7ba1e11e7d899941a17020e15 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 10:55:42 -0700 Subject: [PATCH 05/19] fix: queue prompt-history writes until the startup read settles; complete markers - prompt/history.tsx: appends during the startup read update the in-memory store immediately but defer the file write until the read has captured hadHistoryAtStartup, so a racing append can never flip the returning-user signal either way; the read merges with entries appended this launch instead of overwriting them - wrap four spans that only had single-line markers over multi-line code (the marker guard diffs committed HEAD, so these surfaced only in CI) Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/acp/directory.ts | 8 +++ packages/opencode/src/acp/service.ts | 4 +- packages/tui/src/context/local.tsx | 23 +++++---- packages/tui/src/prompt/history.tsx | 49 ++++++++++++++----- .../test/prompt/history-startup-race.test.tsx | 9 ++-- 5 files changed, 68 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index afd900e9c1..d69b99a0ef 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -34,10 +34,14 @@ export type Snapshot = { readonly defaultModeID: string readonly availableCommands: readonly Command.Info[] readonly defaultModel?: DefaultModel + // altimate_change start — cache the project config that drives default-model selection, so the + // mutable model.json state (recents, decline flag) can be re-read at each selection instead of + // being frozen into the snapshot readonly defaultModelConfig?: { readonly model?: string readonly provider?: Record } + // altimate_change end } export interface LoaderInterface { @@ -65,7 +69,9 @@ export const build = (input: { readonly defaultModeID: string readonly commands: readonly Command.Info[] readonly defaultModel?: DefaultModel + // altimate_change start — see `Snapshot.defaultModelConfig` readonly defaultModelConfig?: Snapshot["defaultModelConfig"] + // altimate_change end }): Snapshot => { const modelOptions = Provider.sort( Object.values(input.providers).flatMap((provider) => @@ -115,7 +121,9 @@ export const build = (input: { : (input.modes[0]?.id ?? input.defaultModeID), availableCommands: input.commands, ...(input.defaultModel ? { defaultModel: input.defaultModel } : {}), + // altimate_change start — see `Snapshot.defaultModelConfig` ...(input.defaultModelConfig ? { defaultModelConfig: input.defaultModelConfig } : {}), + // altimate_change end } } diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index d4a4c4dec4..5958e70316 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -833,8 +833,10 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { modes, defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build", commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)), - // Cache project config, but resolve mutable model.json state at each default selection. + // altimate_change start — cache project config, but resolve mutable model.json state at each + // default selection defaultModelConfig: { model: config?.model, provider: config?.provider }, + // altimate_change end }) }) } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 397d639e72..112959493f 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -397,24 +397,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ state.pending = true return } + // altimate_change start — PR #1302 review (CodeRabbit + cubic "Await the atomic writes + // before disposing the state directory"; see `pendingWrites`' declaration above): + // `const write =` captures the promise (this used to be a bare `void + // writeJsonAtomic(...)`), tracked in `pendingWrites` below so `persisted()` can await + // every outstanding write, not just the latest. `.catch()` on `write` itself keeps it + // from ever being an unhandled rejection (a handler is attached directly to it); + // `persisted()`'s `Promise.allSettled` tolerates either outcome regardless. state.pending = false - // altimate_change — see `pendingWrites`' declaration above const write = writeJsonAtomic(filePath, { recent: modelStore.recent, favorite: modelStore.favorite, variant: modelStore.variant, - // altimate_change start — persist the last explicitly-picked model across launches - explicitDefault: modelStore.explicitDefault, - // altimate_change end - // altimate_change start — see the `declinedManagedBaseDefault` field declaration above - declinedManagedBaseDefault: modelStore.declinedManagedBaseDefault, - // altimate_change end + explicitDefault: modelStore.explicitDefault, // fixes #1301: persist the last explicit pick + declinedManagedBaseDefault: modelStore.declinedManagedBaseDefault, // fixes #1301 }) pendingWrites.add(write) - // `.catch()` here keeps `write` itself from ever being an unhandled rejection (a handler - // is now attached directly to it); `persisted()`'s `Promise.allSettled` below tolerates - // either outcome regardless. write.catch(() => {}).finally(() => pendingWrites.delete(write)) + // altimate_change end } readJson(filePath) @@ -699,9 +699,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ launchDefault: fallbackModel, launchDefaultDisplay: createMemo(() => modelDisplayName(fallbackModel())), // altimate_change end + // altimate_change start — body factored into `modelDisplayName` so `launchDefaultDisplay` + // above resolves names identically parsed: createMemo(() => { return modelDisplayName(currentModel()) }), + // altimate_change end cycle(direction: 1 | -1) { const current = currentModel() if (!current) return diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index b2a5a7ca6f..47cdc1030b 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -99,10 +99,22 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // sent during THIS launch must not retroactively make the launch look like a return visit. const [loaded, setLoaded] = createSignal(false) let hadHistoryAtStartup = false + // altimate_change — Codex review round 4: the file writes below are fire-and-forget + // (`.catch(() => {})`, never awaited) — `loaded()` becoming true does NOT mean a write kicked + // off in the same tick (the onMount flush, or an `append()` that had been deferred) has + // actually landed on disk yet. Track the most recently kicked-off write so a caller (tests, + // primarily) can wait for it via `flushed()` below instead of assuming `loaded()` implies it. + let pendingWrite: Promise = Promise.resolve() onMount(async () => { try { const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) - setStore("history", lines) + // altimate_change — Codex review round 4: MERGE, never blind-overwrite. An `append()` + // that ran while this read was in flight already pushed its entry onto `store.history` + // (in-memory only — its file write is deferred, see `append()` below); a plain + // `setStore("history", lines)` here would silently discard that entry the moment the read + // resolves. `lines` (older, from disk) comes first, whatever was already appended this + // launch comes after. + setStore("history", (prev) => [...lines, ...prev]) // altimate_change — Codex review round 4: captured from the READ RESULT ALONE, before // `append()` below can have merged anything else into `store.history`. Subtracting a // count of races that happened DURING the read (the previous fix) was itself unsound: an @@ -127,7 +139,10 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // `store.history` already reflects both, in-memory updates there are always immediate. // One write covers both cases; `store.history.length > 0` is true for either. if (store.history.length > 0) - writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) + pendingWrite = writeText( + historyPath, + store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", + ).catch(() => {}) } }) // altimate_change end @@ -180,21 +195,33 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }), ) - // altimate_change — Codex review round 4: the IN-MEMORY update above always happens - // immediately (so the UI — history navigation, drafts — is unaffected either way); only - // the FILE write is deferred while the startup read is still in flight, so this launch's - // own write cannot land in `historyPath` before that read's `hadHistoryAtStartup` - // snapshot is taken from it (see onMount above). `onMount`'s `finally` flushes the - // merged `store.history` in one write once `loaded()` settles — writing here too could - // race that flush and get silently clobbered by it. + // altimate_change start — Codex review round 4: the IN-MEMORY update above always + // happens immediately (so the UI — history navigation, drafts — is unaffected either + // way); only the FILE write is deferred while the startup read is still in flight, so + // this launch's own write cannot land in `historyPath` before that read's + // `hadHistoryAtStartup` snapshot is taken from it (see onMount above). `onMount`'s + // `finally` flushes the merged `store.history` in one write once `loaded()` settles — + // writing here too could race that flush and get silently clobbered by it. `pendingWrite` + // tracking (see its declaration above) lets a caller (tests, primarily) await the write + // via `flushed()` below instead of assuming it already landed once kicked off. if (!loaded()) return if (trimmed) { - writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {}) + pendingWrite = writeText( + historyPath, + store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", + ).catch(() => {}) return } - appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) + pendingWrite = appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) + // altimate_change end + }, + // altimate_change start — see `pendingWrite`'s declaration above. Awaiting this settles + // once the most recently kicked-off write has landed (or failed). + flushed() { + return pendingWrite }, + // altimate_change end } }, }) diff --git a/packages/tui/test/prompt/history-startup-race.test.tsx b/packages/tui/test/prompt/history-startup-race.test.tsx index 948c2d3636..4b64fd581c 100644 --- a/packages/tui/test/prompt/history-startup-race.test.tsx +++ b/packages/tui/test/prompt/history-startup-race.test.tsx @@ -78,8 +78,10 @@ test.serial( // The append's own write eventually lands (it's in `history()`), but it must not be // mistaken for history that existed BEFORE this launch. expect(mounted.history.hadHistoryAtStartup()).toBe(false) - // The deferred write actually reaches disk once `loaded()` settles — this is the fix: the - // write was deferred, not dropped. + // The deferred write actually reaches disk once it settles — this is the fix: the write + // was deferred, not dropped. `loaded()` alone does not guarantee the write already landed + // (it's fire-and-forget), so this awaits `flushed()` rather than reading immediately. + await mounted.history.flushed() const onDisk = parsePromptHistory(await Bun.file(mounted.historyPath).text()) expect(onDisk).toEqual([{ input: "first prompt of this launch", parts: [] }]) } finally { @@ -100,7 +102,8 @@ test.serial( try { await waitUntil(() => mounted.history.loaded()) expect(mounted.history.hadHistoryAtStartup()).toBe(true) - // Both the pre-existing entry and the deferred append are on disk once `loaded()` settles. + // Both the pre-existing entry and the deferred append are on disk once the flush settles. + await mounted.history.flushed() const onDisk = parsePromptHistory(await Bun.file(mounted.historyPath).text()) expect(onDisk).toEqual([ { input: "from a previous launch", parts: [] }, From 9af9ab4be9137ae5b4f707973f942b6d38377b59 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 21:49:26 -0700 Subject: [PATCH 06/19] fix: address remaining bot findings on PR #1302 (prompt-history write race, migration telemetry, model cycling, test state isolation) TUI - prompt/history.tsx: serialize the startup rewrite and every append's write through one FIFO write queue (`queueWrite`), so a racing append can no longer be clobbered by a still in-flight startup flush; also enforce the no-consecutive-duplicate and MAX_HISTORY_ENTRIES cap invariants when merging startup-read history with entries appended during the read - altimate-onboarding.tsx: track an explicit `chosen` flag set only inside `no()`/`yes()`, so the unconditional `onCleanup` fallback no longer records `altimate_base_choice` telemetry for a bare process exit / top-level quit (previously misread as a decline because `lastCloseReason` stayed `undefined`, which the old check treated the same as an actual dismissal) - altimate-onboarding.tsx: snapshot `launchDefaultDisplay` alongside `launchDefault` once, at mount, instead of reading the live `fallbackModel()`-backed memo from JSX, so the migration disclosure cannot rename itself mid-dialog once `yes()`'s registration moves the live default - local.tsx: `fallbackModel()` now honors a valid, still-allowed `explicitDefault` before falling through to `recent`'s order, so `cycle()`'s deliberate-pick marker (which intentionally does not reorder `recent`) is reflected as the actual launch default on the next launch instead of silently reopening the migration dialog - ui/dialog.tsx: fix stale comments that still described Ctrl+C as behaving "same as Escape" after Ctrl+C was split into its own "interrupt" reason that deliberately records no decline Tests - Global.Path.state gets the same OPENCODE_TEST_HOME-style test-isolation override `home` already had (via OPENCODE_TEST_STATE_HOME), plus a shared `withTestStateHome` helper in test/fixture/fixture.ts; applied to every test in service-session.test.ts, default-model.test.ts, and provider.test.ts that previously read/wrote the real model.json directly, which raced other test files and risked clobbering real developer state if a run was killed mid-write Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude --- packages/opencode/src/global/index.ts | 14 +- .../opencode/test/acp/default-model.test.ts | 17 +- .../opencode/test/acp/service-session.test.ts | 16 +- packages/opencode/test/fixture/fixture.ts | 27 ++ .../opencode/test/provider/provider.test.ts | 230 +++++++++--------- .../tui/src/component/altimate-onboarding.tsx | 64 +++-- packages/tui/src/context/local.tsx | 18 ++ packages/tui/src/prompt/history.tsx | 54 +++- packages/tui/src/ui/dialog.tsx | 10 +- 9 files changed, 289 insertions(+), 161 deletions(-) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 1885142786..5e0dc7609e 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -24,7 +24,19 @@ export namespace Global { log: path.join(data, "log"), cache, config, - state, + // altimate_change start — cubic review round 5, P2: unlike `home` above, `state` was a + // plain module-load-time const with no test-isolation override, so any test reading or + // writing through `Global.Path.state` (recent-model / migration-decline persistence in + // `model.json`) was silently touching the REAL, current developer's state directory — + // racing any other test file doing the same thing in parallel, and risking clobbering real + // state if a test run were killed mid-write. Mirror `home`'s pattern with its own getter and + // env var so `Global.Path.state` can be redirected to a throwaway temp dir per test (see + // `test/fixture/fixture.ts`'s `withTestStateHome`), without changing production behavior — + // the getter is evaluated fresh on every access, and the env var is unset outside tests. + get state() { + return process.env.OPENCODE_TEST_STATE_HOME || state + }, + // altimate_change end } } diff --git a/packages/opencode/test/acp/default-model.test.ts b/packages/opencode/test/acp/default-model.test.ts index 4c7ad40a95..5cdfebfc1c 100644 --- a/packages/opencode/test/acp/default-model.test.ts +++ b/packages/opencode/test/acp/default-model.test.ts @@ -11,6 +11,7 @@ import { Provider } from "@/provider/provider" import { ProviderSchema } from "@/provider/schema" import { ACPService } from "@/acp/service" import { Directory } from "@/acp/directory" +import { withTestStateHome } from "../fixture/fixture" const model = (providerID: ProviderSchema.ProviderID, id: string): Provider.Model => ({ id: ProviderSchema.ModelID.make(id), @@ -158,10 +159,13 @@ describe("ACP defaultModelFromConfig", () => { { flag: undefined, providerID: "altimate-free", modelID: "altimate-base" }, { flag: "yes", providerID: "altimate-free", modelID: "altimate-base" }, ])("honors persisted default-switch decline flag $flag", async ({ flag, providerID, modelID }) => { - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) - try { - await fs.mkdir(Global.Path.state, { recursive: true }) + // altimate_change — Cursor/cubic review round 5, P2/P3: `Global.Path.state` is not + // test-isolated on its own (unlike `Global.Path.home`), so writing `model.json` through it + // directly touched the real developer state directory and raced other tests doing the same. + // `withTestStateHome` redirects it to a throwaway temp dir (already `mkdir`'d) for the + // duration of this test; see its declaration in `test/fixture/fixture.ts`. + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") await fs.writeFile(stateFile, JSON.stringify({ recent: [], declinedManagedBaseDefault: flag })) const zen = provider("opencode", ["big-pickle", "nemotron-3-super-free"]) zen.options.apiKey = "public" @@ -177,10 +181,7 @@ describe("ACP defaultModelFromConfig", () => { providerID: ProviderV2.ID.make(providerID), modelID: ModelV2.ID.make(modelID), }) - } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) - } + }) }) test("a decline still permits Base as the last resort and as an explicit choice", () => { diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 2f80ed079d..654d8c36d1 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -25,6 +25,7 @@ import * as ACPService from "@/acp/service" import * as ACPError from "@/acp/error" import { UsageService } from "@/acp/usage" import type { Provider } from "@/provider/provider" +import { withTestStateHome } from "../fixture/fixture" const providerID = ProviderV2.ID.make("test") const modelID = ModelV2.ID.make("test-model") @@ -374,9 +375,13 @@ describe("ACP service sessions", () => { }, }, } satisfies Provider.Info - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) - try { + // altimate_change — Cursor/cubic review round 5, P2/P3: `Global.Path.state` is not + // test-isolated on its own (unlike `Global.Path.home`), so writing `model.json` through it + // directly touched the real developer state directory and raced other tests doing the same. + // `withTestStateHome` redirects it to a throwaway temp dir for the duration of this test; see + // its declaration in `test/fixture/fixture.ts`. + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") await fs.writeFile(stateFile, JSON.stringify({ recent: [] })) const { service } = makeService([], { providers: [zen, base] }) const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -389,10 +394,7 @@ describe("ACP service sessions", () => { await fs.writeFile(stateFile, JSON.stringify({ recent: [] })) const third = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) expect(select(third, "model")?.currentValue).toBe("altimate-free/altimate-base") - } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) - } + }) }) it("fails before creating a session when the configured model is unavailable", async () => { diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 0ed16587ef..85a058aed1 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -289,3 +289,30 @@ export function provideTmpdirServer( }) }) } + +/** + * Isolates `Global.Path.state` (where `model.json` — recent-model and migration-decline + * persistence — lives) to a throwaway temp directory for the duration of `fn`, via the + * `OPENCODE_TEST_STATE_HOME` env var `Global.Path.state` reads (see `src/global/index.ts`). + * + * Without this, a test reading/writing `model.json` through `Global.Path.state` directly + * touches the REAL, current developer's state directory: unlike `Global.Path.home`, `state` had + * no test-isolation override, so those tests raced every other test file doing the same thing in + * parallel and could clobber real state if a run was killed mid-write (between deleting the real + * file and restoring it from a saved snapshot). + */ +export async function withTestStateHome(fn: () => Promise): Promise { + const dirpath = sanitizePath( + path.join(os.tmpdir(), "opencode-test-state-" + Math.random().toString(36).slice(2)), + ) + await fs.mkdir(dirpath, { recursive: true }) + const original = process.env.OPENCODE_TEST_STATE_HOME + process.env.OPENCODE_TEST_STATE_HOME = dirpath + try { + return await fn() + } finally { + if (original === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = original + await clean(dirpath).catch(() => undefined) + } +} diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index a8eb99aab1..be1c93a731 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -3,7 +3,7 @@ import path from "path" import fs from "fs/promises" import { generateText } from "ai" -import { tmpdir } from "../fixture/fixture" +import { tmpdir, withTestStateHome } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { ProjectID } from "../../src/project/schema" import { Provider } from "../../src/provider/provider" @@ -233,32 +233,35 @@ test.each([ baseURL: ALTIMATE_BASE_GATEWAY_URL, installSecret: "install-secret", }) - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + // altimate_change — Cursor/cubic review round 5, P2/P3: `Global.Path.state` is not + // test-isolated on its own (unlike `Global.Path.home`), so writing `model.json` through it + // directly touched the real developer state directory and raced other tests doing the same. + // `withTestStateHome` redirects it to a throwaway temp dir (already `mkdir`'d) for the + // duration of this test; see its declaration in `test/fixture/fixture.ts`. try { - await fs.mkdir(Global.Path.state, { recursive: true }) - await fs.writeFile(stateFile, JSON.stringify({ recent: [], declinedManagedBaseDefault: flag })) - await using tmp = await tmpdir({ - config: { provider: {}, enabled_providers: ["opencode", FreeTier.PROVIDER_ID] }, - }) - await provideProviderTestInstance({ - directory: tmp.path, - init: async () => Env.remove("OPENCODE_API_KEY"), - fn: async () => { - const providers = await Provider.list() - expect(Object.keys(providers).sort()).toEqual([FreeTier.PROVIDER_ID, "opencode"]) - expect(providers.opencode.options.apiKey).toBe("public") - expect(providers.opencode.key).toBeUndefined() - expect(providers.opencode.models["nemotron-3-super-free"]).toBeDefined() - expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make(providerID), - modelID: ModelID.make(modelID), - }) - }, + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile(stateFile, JSON.stringify({ recent: [], declinedManagedBaseDefault: flag })) + await using tmp = await tmpdir({ + config: { provider: {}, enabled_providers: ["opencode", FreeTier.PROVIDER_ID] }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.remove("OPENCODE_API_KEY"), + fn: async () => { + const providers = await Provider.list() + expect(Object.keys(providers).sort()).toEqual([FreeTier.PROVIDER_ID, "opencode"]) + expect(providers.opencode.options.apiKey).toBe("public") + expect(providers.opencode.key).toBeUndefined() + expect(providers.opencode.models["nemotron-3-super-free"]).toBeDefined() + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), + }) + }, + }) }) } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) credentials.mockRestore() } }) @@ -269,29 +272,32 @@ test("a persisted public Zen recent outranks registered Altimate Base", async () baseURL: ALTIMATE_BASE_GATEWAY_URL, installSecret: "install-secret", }) - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + // altimate_change — Cursor/cubic review round 5, P2/P3: see `withTestStateHome`'s declaration + // in `test/fixture/fixture.ts` — isolates `Global.Path.state` to a throwaway temp dir. try { - await fs.mkdir(Global.Path.state, { recursive: true }) - await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "nemotron-3-super-free" }] })) - await using tmp = await tmpdir({ config: { provider: {} } }) - await provideProviderTestInstance({ - directory: tmp.path, - init: async () => Env.remove("OPENCODE_API_KEY"), - fn: async () => { - const providers = await Provider.list() - expect(providers.opencode.options.apiKey).toBe("public") - expect(providers.opencode.key).toBeUndefined() - expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() - expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make("opencode"), - modelID: ModelID.make("nemotron-3-super-free"), - }) - }, + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile( + stateFile, + JSON.stringify({ recent: [{ providerID: "opencode", modelID: "nemotron-3-super-free" }] }), + ) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.remove("OPENCODE_API_KEY"), + fn: async () => { + const providers = await Provider.list() + expect(providers.opencode.options.apiKey).toBe("public") + expect(providers.opencode.key).toBeUndefined() + expect(providers[FreeTier.PROVIDER_ID]).toBeDefined() + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("nemotron-3-super-free"), + }) + }, + }) }) } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) credentials.mockRestore() } }) @@ -305,26 +311,28 @@ test.each(["__proto__/x", "constructor/x", "opencode/__proto__", "opencode/const baseURL: ALTIMATE_BASE_GATEWAY_URL, installSecret: "install-secret", }) - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + // altimate_change — Cursor/cubic review round 5, P2/P3: see `withTestStateHome`'s + // declaration in `test/fixture/fixture.ts` — isolates `Global.Path.state` to a throwaway + // temp dir. try { - await fs.writeFile(stateFile, JSON.stringify({ recent: [Provider.parseModel(recent)] })) - await using tmp = await tmpdir({ - config: { provider: {}, enabled_providers: ["opencode", FreeTier.PROVIDER_ID] }, - }) - await provideProviderTestInstance({ - directory: tmp.path, - init: async () => Env.remove("OPENCODE_API_KEY"), - fn: async () => { - expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make(FreeTier.PROVIDER_ID), - modelID: ModelID.make(FreeTier.MODEL_ID), - }) - }, + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile(stateFile, JSON.stringify({ recent: [Provider.parseModel(recent)] })) + await using tmp = await tmpdir({ + config: { provider: {}, enabled_providers: ["opencode", FreeTier.PROVIDER_ID] }, + }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.remove("OPENCODE_API_KEY"), + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make(FreeTier.PROVIDER_ID), + modelID: ModelID.make(FreeTier.MODEL_ID), + }) + }, + }) }) } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) credentials.mockRestore() } }, @@ -336,50 +344,50 @@ test("a persisted Big Pickle default is not silently migrated headlessly", async baseURL: ALTIMATE_BASE_GATEWAY_URL, installSecret: "install-secret", }) - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + // altimate_change — Cursor/cubic review round 5, P2/P3: see `withTestStateHome`'s declaration + // in `test/fixture/fixture.ts` — isolates `Global.Path.state` to a throwaway temp dir. try { - await fs.mkdir(Global.Path.state, { recursive: true }) - await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "big-pickle" }] })) - await using tmp = await tmpdir({ config: { provider: {} } }) - await provideProviderTestInstance({ - directory: tmp.path, - fn: async () => { - // The TUI owns the migration because it owns the disclosure; rewriting the recent pick - // here would move a user who declined onto the request-logging tier with no prompt. - expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make("opencode"), - modelID: ModelID.make("big-pickle"), - }) - }, + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "big-pickle" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + // The TUI owns the migration because it owns the disclosure; rewriting the recent pick + // here would move a user who declined onto the request-logging tier with no prompt. + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) }) } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) credentials.mockRestore() } }) test("a persisted Big Pickle default remains until Altimate Base consent exists", async () => { const credentials = spyOn(FreeTier, "credentialsForLoad").mockResolvedValue(undefined) - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + // altimate_change — Cursor/cubic review round 5, P2/P3: see `withTestStateHome`'s declaration + // in `test/fixture/fixture.ts` — isolates `Global.Path.state` to a throwaway temp dir. try { - await fs.mkdir(Global.Path.state, { recursive: true }) - await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "big-pickle" }] })) - await using tmp = await tmpdir({ config: { provider: {} } }) - await provideProviderTestInstance({ - directory: tmp.path, - fn: async () => { - expect(await Provider.defaultModel()).toEqual({ - providerID: ProviderID.make("opencode"), - modelID: ModelID.make("big-pickle"), - }) - }, + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile(stateFile, JSON.stringify({ recent: [{ providerID: "opencode", modelID: "big-pickle" }] })) + await using tmp = await tmpdir({ config: { provider: {} } }) + await provideProviderTestInstance({ + directory: tmp.path, + fn: async () => { + expect(await Provider.defaultModel()).toEqual({ + providerID: ProviderID.make("opencode"), + modelID: ModelID.make("big-pickle"), + }) + }, + }) }) } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) credentials.mockRestore() } }) @@ -403,27 +411,27 @@ test("a provider allowlist filters a persisted Altimate Base recent before impli baseURL: ALTIMATE_BASE_GATEWAY_URL, installSecret: "install-secret", }) - const stateFile = path.join(Global.Path.state, "model.json") - const previous = await fs.readFile(stateFile, "utf8").catch(() => undefined) + // altimate_change — Cursor/cubic review round 5, P2/P3: see `withTestStateHome`'s declaration + // in `test/fixture/fixture.ts` — isolates `Global.Path.state` to a throwaway temp dir. try { - await fs.mkdir(Global.Path.state, { recursive: true }) - await fs.writeFile( - stateFile, - JSON.stringify({ recent: [{ providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }] }), - ) - await using tmp = await tmpdir({ config: { provider: { anthropic: {} } } }) - await provideProviderTestInstance({ - directory: tmp.path, - init: async () => Env.set("ANTHROPIC_API_KEY", "test-api-key"), - fn: async () => { - const model = await Provider.defaultModel() - expect(String(model.providerID)).toBe("anthropic") - expect(String(model.modelID)).not.toBe(FreeTier.MODEL_ID) - }, + await withTestStateHome(async () => { + const stateFile = path.join(Global.Path.state, "model.json") + await fs.writeFile( + stateFile, + JSON.stringify({ recent: [{ providerID: FreeTier.PROVIDER_ID, modelID: FreeTier.MODEL_ID }] }), + ) + await using tmp = await tmpdir({ config: { provider: { anthropic: {} } } }) + await provideProviderTestInstance({ + directory: tmp.path, + init: async () => Env.set("ANTHROPIC_API_KEY", "test-api-key"), + fn: async () => { + const model = await Provider.defaultModel() + expect(String(model.providerID)).toBe("anthropic") + expect(String(model.modelID)).not.toBe(FreeTier.MODEL_ID) + }, + }) }) } finally { - if (previous === undefined) await fs.rm(stateFile, { force: true }) - else await fs.writeFile(stateFile, previous) credentials.mockRestore() } }) diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 9c4129096e..935020d764 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -422,9 +422,32 @@ export function DialogAltimateBaseConfirm(props: { // from })` below so eligibility is re-checked against what the launch default WAS, not what it // has since become. const launchDefault = local.model.launchDefault() + // altimate_change start — cubic review round 5, P2: same snapshot reasoning as + // `launchDefault` above, applied to its display name too. `launchDefaultDisplay()` is a LIVE + // memo over the same `fallbackModel()` — calling it from JSX (as the disclosure copy used to) + // re-reads it on every re-render, so once `yes()`'s registration makes Altimate Base the new + // `fallbackModel()`, the disclosure still on screen (`yes()` awaits registration before the + // dialog closes) could rename itself to "Altimate Base" mid-sentence in copy that is + // specifically explaining why the CURRENT default is being replaced. Snapshotting here, once, + // alongside `launchDefault`, keeps the copy naming the model that was actually true when the + // dialog opened. + const launchDefaultDisplay = local.model.launchDefaultDisplay() + // altimate_change end let decided = false let choiceRecorded = false let disposed = false + // altimate_change start — Cursor/CodeRabbit/cubic review round 5: `recordChoice`'s + // `lastCloseReason !== "programmatic" && lastCloseReason !== "interrupt"` check treated + // `lastCloseReason === undefined` as "record it" — but `undefined` is also what a genuine + // top-level quit (process exit, Ctrl+C at the top of the app disposing the whole Solid root) + // leaves behind, since that teardown runs `onCleanup` without the close guard ever being + // consulted. That silently counted app quits as declines in `altimate_base_choice` telemetry. + // `chosen` is the positive signal instead: it is set ONLY inside `no()`/`yes()`, i.e. only when + // the user (or the guard's `queueMicrotask(no)` for a genuine dismiss) actually reached a + // decision. `onCleanup`'s unconditional `recordChoice("cancel")` fallback now records nothing + // for migration unless a decision was actually made. + let chosen = false + // altimate_change end // altimate_change start — fixes #1301: the migration origin never entered the first-run funnel // at all (it was gated on `firstRunActive()`, which migration never sets), so the disclosure // that matters most for measuring the fix was invisible to telemetry. Migration is still not @@ -478,21 +501,12 @@ export function DialogAltimateBaseConfirm(props: { function recordChoice(choice: "accept" | "cancel") { if (choiceRecorded) return choiceRecorded = true - // altimate_change — PR #1302 review (cubic P2; round 3: Ctrl+C interrupt). Neither a - // PROGRAMMATIC close of the migration dialog (an unrelated feature replacing the dialog - // stack — command palette, session list) nor an INTERRUPT (Ctrl+C — a "get me out" gesture, - // not a refusal) is the user declining anything — neither dismissed THIS dialog. Both reach - // `onCleanup`'s `recordChoice("cancel")` fallback below with `decided` still false, and both - // must stay out of telemetry, same as they're already kept out of the persisted-decline - // fallback. `lastCloseReason` is `undefined` for every OTHER path that calls `recordChoice` - // directly (explicit Yes/No, the visible "esc" label's own `no()` call) — the guard is never - // consulted for those, so excluding only the two known-uninformative reasons (not requiring - // `"dismiss"` specifically) is what keeps those explicit choices in telemetry. - if ( - props.origin === "migration" - ? lastCloseReason !== "programmatic" && lastCloseReason !== "interrupt" - : firstRunActive() - ) { + // altimate_change — Cursor/CodeRabbit/cubic review round 5: see `chosen`'s declaration above. + // `lastCloseReason === "dismiss"` is kept alongside `chosen` defensively (a genuine dismiss + // always routes through `no()`, which sets `chosen` first, but this keeps the condition + // correct even if that ordering ever changes) — it is `undefined` (top-level quit) and + // `"programmatic"`/`"interrupt"` (unrelated close, Ctrl+C) that must NOT record a choice. + if (props.origin === "migration" ? chosen || lastCloseReason === "dismiss" : firstRunActive()) { trackOnboarding({ name: "altimate_base_choice", choice, origin: props.origin }) } } @@ -532,6 +546,8 @@ export function DialogAltimateBaseConfirm(props: { function no() { if (decided || busy()) return decided = true + // altimate_change — Cursor/CodeRabbit/cubic review round 5: see `chosen`'s declaration above + chosen = true recordChoice("cancel") // altimate_change — a migration decline no longer just leaves the dialog cleared: Big Pickle // is retired, so "pick something else" must actually route somewhere. `onDecline` still @@ -548,6 +564,8 @@ export function DialogAltimateBaseConfirm(props: { async function yes() { if (decided || busy()) return + // altimate_change — Cursor/CodeRabbit/cubic review round 5: see `chosen`'s declaration above + chosen = true recordChoice("accept") setBusy(true) setError(undefined) @@ -678,16 +696,20 @@ export function DialogAltimateBaseConfirm(props: { Zen defaults besides the retired Big Pickle id, so the copy must name whichever model is actually being moved rather than always naming Big Pickle specifically. PR #1302 review (CodeRabbit + cubic, both flagged this): this must describe the LAUNCH - default (`local.model.launchDefault()`, = `fallbackModel()`) — the model migration - eligibility and `migrateLegacyDefault()` actually reason about — not - `local.model.current()`/`parsed()`, which can resolve to a session-restored model on - `restoreSession`/`--continue` and so name (or Big-Pickle-classify) the wrong model. */} + default (the captured `launchDefault`/`launchDefaultDisplay` snapshots above, = what + `fallbackModel()` resolved to when the dialog opened) — the model migration eligibility + and `migrateLegacyDefault()` actually reason about — not `local.model.current()`/ + `parsed()` (a session-restored model on `restoreSession`/`--continue`) NOR the live + `local.model.launchDefault()`/`launchDefaultDisplay()` memos themselves (cubic review + round 5: those can change mid-dialog once `yes()`'s registration makes Altimate Base + the new live fallback, renaming this copy out from under the user while it explains why + the OLD default is being replaced). */} - {`Your default model, ${local.model.launchDefaultDisplay().model}, is a public free model. Altimate Base is the free model Altimate hosts for data work.`} + {`Your default model, ${launchDefaultDisplay.model}, is a public free model. Altimate Base is the free model Altimate hosts for data work.`} } > diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 112959493f..724f5fceee 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -511,6 +511,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const isManagedBaseModel = (model: ModelRef) => model.providerID === ALTIMATE_BASE_MODEL.providerID && model.modelID === ALTIMATE_BASE_MODEL.modelID + // altimate_change start — cubic review round 5, P2: honor a persisted `explicitDefault` + // as the launch default BEFORE falling through to `recent`'s order. `cycle()` below marks + // the cycled-to model explicit (`selectModel(val, { explicit: true })`) but deliberately + // does not reorder `recent` (cycling would otherwise scramble its own navigation order — + // see `cycle()`'s comment), so without this, this memo's `recent`-order-derived answer and + // the persisted `explicitDefault` marker can disagree the moment the cycled-to model isn't + // already first in `recent`. On the NEXT launch that disagreement fails + // `hasExplicitDefault()`'s `isConfirmedExplicitSelection(fallbackModel(), explicitDefault)` + // check (the launch default no longer matches the marker it is compared against), so a + // free default the user deliberately cycled to re-reads as implicit and can reopen the + // migration dialog. Same allowlist policy as the `recent` loop below: honored unless it is + // specifically the managed-base model and that is currently disallowed. + if (modelStore.explicitDefault && isModelValid(modelStore.explicitDefault)) { + const explicit = modelStore.explicitDefault + if (managedBaseAllowed || !isManagedBaseModel(explicit)) return explicit + } + // altimate_change end + // A recent entry is the user's own past pick, so — matching `Provider.defaultModel()`'s // comment on the same tradeoff — it stays honored for every provider except the // consent-gated managed one; a narrowed project allowlist does not retroactively invalidate diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 47cdc1030b..0227c52ba4 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -46,6 +46,21 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI return JSON.stringify(previous) === JSON.stringify(next) } +// altimate_change start — cubic review: the startup merge of disk-read `lines` with whatever +// `append()` already pushed in-memory during the read (`prev`) must honor the same two +// invariants normal appends do — no consecutive duplicate entries, capped at +// MAX_HISTORY_ENTRIES — rather than a raw concatenation that could reintroduce a duplicate +// straddling the two halves or exceed the cap. +export function mergeStartupHistory(lines: readonly PromptInfo[], prev: readonly PromptInfo[]): PromptInfo[] { + const merged: PromptInfo[] = [] + for (const entry of [...lines, ...prev]) { + if (isDuplicateEntry(merged.at(-1), entry)) continue + merged.push(entry) + } + return merged.slice(-MAX_HISTORY_ENTRIES) +} +// altimate_change end + // altimate_change start — preserve in-progress prompt while browsing history export type PromptHistoryNavigationState = { index: number @@ -105,6 +120,21 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // actually landed on disk yet. Track the most recently kicked-off write so a caller (tests, // primarily) can wait for it via `flushed()` below instead of assuming `loaded()` implies it. let pendingWrite: Promise = Promise.resolve() + // altimate_change start — Cursor review round 5: serialize the startup flush and every + // append's write through one FIFO queue. Before this, each call site reassigned + // `pendingWrite` independently — that tracked only the LAST write kicked off, it never made + // one write wait for the previous one to actually land. Two writes could then run + // concurrently (this onMount flush and a later append, once `loaded()` had already flipped + // true) and race on disk: whichever finished last "wins", not necessarily the one kicked off + // last, so a racing append could be silently clobbered by the still in-flight flush. + // Chaining every write onto `pendingWrite` guarantees strict start-after-previous-settles + // ordering; each write closure re-reads `store.history` at the moment it actually runs + // (after prior writes have settled), never a stale snapshot taken when it was queued. + function queueWrite(write: () => Promise) { + pendingWrite = pendingWrite.then(write) + return pendingWrite + } + // altimate_change end onMount(async () => { try { const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) @@ -114,7 +144,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // `setStore("history", lines)` here would silently discard that entry the moment the read // resolves. `lines` (older, from disk) comes first, whatever was already appended this // launch comes after. - setStore("history", (prev) => [...lines, ...prev]) + setStore("history", (prev) => mergeStartupHistory(lines, prev)) // altimate_change — Codex review round 4: captured from the READ RESULT ALONE, before // `append()` below can have merged anything else into `store.history`. Subtracting a // count of races that happened DURING the read (the previous fix) was itself unsound: an @@ -139,10 +169,12 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // `store.history` already reflects both, in-memory updates there are always immediate. // One write covers both cases; `store.history.length > 0` is true for either. if (store.history.length > 0) - pendingWrite = writeText( - historyPath, - store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", - ).catch(() => {}) + queueWrite(() => + writeText( + historyPath, + store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", + ).catch(() => {}), + ) } }) // altimate_change end @@ -207,13 +239,15 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (!loaded()) return if (trimmed) { - pendingWrite = writeText( - historyPath, - store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", - ).catch(() => {}) + queueWrite(() => + writeText( + historyPath, + store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", + ).catch(() => {}), + ) return } - pendingWrite = appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {}) + queueWrite(() => appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})) // altimate_change end }, // altimate_change start — see `pendingWrite`'s declaration above. Awaiting this settles diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 1d6ae8972f..10f3dd7f3c 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -163,7 +163,9 @@ function init() { // altimate_change start — fixes #1301 (Codex review round 2, P2): shared body for `clear()` // (a "programmatic" close — used all over the codebase, including a dialog closing itself) and // `dismiss()` (a "dismiss" close — the ONE caller is the backdrop click, which is just as much - // a user dismissal as Escape/Ctrl+C and must be reported to the guard the same way). + // a user dismissal as Escape and must be reported to the guard the same way. Ctrl+C is a + // separate "interrupt" reason — a "get me out" gesture, not a decline — see `closeTop` below; + // update this comment too if that distinction ever changes). function clearAll(reason: "dismiss" | "programmatic") { if (!canClose(reason)) return false for (const item of store.stack) { @@ -268,9 +270,11 @@ export function DialogProvider(props: ParentProps) { > {/* altimate_change start — fixes #1301: backdrop click is a USER dismissal, same as - Escape/Ctrl+C. `dismiss()` reports "dismiss" to the close guard, unlike every other + Escape. `dismiss()` reports "dismiss" to the close guard, unlike every other `clear()`/`replace()` call site (self-close, or an unrelated feature taking over - the stack), which stays "programmatic". */} + the stack), which stays "programmatic". Ctrl+C is neither: it reports its own + "interrupt" reason (see `closeTop` below) and deliberately does not record a + decline, since quitting the app is not the same as dismissing this dialog. */} value.dismiss()} size={value.size}> {value.stack.at(-1)!.element} From 21a5998db77279e9d23d3dbb9a8c449d0d0ba0a8 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 21:51:10 -0700 Subject: [PATCH 07/19] fix: close the altimate_change marker after append()'s closing brace Co-Authored-By: Claude --- packages/tui/src/prompt/history.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 0227c52ba4..475a9063a8 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -248,8 +248,8 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create return } queueWrite(() => appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})) - // altimate_change end }, + // altimate_change end // altimate_change start — see `pendingWrite`'s declaration above. Awaiting this settles // once the most recently kicked-off write has landed (or failed). flushed() { From 0370cfa0f5d922700b527881979755fb11455242 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 22:43:52 -0700 Subject: [PATCH 08/19] fix: only flip prompt-history loaded() after the startup flush settles (#1302 critical finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - history.tsx: `setLoaded(true)` used to fire before the startup rewrite was even kicked off, so a racing append could queue behind (or, before the previous commit's serialization, race) a flush that hadn't landed on disk yet. `loaded()` becoming true now guarantees this launch's startup flush, if one was needed, has actually settled — not merely "kicked off, and safe only because nothing else raced it yet." - history-startup-race.test.tsx: new regression test asserts the disk content the INSTANT loaded() flips, with no separate `await flushed()`, unlike the pre-existing tests written before `loaded()` implied this. Comment thread: https://github.com/AltimateAI/altimate-code/pull/1302#discussion_r3981939207 Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/prompt/history.tsx | 26 ++++++++++++----- .../test/prompt/history-startup-race.test.tsx | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index 475a9063a8..ee3db24631 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -114,11 +114,14 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // sent during THIS launch must not retroactively make the launch look like a return visit. const [loaded, setLoaded] = createSignal(false) let hadHistoryAtStartup = false - // altimate_change — Codex review round 4: the file writes below are fire-and-forget - // (`.catch(() => {})`, never awaited) — `loaded()` becoming true does NOT mean a write kicked - // off in the same tick (the onMount flush, or an `append()` that had been deferred) has - // actually landed on disk yet. Track the most recently kicked-off write so a caller (tests, - // primarily) can wait for it via `flushed()` below instead of assuming `loaded()` implies it. + // altimate_change — Codex review round 4 / Cursor review round 5 (HIGH): the startup flush + // below is now AWAITED before `loaded()` flips true (see the `onMount` `finally` block), so + // `loaded()` becoming true DOES mean that write, if one was needed, has landed on disk. What + // it still does NOT cover is a LATER `append()`'s own write — those are kicked off (and + // queued, see `queueWrite` below) only after `loaded()` is already true, and remain + // fire-and-forget from the caller's perspective. Track the most recently kicked-off write so + // a caller (tests, primarily) can wait for it via `flushed()` below instead of assuming + // `loaded()` implies it. let pendingWrite: Promise = Promise.resolve() // altimate_change start — Cursor review round 5: serialize the startup flush and every // append's write through one FIFO queue. Before this, each call site reassigned @@ -162,19 +165,28 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // `lines` in the first place. hadHistoryAtStartup = lines.length > 0 } finally { - setLoaded(true) // altimate_change — flush: a rewrite is needed either to self-heal a corrupted/malformed // file (whenever the read above found anything at all) or to persist any `append()` that // deferred its write while this read was still in flight (see `append()` below) — by now // `store.history` already reflects both, in-memory updates there are always immediate. // One write covers both cases; `store.history.length > 0` is true for either. + // + // altimate_change — Cursor review round 5, HIGH: `setLoaded(true)` used to fire BEFORE + // this write was even kicked off, let alone landed. `queueWrite`'s serialization (see its + // declaration above) already prevents a racing append from being clobbered by this write + // once both are in the same queue — but that guarantee lived entirely in how the two + // writes happen to interleave, not in what `loaded()` itself promises. Awaiting the write + // here makes the invariant explicit and independently verifiable: `loaded()` becoming + // true means this launch's startup rewrite has actually settled on disk, full stop — not + // merely "kicked off, and safe only because nothing else raced it yet." if (store.history.length > 0) - queueWrite(() => + await queueWrite(() => writeText( historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", ).catch(() => {}), ) + setLoaded(true) } }) // altimate_change end diff --git a/packages/tui/test/prompt/history-startup-race.test.tsx b/packages/tui/test/prompt/history-startup-race.test.tsx index 4b64fd581c..bd06bbcf6d 100644 --- a/packages/tui/test/prompt/history-startup-race.test.tsx +++ b/packages/tui/test/prompt/history-startup-race.test.tsx @@ -115,6 +115,34 @@ test.serial( }, ) +test.serial( + "loaded() does not flip until the startup flush has actually landed on disk (Cursor review round 5, HIGH)", + async () => { + // Before this fix, `setLoaded(true)` fired BEFORE the startup rewrite was even kicked off — + // `loaded()` becoming true said nothing about the write's disk state, only that the read had + // resolved. That let a racing write (this flush, or a later append queued right behind it) + // land in either order, so a still in-flight flush could silently clobber a write that + // started after it. `setLoaded(true)` now runs only after the flush's `queueWrite(...)` has + // settled (see `onMount`'s `finally` block in `history.tsx`), so by the time any caller + // observes `loaded() === true`, the flush is no longer "in flight" — there is nothing left + // for a later write to race. This asserts that directly: read the file the INSTANT `loaded()` + // flips, with no separate `await flushed()` (unlike the tests above, written before this fix, + // which needed that extra await specifically because `loaded()` didn't yet imply it). + const existing = JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n" + const mounted = await mountWithRacingAppend(existing) + try { + await waitUntil(() => mounted.history.loaded()) + const onDisk = parsePromptHistory(await Bun.file(mounted.historyPath).text()) + expect(onDisk).toEqual([ + { input: "from a previous launch", parts: [] }, + { input: "first prompt of this launch", parts: [] }, + ]) + } finally { + await mounted.cleanup() + } + }, +) + test.serial("real pre-existing history is still recognized when nothing races ahead of the read", async () => { const tmp = await tmpdir() const state = path.join(tmp.path, "state") From 93ea10a5e38b79595c017b7d25af69f768afa442 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 23:26:58 -0700 Subject: [PATCH 09/19] fix: round 6 bot review findings on PR #1302 (history race, cross-surface default divergence, kv-ready gate, stale close reason, onboarding skip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI - prompt/history.tsx: the startup flush and append()'s trimmed-rewrite branch now snapshot `store.history` to a string SYNCHRONOUSLY at enqueue time (`serializeHistory`), instead of reading it lazily from inside the queued write closure. Combined with removing the earlier `await` before `setLoaded(true)`, all three steps (snapshot, enqueue, flip loaded()) run with no await between them: an append that already ran is already in the snapshot and writes nothing itself (persisted exactly once, by the flush); an append that runs after sees `loaded() === true` and queues its own write behind the flush (persisted exactly once too). This closes both a dropped-append bug (from awaiting the write before setLoaded) and a duplicate-entry bug (from the lazy read) at once. - context/local.tsx: reverted the `explicitDefault`-before-`recent` precedence in `fallbackModel()` — it made the TUI and headless/ACP resolve different launch defaults from the same `model.json` after a cycle, since the server never reads `explicitDefault`, and let a malformed `explicitDefault` (prototype-name `modelID`) poison the TUI default ahead of `recent`'s own validity checks. `cycle()` now passes `{ explicit: true, recent: true }` so the cycled model moves to the front of `recent` instead — the single source of truth for TUI, `Provider.defaultModel()`, and ACP alike. - context/local.tsx: `hasUsableFreeDefault()` no longer reads the kv migration-decline key before `kv.ready` — an unready kv now reads as undecided (assumed usable) instead of "not declined", so the prompt gate can no longer discard a submitted prompt during kv hydration. - context/local.tsx / app.tsx: extracted `shouldSkipOnboardingAtStartup` — app.tsx's startup effect no longer latches "no onboarding needed" off a same-launch setup (an impatient first-run user picking a free model before the effect settles), which used to skip the `onboardingReady()` branch's funnel telemetry and scan gate entirely for that case. - component/altimate-onboarding.tsx: the close guard no longer records `lastCloseReason` for a dismiss vetoed by `busy()` (registration in flight) — that stale reason could otherwise survive into an abrupt teardown and make `onCleanup`'s fallback persist a decline nobody made. Also corrected two comments that still described Ctrl+C as behaving like Escape/dismiss. Server - acp/service.ts: corrected a stale module comment that contradicted the recents loop it sits above — a persisted Big Pickle recent IS honored (matching `Provider.defaultModel()`), only the implicit scans exclude it. Tests - history-startup-race.test.tsx: two new regression tests — an append landing before loaded() flips, and one landing while the startup flush is still in flight — both assert exactly-once persistence with an explicit no-duplicate-lines check. - local.test.ts: new tests for cycle()-via-recents-order, the kv.ready gate, and the onboarding-skip discriminator. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/acp/service.ts | 7 +- packages/tui/src/app.tsx | 30 ++++- .../tui/src/component/altimate-onboarding.tsx | 49 +++++--- packages/tui/src/context/local.tsx | 107 +++++++++++++----- packages/tui/src/prompt/history.tsx | 104 +++++++++++------ packages/tui/test/context/local.test.ts | 78 +++++++++++++ .../test/prompt/history-startup-race.test.tsx | 78 +++++++++++-- 7 files changed, 360 insertions(+), 93 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 5958e70316..74b41a1b99 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -930,7 +930,12 @@ export function defaultModelFromConfig( // altimate_change end } -// altimate_change start — keep Big Pickle explicitly selectable but never choose it implicitly +// altimate_change start — Big Pickle is never chosen by the implicit provider/model SCANS below +// (the `opencodeModel`/`best` fallbacks both exclude it) — but a persisted `recent` entry is the +// user's own past pick, so it is honored verbatim, including a legacy Big Pickle one (kilo review +// round 6, 3986171219: mirrors `Provider.defaultModel()`'s identical recents-loop rationale in +// provider.ts — the TUI owns the migration because it owns the disclosure, so rewriting it here +// would move a declining user to the request-logging tier with no prompt). export async function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModelConfig) { const started = performance.now() diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index df97ad3321..b8453a84a8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -60,7 +60,12 @@ import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" // altimate_change — fixes #1301 (Codex review, P2): `ALTIMATE_BASE_MIGRATION_DECLINED_KEY` moved // to local.tsx so `local.model.hasUsableFreeDefault()` can read the same kv key. -import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY } from "./context/local" +import { + LocalProvider, + useLocal, + ALTIMATE_BASE_MIGRATION_DECLINED_KEY, + shouldSkipOnboardingAtStartup, +} from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" @@ -728,10 +733,31 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // (in particular `useReady()`/the prompt gate), not only here at startup, or the SAME user // hits the picker again on their next submit and loses whatever they typed. See // `hasUsableFreeDefault`'s declaration in local.tsx for where the fold now lives. - if (local.model.hasExistingLegacySelection() || local.model.hasUsableFreeDefault()) { + // altimate_change start — Kilo review round 6 (3986171188): `shouldSkipOnboardingAtStartup` + // — see its declaration in local.tsx — added the `setupComplete()` discriminator. Without it, + // an impatient first-run user who submits before this effect settles — the prompt gate + // (component/prompt/index.tsx) opens the picker on its own, they pick a free Zen model, + // `set()` marks it explicit/recent and `markSetupComplete()` runs — made + // `hasUsableFreeDefault()` true by the time THIS effect finally runs, latching here and + // returning before the `onboardingReady()` branch below (which exists for exactly this + // impatient-user case, per its own comment) ever got a chance to fire the funnel telemetry + // and `openScanGate()`. A genuine RETURNING user's `setupComplete()` is always false here — + // it starts false every launch and is set only by a setup completed DURING this one (see the + // comment on the `onboardingReady()` branch) — so this changes nothing for that case; it only + // stops THIS branch from swallowing a same-launch setup that `onboardingReady()` needs to see. + // A paid pick is unaffected either way: `hasUsableFreeDefault()` requires a free model, so it + // was never true for one. + if ( + shouldSkipOnboardingAtStartup( + local.model.hasExistingLegacySelection(), + local.model.hasUsableFreeDefault(), + setupComplete(), + ) + ) { startupDecisionHandled = true return } + // altimate_change end if (onboardingReady()) { // Not necessarily a returning user. The prompt gate (component/prompt/index.tsx) opens the // same picker as soon as the user tries to submit, which can happen BEFORE sync finishes diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index 935020d764..f32ec48c8a 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -454,12 +454,24 @@ export function DialogAltimateBaseConfirm(props: { // FIRST-RUN onboarding, so it stays out of the `firstRunActive()`-gated events below, but it // gets its own unconditional emission with `origin: "migration"` on every event. // - // `lastCloseReason` remembers which kind of close the guard most recently saw (`"dismiss"` for - // Escape/Ctrl+C AND the backdrop click — `dialog.tsx`'s `dismiss()`, wired to the backdrop + // `lastCloseReason` remembers which kind of close the guard most recently PERMITTED (`"dismiss"` + // for Escape/the backdrop click — `dialog.tsx`'s `dismiss()`, wired to the backdrop // specifically; `"programmatic"` for this dialog's own `clear()`/`replace()` or an unrelated - // feature's — see `ui/dialog.tsx`) so the `onCleanup` fallback below can tell them apart too. + // feature's; `"interrupt"` for Ctrl+C — see `ui/dialog.tsx`) so the `onCleanup` fallback below + // can tell them apart too. let lastCloseReason: "dismiss" | "interrupt" | "programmatic" | undefined const releaseCloseGuard = dialog.guardClose((reason) => { + // altimate_change — Kilo review round 6 (3986171185): a dismiss attempted WHILE `busy()` + // (registration in flight) is VETOED below — the close does not happen, no decision is made, + // `no()` is deliberately not queued. Recording `lastCloseReason` before that veto check used + // to leave it set to `"dismiss"` anyway, as a side effect of an attempt that never actually + // went through. If the app was then torn down before the guard was consulted again (mid + // registration, then a hard quit — the exact guard-free teardown path `onCleanup`'s fallback + // below exists for), that stale `"dismiss"` made the fallback persist a decline nobody + // actually made. Bail out before recording anything whenever the close is going to be + // vetoed for being busy — `lastCloseReason` now only ever reflects a close the guard + // actually PERMITTED (or explicitly routed to `no()`, below). + if (busy()) return false lastCloseReason = reason // Escape closes through `DialogProvider`'s keymap binding (`closeTop("dismiss")`), which // calls this guard BEFORE the dialog's own `useKeyboard` below ever sees the key — so @@ -490,11 +502,11 @@ export function DialogAltimateBaseConfirm(props: { // `no()` for them turned harmless UI navigation (or quitting) into a persisted refusal plus an // unwanted picker takeover. The `onCleanup` fallback below only persists a decline for the // reasons this guard could not itself resolve into a decision. - if (reason === "dismiss" && props.origin === "migration" && !decided && !busy()) { + if (reason === "dismiss" && props.origin === "migration" && !decided) { queueMicrotask(no) return false } - return !busy() + return true }) // altimate_change end @@ -520,17 +532,24 @@ export function DialogAltimateBaseConfirm(props: { onCleanup(() => { releaseCloseGuard() disposed = true - // altimate_change start — PR #1302 review (CodeRabbit + cubic, both flagged this): every - // genuine user DISMISSAL is already fully handled above via `queueMicrotask(no)`, which sets - // `decided` before this ever runs — keyboard Escape/Ctrl+C AND the backdrop click, which - // `dialog.tsx` reports as `dismiss()` (reason "dismiss"), same as this dialog's own visible - // "esc" label (see its `onMouseUp` above, which calls `no()` directly). So this branch never - // doubles any of those, and requiring `lastCloseReason === "dismiss"` here is not actually - // reachable for them either — it exists purely as documentation of intent alongside the - // negative case below. + // altimate_change start — PR #1302 review (CodeRabbit + cubic, both flagged this; Kilo review + // round 6, 3986171185, corrected further): a genuine user DISMISSAL — keyboard Escape or the + // backdrop click, which `dialog.tsx` reports as `dismiss()` (reason "dismiss") — is normally + // fully handled above via `queueMicrotask(no)`, which sets `decided` before this ever runs, + // same as this dialog's own visible "esc" label (see its `onMouseUp` above, which calls + // `no()` directly). Ctrl+C is a separate "interrupt" reason, never "dismiss" — see the guard + // above. So this branch does not double an ORDINARY dismissal. It is not purely + // documentation, though: it is the actual safety net for a dismiss attempted WHILE `busy()` + // was true (registration in flight) followed by teardown before the guard is consulted + // again — the guard above now bails out BEFORE recording anything in that case, so + // `lastCloseReason` stays whatever it was before the vetoed attempt (typically `undefined`, + // since a legitimate prior close would already have set `decided`), and this condition + // correctly stays false for it too. A true positive here (a real, unqueued dismiss reaching + // teardown) would be an ordering bug elsewhere; this remains a deliberate belt-and-suspenders + // check, not dead code. // - // The bug this fixes: renderer teardown (process exit, Ctrl+C-to-quit at the TOP level, not - // this dialog's own Ctrl+C binding) runs this cleanup WITHOUT the guard ever having been + // The bug this also fixes: renderer teardown (process exit, Ctrl+C-to-quit at the TOP level, + // not this dialog's own Ctrl+C binding) runs this cleanup WITHOUT the guard ever having been // consulted, so `lastCloseReason` stays `undefined`. The previous `!== "programmatic"` check // treated "no reason at all" the same as "dismissed", persisting a refusal the user never // made just from quitting the app. Requiring the reason to be the observed, positive diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 724f5fceee..14e4d48502 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -147,6 +147,52 @@ export function isUsableFreeDefault( } // altimate_change end +// altimate_change start — Kilo review round 6: `hasUsableFreeDefault()`'s call site reads +// `kv.get(ALTIMATE_BASE_MIGRATION_DECLINED_KEY, false)` — a default that means "not declined" +// as far as `isUsableFreeDefault` above can tell, whether that's the true persisted value or +// just kv hasn't hydrated yet. For a pre-0.11.x decliner whose refusal lives ONLY in kv (no +// `explicitDefault` marker, no picker-written recent, and big-pickle so +// `hasOwnPickOfImplicitDefault()` is also false — exactly the population this migration +// targets), reading that default as "not declined" before kv is ready makes the WHOLE predicate +// false, flips `useReady()` false, and the prompt gate (component/prompt/index.tsx) opens the +// picker and discards whatever was just typed — deterministically reachable via `--prompt` +// auto-submit (home.tsx waits only on `sync.ready`/`local.model.ready`, not `kv.ready`). +// app.tsx's own startup decision explicitly waits on `kv.ready` before deciding anything; this +// predicate cannot "wait" the same way (it must answer synchronously on every reactive read of +// `useReady()`), so instead: treat an unready kv as UNDECIDED, not "not declined" — assume +// usable (don't block/discard) rather than risk a false negative over a value about to flip +// `true` the moment kv catches up. Extracted as a pure wrapper so the gate itself is directly +// testable without mounting the full kv/model provider stack. +export function hasUsableFreeDefaultGated(kvReady: boolean, computeUsable: () => boolean): boolean { + if (!kvReady) return true + return computeUsable() +} +// altimate_change end + +// altimate_change start — Kilo review round 6 (3986171188): app.tsx's startup effect used to +// latch "this launch needs no onboarding" purely off `hasExistingLegacySelection() || +// hasUsableFreeDefault()`, which can go true from a setup the user JUST completed THIS launch +// (an impatient first-run user submits before this effect settles, the prompt gate opens the +// picker on its own, they pick a free Zen model — `set()` marks it explicit/recent and +// `markSetupComplete()` runs) just as easily as from a genuinely RETURNING user's persisted +// state. Latching on the former skipped the `onboardingReady()` branch below it — which exists +// specifically to catch that same-launch-setup case and fire the funnel telemetry +// (`onboarding_started`/`onboarding_completed`/`scan_gate_shown`) plus `openScanGate()` — before +// it ever ran. `setupCompleteThisLaunch` is the discriminator app.tsx already uses one branch +// below for the identical reason: it starts `false` every launch and is set only by a setup +// completed DURING this one, so a genuine returning user's value is always `false` here and this +// gate's behavior for them is unchanged. Extracted as a pure predicate so app.tsx's startup +// effect (a large, deeply-nested `createEffect` not otherwise unit-testable) has one small, +// directly-testable seam for this specific ordering bug. +export function shouldSkipOnboardingAtStartup( + hasExistingLegacySelection: boolean, + hasUsableFreeDefault: boolean, + setupCompleteThisLaunch: boolean, +): boolean { + return (hasExistingLegacySelection || hasUsableFreeDefault) && !setupCompleteThisLaunch +} +// altimate_change end + // altimate_change start — fixes #1301 (Codex review round 2, P1): an older picker-written Zen // recent that predates the `explicitDefault` marker (see that field's declaration comment) is // still the user's OWN past pick, not a truly implicit default — `recentModels()` only ever adds @@ -511,23 +557,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const isManagedBaseModel = (model: ModelRef) => model.providerID === ALTIMATE_BASE_MODEL.providerID && model.modelID === ALTIMATE_BASE_MODEL.modelID - // altimate_change start — cubic review round 5, P2: honor a persisted `explicitDefault` - // as the launch default BEFORE falling through to `recent`'s order. `cycle()` below marks - // the cycled-to model explicit (`selectModel(val, { explicit: true })`) but deliberately - // does not reorder `recent` (cycling would otherwise scramble its own navigation order — - // see `cycle()`'s comment), so without this, this memo's `recent`-order-derived answer and - // the persisted `explicitDefault` marker can disagree the moment the cycled-to model isn't - // already first in `recent`. On the NEXT launch that disagreement fails - // `hasExplicitDefault()`'s `isConfirmedExplicitSelection(fallbackModel(), explicitDefault)` - // check (the launch default no longer matches the marker it is compared against), so a - // free default the user deliberately cycled to re-reads as implicit and can reopen the - // migration dialog. Same allowlist policy as the `recent` loop below: honored unless it is - // specifically the managed-base model and that is currently disallowed. - if (modelStore.explicitDefault && isModelValid(modelStore.explicitDefault)) { - const explicit = modelStore.explicitDefault - if (managedBaseAllowed || !isManagedBaseModel(explicit)) return explicit - } - // altimate_change end + // altimate_change — round 6 review (cursor/cubic/kilo, all agreeing): a prior fix here + // made `fallbackModel()` prefer a persisted `explicitDefault` over `recent`'s order, so + // `cycle()`'s deliberate pick (which marks `explicitDefault` without reordering `recent`) + // would survive to the next TUI launch. That introduced a WORSE bug: headless/ACP default + // resolution (`Provider.readDefaultModelState()`/`defaultModelFromConfig()`) reads only + // `recent`, never `explicitDefault`, so the TUI and server could resolve two different + // defaults from the same `model.json` after a cycle — and a malformed `explicitDefault` + // (e.g. a prototype-name `modelID`) would have poisoned the TUI launch default ahead of + // the same validity checks `recent` already goes through. Reverted; see `cycle()` below, + // which now reorders `recent` instead (`{ explicit: true, recent: true }`) so `recent` + // stays the single source of truth for TUI, `Provider.defaultModel()`, and ACP alike. // A recent entry is the user's own past pick, so — matching `Provider.defaultModel()`'s // comment on the same tradeoff — it stays honored for every provider except the @@ -658,7 +698,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // `explicitDefault = B`; comparing that against A made this predicate go false right // after a deliberate pick, flipping `useReady()` true→false and reopening the picker (and // clearing the prompt) on the very next submit. - return ( + // + // altimate_change — Kilo review round 6: gated through `hasUsableFreeDefaultGated` — see + // its declaration above — so an unready `kv` reads as undecided (assume usable) rather + // than "not declined." + return hasUsableFreeDefaultGated(kv.ready, () => ( isUsableFreeDefault( currentModel(), isModelValid, @@ -672,7 +716,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // `useReady()`, built on this predicate) agrees, instead of catching that same user on // their next submit and discarding whatever they typed. hasOwnPickOfImplicitDefault() - ) + )) } // altimate_change end @@ -734,13 +778,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next >= recent.length) next = 0 const val = recent[next] if (!val) return - // altimate_change start — PR #1302 review, cubic P2: route through `selectModel` (with - // `explicit: true`, no `recent: true` — this shortcut cycles WITHIN `recent`, it doesn't - // reorder it) so this deliberate keyboard pick also marks `explicitDefault`, mirroring - // `cycleFavorite` below. Without this, an all-free-tier user who cycles onto a free Zen - // model stays "implicit" per `hasExplicitDefault()`/`hasUsableFreeDefault()`, and the - // first-run picker reopens on every later launch even though they chose it on purpose. - selectModel(val, { explicit: true }) + // altimate_change start — PR #1302 review, cubic P2 (round 6: also pass `recent: true`, + // like `cycleFavorite` below): route through `selectModel` so this deliberate keyboard + // pick also marks `explicitDefault` AND moves the cycled-to model to the front of + // `recent`. Without the explicit marker, an all-free-tier user who cycles onto a free + // Zen model stays "implicit" per `hasExplicitDefault()`/`hasUsableFreeDefault()`, and + // the first-run picker reopens on every later launch even though they chose it on + // purpose. `recent: true` used to be deliberately omitted here (to keep `cycle()`'s own + // index-based navigation order stable across repeated presses) — but `recent` is the + // ONLY thing headless/ACP default resolution (`Provider.readDefaultModelState()`, + // `defaultModelFromConfig()`) reads; they have no notion of `explicitDefault`. Omitting + // `recent: true` let the TUI and server resolve two different launch defaults from the + // same `model.json` after a cycle. Reordering `recent` on every cycle does mean + // `cycle()`'s own index computation above now walks a list that just reshuffled — a + // repeated cycle in the same direction can revisit an entry sooner than before — but + // that's a minor UX quirk against a real cross-surface correctness bug, and it keeps + // `recent` the single source of truth everywhere instead of teaching the server about a + // second, TUI-only marker. + selectModel(val, { explicit: true, recent: true }) // altimate_change end }, cycleFavorite(direction: 1 | -1) { diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index ee3db24631..e6b3540f18 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -61,6 +61,15 @@ export function mergeStartupHistory(lines: readonly PromptInfo[], prev: readonly } // altimate_change end +// altimate_change start — round 6 review: both full-rewrite call sites (the startup flush and +// `append()`'s trimmed branch) must snapshot `store.history` to a STRING synchronously, at the +// moment they decide to enqueue a write — never read it lazily from inside the queued closure. +// See the onMount `finally` block below for why. +function serializeHistory(history: readonly PromptInfo[]): string { + return history.map((line) => JSON.stringify(line)).join("\n") + "\n" +} +// altimate_change end + // altimate_change start — preserve in-progress prompt while browsing history export type PromptHistoryNavigationState = { index: number @@ -114,14 +123,16 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // sent during THIS launch must not retroactively make the launch look like a return visit. const [loaded, setLoaded] = createSignal(false) let hadHistoryAtStartup = false - // altimate_change — Codex review round 4 / Cursor review round 5 (HIGH): the startup flush - // below is now AWAITED before `loaded()` flips true (see the `onMount` `finally` block), so - // `loaded()` becoming true DOES mean that write, if one was needed, has landed on disk. What - // it still does NOT cover is a LATER `append()`'s own write — those are kicked off (and - // queued, see `queueWrite` below) only after `loaded()` is already true, and remain - // fire-and-forget from the caller's perspective. Track the most recently kicked-off write so - // a caller (tests, primarily) can wait for it via `flushed()` below instead of assuming - // `loaded()` implies it. + // altimate_change — Codex review round 4 / round 6 (cursor/cubic/kilo): the startup flush and + // every `append()` write are fire-and-forget from `onMount`'s perspective — `loaded()` + // becoming true means the startup flush, if one was needed, has been SNAPSHOTTED and HANDED + // TO the write queue (see the `onMount` `finally` block below), not that it has landed on + // disk yet. An earlier fix tried making `loaded()` also imply "landed on disk" by awaiting + // the write first, but that opened a WORSE window: an `append()` that lands during that await + // still sees `loaded() === false`, takes the early return, and is dropped for good (the + // flush it deferred to has already been snapshotted and sent without it). Track the most + // recently kicked-off write so a caller (tests, primarily) can wait for it via `flushed()` + // below instead of assuming `loaded()` implies it. let pendingWrite: Promise = Promise.resolve() // altimate_change start — Cursor review round 5: serialize the startup flush and every // append's write through one FIFO queue. Before this, each call site reassigned @@ -131,8 +142,11 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // true) and race on disk: whichever finished last "wins", not necessarily the one kicked off // last, so a racing append could be silently clobbered by the still in-flight flush. // Chaining every write onto `pendingWrite` guarantees strict start-after-previous-settles - // ordering; each write closure re-reads `store.history` at the moment it actually runs - // (after prior writes have settled), never a stale snapshot taken when it was queued. + // ordering. Round 6 review: every call site now passes a closure over content already + // captured synchronously at enqueue time (a pre-serialized snapshot string for a full + // rewrite, or the already-`structuredClone`d entry for a plain append) — never one that + // lazily re-reads live `store.history` when it finally runs, which used to let a rewrite's + // closure pick up an entry a later, already-queued append would ALSO write, duplicating it. function queueWrite(write: () => Promise) { pendingWrite = pendingWrite.then(write) return pendingWrite @@ -165,27 +179,38 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create // `lines` in the first place. hadHistoryAtStartup = lines.length > 0 } finally { - // altimate_change — flush: a rewrite is needed either to self-heal a corrupted/malformed - // file (whenever the read above found anything at all) or to persist any `append()` that - // deferred its write while this read was still in flight (see `append()` below) — by now - // `store.history` already reflects both, in-memory updates there are always immediate. - // One write covers both cases; `store.history.length > 0` is true for either. - // - // altimate_change — Cursor review round 5, HIGH: `setLoaded(true)` used to fire BEFORE - // this write was even kicked off, let alone landed. `queueWrite`'s serialization (see its - // declaration above) already prevents a racing append from being clobbered by this write - // once both are in the same queue — but that guarantee lived entirely in how the two - // writes happen to interleave, not in what `loaded()` itself promises. Awaiting the write - // here makes the invariant explicit and independently verifiable: `loaded()` becoming - // true means this launch's startup rewrite has actually settled on disk, full stop — not - // merely "kicked off, and safe only because nothing else raced it yet." - if (store.history.length > 0) - await queueWrite(() => - writeText( - historyPath, - store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", - ).catch(() => {}), - ) + // altimate_change — round 6 review (cursor 3986264135/3986264141, cubic 3986055642, + // kilo 3986287554, coderabbit 3982012207, all independently converging here): the ATOMIC + // transition. `store.history` at this point already reflects the merge above AND + // whatever `append()` calls raced the read (in-memory updates there are always + // immediate, see `append()` below) — a rewrite is needed either to self-heal a + // corrupted/malformed file or to persist those raced appends, and `store.history.length + // > 0` is true for either case. These three steps run SYNCHRONOUSLY, with no `await` + // between them, which is what makes the whole thing safe: + // 1. snapshot `store.history` to a STRING right now, via `serializeHistory` (see its + // declaration above) — never read `store.history` lazily from inside the queued + // write closure. + // 2. hand that snapshot to `queueWrite` — kicked off immediately, NOT awaited, so + // `setLoaded` below never waits on disk I/O. + // 3. flip `loaded()` true. + // Two independent bugs this closes at once: + // - Awaiting the write before `setLoaded(true)` (a prior fix) made `loaded()` also mean + // "landed on disk", but opened a worse window: an `append()` arriving during that + // await still saw `loaded() === false`, took the early return below, and was DROPPED + // — the flush it deferred to had already been sent without it. + // - Reading `store.history` lazily inside the write closure (instead of snapshotting + // synchronously here) let the closure pick up entries a LATER, already-queued + // append() would ALSO write — once both closures actually ran, the same entry landed + // in the file TWICE. + // With the snapshot frozen at this exact synchronous instant: an append that already ran + // is already in the snapshot, and (because `loaded()` was still false when it ran) wrote + // nothing itself — persisted exactly once, by this flush. An append that runs after this + // point sees `loaded() === true` and queues its own write BEHIND this one in the same + // FIFO queue — persisted exactly once too, never overlapping with this flush's content. + if (store.history.length > 0) { + const snapshot = serializeHistory(store.history) + queueWrite(() => writeText(historyPath, snapshot).catch(() => {})) + } setLoaded(true) } }) @@ -251,12 +276,17 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create if (!loaded()) return if (trimmed) { - queueWrite(() => - writeText( - historyPath, - store.history.map((line) => JSON.stringify(line)).join("\n") + "\n", - ).catch(() => {}), - ) + // altimate_change — round 6 review: same snapshot-at-enqueue reasoning as the onMount + // flush above — `store.history` is captured to a string synchronously, right here, + // rather than lazily inside the queued closure. This call is itself fully synchronous + // (no `await` between the `setStore` above and this `queueWrite`), so no OTHER + // `append()` can interleave with it directly — but a LATER append(), enqueued after + // this one, would still update `store.history` immediately (in-memory) before its own + // write reaches the front of the queue; a lazy read here could pick that entry up too, + // duplicating it once this rewrite's closure and that later append's own queued write + // both eventually run. + const snapshot = serializeHistory(store.history) + queueWrite(() => writeText(historyPath, snapshot).catch(() => {})) return } queueWrite(() => appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})) diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index f17c5228ba..eb23618920 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -11,6 +11,12 @@ import { // altimate_change start — fixes #1301 (Codex review, P2): usable-free-default predicate isUsableFreeDefault, // altimate_change end + // altimate_change start — Kilo review round 6: kv.ready gate for hasUsableFreeDefault() + hasUsableFreeDefaultGated, + // altimate_change end + // altimate_change start — Kilo review round 6: app.tsx startup onboarding-skip discriminator + shouldSkipOnboardingAtStartup, + // altimate_change end // altimate_change start — fixes #1301 (Codex review round 2, P1): migration correctness isOwnPastPickOfFreeDefault, shouldMoveAgentModelDuringMigration, @@ -292,6 +298,78 @@ test("readiness after cycling: explicitness must be judged against the model in }) // altimate_change end +// altimate_change start — round 6 review (cursor 3986044810/3986264141, cubic 3986055646, +// kilo 3986171198, all independently converging): `cycle()` now passes `{ explicit: true, +// recent: true }` so the cycled-to model moves to the FRONT of `recent` — the only state +// headless/ACP default resolution (`Provider.readDefaultModelState()`, +// `defaultModelFromConfig()`) reads. A prior fix instead made `fallbackModel()` prefer a +// persisted `explicitDefault` over `recent`'s order, without teaching the server about that +// TUI-only marker at all — so the TUI and server could resolve two different launch defaults +// from the same `model.json` after a cycle. Reverted; `recent`'s order is the single source of +// truth for every surface. +test("cycling persists the launch default via recents order, not a TUI-only marker", () => { + const recent = [NEMOTRON, LEGACY_BIG_PICKLE_MODEL] + const cycledTo = LEGACY_BIG_PICKLE_MODEL // B: what cycle(1) from NEMOTRON selects + + // This mirrors exactly what `cycle()` → `selectModel(val, { recent: true })` persists: + // `recentModels(cycledTo, recent)` moves B to the front, same as any other deliberate pick + // (`cycleFavorite`, `/model`) already does. + const persisted = recentModels(cycledTo, recent) + expect(persisted).toEqual([LEGACY_BIG_PICKLE_MODEL, NEMOTRON]) + + // `fallbackModel()`'s `recent` loop (TUI) and `Provider.readDefaultModelState()` / + // `defaultModelFromConfig()` (headless/ACP, server-side) all resolve the launch default to the + // FIRST valid entry in `recent` — so after a cycle, every surface reading the same persisted + // array agrees on B, with no separate marker for the server to not know about. + expect(persisted[0]).toEqual(cycledTo) +}) +// altimate_change end + +// altimate_change start — Kilo review round 6 (3986171192): `hasUsableFreeDefault()` used to +// read the kv migration-decline key with no `kv.ready` gate. A pre-0.11.x decliner whose refusal +// lives ONLY in kv (no `explicitDefault`, no picker-written recent, legacy Big Pickle so +// `hasOwnPickOfImplicitDefault()` is also false) reads as "not declined" before kv hydrates, +// flipping `useReady()` false and letting the prompt gate discard whatever was just typed — +// deterministically reachable via `--prompt` auto-submit, which waits only on +// `sync.ready`/`local.model.ready`, not `kv.ready`. `hasUsableFreeDefaultGated` (see its +// declaration in local.tsx) is the extracted gate: an unready kv must read as UNDECIDED — +// assumed usable, not "not declined" — so the prompt gate never discards input over a value +// about to flip `true` the moment kv catches up. +test("hasUsableFreeDefault treats an unready kv as usable (undecided), not as 'not declined'", () => { + // kv not ready yet: must not block/discard, regardless of what the underlying computation + // would otherwise say. + expect(hasUsableFreeDefaultGated(false, () => false)).toBe(true) + expect(hasUsableFreeDefaultGated(false, () => true)).toBe(true) + // kv ready: the underlying computation is authoritative again. + expect(hasUsableFreeDefaultGated(true, () => false)).toBe(false) + expect(hasUsableFreeDefaultGated(true, () => true)).toBe(true) +}) +// altimate_change end + +// altimate_change start — Kilo review round 6 (3986171188): app.tsx's startup effect used to +// latch "no onboarding needed this launch" purely off `hasExistingLegacySelection() || +// hasUsableFreeDefault()`, skipping the `onboardingReady()` branch (funnel telemetry + +// `openScanGate()`) even when THIS launch's own impatient-user setup — not a returning user's +// persisted state — is what made that true. `setupCompleteThisLaunch` is the fix. +test("shouldSkipOnboardingAtStartup: a same-launch setup must not swallow the onboardingReady() branch", () => { + // A genuine returning user: legacy/free-default signal true, but nothing was set up THIS + // launch — skip onboarding, as before. + expect(shouldSkipOnboardingAtStartup(true, false, false)).toBe(true) + expect(shouldSkipOnboardingAtStartup(false, true, false)).toBe(true) + + // The regression this guards: an impatient first-run user's own submit-before-ready flow made + // `hasUsableFreeDefault()` (or `hasExistingLegacySelection()`) true THIS launch, via + // `setupComplete()`. Must NOT skip — `onboardingReady()` needs to see this to fire telemetry + // and the scan gate. + expect(shouldSkipOnboardingAtStartup(true, false, true)).toBe(false) + expect(shouldSkipOnboardingAtStartup(false, true, true)).toBe(false) + + // Neither signal true: nothing to skip either way. + expect(shouldSkipOnboardingAtStartup(false, false, false)).toBe(false) + expect(shouldSkipOnboardingAtStartup(false, false, true)).toBe(false) +}) +// altimate_change end + // altimate_change start — PR #1302 Codex review round 2, P2: `migrateLegacyDefault({ from })`'s // captured `from` must not bypass free-model validation entirely. test("isMigrationStillEligibleAfterCapture: only the launch-default-unchanged or registration-induced-Base transitions stay eligible", () => { diff --git a/packages/tui/test/prompt/history-startup-race.test.tsx b/packages/tui/test/prompt/history-startup-race.test.tsx index bd06bbcf6d..e0de27f3e3 100644 --- a/packages/tui/test/prompt/history-startup-race.test.tsx +++ b/packages/tui/test/prompt/history-startup-race.test.tsx @@ -116,33 +116,87 @@ test.serial( ) test.serial( - "loaded() does not flip until the startup flush has actually landed on disk (Cursor review round 5, HIGH)", + "an append that lands before loaded() flips is persisted exactly once, not duplicated by the flush (round 6)", async () => { - // Before this fix, `setLoaded(true)` fired BEFORE the startup rewrite was even kicked off — - // `loaded()` becoming true said nothing about the write's disk state, only that the read had - // resolved. That let a racing write (this flush, or a later append queued right behind it) - // land in either order, so a still in-flight flush could silently clobber a write that - // started after it. `setLoaded(true)` now runs only after the flush's `queueWrite(...)` has - // settled (see `onMount`'s `finally` block in `history.tsx`), so by the time any caller - // observes `loaded() === true`, the flush is no longer "in flight" — there is nothing left - // for a later write to race. This asserts that directly: read the file the INSTANT `loaded()` - // flips, with no separate `await flushed()` (unlike the tests above, written before this fix, - // which needed that extra await specifically because `loaded()` didn't yet imply it). + // Round 6 review (cursor 3986264135, cubic 3986055642): the startup flush's write closure + // used to read `store.history` LAZILY, at the moment it actually ran — which could be AFTER + // this racing append had already updated `store.history` in memory. The flush's snapshot + // would then include the append's entry too, and the append's OWN queued write (behind the + // flush in the FIFO) would write it AGAIN once it ran — landing the same entry on disk twice. + // `history.tsx` now snapshots `store.history` to a string synchronously, in the same + // microtask-free block that flips `loaded()`, so nothing that runs after that point (which + // is the earliest any append's own write can be queued) can ever be in the flush's content. const existing = JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n" const mounted = await mountWithRacingAppend(existing) try { await waitUntil(() => mounted.history.loaded()) - const onDisk = parsePromptHistory(await Bun.file(mounted.historyPath).text()) + await mounted.history.flushed() + const text = await Bun.file(mounted.historyPath).text() + const onDisk = parsePromptHistory(text) expect(onDisk).toEqual([ { input: "from a previous launch", parts: [] }, { input: "first prompt of this launch", parts: [] }, ]) + // Explicit no-duplicate-lines check, independent of `parsePromptHistory`'s own parsing: + // exactly one non-empty line per entry, no repeats. + const lines = text.split("\n").filter(Boolean) + expect(lines.length).toBe(2) + expect(new Set(lines).size).toBe(2) } finally { await mounted.cleanup() } }, ) +test.serial( + "an append that lands while the startup flush is still in flight (after loaded(), before the write settles) is persisted exactly once (round 6)", + async () => { + // Distinct from the racing-append tests above (which append BEFORE the read even settles): + // this appends the INSTANT `loaded()` flips — i.e. right as the startup flush's write has + // been snapshotted and handed to the queue, but before that write's disk I/O has necessarily + // completed. `loaded()` no longer implies "landed on disk" (round 6 removed the earlier fix + // that awaited the write before `setLoaded(true)`, because THAT fix dropped an append landing + // in that exact window — see history.tsx). What must still hold: the flush's snapshot was + // frozen before this append ran, so this append cannot be IN that snapshot, and its own write + // is queued strictly behind the flush's — so once both settle, the entry exists exactly once. + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + const historyPath = path.join(state, "prompt-history.jsonl") + await Bun.write(historyPath, JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n") + + let history: ReturnType | undefined + function Capture() { + history = usePromptHistory() + return null + } + const app = await testRender(() => ( + + + + + + )) + try { + await waitUntil(() => history!.loaded()) + history!.append({ input: "landed during the in-flight flush", parts: [] }) + await history!.flushed() + const text = await Bun.file(historyPath).text() + const onDisk = parsePromptHistory(text) + expect(onDisk).toEqual([ + { input: "from a previous launch", parts: [] }, + { input: "landed during the in-flight flush", parts: [] }, + ]) + const lines = text.split("\n").filter(Boolean) + expect(lines.length).toBe(2) + expect(new Set(lines).size).toBe(2) + } finally { + app.renderer.destroy() + await tmp[Symbol.asyncDispose]() + } + }, +) + test.serial("real pre-existing history is still recognized when nothing races ahead of the read", async () => { const tmp = await tmpdir() const state = path.join(tmp.path, "state") From 96375f65035293bb03c736d056658b86e2828677 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Thu, 10 Sep 2026 23:27:37 -0700 Subject: [PATCH 10/19] fix: keep the app.tsx local.tsx import single-line for the marker guard Co-Authored-By: Claude --- packages/tui/src/app.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b8453a84a8..88af941ea6 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -60,12 +60,7 @@ import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" // altimate_change — fixes #1301 (Codex review, P2): `ALTIMATE_BASE_MIGRATION_DECLINED_KEY` moved // to local.tsx so `local.model.hasUsableFreeDefault()` can read the same kv key. -import { - LocalProvider, - useLocal, - ALTIMATE_BASE_MIGRATION_DECLINED_KEY, - shouldSkipOnboardingAtStartup, -} from "./context/local" +import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY, shouldSkipOnboardingAtStartup } from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" From b01a139d2da8c5a2d79a6976d8a345537f9638a2 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 00:31:41 -0700 Subject: [PATCH 11/19] fix: Codex HOLD findings on PR #1302 (kv-ready pending direction, cycle() stability, history race test rigor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. kv.ready gate direction (local.tsx, altimate-onboarding.tsx, component/prompt/index.tsx): `hasUsableFreeDefaultGated` previously treated an unready kv as "assume usable" (`true`) — the wrong direction. With delayed kv hydration, `--prompt` or a fast manual submit could pass `useReady()` and submit to the implicit public Zen model before onboarding/migration ever ran. It now reports `"pending"` (genuinely undecided), not a boolean guess either way. Every consumer of `useReady()` except the prompt submit gate collapses `"pending"` to `false` (the same conservative default as before kv.ready-awareness existed). The submit gate gets a new `useReadyPending()` seam: a submit attempted while pending defers (keeps the typed prompt, opens nothing) and is automatically retried via a `createEffect` once kv resolves either way — sent if it turns out usable, discarded (matching the normal picker-reopen path) if not. 2. cycle() traversal stability (local.tsx): passing `{ recent: true }` (round 6, fixing a cross-surface TUI/server default divergence) broke `cycle()`'s own navigation — reordering `recent` on every pick meant the next press read its index off a list that had just reshuffled, bouncing between two models instead of visiting every one. `cycle()` now captures its traversal order once, lazily, into a separate `cycleOrder` snapshot that stays stable across a whole cycling sequence (re-captured only if the current model falls out of it, e.g. an unrelated explicit pick) — while still persisting every pick to the front of `recent` via `selectModel(..., { recent: true })`, so cross-surface consistency is unaffected. 3. History startup-race test rigor (history-startup-race.test.tsx): the two round-6 regression tests polled `loaded()` via `Bun.sleep`, which does not reliably keep the startup write "in flight" — confirmed they passed even against the dropped-append bug they were meant to catch. Replaced with three barrier-controlled tests using `spyOn(persistence, "readText"/"writeText")` against manually-resolved deferred promises: append while the read is pending, append while the rewrite's write is still gated/in-flight (FIFO ordering), and append synchronously in the same reactive tick `loaded()` flips (the precise shape that reproduces the lazy-snapshot duplicate bug). All three were confirmed to fail against the relevant old behavior and pass on HEAD before being kept. New tests: cycle-stability.test.tsx (calls the real cycle() three times, asserts the visited sequence — verified fails against the pre-fix bounce), ready-pending.test.tsx (the defer/retry mechanism, verified against both the "sent early" and "usable" directions). Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/app.tsx | 6 +- .../tui/src/component/altimate-onboarding.tsx | 25 +- packages/tui/src/component/prompt/index.tsx | 37 ++- packages/tui/src/context/local.tsx | 110 +++++--- .../tui/test/context/cycle-stability.test.tsx | 200 ++++++++++++++ packages/tui/test/context/local.test.ts | 33 +-- .../tui/test/context/ready-pending.test.tsx | 175 +++++++++++++ .../test/prompt/history-startup-race.test.tsx | 243 +++++++++++++----- 8 files changed, 708 insertions(+), 121 deletions(-) create mode 100644 packages/tui/test/context/cycle-stability.test.tsx create mode 100644 packages/tui/test/context/ready-pending.test.tsx diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 88af941ea6..1a6c1d0a0a 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -745,7 +745,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi if ( shouldSkipOnboardingAtStartup( local.model.hasExistingLegacySelection(), - local.model.hasUsableFreeDefault(), + // `=== true`: `hasUsableFreeDefault()` can also return `"pending"` (see its declaration + // and `hasUsableFreeDefaultGated` in local.tsx) — but this whole effect already returned + // early above unless `kv.ready`, so it is always a plain boolean by the time it runs + // here; the explicit check just satisfies the union type without widening it elsewhere. + local.model.hasUsableFreeDefault() === true, setupComplete(), ) ) { diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index f32ec48c8a..d419afdf3d 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -77,10 +77,33 @@ export function useReady() { // component/prompt/index.tsx). `LocalProvider` wraps the whole app above `DialogProvider` (see // app.tsx), so `useLocal()` is always available to every caller of `useReady()`. const local = useLocal() - return createMemo(() => connected() || setupComplete() || local.model.hasUsableFreeDefault()) + // altimate_change — Codex HOLD finding 1: `hasUsableFreeDefault()` can now return `"pending"` + // (kv not hydrated yet, see its declaration in local.tsx) as well as a boolean. Every consumer + // of `useReady()` EXCEPT the prompt submit gate only needs a plain boolean (display text, + // whether a command is enabled, the first-run chat lock) — `"pending"` collapses to `false` for + // all of them, the same conservative default this code had before kv.ready-awareness existed. + // `useReadyPending()` below is the ONE seam the submit gate uses to see the pending state + // itself, so it can defer instead of discarding. + return createMemo(() => connected() || setupComplete() || local.model.hasUsableFreeDefault() === true) // altimate_change end } +// altimate_change start — Codex HOLD finding 1: true only when overall readiness cannot be +// decided YET — none of `connected()`/`setupComplete()` are already true, and the free-default +// predicate is specifically `"pending"` (kv still hydrating), not a settled `false`. The prompt +// submit gate (component/prompt/index.tsx) is the one caller that needs this: `useReady()` alone +// cannot distinguish "genuinely not usable, show the picker" from "don't know yet, kv is still +// loading" — both read as `false` there by design (see `useReady()`'s comment above), which is +// the right default for every OTHER consumer (display text, command enablement) but wrong for a +// submit gate whose `false` branch discards the typed prompt. This predicate lets the submit +// gate keep the prompt and retry once kv resolves, instead of guessing either way. +export function useReadyPending() { + const connected = useConnected() + const local = useLocal() + return createMemo(() => !connected() && !setupComplete() && local.model.hasUsableFreeDefault() === "pending") +} +// altimate_change end + /** * Setup completion ONLY — deliberately without the `connected()` term. * diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index b94bf9e4fa..2490e43d37 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -48,7 +48,7 @@ import { useDialog } from "../../ui/dialog" import { DialogProvider as DialogProviderConnect, WARNLIST } from "../dialog-provider" // altimate_change — first-run submit gate: open the curated welcome picker instead // of erroring when no model is ready yet (see altimate-onboarding.tsx). -import { DialogModelWelcome, useReady } from "../altimate-onboarding" +import { DialogModelWelcome, useReady, useReadyPending } from "../altimate-onboarding" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" @@ -268,6 +268,12 @@ export function Prompt(props: PromptProps) { // up; and flag known-bad tool-callers with a persistent "⚠ unreliable model" chip in // the prompt meta row (same WARNLIST the model picker warns with). const ready = useReady() + // altimate_change — Codex HOLD finding 1: `!ready()` alone cannot distinguish "genuinely not + // usable, show the picker" from "don't know yet, kv is still hydrating" — see `readyPending`'s + // consumer below (`submitInner`) for why that distinction matters for a submit gate + // specifically, and `useReadyPending`'s declaration in altimate-onboarding.tsx for why it's a + // separate accessor rather than folded into `ready` itself. + const readyPending = useReadyPending() const unreliableModel = createMemo(() => Boolean(WARNLIST[local.model.parsed().model])) // altimate_change end @@ -1027,6 +1033,23 @@ export function Prompt(props: PromptProps) { }) let submitting = false + // altimate_change start — Codex HOLD finding 1: a submit attempted while `readyPending()` is + // true (kv still hydrating, see its declaration above) defers instead of discarding — see + // `submitInner`'s `readyPending()` branch below, which sets this flag rather than clearing the + // prompt. This effect is the retry: once `readyPending()` flips false (kv resolved either way), + // re-attempt the exact same `submit()` call automatically, so a submission made during that + // window is neither lost nor stuck waiting on the user to press Enter again. Re-running + // `submit()` (not some cached decision) means it re-evaluates `ready()` fresh against whatever + // `store.prompt.input` currently holds — if the user kept typing while deferred, that's what + // goes out; if they cleared it, `submitInner`'s own `if (!store.prompt.input) return false` + // early-exit makes this a no-op. + let deferredSubmit = false + createEffect(() => { + if (readyPending() || !deferredSubmit) return + deferredSubmit = false + void submit() + }) + // altimate_change end async function submit() { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the // input's native onSubmit racing another dispatch). Without this guard, @@ -1068,6 +1091,18 @@ export function Prompt(props: PromptProps) { // message with no provider ready opens the welcome picker (the message is // discarded) with a friendly line, rather than erroring. if (!ready()) { + // altimate_change — Codex HOLD finding 1: `readyPending()` (see its declaration above) + // means readiness genuinely cannot be decided yet — kv is still hydrating, and none of + // `connected()`/`setupComplete()` are already true either. Discarding the prompt and + // opening the picker HERE, before kv even finishes loading, is exactly the bug: a decliner + // whose refusal lives only in kv would look un-declined for that brief window and get + // bounced into onboarding they already completed once, losing whatever they just typed. + // Defer instead — keep the prompt exactly as-is, do not open anything — and let the retry + // effect above resubmit once `readyPending()` settles. + if (readyPending()) { + deferredSubmit = true + return false + } dialog.replace(() => ( boolean): boolean { - if (!kvReady) return true +// false. Kilo's original finding stopped there; Codex caught the first attempted fix (treat an +// unready kv as "assume usable", i.e. return `true`) going the WRONG direction: that makes +// `useReady()` true immediately, before onboarding/migration has had any chance to run, so +// `--prompt` (or a fast manual submit) sails straight through to whatever implicit default is +// currently selected — including the public Zen tier a migration disclosure should have offered +// to move off of. "Assume usable" trades a false negative (discarded input) for a false positive +// (skipped onboarding) — worse, not better. +// The correct third state is PENDING, not `true`: an unready kv means this predicate genuinely +// cannot answer yet, so it must say so explicitly rather than guessing either boolean. Callers +// that only need a boolean (headless call sites, `app.tsx`'s startup effect, which already waits +// on `kv.ready` before running at all) coerce `pending` to `false` — the same conservative +// default the code had before kv.ready-awareness existed. The ONE caller that must NOT collapse +// `pending` to `false` is the prompt submit gate (`component/prompt/index.tsx`): a `false` there +// means "discard the input and open the picker", which is exactly the data-loss bug this was +// supposed to fix. `useReadyPending()` (see `altimate-onboarding.tsx`) is the seam that lets the +// submit gate DEFER — keep the typed prompt, don't judge yet, retry once kv actually resolves — +// instead of discarding it over an answer that was never computed. +export function hasUsableFreeDefaultGated(kvReady: boolean, computeUsable: () => boolean): boolean | "pending" { + if (!kvReady) return "pending" return computeUsable() } // altimate_change end @@ -699,9 +707,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // after a deliberate pick, flipping `useReady()` true→false and reopening the picker (and // clearing the prompt) on the very next submit. // - // altimate_change — Kilo review round 6: gated through `hasUsableFreeDefaultGated` — see - // its declaration above — so an unready `kv` reads as undecided (assume usable) rather - // than "not declined." + // altimate_change — Kilo review round 6 / Codex HOLD finding 1: gated through + // `hasUsableFreeDefaultGated` — see its declaration above — so an unready `kv` reads as + // `"pending"` (genuinely undecided), not a boolean guess either way. Callers that need a + // plain boolean coerce it (`=== true`); `useReadyPending()` in altimate-onboarding.tsx is + // the one caller (the prompt submit gate) that must see the `"pending"` state itself. return hasUsableFreeDefaultGated(kv.ready, () => ( isUsableFreeDefault( currentModel(), @@ -742,6 +752,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } // altimate_change end + // altimate_change start — Codex HOLD finding 2: `cycle()`'s traversal order, captured + // lazily on first use and held stable for the rest of the cycling sequence — see + // `cycle()`'s own comment below for why a LIVE read of `modelStore.recent` (which `cycle()` + // itself reorders via `selectModel(val, { recent: true })`) breaks repeated presses. + let cycleOrder: readonly { providerID: string; modelID: string }[] | undefined + // altimate_change end + return { current: currentModel, get ready() { @@ -767,37 +784,46 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return modelDisplayName(currentModel()) }), // altimate_change end + // altimate_change start — PR #1302 review, cubic P2 (round 6: also pass `recent: true`, + // like `cycleFavorite` below) / Codex HOLD finding 2 (round 7: stable traversal order). + // Two requirements that pull in opposite directions if both aimed at the SAME array: + // 1. Cycling must move the picked model to the front of PERSISTED `recent` — that's the + // ONLY state headless/ACP default resolution (`Provider.readDefaultModelState()`, + // `defaultModelFromConfig()`) reads; without it the TUI and server can resolve two + // different launch defaults from the same `model.json` after a cycle (they have no + // notion of the earlier `explicitDefault`-only marker this used to rely on instead). + // 2. Cycling must visit every model in a stable order across repeated presses — reading + // the INDEX to advance from directly off that same, just-reordered `modelStore.recent` + // breaks this: cycling forward from B in [A, B, C] persists [B, A, C], so the NEXT + // forward press finds B now at index 0 (not 1) and its "next" becomes A — landing + // B → A → B forever instead of visiting every model (Codex caught this by actually + // executing it: HEAD's behavior was B → A → B; the correct behavior, matching the + // order before any cycling started, is B → C → A). + // `cycleOrder` (declared above, alongside `modelStore`) resolves this: it is a SEPARATE, + // stable snapshot of `recent`'s order, captured lazily on first use and held fixed for + // the rest of the cycling sequence — `cycle()`'s own index math walks THIS frozen list, + // never the live, self-reordering `modelStore.recent`. `selectModel(..., { recent: true })` + // still updates the real persisted `recent` on every pick, satisfying requirement 1; it + // just no longer feeds back into what `cycle()` itself reads for requirement 2. If the + // current model isn't found in the captured order (the user picked something else via + // `/model` since cycling last started, or this is the very first cycle this session), + // (re-)capture fresh from the CURRENT `modelStore.recent` and start the sequence there. cycle(direction: 1 | -1) { const current = currentModel() if (!current) return - const recent = modelStore.recent - const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) + const findCurrent = (order: readonly { providerID: string; modelID: string }[]) => + order.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) + if (!cycleOrder || findCurrent(cycleOrder) === -1) cycleOrder = modelStore.recent.slice() + const index = findCurrent(cycleOrder) if (index === -1) return let next = index + direction - if (next < 0) next = recent.length - 1 - if (next >= recent.length) next = 0 - const val = recent[next] + if (next < 0) next = cycleOrder.length - 1 + if (next >= cycleOrder.length) next = 0 + const val = cycleOrder[next] if (!val) return - // altimate_change start — PR #1302 review, cubic P2 (round 6: also pass `recent: true`, - // like `cycleFavorite` below): route through `selectModel` so this deliberate keyboard - // pick also marks `explicitDefault` AND moves the cycled-to model to the front of - // `recent`. Without the explicit marker, an all-free-tier user who cycles onto a free - // Zen model stays "implicit" per `hasExplicitDefault()`/`hasUsableFreeDefault()`, and - // the first-run picker reopens on every later launch even though they chose it on - // purpose. `recent: true` used to be deliberately omitted here (to keep `cycle()`'s own - // index-based navigation order stable across repeated presses) — but `recent` is the - // ONLY thing headless/ACP default resolution (`Provider.readDefaultModelState()`, - // `defaultModelFromConfig()`) reads; they have no notion of `explicitDefault`. Omitting - // `recent: true` let the TUI and server resolve two different launch defaults from the - // same `model.json` after a cycle. Reordering `recent` on every cycle does mean - // `cycle()`'s own index computation above now walks a list that just reshuffled — a - // repeated cycle in the same direction can revisit an entry sooner than before — but - // that's a minor UX quirk against a real cross-surface correctness bug, and it keeps - // `recent` the single source of truth everywhere instead of teaching the server about a - // second, TUI-only marker. selectModel(val, { explicit: true, recent: true }) - // altimate_change end }, + // altimate_change end cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) if (!favorites.length) { diff --git a/packages/tui/test/context/cycle-stability.test.tsx b/packages/tui/test/context/cycle-stability.test.tsx new file mode 100644 index 0000000000..78a0c654b3 --- /dev/null +++ b/packages/tui/test/context/cycle-stability.test.tsx @@ -0,0 +1,200 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — Codex HOLD finding 2: `cycle()` must traverse a STABLE order. +// +// Passing `{ recent: true }` to `selectModel` (round 6, cubic 3986171198/cursor 3986044810) fixed +// a real cross-surface bug (TUI vs headless/ACP default divergence after a cycle) but, on its +// own, broke `cycle()`'s OWN navigation: reordering `recent` on every pick means the very next +// press reads its "next" index off a list that just reshuffled out from under it. Codex caught +// this by actually executing it: cycling forward through [A, B, C] starting from B went +// B -> A -> B forever instead of visiting every model. The unit test previously at +// test/context/local.test.ts:317 ("cycling persists the launch default via recents order") only +// called `recentModels()` directly — it asserted the persistence half of the fix and would have +// passed on the broken code, never exercising `cycle()` itself. This file calls the REAL +// `local.model.cycle()` three times over three models and asserts the actual visited sequence. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +const MODEL_A = { providerID: "opencode", modelID: "model-a" } +const MODEL_B = { providerID: "opencode", modelID: "model-b" } +const MODEL_C = { providerID: "opencode", modelID: "model-c" } + +function makeModel(id: string) { + return { + id, + providerID: "opencode", + name: id, + family: "opencode", + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 65_536, output: 4_096 }, + } +} + +async function mount() { + const [ + { KVProvider }, + { LocalProvider, useLocal }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + ] = await Promise.all([ + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + ]) + + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + // Three free models, all persisted as recents in order A, B, C — this is the "order as of TUI + // launch" `cycle()` must traverse, independent of how it self-reorders `recent` on each pick. + await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [MODEL_A, MODEL_B, MODEL_C] })) + + const openCodeProvider = { + id: "opencode", + name: "Legacy Zen", + models: { + "model-a": makeModel("model-a"), + "model-b": makeModel("model-b"), + "model-c": makeModel("model-c"), + }, + env: [], + } + const agent = { + name: "build", + mode: "primary" as const, + hidden: false, + permission: {}, + options: {}, + } + const inner = createFetch((url) => { + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") return json({ providers: [openCodeProvider], default: {} }) + if (url.pathname === "/provider") return json({ all: [openCodeProvider], default: {}, connected: ["opencode"] }) + if (url.pathname === "/agent") return json([agent]) + if (url.pathname === "/project/proj_test/directories") return json([]) + return undefined + }) + const source = createEventSource() + + let localAccessor: ReturnType | undefined + function Capture() { + localAccessor = useLocal() + return null + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) + const local = localAccessor! + + // `fallbackModel()` resolves the launch default to `recent[0]` (model-a) with nothing else + // configured — confirm the harness actually started where the test assumes before asserting + // anything about `cycle()`. + await waitUntil(() => local.model.current()?.modelID === "model-a") + // Move to B WITHOUT touching `recent`'s order (no `recent: true`) — matches Codex's repro + // shape: current = B, persisted recent order = [A, B, C]. + local.model.set(MODEL_B) + await waitUntil(() => local.model.current()?.modelID === "model-b") + + return { + local, + async cleanup() { + app.renderer.destroy() + await local.model.persisted().catch(() => {}) + await tmp[Symbol.asyncDispose]() + }, + } +} + +test.serial( + "cycle() traverses a stable order across repeated presses — visits every model, does not bounce (Codex HOLD finding 2)", + async () => { + const mounted = await mount() + try { + const visited: string[] = [] + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + + // Starting at B, forward cycling must visit C then A (not bounce back to A immediately — + // the bug Codex's own execution caught was B -> A -> B) and complete the traversal back to + // B on the third press, having visited every one of the three models exactly once. + expect(visited).toEqual(["model-c", "model-a", "model-b"]) + expect(new Set(visited).size).toBe(3) + } finally { + await mounted.cleanup() + } + }, +) + +test.serial("cycle() still moves the picked model to the front of persisted recent on every press", async () => { + const mounted = await mount() + try { + mounted.local.model.cycle(1) + await waitUntil(() => mounted.local.model.recent()[0]?.modelID === "model-c") + expect(mounted.local.model.recent()[0]).toEqual({ providerID: "opencode", modelID: "model-c" }) + } finally { + await mounted.cleanup() + } +}) +// altimate_change end diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index eb23618920..c80a5edd0d 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -325,22 +325,23 @@ test("cycling persists the launch default via recents order, not a TUI-only mark }) // altimate_change end -// altimate_change start — Kilo review round 6 (3986171192): `hasUsableFreeDefault()` used to -// read the kv migration-decline key with no `kv.ready` gate. A pre-0.11.x decliner whose refusal -// lives ONLY in kv (no `explicitDefault`, no picker-written recent, legacy Big Pickle so -// `hasOwnPickOfImplicitDefault()` is also false) reads as "not declined" before kv hydrates, -// flipping `useReady()` false and letting the prompt gate discard whatever was just typed — -// deterministically reachable via `--prompt` auto-submit, which waits only on -// `sync.ready`/`local.model.ready`, not `kv.ready`. `hasUsableFreeDefaultGated` (see its -// declaration in local.tsx) is the extracted gate: an unready kv must read as UNDECIDED — -// assumed usable, not "not declined" — so the prompt gate never discards input over a value -// about to flip `true` the moment kv catches up. -test("hasUsableFreeDefault treats an unready kv as usable (undecided), not as 'not declined'", () => { - // kv not ready yet: must not block/discard, regardless of what the underlying computation - // would otherwise say. - expect(hasUsableFreeDefaultGated(false, () => false)).toBe(true) - expect(hasUsableFreeDefaultGated(false, () => true)).toBe(true) - // kv ready: the underlying computation is authoritative again. +// altimate_change start — Kilo review round 6 (3986171192) / Codex HOLD finding 1: `hasUsableFreeDefault()` +// used to read the kv migration-decline key with no `kv.ready` gate. A pre-0.11.x decliner whose +// refusal lives ONLY in kv (no `explicitDefault`, no picker-written recent, legacy Big Pickle so +// `hasOwnPickOfImplicitDefault()` is also false) reads as "not declined" before kv hydrates. +// A FIRST fix attempt made an unready kv read as `true` ("assume usable") — Codex caught that +// going the WRONG direction: it makes `useReady()` true immediately, before onboarding/migration +// ever runs, so `--prompt` (or a fast manual submit) sails straight through to the implicit +// public Zen default — trading a false negative (discarded input) for a false positive (skipped +// onboarding/migration), which is worse. The correct third state is `"pending"`, not a boolean +// guess either way — see `hasUsableFreeDefaultGated`'s declaration in local.tsx, and +// `useReadyPending()`/the submit-gate defer logic in component/prompt/index.tsx for how the ONE +// caller that must see `"pending"` (the prompt submit gate) uses it to defer without discarding. +test("hasUsableFreeDefault reports 'pending' (not a boolean guess) while kv is unready", () => { + // kv not ready yet: neither `true` nor `false` — explicitly "don't know yet." + expect(hasUsableFreeDefaultGated(false, () => false)).toBe("pending") + expect(hasUsableFreeDefaultGated(false, () => true)).toBe("pending") + // kv ready: the underlying computation is authoritative. expect(hasUsableFreeDefaultGated(true, () => false)).toBe(false) expect(hasUsableFreeDefaultGated(true, () => true)).toBe(true) }) diff --git a/packages/tui/test/context/ready-pending.test.tsx b/packages/tui/test/context/ready-pending.test.tsx new file mode 100644 index 0000000000..4450475deb --- /dev/null +++ b/packages/tui/test/context/ready-pending.test.tsx @@ -0,0 +1,175 @@ +/** @jsxImportSource @opentui/solid */ +// altimate_change start — Codex HOLD finding 1: coverage for the kv.ready "pending" defer path. +// +// `hasUsableFreeDefaultGated`'s own unit test (local.test.ts) proves the pure gate itself reports +// `"pending"` (not a boolean guess either way) while kv is unready. This file proves the +// CONSEQUENCE: a submit attempted while `useReadyPending()` is true must be neither sent early +// (Codex's finding — skips onboarding/migration) nor discarded (Kilo's original finding), but +// deferred and automatically retried once pending resolves — reproducing +// `component/prompt/index.tsx`'s exact `deferredSubmit` + `createEffect` shape against a +// manually-controlled signal standing in for `useReadyPending()`/`useReady()`. Mounted via +// `testRender` (a bare component, no context providers) rather than a plain `createRoot()` call: +// bare `solid-js` imported outside `@opentui/solid`'s render pipeline resolves to its SSR build in +// this test environment, whose effects run once at creation and never re-fire on a later signal +// write — `testRender` is what gives this file the real, client-reactive `solid-js` runtime the +// production code actually runs under. +// +// IMPORTANT — this is deliberately NOT an end-to-end mount of `` inside the real provider +// tree, and that is a documented finding, not an oversight: `KVProvider` and `LocalProvider` are +// both built on `createSimpleContext` (context/helper.tsx), whose `provider` wraps `children` in +// ``. Since both providers expose a +// `ready` getter, NEITHER renders its children — and ``/``/`` sit nested inside +// both, per app.tsx's provider tree — until `kv.ready` AND `local.model.ready` are already true. +// Verified empirically while writing this test: a capture component mounted inside the real +// `` tree never observes `kv.ready === false` — it simply never renders +// until `kv.ready` is already true, because `Show` withholds its children rather than rendering +// them and letting a child branch on readiness itself. That means the exact "prompt gate fires +// while kv is still hydrating" window Kilo originally flagged is very likely NOT reachable through +// the actual interactive Prompt path in the current codebase — reported alongside this file. The +// fix is kept anyway (a `"pending"` third state is a more honest contract than guessing a boolean +// either way, costs nothing, and is defense-in-depth against this invariant ever changing), and +// this test validates the MECHANISM directly rather than asserting an end-to-end scenario that +// cannot currently be constructed through the real provider tree. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { createEffect, createSignal } from "solid-js" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +/** Verbatim shape of component/prompt/index.tsx's `submitInner()` gate + retry effect. */ +function DeferThenRetryHarness(props: { + ready: () => boolean + pending: () => boolean + promptText: () => string + setPromptText: (value: string) => void + onSend: (value: string) => void + onDiscard: () => void + exposeSubmit: (fn: () => boolean) => void +}) { + let deferredSubmit = false + function attemptSubmit() { + if (!props.promptText()) return false + if (!props.ready()) { + if (props.pending()) { + deferredSubmit = true + return false + } + props.setPromptText("") + props.onDiscard() + return false + } + props.onSend(props.promptText()) + props.setPromptText("") + return true + } + createEffect(() => { + if (props.pending() || !deferredSubmit) return + deferredSubmit = false + attemptSubmit() + }) + props.exposeSubmit(attemptSubmit) + return null +} + +async function mountHarness(options: { initialPending: boolean; willBeReady: boolean; promptText?: string }) { + const [pending, setPending] = createSignal(options.initialPending) + const ready = () => !pending() && options.willBeReady + const [promptText, setPromptText] = createSignal(options.promptText ?? "hello from before kv.ready") + const submitSpy: string[] = [] + let discarded = false + let submit: (() => boolean) | undefined + + const app = await testRender(() => ( + submitSpy.push(value)} + onDiscard={() => { + discarded = true + }} + exposeSubmit={(fn) => { + submit = fn + }} + /> + )) + await app.renderOnce() + await waitUntil(() => submit !== undefined) + + return { + attemptSubmit: () => submit!(), + setPending, + promptText, + submitSpy, + discarded: () => discarded, + cleanup() { + app.renderer.destroy() + }, + } +} + +test.serial( + "defer-then-retry: a submit issued while pending is neither sent nor lost, and resolves once pending clears (usable)", + async () => { + const h = await mountHarness({ initialPending: true, willBeReady: true }) + try { + // The submit attempted while pending: deferred, not sent, not discarded. + expect(h.attemptSubmit()).toBe(false) + expect(h.submitSpy).toEqual([]) + expect(h.promptText()).toBe("hello from before kv.ready") + + // kv resolves ("declined" — usable): the retry effect fires automatically, with no second + // `attemptSubmit()` call from the test — proving the RETRY is automatic, not manual. + h.setPending(false) + await waitUntil(() => h.submitSpy.length > 0) + expect(h.submitSpy).toEqual(["hello from before kv.ready"]) + expect(h.promptText()).toBe("") + } finally { + h.cleanup() + } + }, +) + +test.serial( + "defer-then-retry: a submit issued while pending is neither sent nor lost, and is correctly discarded once pending clears (not usable)", + async () => { + const h = await mountHarness({ initialPending: true, willBeReady: false }) + try { + expect(h.attemptSubmit()).toBe(false) + expect(h.submitSpy).toEqual([]) + expect(h.promptText()).toBe("hello from before kv.ready") + expect(h.discarded()).toBe(false) + + // kv resolves to "not declined", and nothing else makes this launch ready — the deferred + // retry re-evaluates `ready()` fresh and correctly finds it still false, taking the + // discard branch (matching the real gate's picker-reopen path) rather than sending stale + // input through. + h.setPending(false) + await waitUntil(() => h.discarded()) + expect(h.submitSpy).toEqual([]) + expect(h.promptText()).toBe("") + } finally { + h.cleanup() + } + }, +) + +test.serial("defer-then-retry: a submit issued once already ready sends immediately, no defer", async () => { + const h = await mountHarness({ initialPending: false, willBeReady: true, promptText: "hello, already ready" }) + try { + expect(h.attemptSubmit()).toBe(true) + expect(h.submitSpy).toEqual(["hello, already ready"]) + expect(h.promptText()).toBe("") + expect(h.discarded()).toBe(false) + } finally { + h.cleanup() + } +}) +// altimate_change end diff --git a/packages/tui/test/prompt/history-startup-race.test.tsx b/packages/tui/test/prompt/history-startup-race.test.tsx index e0de27f3e3..2f7abf029e 100644 --- a/packages/tui/test/prompt/history-startup-race.test.tsx +++ b/packages/tui/test/prompt/history-startup-race.test.tsx @@ -11,14 +11,32 @@ // construction instead: `append()` defers its FILE write (never the in-memory store update) while // `!loaded()`, so nothing from this launch can reach the read's `lines` at all; `onMount`'s // `finally` flushes the merged `store.history` in one write once the snapshot is captured. These -// tests cover both completion orders — a genuinely fresh launch, and a returning one — and assert -// the deferred write actually lands on disk once `loaded()` settles. +// first tests cover both completion orders — a genuinely fresh launch, and a returning one — and +// assert the deferred write actually lands on disk once `loaded()` settles. +// +// altimate_change — Codex HOLD finding 3 (round 7): the two tests that used to live here for the +// round-6 flush/append races ("an append that lands before loaded() flips…" and "…while the +// startup flush is still in flight…") did not actually establish either race — POLLING +// `loaded()` via `Bun.sleep(5)` does not keep the startup write in flight; by the time the poll +// observes `loaded() === true`, the flush's tiny write has very likely already completed on real +// disk. Codex proved this by executing them: both passed even against 0370cfa's dropped-append +// bug (`await` before `setLoaded(true)`), which they were supposed to guard against. Replaced +// below with three tests built on CONTROLLABLE barriers — `spyOn(persistence, "readText"/"writeText")` +// returning a manually-resolved deferred promise — so each scenario is reproduced by +// construction, not by timing luck: (a) append while the startup READ is still pending, (b) +// append while the startup REWRITE's write is still in flight, (c) append SYNCHRONOUSLY in the +// same reactive tick `loaded()` flips (the specific shape that reproduces the lazy-snapshot +// duplicate bug — see that test's own comment for why timing this precisely matters). All three +// were confirmed to FAIL against the relevant old behavior and PASS on HEAD before being kept; +// see this session's report for the exact failure output. import { testRender } from "@opentui/solid" -import { expect, test } from "bun:test" +import { expect, spyOn, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" +import { createEffect } from "solid-js" import { TestTuiContexts } from "../fixture/tui-environment" import { tmpdir } from "../fixture/fixture" +import * as persistence from "../../src/util/persistence" import { PromptHistoryProvider, usePromptHistory, parsePromptHistory } from "../../src/prompt/history" async function waitUntil(predicate: () => boolean, timeout = 2_000) { @@ -29,6 +47,14 @@ async function waitUntil(predicate: () => boolean, timeout = 2_000) { } } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + async function mountWithRacingAppend(existing?: string) { const tmp = await tmpdir() const state = path.join(tmp.path, "state") @@ -115,50 +141,170 @@ test.serial( }, ) +test.serial("real pre-existing history is still recognized when nothing races ahead of the read", async () => { + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write( + path.join(state, "prompt-history.jsonl"), + JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n", + ) + + let history: ReturnType | undefined + function Capture() { + history = usePromptHistory() + return null + } + + const app = await testRender(() => ( + + + + + + )) + try { + await waitUntil(() => history!.loaded()) + expect(history!.hadHistoryAtStartup()).toBe(true) + } finally { + app.renderer.destroy() + await tmp[Symbol.asyncDispose]() + } +}) + +// altimate_change start — Codex HOLD finding 3: barrier-controlled races (see file header). +async function mountBare(existing?: string) { + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + const historyPath = path.join(state, "prompt-history.jsonl") + if (existing !== undefined) await Bun.write(historyPath, existing) + + let history: ReturnType | undefined + function Capture() { + history = usePromptHistory() + return null + } + const app = await testRender(() => ( + + + + + + )) + await app.renderOnce() + return { + historyPath, + history: history!, + async cleanup() { + app.renderer.destroy() + await tmp[Symbol.asyncDispose]() + }, + } +} + +function diskLines(text: string) { + return text.split("\n").filter(Boolean) +} + +test.serial( + "barrier: append while the startup READ is pending is persisted exactly once, in FIFO order", + async () => { + const read = deferred() + const readSpy = spyOn(persistence, "readText").mockImplementation(() => read.promise) + const mounted = await mountBare() + try { + expect(mounted.history.loaded()).toBe(false) + mounted.history.append({ input: "appended while read pending", parts: [] }) + // The in-memory update is immediate regardless of `loaded()` — the read is still pending, + // so no file write has happened yet either way. + expect(mounted.history.loaded()).toBe(false) + + read.resolve(JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n") + await waitUntil(() => mounted.history.loaded()) + await mounted.history.flushed() + + const text = await Bun.file(mounted.historyPath).text() + const onDisk = parsePromptHistory(text) + expect(onDisk).toEqual([ + { input: "from a previous launch", parts: [] }, + { input: "appended while read pending", parts: [] }, + ]) + const lines = diskLines(text) + expect(lines.length).toBe(2) + expect(new Set(lines).size).toBe(2) + } finally { + readSpy.mockRestore() + await mounted.cleanup() + } + }, +) + test.serial( - "an append that lands before loaded() flips is persisted exactly once, not duplicated by the flush (round 6)", + "barrier: append while the startup REWRITE's write is still in flight is persisted exactly once, in FIFO order", async () => { - // Round 6 review (cursor 3986264135, cubic 3986055642): the startup flush's write closure - // used to read `store.history` LAZILY, at the moment it actually ran — which could be AFTER - // this racing append had already updated `store.history` in memory. The flush's snapshot - // would then include the append's entry too, and the append's OWN queued write (behind the - // flush in the FIFO) would write it AGAIN once it ran — landing the same entry on disk twice. - // `history.tsx` now snapshots `store.history` to a string synchronously, in the same - // microtask-free block that flips `loaded()`, so nothing that runs after that point (which - // is the earliest any append's own write can be queued) can ever be in the flush's content. + const originalWriteText = persistence.writeText + const gate = deferred() + const writeSpy = spyOn(persistence, "writeText").mockImplementation(async (filePath, content) => { + await gate.promise + return originalWriteText(filePath, content) + }) const existing = JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n" - const mounted = await mountWithRacingAppend(existing) + const mounted = await mountBare(existing) try { + // The read resolves normally (real, unmocked disk read); onMount's `finally` then + // synchronously snapshots + enqueues the (now gated) flush write + flips `loaded()` — all + // before the gated `writeText` call has done anything beyond starting to await the gate. await waitUntil(() => mounted.history.loaded()) + + // Confirm the flush's write is genuinely still in flight (gated), not merely "probably + // still running" — `flushed()` must not have settled yet. + let flushSettled = false + void mounted.history.flushed().then(() => { + flushSettled = true + }) + await Bun.sleep(20) + expect(flushSettled).toBe(false) + + // Append NOW, deterministically while the flush's write is gated/in flight. + mounted.history.append({ input: "appended during in-flight rewrite", parts: [] }) + + // Release the gate: the flush's write proceeds first; the append's own queued `appendText` + // runs strictly AFTER it (FIFO, via `queueWrite`), never concurrently. + gate.resolve() + await waitUntil(() => flushSettled) await mounted.history.flushed() + const text = await Bun.file(mounted.historyPath).text() const onDisk = parsePromptHistory(text) expect(onDisk).toEqual([ { input: "from a previous launch", parts: [] }, - { input: "first prompt of this launch", parts: [] }, + { input: "appended during in-flight rewrite", parts: [] }, ]) - // Explicit no-duplicate-lines check, independent of `parsePromptHistory`'s own parsing: - // exactly one non-empty line per entry, no repeats. - const lines = text.split("\n").filter(Boolean) + const lines = diskLines(text) expect(lines.length).toBe(2) expect(new Set(lines).size).toBe(2) } finally { + writeSpy.mockRestore() await mounted.cleanup() } }, ) test.serial( - "an append that lands while the startup flush is still in flight (after loaded(), before the write settles) is persisted exactly once (round 6)", + "barrier: an append synchronous with the SAME reactive tick loaded() flips is not duplicated by the flush", async () => { - // Distinct from the racing-append tests above (which append BEFORE the read even settles): - // this appends the INSTANT `loaded()` flips — i.e. right as the startup flush's write has - // been snapshotted and handed to the queue, but before that write's disk I/O has necessarily - // completed. `loaded()` no longer implies "landed on disk" (round 6 removed the earlier fix - // that awaited the write before `setLoaded(true)`, because THAT fix dropped an append landing - // in that exact window — see history.tsx). What must still hold: the flush's snapshot was - // frozen before this append ran, so this append cannot be IN that snapshot, and its own write - // is queued strictly behind the flush's — so once both settle, the entry exists exactly once. + // The lazy-snapshot duplicate bug (cursor 3986264135, cubic 3986055642) required the append + // to land in-memory BEFORE the flush's write closure actually evaluated its content — which, + // under the old lazy-read code, happened on the very next microtask after `setLoaded(true)`, + // not after any `Bun.sleep`-based poll could observe `loaded()`. Solid's `createEffect` + // reacting to a signal read re-runs SYNCHRONOUSLY, in the same call stack as the `setLoaded` + // that triggered it — so an effect watching `loaded()` that calls `append()` the instant it + // becomes true reproduces that exact race by construction: the append happens before the + // queued flush closure's `.then()` microtask has had a chance to run, whether that closure + // reads `store.history` lazily (old, buggy) or was already handed a frozen snapshot before + // `setLoaded` ran (current `history.tsx` — the snapshot is computed BEFORE `setLoaded`, in + // the same synchronous block, so this effect's append can never be included in it). const tmp = await tmpdir() const state = path.join(tmp.path, "state") await mkdir(state, { recursive: true }) @@ -166,8 +312,14 @@ test.serial( await Bun.write(historyPath, JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n") let history: ReturnType | undefined + let appended = false function Capture() { history = usePromptHistory() + createEffect(() => { + if (!history!.loaded() || appended) return + appended = true + history!.append({ input: "appended in the same tick loaded() flipped", parts: [] }) + }) return null } const app = await testRender(() => ( @@ -178,16 +330,18 @@ test.serial( )) try { + await app.renderOnce() + await waitUntil(() => appended) await waitUntil(() => history!.loaded()) - history!.append({ input: "landed during the in-flight flush", parts: [] }) await history!.flushed() + const text = await Bun.file(historyPath).text() const onDisk = parsePromptHistory(text) expect(onDisk).toEqual([ { input: "from a previous launch", parts: [] }, - { input: "landed during the in-flight flush", parts: [] }, + { input: "appended in the same tick loaded() flipped", parts: [] }, ]) - const lines = text.split("\n").filter(Boolean) + const lines = diskLines(text) expect(lines.length).toBe(2) expect(new Set(lines).size).toBe(2) } finally { @@ -196,35 +350,4 @@ test.serial( } }, ) - -test.serial("real pre-existing history is still recognized when nothing races ahead of the read", async () => { - const tmp = await tmpdir() - const state = path.join(tmp.path, "state") - await mkdir(state, { recursive: true }) - await Bun.write( - path.join(state, "prompt-history.jsonl"), - JSON.stringify({ input: "from a previous launch", parts: [] }) + "\n", - ) - - let history: ReturnType | undefined - function Capture() { - history = usePromptHistory() - return null - } - - const app = await testRender(() => ( - - - - - - )) - try { - await waitUntil(() => history!.loaded()) - expect(history!.hadHistoryAtStartup()).toBe(true) - } finally { - app.renderer.destroy() - await tmp[Symbol.asyncDispose]() - } -}) // altimate_change end From ce0d3afd69de074beeb2a7c8f4ce8ee8596b87d5 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 00:42:50 -0700 Subject: [PATCH 12/19] fix: scope the shouldSkipOnboardingAtStartup discriminator to a genuine first-run completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic (3986532221) correctly flagged this as a regression introduced by the round-7 shouldSkipOnboardingAtStartup fix, not a pre-existing follow-up: `setupComplete()` is a global flag set by ANY model selection (`markSetupComplete()`, called from `DialogModel` on every pick), not only a first-run/impatient-picker completion. A RETURNING user (e.g. an existing Big Pickle user) who did an ordinary `/model` switch while app.tsx's startup effect was still settling made `setupComplete()` true too, falling through to the `onboardingReady()` branch and firing `onboarding_started`/`onboarding_completed`/`scan_gate_shown` telemetry plus `openScanGate()` for a routine model change — not a genuine first run. Fix: `firstRunOpenedThisLaunch` (altimate-onboarding.tsx) is a new one-way latch, set only when the first-run picker actually opens THIS launch — app.tsx's own startup fallthrough, or the prompt gate's equivalent (component/prompt/index.tsx now calls `markFirstRunActive()` there too, closing the gap cubic named explicitly). app.tsx's `shouldSkipOnboardingAtStartup` call site now passes `setupComplete() && firstRunOpenedThisLaunch()` instead of the bare global flag — true only for a genuine same-launch first-run completion, so a returning user's routine switch reads false and keeps skipping exactly as before this whole discriminator existed. Tests: two new tests in local.test.ts exercise the real production signals (markSetupComplete/markFirstRunActive/firstRunOpenedThisLaunch) against the actual discriminator computation app.tsx performs — a returning user's routine switch must skip (no telemetry, no scan gate), a genuine first-run completion must not skip. Confirmed the second test fails when the `firstRunOpenedThisLaunch` latch is temporarily removed, then passes again restored. Removed the corresponding item from tracking issue #1303 (fixed directly, not deferred). Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/app.tsx | 36 +++++++--- .../tui/src/component/altimate-onboarding.tsx | 29 ++++++++ packages/tui/src/component/prompt/index.tsx | 9 ++- packages/tui/test/context/local.test.ts | 67 +++++++++++++++++++ 4 files changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 1a6c1d0a0a..2285ed2695 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -45,6 +45,7 @@ import { useSetupComplete, markFirstRunActive, resetSetupComplete, + useFirstRunOpenedThisLaunch, } from "./component/altimate-onboarding" // altimate_change end // altimate_change — Part 2 scan gate (fires once when Part 1 first completes) @@ -607,6 +608,9 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const onboardingReady = useReady() // altimate_change — setup completion alone (no `connected()` term); see the scan-gate effect const setupComplete = useSetupComplete() + // altimate_change — cubic review (3986532221): see `firstRunOpenedThisLaunch`'s declaration in + // altimate-onboarding.tsx + const firstRunOpenedThisLaunch = useFirstRunOpenedThisLaunch() // altimate_change — onboarding funnel tracker (no-op when the host injected none) const trackOnboarding = useOnboardingTelemetry() // altimate_change end @@ -729,17 +733,27 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // hits the picker again on their next submit and loses whatever they typed. See // `hasUsableFreeDefault`'s declaration in local.tsx for where the fold now lives. // altimate_change start — Kilo review round 6 (3986171188): `shouldSkipOnboardingAtStartup` - // — see its declaration in local.tsx — added the `setupComplete()` discriminator. Without it, - // an impatient first-run user who submits before this effect settles — the prompt gate - // (component/prompt/index.tsx) opens the picker on its own, they pick a free Zen model, - // `set()` marks it explicit/recent and `markSetupComplete()` runs — made - // `hasUsableFreeDefault()` true by the time THIS effect finally runs, latching here and - // returning before the `onboardingReady()` branch below (which exists for exactly this + // — see its declaration in local.tsx — added a discriminator for "did first-run genuinely + // complete THIS launch". Without it, an impatient first-run user who submits before this + // effect settles — the prompt gate (component/prompt/index.tsx) opens the picker on its own, + // they pick a free Zen model, `set()` marks it explicit/recent and `markSetupComplete()` runs + // — made `hasUsableFreeDefault()` true by the time THIS effect finally runs, latching here + // and returning before the `onboardingReady()` branch below (which exists for exactly this // impatient-user case, per its own comment) ever got a chance to fire the funnel telemetry - // and `openScanGate()`. A genuine RETURNING user's `setupComplete()` is always false here — - // it starts false every launch and is set only by a setup completed DURING this one (see the - // comment on the `onboardingReady()` branch) — so this changes nothing for that case; it only - // stops THIS branch from swallowing a same-launch setup that `onboardingReady()` needs to see. + // and `openScanGate()`. + // + // cubic review (3986532221): a bare `setupComplete()` is NOT that discriminator — it is a + // global flag `markSetupComplete()` sets for ANY model selection, first-run or not, so a + // RETURNING user (`hasExistingLegacySelection()` or `hasUsableFreeDefault()` already true) + // who does an ordinary `/model` switch while this effect is still settling ALSO makes + // `setupComplete()` true — which used to fall through to the `onboardingReady()` branch and + // fire onboarding telemetry + open the scan gate for a routine model change, not a first run. + // `firstRunOpenedThisLaunch()` (see its declaration in altimate-onboarding.tsx) is the fix: a + // one-way latch set only when the first-run picker itself actually opened THIS launch (this + // effect's own fallthrough below, or the prompt gate's equivalent) — `setupComplete() && + // firstRunOpenedThisLaunch()` is true only for a GENUINE first-run completion. A returning + // user's routine switch never sets `firstRunOpenedThisLaunch()`, so it correctly reads false + // and this branch keeps skipping for them, exactly as before this whole discriminator existed. // A paid pick is unaffected either way: `hasUsableFreeDefault()` requires a free model, so it // was never true for one. if ( @@ -750,7 +764,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // early above unless `kv.ready`, so it is always a plain boolean by the time it runs // here; the explicit check just satisfies the union type without widening it elsewhere. local.model.hasUsableFreeDefault() === true, - setupComplete(), + setupComplete() && firstRunOpenedThisLaunch(), ) ) { startupDecisionHandled = true diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index d419afdf3d..c7c055afd2 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -35,8 +35,33 @@ const [setupComplete, setSetupComplete] = createSignal(false) // model switching, so it consults this before emitting any funnel event — otherwise every model // change for the life of the product would look like an onboarding provider choice. const [firstRunActive, setFirstRunActive] = createSignal(false) +// altimate_change start — cubic review (3986532221): app.tsx's startup effect used +// `setupComplete()` alone to decide whether THIS launch's model selection is a genuine +// first-run/impatient-picker completion (worth firing onboarding telemetry + the scan gate for) +// versus a RETURNING user's ordinary `/model` switch that merely raced the startup effect — +// `markSetupComplete()` fires for BOTH cases identically. `firstRunActive` above cannot answer +// this either: `markSetupComplete()` deliberately CLEARS it (so a later routine switch doesn't +// look like onboarding), so by the time app.tsx's effect gets around to checking it, it has +// already been reset to `false` for both a genuine first-run AND the very completion that would +// prove it happened. `firstRunOpenedThisLaunch` is a separate, ONE-WAY latch: set whenever the +// first-run picker actually opens THIS launch — either app.tsx's own startup fallthrough, or the +// prompt gate's equivalent for an impatient submit before that effect settles (see +// `component/prompt/index.tsx`'s `markFirstRunActive()` call) — and never cleared by +// `markSetupComplete()`/`clearFirstRunActive()` (only by `resetSetupComplete()`, on `/logout`, +// which returns the user to a genuinely fresh state). `setupComplete() && +// firstRunOpenedThisLaunch()` is the correct "did first-run genuinely complete this launch" +// signal; a returning user's routine mid-race `/model` switch has `setupComplete() === true` but +// `firstRunOpenedThisLaunch() === false`, so it reads as `false` and no longer fires anything. +const [firstRunOpenedThisLaunch, setFirstRunOpenedThisLaunch] = createSignal(false) +export function useFirstRunOpenedThisLaunch() { + return firstRunOpenedThisLaunch +} +// altimate_change end export function markFirstRunActive() { setFirstRunActive(true) + // altimate_change — see `firstRunOpenedThisLaunch`'s declaration above + setFirstRunOpenedThisLaunch(true) + // altimate_change end } /** * Clear without marking setup complete. @@ -65,6 +90,10 @@ export function markSetupComplete() { export function resetSetupComplete() { setSetupComplete(false) setFirstRunActive(false) + // altimate_change — see `firstRunOpenedThisLaunch`'s declaration above: /logout returns the + // user to a genuinely fresh state, so a first run after it must be free to latch again. + setFirstRunOpenedThisLaunch(false) + // altimate_change end } export function useReady() { const connected = useConnected() diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 2490e43d37..238e6c9614 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -48,7 +48,7 @@ import { useDialog } from "../../ui/dialog" import { DialogProvider as DialogProviderConnect, WARNLIST } from "../dialog-provider" // altimate_change — first-run submit gate: open the curated welcome picker instead // of erroring when no model is ready yet (see altimate-onboarding.tsx). -import { DialogModelWelcome, useReady, useReadyPending } from "../altimate-onboarding" +import { DialogModelWelcome, markFirstRunActive, useReady, useReadyPending } from "../altimate-onboarding" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" @@ -1103,6 +1103,13 @@ export function Prompt(props: PromptProps) { deferredSubmit = true return false } + // altimate_change — cubic review (3986532221): this is the prompt-gate's own equivalent of + // app.tsx's first-run picker (an impatient submit before that startup effect settled), so + // it must latch the SAME "first-run opened this launch" signal — see + // `firstRunOpenedThisLaunch`'s declaration in altimate-onboarding.tsx for why app.tsx's + // startup effect needs this to tell a genuine first-run completion apart from a returning + // user's routine `/model` switch racing that same effect. + markFirstRunActive() dialog.replace(() => ( { + resetSetupComplete() + try { + // Returning user: has an existing legacy/free-default selection (`hasExistingLegacySelection` + // true below). They switch models via an ORDINARY `/model` pick — NOT through the first-run + // picker — while app.tsx's startup effect is still settling. `markSetupComplete()` fires for + // this exactly as it does for every model pick, first-run or not. + markSetupComplete() + const setupComplete = useSetupComplete() + const firstRunOpenedThisLaunch = useFirstRunOpenedThisLaunch() + expect(setupComplete()).toBe(true) + expect(firstRunOpenedThisLaunch()).toBe(false) + + // Mirrors app.tsx's actual call site exactly. + const skip = shouldSkipOnboardingAtStartup(true, false, setupComplete() && firstRunOpenedThisLaunch()) + expect(skip).toBe(true) + } finally { + resetSetupComplete() + } +}) + +test("a genuine impatient first-run completion (the prompt gate opened this launch) still fires the onboardingReady() branch", () => { + resetSetupComplete() + try { + // The prompt gate (component/prompt/index.tsx's `!ready()` branch) — or app.tsx's own + // startup fallthrough — actually opened the first-run picker THIS launch... + markFirstRunActive() + // ...and the user picked a free model there, completing it. + markSetupComplete() + const setupComplete = useSetupComplete() + const firstRunOpenedThisLaunch = useFirstRunOpenedThisLaunch() + expect(setupComplete()).toBe(true) + expect(firstRunOpenedThisLaunch()).toBe(true) + + const skip = shouldSkipOnboardingAtStartup(false, true, setupComplete() && firstRunOpenedThisLaunch()) + expect(skip).toBe(false) + } finally { + resetSetupComplete() + } +}) +// altimate_change end + // altimate_change start — PR #1302 Codex review round 2, P2: `migrateLegacyDefault({ from })`'s // captured `from` must not bypass free-model validation entirely. test("isMigrationStillEligibleAfterCapture: only the launch-default-unchanged or registration-induced-Base transitions stay eligible", () => { From cb9e4d76243ee82da093fed24c7a54e649c481c6 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:01:45 -0700 Subject: [PATCH 13/19] fix: Codex re-review round 8 (cycleOrder staleness, ready-pending test coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. cycleOrder goes stale after an external recents change (local.tsx): the round-7 fix (cycleOrder, re-captured only when the current model fell out of the snapshot) still went stale the moment a PICKER selection reordered `recent` without also knocking the current model out of the old snapshot. Codex's repro: [A, B, C], cycle once, then pick D and A via the picker -> recents [A, D, C, B]; A stays present in the stale [A, B, C] snapshot the whole time, so the round-7 check never re-fires, and D stays permanently unreachable by cycling. Fixed with a `recentsVersion` counter (bumped by a new `setRecent` wrapper — the only path that writes to `modelStore.recent` now) and a `cycleOrderVersion` on the cycle() closure: cycle() re-captures whenever the live version has moved past what it last saw or resynced to, which happens for ANY external write, picker included — while resyncing after its own write so repeated presses with nothing else interleaved keep reusing the same stable snapshot. Regression test: extended cycle-stability.test.tsx's existing "stable order" scenario with Codex's exact repro (pick D and A via the picker after cycling once, assert D becomes reachable and the re-captured order stays stable afterward). All three tests in that file now share one mount via beforeAll/afterAll rather than each mounting independently — a second or third independent heavy provider-tree mount in this file was found to reproducibly hang during its own bootstrap (a test-infrastructure resource-contention issue, worse the more work a prior mount did, unrelated to anything under test) — chaining the scenarios onto one shared, stateful mount sidesteps it while still exercising the real cycle() end to end. Confirmed the new assertion fails (D never reachable, times out) against the round-7-only invalidation check, and passes on HEAD. 2. ready-pending.test.tsx exercised a hand-rolled reimplementation of the defer/retry shape instead of production code, so reverting the real fix in component/prompt/index.tsx left it passing regardless. Extracted `createDeferredRetry` (packages/tui/src/util/signal.ts) — a small, generic, reactive defer-then-retry primitive — and made both prompt/index.tsx's submit gate and the test's harness call the SAME function. Confirmed the test fails (2 of 3 cases, exactly the ones relying on automatic retry) when `createDeferredRetry`'s retry is temporarily broken, and passes on HEAD once restored. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/component/prompt/index.tsx | 32 ++--- packages/tui/src/context/local.tsx | 53 ++++++-- packages/tui/src/util/signal.ts | 23 ++++ .../tui/test/context/cycle-stability.test.tsx | 126 +++++++++++++----- .../tui/test/context/ready-pending.test.tsx | 47 ++++--- 5 files changed, 206 insertions(+), 75 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 238e6c9614..bf043ace47 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -52,7 +52,7 @@ import { DialogModelWelcome, markFirstRunActive, useReady, useReadyPending } fro import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" -import { createFadeIn } from "../../util/signal" +import { createDeferredRetry, createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "../../context/args" @@ -1035,20 +1035,20 @@ export function Prompt(props: PromptProps) { let submitting = false // altimate_change start — Codex HOLD finding 1: a submit attempted while `readyPending()` is // true (kv still hydrating, see its declaration above) defers instead of discarding — see - // `submitInner`'s `readyPending()` branch below, which sets this flag rather than clearing the - // prompt. This effect is the retry: once `readyPending()` flips false (kv resolved either way), - // re-attempt the exact same `submit()` call automatically, so a submission made during that - // window is neither lost nor stuck waiting on the user to press Enter again. Re-running - // `submit()` (not some cached decision) means it re-evaluates `ready()` fresh against whatever - // `store.prompt.input` currently holds — if the user kept typing while deferred, that's what - // goes out; if they cleared it, `submitInner`'s own `if (!store.prompt.input) return false` - // early-exit makes this a no-op. - let deferredSubmit = false - createEffect(() => { - if (readyPending() || !deferredSubmit) return - deferredSubmit = false - void submit() - }) + // `submitInner`'s `readyPending()` branch below, which calls `deferredSubmit.defer()` rather + // than clearing the prompt. `createDeferredRetry` (util/signal.ts) is the retry: once + // `readyPending()` flips false (kv resolved either way), it re-attempts the exact same + // `submit()` call automatically, so a submission made during that window is neither lost nor + // stuck waiting on the user to press Enter again. Re-running `submit()` (not some cached + // decision) means it re-evaluates `ready()` fresh against whatever `store.prompt.input` + // currently holds — if the user kept typing while deferred, that's what goes out; if they + // cleared it, `submitInner`'s own `if (!store.prompt.input) return false` early-exit makes + // this a no-op. Codex re-review round 8: extracted into a standalone, shared primitive (rather + // than the flag + `createEffect` inlined here) specifically so + // test/context/ready-pending.test.tsx exercises the SAME production code this component runs, + // not a hand-rolled reimplementation that could drift from — or stop reflecting — a change + // made only here. + const deferredSubmit = createDeferredRetry(readyPending, () => void submit()) // altimate_change end async function submit() { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the @@ -1100,7 +1100,7 @@ export function Prompt(props: PromptProps) { // Defer instead — keep the prompt exactly as-is, do not open anything — and let the retry // effect above resubmit once `readyPending()` settles. if (readyPending()) { - deferredSubmit = true + deferredSubmit.defer() return false } // altimate_change — cubic review (3986532221): this is the prompt-gate's own equivalent of diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 2ff1c75cb7..2be89ac2b5 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -432,6 +432,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // altimate_change end }) + // altimate_change start — Codex re-review round 8: `cycle()`'s stable-order snapshot + // (`cycleOrder`, declared near its own definition below) needs to know when `recent` has + // changed for a reason OTHER than cycle()'s own pick, so it can re-capture and pick up + // entries a picker selection just added — otherwise a `/model` pick that reorders `recent` + // out from under a stale `cycleOrder` permanently excludes the newly-recent-ed model from + // the cycle. `recentsVersion` increments on every write to `modelStore.recent`, routed + // through `setRecent` (never call `setModelStore("recent", ...)` directly) so it can never + // drift out of sync with reality. + let recentsVersion = 0 + function setRecent(value: { providerID: string; modelID: string }[]) { + recentsVersion++ + setModelStore("recent", value) + } + // altimate_change end + const filePath = path.join(paths.state, "model.json") const state = { pending: false, @@ -476,7 +491,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!x || typeof x !== "object") return const value = x as Record // altimate_change start — discard malformed persisted model references before default migration - if (Array.isArray(value.recent)) setModelStore("recent", value.recent.filter(isModelRef)) + if (Array.isArray(value.recent)) setRecent(value.recent.filter(isModelRef)) // altimate_change end if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite) if (typeof value.variant === "object" && value.variant !== null) @@ -634,7 +649,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const a = agent.current() if (!a) return setModelStore("model", a.name, model) - if (options?.recent) setModelStore("recent", recentModels(model, modelStore.recent)) + if (options?.recent) setRecent(recentModels(model, modelStore.recent)) // A picker-driven selection, as opposed to session restore or programmatic migration — // see `hasExplicitModel` above for why this needs its own persisted marker. if (options?.explicit) setModelStore("explicitDefault", { providerID: model.providerID, modelID: model.modelID }) @@ -757,6 +772,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // `cycle()`'s own comment below for why a LIVE read of `modelStore.recent` (which `cycle()` // itself reorders via `selectModel(val, { recent: true })`) breaks repeated presses. let cycleOrder: readonly { providerID: string; modelID: string }[] | undefined + // altimate_change — Codex re-review round 8: the version `cycleOrder` was captured at (or + // last resynced to, after cycle()'s own write) — see `recentsVersion`'s declaration above. + // A mismatch against the LIVE `recentsVersion` means something OTHER than `cycle()` wrote + // to `recent` since, and `cycleOrder` must be re-captured to see it. + let cycleOrderVersion = -1 // altimate_change end return { @@ -804,16 +824,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // the rest of the cycling sequence — `cycle()`'s own index math walks THIS frozen list, // never the live, self-reordering `modelStore.recent`. `selectModel(..., { recent: true })` // still updates the real persisted `recent` on every pick, satisfying requirement 1; it - // just no longer feeds back into what `cycle()` itself reads for requirement 2. If the - // current model isn't found in the captured order (the user picked something else via - // `/model` since cycling last started, or this is the very first cycle this session), - // (re-)capture fresh from the CURRENT `modelStore.recent` and start the sequence there. + // just no longer feeds back into what `cycle()` itself reads for requirement 2. + // + // altimate_change — Codex re-review round 8: "held fixed for the rest of the cycling + // sequence" must not mean "held fixed forever." Only invalidating on "the current model + // fell out of `cycleOrder`" (the original round-7 check) went stale the moment a PICKER + // selection reordered `recent` without also knocking the current model out of the old + // snapshot: e.g. `recent = [A, B, C]`, cycle once (B is now current, `recent = [B, A, + // C]`), then the user picks D and A via `/model` (`recent` ends up `[A, D, C, B]`) — A is + // still present in the STALE `cycleOrder` (`[A, B, C]`), so the old check never + // re-captured, and D stayed permanently unreachable by cycling. `cycleOrderVersion` (see + // its declaration above) closes this: it also re-captures whenever `recentsVersion` has + // moved since `cycleOrder` was last captured OR resynced — which happens for ANY write + // to `recent`, picker or otherwise — while still recognizing cycle()'s OWN write (via the + // resync at the end of this function) so repeated presses with nothing else interleaved + // keep reusing the same stable snapshot, unaffected. cycle(direction: 1 | -1) { const current = currentModel() if (!current) return const findCurrent = (order: readonly { providerID: string; modelID: string }[]) => order.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) - if (!cycleOrder || findCurrent(cycleOrder) === -1) cycleOrder = modelStore.recent.slice() + if (!cycleOrder || cycleOrderVersion !== recentsVersion || findCurrent(cycleOrder) === -1) { + cycleOrder = modelStore.recent.slice() + cycleOrderVersion = recentsVersion + } const index = findCurrent(cycleOrder) if (index === -1) return let next = index + direction @@ -822,6 +856,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const val = cycleOrder[next] if (!val) return selectModel(val, { explicit: true, recent: true }) + // Absorb our OWN write (selectModel above bumped `recentsVersion` via `setRecent`) so + // it does not look like an external change the NEXT time `cycle()` runs. + cycleOrderVersion = recentsVersion }, // altimate_change end cycleFavorite(direction: 1 | -1) { @@ -937,7 +974,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (a && shouldMoveAgentModelDuringMigration(currentModel(), previous)) setModelStore("model", a.name, { ...ALTIMATE_BASE_MODEL }) // altimate_change end - setModelStore("recent", migrateLegacyRecentModels(modelStore.recent, previous)) + setRecent(migrateLegacyRecentModels(modelStore.recent, previous)) // altimate_change — fixes #1301 (Codex review round 2, P2): an explicit accept via // migration clears any earlier decline the same way `selectModel` does for every // other explicit Base selection (`/connect`, favorite-cycling) — both flags together. diff --git a/packages/tui/src/util/signal.ts b/packages/tui/src/util/signal.ts index e28c680cd4..2a6128bf44 100644 --- a/packages/tui/src/util/signal.ts +++ b/packages/tui/src/util/signal.ts @@ -1,5 +1,28 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" +// Codex re-review round 8 (cycle-stability/ready-pending test coverage): a reactive +// defer-then-retry primitive — call `.defer()` when a caller can't act yet (e.g. a readiness +// signal is still pending), and the wrapped `retry` callback fires automatically, exactly once, +// the NEXT time `pending()` reads false. Extracted as a standalone, importable function so the +// SAME production code path is exercised by both a real consumer (component/prompt/index.tsx's +// submit gate — see `readyPending`'s declaration there) and its test +// (test/context/ready-pending.test.tsx) — the test was previously a hand-rolled reimplementation +// of this exact shape, which meant reverting the real fix in prompt/index.tsx left the test +// passing regardless, since it never touched production code at all. +export function createDeferredRetry(pending: Accessor, retry: () => void) { + let deferred = false + createEffect(() => { + if (pending() || !deferred) return + deferred = false + retry() + }) + return { + defer() { + deferred = true + }, + } +} + export function createDebouncedSignal(value: T, ms: number): [Accessor, (value: T) => void] { const [get, set] = createSignal(value) let timer: ReturnType | undefined diff --git a/packages/tui/test/context/cycle-stability.test.tsx b/packages/tui/test/context/cycle-stability.test.tsx index 78a0c654b3..ea2e283ca7 100644 --- a/packages/tui/test/context/cycle-stability.test.tsx +++ b/packages/tui/test/context/cycle-stability.test.tsx @@ -1,5 +1,7 @@ /** @jsxImportSource @opentui/solid */ -// altimate_change start — Codex HOLD finding 2: `cycle()` must traverse a STABLE order. +// altimate_change start — Codex HOLD finding 2 (+ re-review round 8): `cycle()` must traverse a +// STABLE order, and that order must stay correct as `recent` changes for reasons OTHER than +// cycle() itself. // // Passing `{ recent: true }` to `selectModel` (round 6, cubic 3986171198/cursor 3986044810) fixed // a real cross-surface bug (TUI vs headless/ACP default divergence after a cycle) but, on its @@ -9,10 +11,24 @@ // B -> A -> B forever instead of visiting every model. The unit test previously at // test/context/local.test.ts:317 ("cycling persists the launch default via recents order") only // called `recentModels()` directly — it asserted the persistence half of the fix and would have -// passed on the broken code, never exercising `cycle()` itself. This file calls the REAL -// `local.model.cycle()` three times over three models and asserts the actual visited sequence. +// passed on the broken code, never exercising `cycle()` itself. +// +// Round 7's fix (a `cycleOrder` snapshot, re-captured only when the CURRENT model fell out of +// it) still went stale after a PICKER selection that reordered `recent` without also knocking +// the current model out of the old snapshot — Codex's re-review reproduced it: `[A, B, C]`, +// cycle once, then pick D and A via the picker, and D stays permanently unreachable by cycling +// (the round-7 check never re-fires because A is still present in the stale snapshot). The +// `cycleOrderVersion` counter (local.tsx) fixes this. +// +// This file calls the REAL `local.model.cycle()` against a REAL mounted `LocalProvider`, in +// three named `test.serial` blocks that all share ONE mount (`beforeAll`/`afterAll`) rather than +// each calling their own — cycle() is inherently a SEQUENCE of interactions anyway, and, more +// practically, a second or third independent heavy provider-tree mount in this same file was +// found to reproducibly hang during its own bootstrap (a test-infrastructure resource-contention +// issue unrelated to anything under test here, worse the more work the PRIOR mount did). Each +// test block's own comment states the state it assumes at that point in the sequence. import { testRender } from "@opentui/solid" -import { expect, test } from "bun:test" +import { afterAll, beforeAll, expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { tmpdir } from "../fixture/fixture" @@ -31,6 +47,7 @@ async function waitUntil(predicate: () => boolean, timeout = 2_000) { const MODEL_A = { providerID: "opencode", modelID: "model-a" } const MODEL_B = { providerID: "opencode", modelID: "model-b" } const MODEL_C = { providerID: "opencode", modelID: "model-c" } +const MODEL_D = { providerID: "opencode", modelID: "model-d" } function makeModel(id: string) { return { @@ -78,6 +95,8 @@ async function mount() { await Bun.write(path.join(state, "kv.json"), "{}") // Three free models, all persisted as recents in order A, B, C — this is the "order as of TUI // launch" `cycle()` must traverse, independent of how it self-reorders `recent` on each pick. + // A fourth (D) is registered with the provider but NOT in the initial `recent` — it only + // enters via a later picker selection, in the round-8 regression test below. await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [MODEL_A, MODEL_B, MODEL_C] })) const openCodeProvider = { @@ -87,6 +106,7 @@ async function mount() { "model-a": makeModel("model-a"), "model-b": makeModel("model-b"), "model-c": makeModel("model-c"), + "model-d": makeModel("model-d"), }, env: [], } @@ -163,38 +183,82 @@ async function mount() { } } +let mounted: Awaited> + +beforeAll(async () => { + mounted = await mount() +}) + +afterAll(async () => { + await mounted.cleanup() +}) + test.serial( "cycle() traverses a stable order across repeated presses — visits every model, does not bounce (Codex HOLD finding 2)", - async () => { - const mounted = await mount() - try { - const visited: string[] = [] - mounted.local.model.cycle(1) - visited.push(mounted.local.model.current()!.modelID) - mounted.local.model.cycle(1) - visited.push(mounted.local.model.current()!.modelID) - mounted.local.model.cycle(1) - visited.push(mounted.local.model.current()!.modelID) - - // Starting at B, forward cycling must visit C then A (not bounce back to A immediately — - // the bug Codex's own execution caught was B -> A -> B) and complete the traversal back to - // B on the third press, having visited every one of the three models exactly once. - expect(visited).toEqual(["model-c", "model-a", "model-b"]) - expect(new Set(visited).size).toBe(3) - } finally { - await mounted.cleanup() - } + () => { + // Starting state (from `mount()`): current = B, recent = [A, B, C]. + const visited: string[] = [] + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + + // Starting at B, forward cycling must visit C then A (not bounce back to A immediately — + // the bug Codex's own execution caught was B -> A -> B) and complete the traversal back to + // B on the third press, having visited every one of the three models exactly once. + expect(visited).toEqual(["model-c", "model-a", "model-b"]) + expect(new Set(visited).size).toBe(3) + // Ends: current = B, recent = [B, A, C]. }, ) test.serial("cycle() still moves the picked model to the front of persisted recent on every press", async () => { - const mounted = await mount() - try { - mounted.local.model.cycle(1) - await waitUntil(() => mounted.local.model.recent()[0]?.modelID === "model-c") - expect(mounted.local.model.recent()[0]).toEqual({ providerID: "opencode", modelID: "model-c" }) - } finally { - await mounted.cleanup() - } + // Continues from the previous test: current = B, recent = [B, A, C], cycleOrder still [A, B, C] + // (unchanged — nothing but cycle() itself has written to `recent` so far). + mounted.local.model.cycle(1) + await waitUntil(() => mounted.local.model.recent()[0]?.modelID === "model-c") + expect(mounted.local.model.recent()[0]).toEqual({ providerID: "opencode", modelID: "model-c" }) + // Ends: current = C, recent = [C, B, A]. }) + +test.serial( + "cycle() re-discovers a model the picker adds after recents were reordered externally, instead of excluding it forever (Codex re-review round 8)", + async () => { + // Continues from the previous test: current = C, recent = [C, B, A], cycleOrder still + // [A, B, C] (still unchanged — cycle() is the only thing that has written to `recent`). + // + // Reproduces Codex's exact repro from here: pick D and A via the PICKER (an explicit /model + // selection with `recent: true`, like DialogModel uses). Round 7's fix only re-captured + // `cycleOrder` when the CURRENT model fell OUT of the stale snapshot — A stays present in + // the stale `[A, B, C]` snapshot throughout, so that check never fires, and D — never in + // that snapshot at all — stays permanently unreachable by cycling. `cycleOrderVersion` (its + // declaration in local.tsx) fixes this: it also invalidates on ANY external write to + // `recent`, picker included. + mounted.local.model.set(MODEL_D, { recent: true }) + await waitUntil(() => mounted.local.model.current()?.modelID === "model-d") + mounted.local.model.set(MODEL_A, { recent: true }) + await waitUntil(() => mounted.local.model.current()?.modelID === "model-a") + expect(mounted.local.model.recent().map((m) => m.modelID)).toEqual(["model-a", "model-d", "model-c", "model-b"]) + + // cycleOrder must re-capture from the CURRENT [A, D, C, B] here (recentsVersion moved past + // cycleOrderVersion since the last cycle() call, from these two picker picks) — forward from + // A lands on D. The OLD bug: the stale `[A, B, C]` snapshot's "next after A" was B, and D was + // never reachable from it at all. + mounted.local.model.cycle(1) + await waitUntil(() => mounted.local.model.current()?.modelID === "model-d") + + // And the newly re-captured order stays stable for subsequent presses, same guarantee as the + // original finding-2 fix — visits C then B then wraps back to A. + const visitedAfterD: string[] = [] + mounted.local.model.cycle(1) + visitedAfterD.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visitedAfterD.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visitedAfterD.push(mounted.local.model.current()!.modelID) + expect(visitedAfterD).toEqual(["model-c", "model-b", "model-a"]) + }, +) // altimate_change end diff --git a/packages/tui/test/context/ready-pending.test.tsx b/packages/tui/test/context/ready-pending.test.tsx index 4450475deb..7605f5788d 100644 --- a/packages/tui/test/context/ready-pending.test.tsx +++ b/packages/tui/test/context/ready-pending.test.tsx @@ -1,18 +1,27 @@ /** @jsxImportSource @opentui/solid */ -// altimate_change start — Codex HOLD finding 1: coverage for the kv.ready "pending" defer path. +// altimate_change start — Codex HOLD finding 1 (+ re-review round 8): coverage for the kv.ready +// "pending" defer path. // // `hasUsableFreeDefaultGated`'s own unit test (local.test.ts) proves the pure gate itself reports // `"pending"` (not a boolean guess either way) while kv is unready. This file proves the // CONSEQUENCE: a submit attempted while `useReadyPending()` is true must be neither sent early // (Codex's finding — skips onboarding/migration) nor discarded (Kilo's original finding), but -// deferred and automatically retried once pending resolves — reproducing -// `component/prompt/index.tsx`'s exact `deferredSubmit` + `createEffect` shape against a -// manually-controlled signal standing in for `useReadyPending()`/`useReady()`. Mounted via -// `testRender` (a bare component, no context providers) rather than a plain `createRoot()` call: -// bare `solid-js` imported outside `@opentui/solid`'s render pipeline resolves to its SSR build in -// this test environment, whose effects run once at creation and never re-fire on a later signal -// write — `testRender` is what gives this file the real, client-reactive `solid-js` runtime the -// production code actually runs under. +// deferred and automatically retried once pending resolves — using `createDeferredRetry` +// (util/signal.ts), the SAME production primitive `component/prompt/index.tsx`'s submit gate +// calls, against a manually-controlled signal standing in for `useReadyPending()`/`useReady()`. +// +// Codex re-review round 8: this file originally re-implemented its own copy of the defer+retry +// flag/effect shape rather than importing the real one — meaning reverting the actual fix in +// prompt/index.tsx left this test passing regardless, since it never touched production code at +// all. `createDeferredRetry` was extracted specifically to close that gap: `DeferThenRetryHarness` +// below now calls it directly, so a regression in the SHARED primitive (or its removal from the +// real submit gate) is exactly what this test would need to still be testing anything. +// +// Mounted via `testRender` (a bare component, no context providers) rather than a plain +// `createRoot()` call: bare `solid-js` imported outside `@opentui/solid`'s render pipeline +// resolves to its SSR build in this test environment, whose effects run once at creation and +// never re-fire on a later signal write — `testRender` is what gives this file the real, +// client-reactive `solid-js` runtime the production code actually runs under. // // IMPORTANT — this is deliberately NOT an end-to-end mount of `` inside the real provider // tree, and that is a documented finding, not an oversight: `KVProvider` and `LocalProvider` are @@ -28,11 +37,13 @@ // the actual interactive Prompt path in the current codebase — reported alongside this file. The // fix is kept anyway (a `"pending"` third state is a more honest contract than guessing a boolean // either way, costs nothing, and is defense-in-depth against this invariant ever changing), and -// this test validates the MECHANISM directly rather than asserting an end-to-end scenario that -// cannot currently be constructed through the real provider tree. +// this test validates the MECHANISM directly — via the real shared primitive — rather than +// asserting an end-to-end scenario that cannot currently be constructed through the real provider +// tree. import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" -import { createEffect, createSignal } from "solid-js" +import { createSignal } from "solid-js" +import { createDeferredRetry } from "../../src/util/signal" async function waitUntil(predicate: () => boolean, timeout = 2_000) { const started = Date.now() @@ -42,7 +53,8 @@ async function waitUntil(predicate: () => boolean, timeout = 2_000) { } } -/** Verbatim shape of component/prompt/index.tsx's `submitInner()` gate + retry effect. */ +/** Mirrors component/prompt/index.tsx's `submitInner()` gate, built on the SAME shared + * `createDeferredRetry` primitive the real submit gate uses (see this file's header comment). */ function DeferThenRetryHarness(props: { ready: () => boolean pending: () => boolean @@ -52,12 +64,11 @@ function DeferThenRetryHarness(props: { onDiscard: () => void exposeSubmit: (fn: () => boolean) => void }) { - let deferredSubmit = false function attemptSubmit() { if (!props.promptText()) return false if (!props.ready()) { if (props.pending()) { - deferredSubmit = true + deferredSubmit.defer() return false } props.setPromptText("") @@ -68,11 +79,7 @@ function DeferThenRetryHarness(props: { props.setPromptText("") return true } - createEffect(() => { - if (props.pending() || !deferredSubmit) return - deferredSubmit = false - attemptSubmit() - }) + const deferredSubmit = createDeferredRetry(props.pending, () => void attemptSubmit()) props.exposeSubmit(attemptSubmit) return null } From 170f8af69bc6226183a1020c224b2bd3e97c787e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:02:11 -0700 Subject: [PATCH 14/19] fix: wrap createDeferredRetry in altimate_change markers Co-Authored-By: Claude --- packages/tui/src/util/signal.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/util/signal.ts b/packages/tui/src/util/signal.ts index 2a6128bf44..ad6290a15e 100644 --- a/packages/tui/src/util/signal.ts +++ b/packages/tui/src/util/signal.ts @@ -1,14 +1,14 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" -// Codex re-review round 8 (cycle-stability/ready-pending test coverage): a reactive -// defer-then-retry primitive — call `.defer()` when a caller can't act yet (e.g. a readiness -// signal is still pending), and the wrapped `retry` callback fires automatically, exactly once, -// the NEXT time `pending()` reads false. Extracted as a standalone, importable function so the -// SAME production code path is exercised by both a real consumer (component/prompt/index.tsx's -// submit gate — see `readyPending`'s declaration there) and its test -// (test/context/ready-pending.test.tsx) — the test was previously a hand-rolled reimplementation -// of this exact shape, which meant reverting the real fix in prompt/index.tsx left the test -// passing regardless, since it never touched production code at all. +// altimate_change start — Codex re-review round 8 (cycle-stability/ready-pending test coverage): +// a reactive defer-then-retry primitive — call `.defer()` when a caller can't act yet (e.g. a +// readiness signal is still pending), and the wrapped `retry` callback fires automatically, +// exactly once, the NEXT time `pending()` reads false. Extracted as a standalone, importable +// function so the SAME production code path is exercised by both a real consumer +// (component/prompt/index.tsx's submit gate — see `readyPending`'s declaration there) and its +// test (test/context/ready-pending.test.tsx) — the test was previously a hand-rolled +// reimplementation of this exact shape, which meant reverting the real fix in prompt/index.tsx +// left the test passing regardless, since it never touched production code at all. export function createDeferredRetry(pending: Accessor, retry: () => void) { let deferred = false createEffect(() => { @@ -22,6 +22,7 @@ export function createDeferredRetry(pending: Accessor, retry: () => voi }, } } +// altimate_change end export function createDebouncedSignal(value: T, ms: number): [Accessor, (value: T) => void] { const [get, set] = createSignal(value) From 9315dd5d50840972b5998aaae5676a5f271a75c6 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:04:54 -0700 Subject: [PATCH 15/19] fix: latch first-run only after a successful prompt-gate dialog.replace() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bonus fix found by cursor (3986991408) and cubic (3987011218) re-reviewing the setupComplete fix (ce0d3afd69) moments after it landed: `markFirstRunActive()` in the prompt gate's `!ready()` branch fired unconditionally, before `dialog.replace()` even ran — including when `dialog.replace()` loses a race to another dialog and returns `false` without opening anything. That latched `firstRunOpenedThisLaunch`/`firstRunActive` for a picker nobody ever saw, so a returning user's LATER, unrelated `/model` pick would look like a genuine first-run completion and fire onboarding telemetry + the scan gate — the inverse of the bug ce0d3afd69 was fixing. Mirrors app.tsx's own identical, already-correct pattern for its own first-run fallthrough: check `dialog.replace()`'s return value and only call `markFirstRunActive()` once it actually succeeded. No new test: this is a direct application of an already-established, already-tested pattern (app.tsx's own `shown` check) to a second call site; the fix was verified via typecheck and the full test suite, not a new mount-based test — exercising the full submit path would need mounting the whole `` component (session/workspace/editor/move dependencies), which this session already assessed as disproportionate for a single conditional-latch fix. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/component/prompt/index.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index bf043ace47..edfdc6a60d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1103,19 +1103,24 @@ export function Prompt(props: PromptProps) { deferredSubmit.defer() return false } - // altimate_change — cubic review (3986532221): this is the prompt-gate's own equivalent of - // app.tsx's first-run picker (an impatient submit before that startup effect settled), so - // it must latch the SAME "first-run opened this launch" signal — see - // `firstRunOpenedThisLaunch`'s declaration in altimate-onboarding.tsx for why app.tsx's - // startup effect needs this to tell a genuine first-run completion apart from a returning - // user's routine `/model` switch racing that same effect. - markFirstRunActive() - dialog.replace(() => ( + // altimate_change — cubic review (3986532221); cursor/cubic re-review round 8 + // (3986991408/3987011218): this is the prompt-gate's own equivalent of app.tsx's first-run + // picker (an impatient submit before that startup effect settled), so it must latch the + // SAME "first-run opened this launch" signal — see `firstRunOpenedThisLaunch`'s declaration + // in altimate-onboarding.tsx for why app.tsx's startup effect needs this to tell a genuine + // first-run completion apart from a returning user's routine `/model` switch racing that + // same effect. Latch only AFTER a successful `dialog.replace()`, not before, mirroring + // app.tsx's own identical `shown` check: `dialog.replace()` can lose a race to another + // dialog and return `false` without opening anything, and latching unconditionally left a + // RETURNING user's next `/model` pick looking like a first-run completion for a picker + // nobody ever saw. + const shown = dialog.replace(() => ( )) + if (shown) markFirstRunActive() input.clear() input.extmarks.clear() setStore("prompt", { input: "", parts: [] }) From d4dfb8aec13551e7eabbb4d1f4c3264972300327 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:19:03 -0700 Subject: [PATCH 16/19] fix: Codex re-review round 9 (onboardingReady() latch, deferred-submit staleness) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. app.tsx's `onboardingReady()` branch (~783) had the same class of bug as the `shouldSkipOnboardingAtStartup` branch above it (cubic 3986532221), just reached from a different starting condition: a bare `setupComplete()` check. `onboardingReady()` (`useReady()`) is also true via `connected()`, not only via `hasUsableFreeDefault()` — so a returning user with a configured legacy default (e.g. Big Pickle) who switches `/model` to a PAID model has BOTH of `shouldSkipOnboardingAtStartup`'s skip predicates false (a paid pick isn't covered by either `hasExistingLegacySelection()` or `hasUsableFreeDefault()`), falls through to this branch, and `onboardingReady()` is already true via `connected()`. If that `/model` switch raced app.tsx's startup effect, `setupComplete()` alone read as true even though no first-run picker ever opened, firing onboarding telemetry and the scan gate for an ordinary provider switch. Fixed the same way as the branch above: require `firstRunOpenedThisLaunch()` alongside `setupComplete()`. Tests: two new cases in local.test.ts against the real production signals — both skip predicates false and the latch false fires nothing; the latch true still fires. 2. A deferred submission could include later, unrelated edits: defer prompt A (kv/readiness still pending), edit the box to B without pressing Enter again, readiness resolves — B was silently auto-submitted, a send the user never asked for. `createDeferredRetry` (util/signal.ts) now takes an optional `getRevision` that is snapshotted at `.defer()` time and compared against the live value right before the retry would fire; a mismatch cancels the retry entirely instead of firing against stale state. component/prompt/index.tsx's submit gate passes `unwrap(store.prompt)` (text + attachments) as the revision. Test: ready-pending.test.tsx's harness passes `getRevision` through the same shared primitive; new cases assert an edited-while-deferred submission is silently canceled (not auto-sent), and an unedited one still sends normally. Confirmed the edited case fails (B gets auto-sent) when the revision check is temporarily removed from `createDeferredRetry`, and passes restored. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/app.tsx | 30 +++++++--- packages/tui/src/component/prompt/index.tsx | 29 ++++++---- packages/tui/src/util/signal.ts | 31 +++++++--- packages/tui/test/context/local.test.ts | 56 +++++++++++++++++++ .../tui/test/context/ready-pending.test.tsx | 53 +++++++++++++++++- 5 files changed, 171 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 2285ed2695..b5e6ec40e8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -776,22 +776,34 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // same picker as soon as the user tries to submit, which can happen BEFORE sync finishes // hydrating — and completing setup there makes onboardingReady() true by the time this // effect finally runs. Bailing out then skipped the funnel and the scan gate entirely for - // exactly the impatient-user case. setupComplete() is the discriminator: it starts false - // every launch and is only set by a setup the user completed during THIS one, so a genuine - // returning user never trips this branch. + // exactly the impatient-user case. startupDecisionHandled = true - if (setupComplete()) { - // Deliberately NOT markFirstRunActive(): its only clear is markSetupComplete(), which has - // already run on this branch and will not run again, so setting it here would latch the - // flag true for the rest of the session and make every later /model switch emit funnel - // events. The three events below are emitted directly and do not consult it. The prompt - // gate arms it instead, at the point the picker actually opens. + // altimate_change — Codex re-review round 9: `setupComplete()` alone is NOT a safe + // discriminator here either — same class of bug as the `shouldSkipOnboardingAtStartup` + // branch above (cubic 3986532221), just reached from a different starting condition. A + // RETURNING user with a configured legacy default (e.g. Big Pickle) whose OWN skip + // predicates (`hasExistingLegacySelection()`/`hasUsableFreeDefault()`) are both false — + // because they switched `/model` to a PAID model, which neither predicate covers — still + // reaches `onboardingReady() === true` via `connected()`. If that `/model` switch raced + // this same startup effect, `setupComplete()` is true too, even though no first-run picker + // ever opened this launch. `firstRunOpenedThisLaunch()` (its declaration in + // altimate-onboarding.tsx) is required alongside it, same as the branch above: true only + // when the first-run picker itself actually opened THIS launch (this effect's own + // fallthrough below, or the prompt gate's equivalent). + if (setupComplete() && firstRunOpenedThisLaunch()) { + // Deliberately NOT markFirstRunActive() again here: it was already latched at the point + // the picker opened (this branch only reaches telemetry when `firstRunOpenedThisLaunch()` + // is already true), and `markSetupComplete()` has already run on this branch and will not + // run again — re-marking here would keep `firstRunActive` (the OTHER, resettable signal) + // true for the rest of the session and make every later `/model` switch emit funnel + // events. The three events below are emitted directly and do not consult it. scanGateShown = true trackOnboarding({ name: "onboarding_started" }) trackOnboarding({ name: "onboarding_completed" }) trackOnboarding({ name: "scan_gate_shown" }) openScanGate() } + // altimate_change end return } // altimate_change start — fixes #1301 (Codex review, P2): latch (and arm the scan gate) only diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index edfdc6a60d..7f244bfe2d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1039,16 +1039,25 @@ export function Prompt(props: PromptProps) { // than clearing the prompt. `createDeferredRetry` (util/signal.ts) is the retry: once // `readyPending()` flips false (kv resolved either way), it re-attempts the exact same // `submit()` call automatically, so a submission made during that window is neither lost nor - // stuck waiting on the user to press Enter again. Re-running `submit()` (not some cached - // decision) means it re-evaluates `ready()` fresh against whatever `store.prompt.input` - // currently holds — if the user kept typing while deferred, that's what goes out; if they - // cleared it, `submitInner`'s own `if (!store.prompt.input) return false` early-exit makes - // this a no-op. Codex re-review round 8: extracted into a standalone, shared primitive (rather - // than the flag + `createEffect` inlined here) specifically so - // test/context/ready-pending.test.tsx exercises the SAME production code this component runs, - // not a hand-rolled reimplementation that could drift from — or stop reflecting — a change - // made only here. - const deferredSubmit = createDeferredRetry(readyPending, () => void submit()) + // stuck waiting on the user to press Enter again. Codex re-review round 8: extracted into a + // standalone, shared primitive (rather than the flag + `createEffect` inlined here) + // specifically so test/context/ready-pending.test.tsx exercises the SAME production code this + // component runs, not a hand-rolled reimplementation that could drift from — or stop + // reflecting — a change made only here. + // + // Codex re-review round 9: `getRevision` snapshots the prompt (text + attachments) at the + // moment it defers, and `createDeferredRetry` compares that snapshot against the LIVE prompt + // right before retrying — if the user edited the box (without pressing Enter again) while the + // submission was deferred, the retry is silently canceled rather than firing. Without this, a + // deferred prompt A followed by an untouched-by-Enter edit to B would have B auto-submitted the + // instant readiness resolved — a send the user never asked for, not a resend of the one they + // did. (A genuinely unedited resubmit still re-reads `store.prompt.input`/`.parts` live inside + // `submitInner`, not this snapshot, so the two can never drift apart when nothing changed; + // `unwrap` matches the same store-to-plain-object pattern already used elsewhere in this file, + // e.g. the prompt stash below.) + const deferredSubmit = createDeferredRetry(readyPending, () => void submit(), { + getRevision: () => unwrap(store.prompt), + }) // altimate_change end async function submit() { // Prevent overlapping invocations (e.g. a double-pressed Enter, or the diff --git a/packages/tui/src/util/signal.ts b/packages/tui/src/util/signal.ts index ad6290a15e..aabdc8b330 100644 --- a/packages/tui/src/util/signal.ts +++ b/packages/tui/src/util/signal.ts @@ -1,24 +1,39 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" -// altimate_change start — Codex re-review round 8 (cycle-stability/ready-pending test coverage): -// a reactive defer-then-retry primitive — call `.defer()` when a caller can't act yet (e.g. a -// readiness signal is still pending), and the wrapped `retry` callback fires automatically, -// exactly once, the NEXT time `pending()` reads false. Extracted as a standalone, importable -// function so the SAME production code path is exercised by both a real consumer -// (component/prompt/index.tsx's submit gate — see `readyPending`'s declaration there) and its -// test (test/context/ready-pending.test.tsx) — the test was previously a hand-rolled +// altimate_change start — Codex re-review round 8 (cycle-stability/ready-pending test coverage) / +// round 9 (stale-revision cancellation): a reactive defer-then-retry primitive — call `.defer()` +// when a caller can't act yet (e.g. a readiness signal is still pending), and the wrapped `retry` +// callback fires automatically, exactly once, the NEXT time `pending()` reads false. Extracted as +// a standalone, importable function so the SAME production code path is exercised by both a real +// consumer (component/prompt/index.tsx's submit gate — see `readyPending`'s declaration there) +// and its test (test/context/ready-pending.test.tsx) — the test was previously a hand-rolled // reimplementation of this exact shape, which meant reverting the real fix in prompt/index.tsx // left the test passing regardless, since it never touched production code at all. -export function createDeferredRetry(pending: Accessor, retry: () => void) { +// +// `options.getRevision`, if given, is called ONCE at `.defer()` time (capturing whatever it +// returns) and again right before `retry()` would fire — if the two differ (by JSON equality), +// `retry()` is skipped entirely rather than fired against stale state. This is what +// component/prompt/index.tsx's submit gate uses to snapshot the prompt (text + attachments) at +// the moment a submission defers: without it, a user who deferred prompt A, then edited the box +// to B WITHOUT pressing Enter again, would have B silently auto-submitted the instant readiness +// resolved — a send the user never asked for, not a resend of the one they did. +export function createDeferredRetry( + pending: Accessor, + retry: () => void, + options?: { getRevision?: () => T }, +) { let deferred = false + let capturedRevision: T | undefined createEffect(() => { if (pending() || !deferred) return deferred = false + if (options?.getRevision && JSON.stringify(options.getRevision()) !== JSON.stringify(capturedRevision)) return retry() }) return { defer() { deferred = true + capturedRevision = options?.getRevision?.() }, } } diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index 37955f05e5..adda7118c6 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -438,6 +438,62 @@ test("a genuine impatient first-run completion (the prompt gate opened this laun }) // altimate_change end +// altimate_change start — Codex re-review round 9: app.tsx's OTHER onboarding-completion check — +// the `onboardingReady()` branch's own `if (setupComplete() && firstRunOpenedThisLaunch())`, a +// few lines below `shouldSkipOnboardingAtStartup`'s call site — had the exact same class of bug +// cubic caught there (3986532221), just reached from a different starting condition: a bare +// `setupComplete()` check. `onboardingReady()` (`useReady()`) is also true via `connected()`, not +// only via `hasUsableFreeDefault()` — so a RETURNING user with a configured legacy default (e.g. +// Big Pickle) who switches `/model` to a PAID model has BOTH of `shouldSkipOnboardingAtStartup`'s +// skip predicates false (paid picks aren't covered by either `hasExistingLegacySelection()` or +// `hasUsableFreeDefault()`) — falling through to THIS branch — while `onboardingReady()` is +// already true via `connected()`. If that `/model` switch raced app.tsx's startup effect, a bare +// `setupComplete()` read as true even though no first-run picker ever opened, firing onboarding +// telemetry and the scan gate for an ordinary provider switch. Fixed the same way: require +// `firstRunOpenedThisLaunch()` alongside `setupComplete()`. +test("onboardingReady() branch: a returning user's /model switch to a paid model, with both shouldSkipOnboardingAtStartup predicates false, fires no onboarding telemetry or scan gate", () => { + resetSetupComplete() + try { + // Both of shouldSkipOnboardingAtStartup's skip predicates are false — a paid /model pick, + // unlike a free one, is covered by neither `hasExistingLegacySelection()` nor + // `hasUsableFreeDefault()` — so app.tsx falls through past that branch and reaches this one. + expect(shouldSkipOnboardingAtStartup(false, false, false)).toBe(false) + + // The paid /model switch itself: markSetupComplete() fires for it exactly as it does for + // every pick, but the first-run picker was never involved. + markSetupComplete() + const setupComplete = useSetupComplete() + const firstRunOpenedThisLaunch = useFirstRunOpenedThisLaunch() + expect(setupComplete()).toBe(true) + expect(firstRunOpenedThisLaunch()).toBe(false) + + // Mirrors app.tsx's actual condition at the onboardingReady() branch exactly — must be false, + // or onboarding telemetry and the scan gate fire for a routine provider switch. + expect(setupComplete() && firstRunOpenedThisLaunch()).toBe(false) + } finally { + resetSetupComplete() + } +}) + +test("onboardingReady() branch: a genuine first-run completion (the picker opened this launch) still fires onboarding telemetry and the scan gate", () => { + resetSetupComplete() + try { + // The first-run picker (app.tsx's own startup fallthrough, or the prompt gate's equivalent) + // actually opened THIS launch, and the user completed setup there. + markFirstRunActive() + markSetupComplete() + const setupComplete = useSetupComplete() + const firstRunOpenedThisLaunch = useFirstRunOpenedThisLaunch() + expect(setupComplete()).toBe(true) + expect(firstRunOpenedThisLaunch()).toBe(true) + + expect(setupComplete() && firstRunOpenedThisLaunch()).toBe(true) + } finally { + resetSetupComplete() + } +}) +// altimate_change end + // altimate_change start — PR #1302 Codex review round 2, P2: `migrateLegacyDefault({ from })`'s // captured `from` must not bypass free-model validation entirely. test("isMigrationStillEligibleAfterCapture: only the launch-default-unchanged or registration-induced-Base transitions stay eligible", () => { diff --git a/packages/tui/test/context/ready-pending.test.tsx b/packages/tui/test/context/ready-pending.test.tsx index 7605f5788d..936996c1c8 100644 --- a/packages/tui/test/context/ready-pending.test.tsx +++ b/packages/tui/test/context/ready-pending.test.tsx @@ -79,7 +79,13 @@ function DeferThenRetryHarness(props: { props.setPromptText("") return true } - const deferredSubmit = createDeferredRetry(props.pending, () => void attemptSubmit()) + // altimate_change — Codex re-review round 9: `getRevision` mirrors component/prompt/index.tsx's + // real usage (a snapshot of the prompt at defer time, compared against the live prompt right + // before retrying) — here the "prompt" is just `promptText()` itself, so the revision IS the + // text. + const deferredSubmit = createDeferredRetry(props.pending, () => void attemptSubmit(), { + getRevision: () => props.promptText(), + }) props.exposeSubmit(attemptSubmit) return null } @@ -114,6 +120,7 @@ async function mountHarness(options: { initialPending: boolean; willBeReady: boo attemptSubmit: () => submit!(), setPending, promptText, + setPromptText, submitSpy, discarded: () => discarded, cleanup() { @@ -168,6 +175,50 @@ test.serial( }, ) +test.serial( + "defer-then-retry: a submission edited (not resubmitted) while deferred is NOT auto-sent once pending clears (Codex re-review round 9)", + async () => { + // Defer prompt A, then edit the box to B WITHOUT pressing Enter again — the user reconsidering + // mid-defer. Once pending resolves, the retry must be canceled (`getRevision` sees A != B at + // retry time), not fire and silently send B — a send the user never asked for. + const h = await mountHarness({ initialPending: true, willBeReady: true, promptText: "A" }) + try { + expect(h.attemptSubmit()).toBe(false) + expect(h.submitSpy).toEqual([]) + expect(h.promptText()).toBe("A") + + h.setPromptText("B") + + h.setPending(false) + // Deterministic window for the retry effect to have fired if it were going to — asserting + // an absence needs a bounded wait, not `waitUntil` (which only proves a positive). + await Bun.sleep(100) + expect(h.submitSpy).toEqual([]) + expect(h.promptText()).toBe("B") + } finally { + h.cleanup() + } + }, +) + +test.serial( + "defer-then-retry: a submission that is deferred and then re-deferred unchanged still sends once pending clears", + async () => { + // Guards against an overzealous fix: identical text at defer-time and retry-time (nothing + // edited) must still send normally. + const h = await mountHarness({ initialPending: true, willBeReady: true, promptText: "unchanged" }) + try { + expect(h.attemptSubmit()).toBe(false) + h.setPending(false) + await waitUntil(() => h.submitSpy.length > 0) + expect(h.submitSpy).toEqual(["unchanged"]) + expect(h.promptText()).toBe("") + } finally { + h.cleanup() + } + }, +) + test.serial("defer-then-retry: a submit issued once already ready sends immediately, no defer", async () => { const h = await mountHarness({ initialPending: false, willBeReady: true, promptText: "hello, already ready" }) try { From 2fc58ac15d932430fd6ad79d578db575ef54635d Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:21:23 -0700 Subject: [PATCH 17/19] fix: don't discard the typed prompt when the prompt-gate picker fails to open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real data-loss bug caught by cursor (3987148590) and cubic (3987174889) moments after the previous fix landed: when `dialog.replace()` loses a race to another dialog (e.g. a close-guarded one — a deferred submit's automatic retry can hit this path directly, bypassing the focus check a manual Enter press would go through) and returns `false`, the previous fix correctly skipped `markFirstRunActive()` but still fell through to clear the input, extmarks, and prompt store — silently discarding whatever the user just typed even though no picker ever opened to explain why. Mirrors app.tsx's own identical `shown` check fully now: return immediately on `!shown`, before touching anything, same as app.tsx's own `if (!shown) return`. The user can just submit again once whatever is blocking the dialog clears. No new test: exercising this exact race (a close-guarded dialog specifically vetoing `dialog.replace()`) would need mounting the full `` component with a real `DialogProvider` and a second, close-guarded dialog already open — the same mount-cost tradeoff already assessed and declined for the sibling fixes in this same function. Verified via typecheck and the full test suite. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/component/prompt/index.tsx | 28 +++++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7f244bfe2d..7c8c5594bf 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1113,23 +1113,29 @@ export function Prompt(props: PromptProps) { return false } // altimate_change — cubic review (3986532221); cursor/cubic re-review round 8 - // (3986991408/3987011218): this is the prompt-gate's own equivalent of app.tsx's first-run - // picker (an impatient submit before that startup effect settled), so it must latch the - // SAME "first-run opened this launch" signal — see `firstRunOpenedThisLaunch`'s declaration - // in altimate-onboarding.tsx for why app.tsx's startup effect needs this to tell a genuine - // first-run completion apart from a returning user's routine `/model` switch racing that - // same effect. Latch only AFTER a successful `dialog.replace()`, not before, mirroring - // app.tsx's own identical `shown` check: `dialog.replace()` can lose a race to another - // dialog and return `false` without opening anything, and latching unconditionally left a - // RETURNING user's next `/model` pick looking like a first-run completion for a picker - // nobody ever saw. + // (3986991408/3987011218); cursor/cubic re-review round 9 (3987148590/3987174889): this is + // the prompt-gate's own equivalent of app.tsx's first-run picker (an impatient submit + // before that startup effect settled), so it must latch the SAME "first-run opened this + // launch" signal — see `firstRunOpenedThisLaunch`'s declaration in altimate-onboarding.tsx + // for why app.tsx's startup effect needs this to tell a genuine first-run completion apart + // from a returning user's routine `/model` switch racing that same effect. `dialog.replace()` + // can lose a race to another dialog (e.g. a close-guarded one, which a deferred submit's + // automatic retry — see `deferredSubmit` above — can hit directly, bypassing any focus + // check a manual Enter press would go through) and return `false` without opening anything. + // Mirror app.tsx's own identical `shown` check in BOTH of the ways it matters: latch only on + // success (round 8's fix — a RETURNING user's next `/model` pick must not look like a + // first-run completion for a picker nobody ever saw), AND return immediately on failure, + // same as app.tsx's own `if (!shown) return`, BEFORE clearing anything (round 9's fix — a + // failed picker must not also silently discard the prompt the user just typed; they can + // just submit again once whatever's blocking the dialog clears). const shown = dialog.replace(() => ( )) - if (shown) markFirstRunActive() + if (!shown) return false + markFirstRunActive() input.clear() input.extmarks.clear() setStore("prompt", { input: "", parts: [] }) From fdae60d4b5e53815654dc54e4579ccc802831e93 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:35:46 -0700 Subject: [PATCH 18/19] fix: fold cycle-stability tests into one, isolate state dir from real Flock lock artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo's rule is no order-dependent or resource-contention-prone tests; both cubic findings on cycle-stability.test.tsx are in scope: 1. The three sequential `test.serial` blocks sharing one `beforeAll`/`afterAll` mount made the third test depend on mutations the first two produced — running it alone (a `-t` filter or `--only-failures`) failed. Folded into ONE `test()` over a single local mount, with five labelled phases (a small `phase()` helper wraps each block so an assertion failure's message names exactly which stage failed) covering the same ground as before: stable traversal order, persisting picks to the front of `recent`, picker-driven staleness/re-discovery, and stable re-traversal afterward. 2. `kv.tsx`'s `Flock.withLock` lock directory is derived from `Global.Path.state` (packages/core/src/global.ts), which had no test-isolation override at all — only `Global.Path.home` did. The test's `kv.json` FILE itself was always correctly isolated via `paths.state` (TestTuiContexts), but the LOCK it takes while reading/writing that file was not, so it could still touch the real, current developer's global state directory. Added an `OPENCODE_TEST_STATE_HOME` override to `Global.Path.state` (mirroring `Global.Path.home`'s existing `OPENCODE_TEST_HOME` pattern, already used throughout this codebase's tests), and made `Flock.setGlobal` take a getter over `Path.state` instead of a frozen snapshot — `Flock`'s lock root was captured once at this module's import time, before any test's own setup code could set the env var, so a plain override on `Path.state` alone would not have been enough. The test now sets/restores this env var around its mount. Verified empirically: before this fix, this test's own runs left new (and orphaned, if killed mid-run) `.lock` directories in the real `~/.local/state/altimate-code/locks` — confirmed via the directory's own mtime changing across a run with the fix temporarily reverted, and staying untouched with it applied. Also confirmed via typecheck (packages/core, packages/tui, packages/opencode) that the getter-based `Flock.setGlobal` argument still satisfies `FlockGlobal`'s type. Removed both items from tracking issue #1303 (fixed directly, not deferred). Co-Authored-By: Claude Fable 5.1 --- packages/core/src/global.ts | 25 ++- .../tui/test/context/cycle-stability.test.tsx | 190 ++++++++++-------- 2 files changed, 133 insertions(+), 82 deletions(-) diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 6f620fa0fb..f9ef256da1 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -30,13 +30,34 @@ const paths = { repos: path.join(data, "repos"), cache, config, - state, + // altimate_change start — cubic review (3986917361): unlike `home` above, `state` was a plain + // const with no test-isolation override, so any consumer reading `Global.Path.state` — or + // `Flock`'s lock directory, which is derived from it (see the `Flock.setGlobal` call below) — + // silently touched the REAL, current developer's state directory in tests. Mirror `home`'s + // pattern: a getter honoring `OPENCODE_TEST_STATE_HOME`, read fresh on every access. + get state() { + return process.env.OPENCODE_TEST_STATE_HOME ?? state + }, + // altimate_change end tmp, } export const Path = paths -Flock.setGlobal({ state }) +// altimate_change start — cubic review (3986917361): `Flock.setGlobal` used to be given the +// frozen `state` const directly, snapshotted once at this module's import time — even after +// adding the `OPENCODE_TEST_STATE_HOME` override to `Path.state` above, `Flock`'s own internal +// lock-directory resolution would still have kept using whatever `state` was BEFORE any test set +// that env var (module imports happen once, before a test's own `beforeAll`/mount code runs). A +// getter-backed property here means `Flock`'s `root()` — which just reads `global.state` as a +// plain property — re-evaluates `Path.state` fresh on every lock acquisition instead, so setting +// `OPENCODE_TEST_STATE_HOME` redirects BOTH `Global.Path.state` reads and `Flock`'s lock root. +Flock.setGlobal({ + get state() { + return Path.state + }, +}) +// altimate_change end await Promise.all([ fs.mkdir(Path.data, { recursive: true }), diff --git a/packages/tui/test/context/cycle-stability.test.tsx b/packages/tui/test/context/cycle-stability.test.tsx index ea2e283ca7..4e9fec9cab 100644 --- a/packages/tui/test/context/cycle-stability.test.tsx +++ b/packages/tui/test/context/cycle-stability.test.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -// altimate_change start — Codex HOLD finding 2 (+ re-review round 8): `cycle()` must traverse a +// altimate_change start — Codex HOLD finding 2 (+ re-review rounds 8-9): `cycle()` must traverse a // STABLE order, and that order must stay correct as `recent` changes for reasons OTHER than // cycle() itself. // @@ -20,15 +20,28 @@ // (the round-7 check never re-fires because A is still present in the stale snapshot). The // `cycleOrderVersion` counter (local.tsx) fixes this. // -// This file calls the REAL `local.model.cycle()` against a REAL mounted `LocalProvider`, in -// three named `test.serial` blocks that all share ONE mount (`beforeAll`/`afterAll`) rather than -// each calling their own — cycle() is inherently a SEQUENCE of interactions anyway, and, more -// practically, a second or third independent heavy provider-tree mount in this same file was -// found to reproducibly hang during its own bootstrap (a test-infrastructure resource-contention -// issue unrelated to anything under test here, worse the more work the PRIOR mount did). Each -// test block's own comment states the state it assumes at that point in the sequence. +// Round 9 (cubic 3987174885, repo rule: no order-dependent tests): this file used to spread the +// scenario across three `test.serial` blocks sharing one `beforeAll`/`afterAll` mount — cycle() +// is inherently a sequence of interactions, so splitting the scenario into separately-named +// blocks made each one implicitly depend on the ones before it, which broke a `-t` filter or +// `--only-failures` run of just one of them. Folded into ONE `test()`, with the mount local to +// it and labelled phases (a `phase()` helper below just for readable failure messages — plain +// comments would work too, but this makes an assertion failure show exactly which stage of the +// sequence it happened in). A second/third independent heavy provider-tree mount in this same +// file was ALSO found to reproducibly hang during its own bootstrap (a resource-contention issue +// unrelated to anything under test) — folding into one test with one mount sidesteps that too. +// +// Round 9 (cubic 3986917361, repo rule: tests must not touch real global state): `kv.tsx`'s +// `Flock.withLock` lock directory is derived from `Global.Path.state` (packages/core/src/global.ts), +// which used to have no test-isolation override at all — only `Global.Path.home` did. This test's +// `kv.json` FILE itself was always correctly isolated (`paths.state`, via `TestTuiContexts`), but +// the LOCK it takes while reading/writing that file was not — it could still land in the real, +// current developer's global state directory. Setting `OPENCODE_TEST_STATE_HOME` (mirroring +// `OPENCODE_TEST_HOME`'s established pattern, used throughout this codebase's tests) around the +// mount redirects both `Global.Path.state` and `Flock`'s lock root to this test's own throwaway +// temp dir instead. import { testRender } from "@opentui/solid" -import { afterAll, beforeAll, expect, test } from "bun:test" +import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { tmpdir } from "../fixture/fixture" @@ -96,7 +109,7 @@ async function mount() { // Three free models, all persisted as recents in order A, B, C — this is the "order as of TUI // launch" `cycle()` must traverse, independent of how it self-reorders `recent` on each pick. // A fourth (D) is registered with the provider but NOT in the initial `recent` — it only - // enters via a later picker selection, in the round-8 regression test below. + // enters via a later picker selection, in the round-8 regression phase below. await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [MODEL_A, MODEL_B, MODEL_C] })) const openCodeProvider = { @@ -183,82 +196,99 @@ async function mount() { } } -let mounted: Awaited> - -beforeAll(async () => { - mounted = await mount() -}) - -afterAll(async () => { - await mounted.cleanup() -}) +/** Labels a phase for a clearer assertion-failure message; otherwise a no-op. */ +function phase(name: string, fn: () => void | Promise) { + try { + return fn() + } catch (err) { + throw err instanceof Error ? new Error(`[phase: ${name}] ${err.message}`, { cause: err }) : err + } +} -test.serial( - "cycle() traverses a stable order across repeated presses — visits every model, does not bounce (Codex HOLD finding 2)", - () => { - // Starting state (from `mount()`): current = B, recent = [A, B, C]. - const visited: string[] = [] - mounted.local.model.cycle(1) - visited.push(mounted.local.model.current()!.modelID) - mounted.local.model.cycle(1) - visited.push(mounted.local.model.current()!.modelID) - mounted.local.model.cycle(1) - visited.push(mounted.local.model.current()!.modelID) +test("cycle() traverses a stable order, re-discovers picker-added entries after recents reorder, and persists picks to the front of recent (Codex HOLD finding 2 + re-review rounds 8-9)", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + const isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path - // Starting at B, forward cycling must visit C then A (not bounce back to A immediately — - // the bug Codex's own execution caught was B -> A -> B) and complete the traversal back to + const mounted = await mount() + try { + // Phase 1 (Codex HOLD finding 2): starting state (from `mount()`) is current = B, + // recent = [A, B, C]. Forward cycling must visit C then A (not bounce back to A immediately + // — the bug Codex's own execution caught was B -> A -> B) and complete the traversal back to // B on the third press, having visited every one of the three models exactly once. - expect(visited).toEqual(["model-c", "model-a", "model-b"]) - expect(new Set(visited).size).toBe(3) // Ends: current = B, recent = [B, A, C]. - }, -) + await phase("1: stable traversal order", async () => { + const visited: string[] = [] + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visited.push(mounted.local.model.current()!.modelID) -test.serial("cycle() still moves the picked model to the front of persisted recent on every press", async () => { - // Continues from the previous test: current = B, recent = [B, A, C], cycleOrder still [A, B, C] - // (unchanged — nothing but cycle() itself has written to `recent` so far). - mounted.local.model.cycle(1) - await waitUntil(() => mounted.local.model.recent()[0]?.modelID === "model-c") - expect(mounted.local.model.recent()[0]).toEqual({ providerID: "opencode", modelID: "model-c" }) - // Ends: current = C, recent = [C, B, A]. -}) + expect(visited).toEqual(["model-c", "model-a", "model-b"]) + expect(new Set(visited).size).toBe(3) + }) + + // Phase 2: continues from phase 1 (current = B, recent = [B, A, C], cycleOrder still + // [A, B, C] — unchanged, nothing but cycle() itself has written to `recent` so far). Every + // cycle() pick must still move the picked model to the front of PERSISTED `recent` — that's + // the only state headless/ACP default resolution reads. + // Ends: current = C, recent = [C, B, A]. + await phase("2: picks move to the front of persisted recent", async () => { + mounted.local.model.cycle(1) + await waitUntil(() => mounted.local.model.recent()[0]?.modelID === "model-c") + expect(mounted.local.model.recent()[0]).toEqual({ providerID: "opencode", modelID: "model-c" }) + }) -test.serial( - "cycle() re-discovers a model the picker adds after recents were reordered externally, instead of excluding it forever (Codex re-review round 8)", - async () => { - // Continues from the previous test: current = C, recent = [C, B, A], cycleOrder still - // [A, B, C] (still unchanged — cycle() is the only thing that has written to `recent`). - // - // Reproduces Codex's exact repro from here: pick D and A via the PICKER (an explicit /model - // selection with `recent: true`, like DialogModel uses). Round 7's fix only re-captured - // `cycleOrder` when the CURRENT model fell OUT of the stale snapshot — A stays present in - // the stale `[A, B, C]` snapshot throughout, so that check never fires, and D — never in - // that snapshot at all — stays permanently unreachable by cycling. `cycleOrderVersion` (its - // declaration in local.tsx) fixes this: it also invalidates on ANY external write to - // `recent`, picker included. - mounted.local.model.set(MODEL_D, { recent: true }) - await waitUntil(() => mounted.local.model.current()?.modelID === "model-d") - mounted.local.model.set(MODEL_A, { recent: true }) - await waitUntil(() => mounted.local.model.current()?.modelID === "model-a") - expect(mounted.local.model.recent().map((m) => m.modelID)).toEqual(["model-a", "model-d", "model-c", "model-b"]) + // Phase 3 (Codex re-review round 8): continues from phase 2 (current = C, + // recent = [C, B, A], cycleOrder still [A, B, C] — still unchanged). Reproduces Codex's exact + // repro from here: pick D and A via the PICKER (an explicit /model selection with + // `recent: true`, like DialogModel uses). Round 7's fix only re-captured `cycleOrder` when + // the CURRENT model fell OUT of the stale snapshot — A stays present in the stale + // `[A, B, C]` snapshot throughout, so that check never fires, and D — never in that snapshot + // at all — stays permanently unreachable by cycling. `cycleOrderVersion` (its declaration in + // local.tsx) fixes this: it also invalidates on ANY external write to `recent`, picker + // included. + await phase("3: picker selections reorder recent", async () => { + mounted.local.model.set(MODEL_D, { recent: true }) + await waitUntil(() => mounted.local.model.current()?.modelID === "model-d") + mounted.local.model.set(MODEL_A, { recent: true }) + await waitUntil(() => mounted.local.model.current()?.modelID === "model-a") + expect(mounted.local.model.recent().map((m) => m.modelID)).toEqual([ + "model-a", + "model-d", + "model-c", + "model-b", + ]) + }) - // cycleOrder must re-capture from the CURRENT [A, D, C, B] here (recentsVersion moved past - // cycleOrderVersion since the last cycle() call, from these two picker picks) — forward from - // A lands on D. The OLD bug: the stale `[A, B, C]` snapshot's "next after A" was B, and D was - // never reachable from it at all. - mounted.local.model.cycle(1) - await waitUntil(() => mounted.local.model.current()?.modelID === "model-d") + // Phase 4: cycleOrder must re-capture from the CURRENT [A, D, C, B] here (recentsVersion + // moved past cycleOrderVersion since the last cycle() call, from phase 3's two picker picks) + // — forward from A lands on D. The OLD bug: the stale `[A, B, C]` snapshot's "next after A" + // was B, and D was never reachable from it at all. + await phase("4: cycle() re-discovers the picker-added model", async () => { + mounted.local.model.cycle(1) + await waitUntil(() => mounted.local.model.current()?.modelID === "model-d") + }) - // And the newly re-captured order stays stable for subsequent presses, same guarantee as the - // original finding-2 fix — visits C then B then wraps back to A. - const visitedAfterD: string[] = [] - mounted.local.model.cycle(1) - visitedAfterD.push(mounted.local.model.current()!.modelID) - mounted.local.model.cycle(1) - visitedAfterD.push(mounted.local.model.current()!.modelID) - mounted.local.model.cycle(1) - visitedAfterD.push(mounted.local.model.current()!.modelID) - expect(visitedAfterD).toEqual(["model-c", "model-b", "model-a"]) - }, -) + // Phase 5: the newly re-captured order stays stable for subsequent presses, same guarantee + // as phase 1 — visits C then B then wraps back to A. + await phase("5: the re-captured order stays stable", async () => { + const visitedAfterD: string[] = [] + mounted.local.model.cycle(1) + visitedAfterD.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visitedAfterD.push(mounted.local.model.current()!.modelID) + mounted.local.model.cycle(1) + visitedAfterD.push(mounted.local.model.current()!.modelID) + expect(visitedAfterD).toEqual(["model-c", "model-b", "model-a"]) + }) + } finally { + await mounted.cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + await isolatedState[Symbol.asyncDispose]() + } +}) // altimate_change end From 55c2705f2ac4e9f3bfd2ededb874d52be19844bf Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 11 Sep 2026 01:48:46 -0700 Subject: [PATCH 19/19] fix: snapshot the deferred-submit revision serialized; share the onboardingReady() predicate; re-defer test; orphan marker - util/signal.ts: `createDeferredRetry` captured `getRevision()` by reference. Solid's `unwrap` returns the store's raw underlying object, the same one later edits mutate in place, so the defer-time value and the retry-time value were one object and an edited prompt was still auto-sent. The defer-time revision is now stored serialized and the live value is serialized again before retrying (cursor 3987286236, cubic 3987320771). ready-pending.test.tsx's harness now hands the primitive one live mutable object, the production shape; its edited-while-deferred test fails on the old primitive. - ready-pending.test.tsx: the "re-deferred" test now actually re-defers (Enter twice while pending) and asserts exactly one send (kilo 3987319604). - context/local.tsx + app.tsx: the onboardingReady() branch's discriminator is an exported `shouldFireFirstRunFunnelAtStartup` that app.tsx calls and local.test.ts tests, instead of an expression the tests re-derived. - app.tsx: remove an orphan `// altimate_change end`. Co-Authored-By: Claude Fable 5.1 --- packages/tui/src/app.tsx | 5 ++- packages/tui/src/context/local.tsx | 9 ++++++ packages/tui/src/util/signal.ts | 16 ++++++---- packages/tui/test/context/local.test.ts | 9 +++--- .../tui/test/context/ready-pending.test.tsx | 31 ++++++++++++++----- 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b5e6ec40e8..f445d0a926 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -61,7 +61,7 @@ import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" // altimate_change — fixes #1301 (Codex review, P2): `ALTIMATE_BASE_MIGRATION_DECLINED_KEY` moved // to local.tsx so `local.model.hasUsableFreeDefault()` can read the same kv key. -import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY, shouldSkipOnboardingAtStartup } from "./context/local" +import { LocalProvider, useLocal, ALTIMATE_BASE_MIGRATION_DECLINED_KEY, shouldSkipOnboardingAtStartup, shouldFireFirstRunFunnelAtStartup } from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" @@ -790,7 +790,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // altimate-onboarding.tsx) is required alongside it, same as the branch above: true only // when the first-run picker itself actually opened THIS launch (this effect's own // fallthrough below, or the prompt gate's equivalent). - if (setupComplete() && firstRunOpenedThisLaunch()) { + if (shouldFireFirstRunFunnelAtStartup(setupComplete(), firstRunOpenedThisLaunch())) { // Deliberately NOT markFirstRunActive() again here: it was already latched at the point // the picker opened (this branch only reaches telemetry when `firstRunOpenedThisLaunch()` // is already true), and `markSetupComplete()` has already run on this branch and will not @@ -822,7 +822,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi startupDecisionHandled = true // altimate_change end }) - // altimate_change end // altimate_change start — Part 2 scan gate: fire EXACTLY once, when the user has actually // finished picking a model during a first run. diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 2be89ac2b5..d5da655f82 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -199,6 +199,15 @@ export function shouldSkipOnboardingAtStartup( ): boolean { return (hasExistingLegacySelection || hasUsableFreeDefault) && !setupCompleteThisLaunch } + +// The onboardingReady() branch's own discriminator (app.tsx), extracted like its sibling above so +// the test exercises the SAME predicate app.tsx calls rather than re-deriving the expression +// (review of #1302, round 10). `setupComplete` alone is a GLOBAL flag set by any model pick; a +// returning user's routine `/model` switch racing the startup effect must not read as a first-run +// completion — only a first-run picker that actually opened THIS launch qualifies. +export function shouldFireFirstRunFunnelAtStartup(setupComplete: boolean, firstRunOpenedThisLaunch: boolean): boolean { + return setupComplete && firstRunOpenedThisLaunch +} // altimate_change end // altimate_change start — fixes #1301 (Codex review round 2, P1): an older picker-written Zen diff --git a/packages/tui/src/util/signal.ts b/packages/tui/src/util/signal.ts index aabdc8b330..ce7deb533f 100644 --- a/packages/tui/src/util/signal.ts +++ b/packages/tui/src/util/signal.ts @@ -10,9 +10,12 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid- // reimplementation of this exact shape, which meant reverting the real fix in prompt/index.tsx // left the test passing regardless, since it never touched production code at all. // -// `options.getRevision`, if given, is called ONCE at `.defer()` time (capturing whatever it -// returns) and again right before `retry()` would fire — if the two differ (by JSON equality), -// `retry()` is skipped entirely rather than fired against stale state. This is what +// `options.getRevision`, if given, is called ONCE at `.defer()` time and again right before +// `retry()` would fire — if the two differ (by JSON equality), `retry()` is skipped entirely +// rather than fired against stale state. The defer-time value is captured SERIALIZED, never as a +// reference: Solid's `unwrap` hands back the store's raw underlying object, the very one later +// edits mutate in place, so holding it and stringifying both sides at retry time compared the +// object to itself and could never see an edit (cursor 3987286236 / cubic 3987320771). This is what // component/prompt/index.tsx's submit gate uses to snapshot the prompt (text + attachments) at // the moment a submission defers: without it, a user who deferred prompt A, then edited the box // to B WITHOUT pressing Enter again, would have B silently auto-submitted the instant readiness @@ -23,17 +26,18 @@ export function createDeferredRetry( options?: { getRevision?: () => T }, ) { let deferred = false - let capturedRevision: T | undefined + let capturedRevision: string | undefined + const snapshot = () => (options?.getRevision ? JSON.stringify(options.getRevision()) : undefined) createEffect(() => { if (pending() || !deferred) return deferred = false - if (options?.getRevision && JSON.stringify(options.getRevision()) !== JSON.stringify(capturedRevision)) return + if (options?.getRevision && snapshot() !== capturedRevision) return retry() }) return { defer() { deferred = true - capturedRevision = options?.getRevision?.() + capturedRevision = snapshot() }, } } diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index adda7118c6..ca128b6e88 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -26,6 +26,7 @@ import { // altimate_change end // altimate_change start — Kilo review round 6: app.tsx startup onboarding-skip discriminator shouldSkipOnboardingAtStartup, + shouldFireFirstRunFunnelAtStartup, // altimate_change end // altimate_change start — fixes #1301 (Codex review round 2, P1): migration correctness isOwnPastPickOfFreeDefault, @@ -467,9 +468,9 @@ test("onboardingReady() branch: a returning user's /model switch to a paid model expect(setupComplete()).toBe(true) expect(firstRunOpenedThisLaunch()).toBe(false) - // Mirrors app.tsx's actual condition at the onboardingReady() branch exactly — must be false, - // or onboarding telemetry and the scan gate fire for a routine provider switch. - expect(setupComplete() && firstRunOpenedThisLaunch()).toBe(false) + // The SAME predicate app.tsx's onboardingReady() branch calls — must be false, or onboarding + // telemetry and the scan gate fire for a routine provider switch. + expect(shouldFireFirstRunFunnelAtStartup(setupComplete(), firstRunOpenedThisLaunch())).toBe(false) } finally { resetSetupComplete() } @@ -487,7 +488,7 @@ test("onboardingReady() branch: a genuine first-run completion (the picker opene expect(setupComplete()).toBe(true) expect(firstRunOpenedThisLaunch()).toBe(true) - expect(setupComplete() && firstRunOpenedThisLaunch()).toBe(true) + expect(shouldFireFirstRunFunnelAtStartup(setupComplete(), firstRunOpenedThisLaunch())).toBe(true) } finally { resetSetupComplete() } diff --git a/packages/tui/test/context/ready-pending.test.tsx b/packages/tui/test/context/ready-pending.test.tsx index 936996c1c8..9254ab40f9 100644 --- a/packages/tui/test/context/ready-pending.test.tsx +++ b/packages/tui/test/context/ready-pending.test.tsx @@ -60,6 +60,9 @@ function DeferThenRetryHarness(props: { pending: () => boolean promptText: () => string setPromptText: (value: string) => void + /** The SAME object on every call, mutated in place by `setPromptText` — exactly what + * `unwrap(store.prompt)` hands component/prompt/index.tsx (cursor 3987286236). */ + livePrompt: () => { input: string } onSend: (value: string) => void onDiscard: () => void exposeSubmit: (fn: () => boolean) => void @@ -79,12 +82,14 @@ function DeferThenRetryHarness(props: { props.setPromptText("") return true } - // altimate_change — Codex re-review round 9: `getRevision` mirrors component/prompt/index.tsx's - // real usage (a snapshot of the prompt at defer time, compared against the live prompt right - // before retrying) — here the "prompt" is just `promptText()` itself, so the revision IS the - // text. + // altimate_change — Codex re-review round 9 / cursor 3987286236: `getRevision` mirrors + // component/prompt/index.tsx's real usage exactly — it returns the store's LIVE raw object (the + // same reference every call, mutated in place by edits), not a fresh string. A primitive that + // merely held that reference and stringified both sides at retry time compared the object to + // itself and never saw an edit; this harness shape is what makes the edited-while-deferred test + // below fail on that bug. const deferredSubmit = createDeferredRetry(props.pending, () => void attemptSubmit(), { - getRevision: () => props.promptText(), + getRevision: () => props.livePrompt(), }) props.exposeSubmit(attemptSubmit) return null @@ -93,7 +98,13 @@ function DeferThenRetryHarness(props: { async function mountHarness(options: { initialPending: boolean; willBeReady: boolean; promptText?: string }) { const [pending, setPending] = createSignal(options.initialPending) const ready = () => !pending() && options.willBeReady - const [promptText, setPromptText] = createSignal(options.promptText ?? "hello from before kv.ready") + const [promptText, setPromptTextSignal] = createSignal(options.promptText ?? "hello from before kv.ready") + // One object, mutated in place — see `livePrompt` on the harness props. + const livePrompt = { input: promptText() } + const setPromptText = (value: string) => { + livePrompt.input = value + setPromptTextSignal(value) + } const submitSpy: string[] = [] let discarded = false let submit: (() => boolean) | undefined @@ -104,6 +115,7 @@ async function mountHarness(options: { initialPending: boolean; willBeReady: boo pending={pending} promptText={promptText} setPromptText={setPromptText} + livePrompt={() => livePrompt} onSend={(value) => submitSpy.push(value)} onDiscard={() => { discarded = true @@ -205,12 +217,17 @@ test.serial( "defer-then-retry: a submission that is deferred and then re-deferred unchanged still sends once pending clears", async () => { // Guards against an overzealous fix: identical text at defer-time and retry-time (nothing - // edited) must still send normally. + // edited) must still send normally — and pressing Enter TWICE while pending (Kilo 3987319604: + // two `.defer()` calls, the revision re-captured each time) must still yield exactly one send. const h = await mountHarness({ initialPending: true, willBeReady: true, promptText: "unchanged" }) try { expect(h.attemptSubmit()).toBe(false) + expect(h.attemptSubmit()).toBe(false) + expect(h.submitSpy).toEqual([]) h.setPending(false) await waitUntil(() => h.submitSpy.length > 0) + // Bounded settle so a second (duplicate) retry would have had time to show up. + await Bun.sleep(50) expect(h.submitSpy).toEqual(["unchanged"]) expect(h.promptText()).toBe("") } finally {