From 66090ed769b08f821e61499030c8aee664e66c86 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 25 Sep 2026 00:46:10 +0300 Subject: [PATCH] Pre-send key guards: no orphan error-shell chats on first send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's asymmetry find (interlude over the zero-keys wave): the models-unconfigured case guards BEFORE send — no request, no chat created, setup opens — but the key cases only reacted to the 503 after POST /api/chats had already created the record, leaving orphan error-shell chats in the sidebar. The guards now twin the models mechanism on BOTH creation paths (home start(), /chat/new send()): - Zero keys (useZeroKeys reuse): no provider key present -> skip creation and send entirely, open SetupDialog (stage 1 first-key screen). - Strong-provider key missing (owner-approved extension): keys exist generally but the strong slot's pinned provider has none — the original deleted-key scenario. New pure strongKeyless(catalog) in setup-cta.tsx: slots.strong resolved pin + providers[] (present-key only, the catalog's own federation surface), false when unconfigured (the models guard owns that case) or catalog not loaded (the server 503 gate remains the belt). useStrongKeyless rides useModelCatalog, re-reading on the 'keys' AND 'models' signals (a deleted key or a re-pinned slot flips it live). Both guards run after the configured check, same skip-everything-and-open-setup treatment; 'force' (setup's onDone replay) bypasses both — completing setup implies a keyed provider. /chat/[id] sessions keep the 503 revert-draft interception unchanged: those chats legitimately exist. Pins: strongKeyless fires on a keyless strong pin (keys elsewhere or none), never fires with the pin's key present (incl. the keyPresent=false belt), false on unset pin / unloaded catalog. Normal-path pins ride the existing startDraftSession contract suite. Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 with a file-key anthropic + openrouter-pinned strong: both guards on both pages (setup opens stage-1 / stage-2 respectively, draft preserved, 154 chats unchanged, zero sidebar entries), normal path with a keyed strong provider still creates and lands the prompt redirect. 929/929, tsc/lint/build green. --- components/home.tsx | 17 ++++++++++++++- components/new-session.tsx | 18 ++++++++++++++-- components/setup-cta.tsx | 29 +++++++++++++++++++++++++- tests/zero-keys.test.tsx | 42 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/components/home.tsx b/components/home.tsx index 154f054..200e23b 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { SetupDialog } from "@/components/setup-dialog"; -import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useDefaultSettings } from "@/components/default-settings"; import { DictationBar, useDictation } from "@/components/dictation"; import { SidebarToggle } from "@/components/sidebar"; @@ -30,6 +30,10 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: // The zero-keys truth (zero-keys wave): no provider key = the pill // takes precedence over the models CTA (keys come first in the chain). const zeroKeys = useZeroKeys(); + // The strong slot's keyless truth (pre-send key guards, owner interlude + // 2026-09-25): keys may exist generally while the STRONG slot's pinned + // provider has none — the same skip-everything treatment on send. + const strongKeyless = useStrongKeyless(); const [setupOpen, setSetupOpen] = useState(false); // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // topic draft; the strip lives in the stats bar's middle slot. @@ -56,6 +60,17 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: setSetupOpen(true); return; } + // The pre-send KEY guards (owner interlude, no-orphan-chats): the + // models case guards BEFORE creation; the key cases must too — a + // refused first send would otherwise leave an orphan error-shell + // chat in the sidebar (the 503 lands only after POST /api/chats + // already created the record). Same skip-everything-and-open-setup + // treatment; `force` (setup's onDone replay) bypasses, and the + // server's 503 gate remains the belt for /chat/[id] sessions. + if (!force && (zeroKeys || strongKeyless)) { + setSetupOpen(true); + return; + } setBusy(true); try { const res = await fetch("/api/chats", { diff --git a/components/new-session.tsx b/components/new-session.tsx index 4285bfe..74e99db 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -8,7 +8,7 @@ import { DictationBar, useDictation } from "@/components/dictation"; import { useDefaultSettings } from "@/components/default-settings"; import { SidebarToggle } from "@/components/sidebar"; import { SetupDialog } from "@/components/setup-dialog"; -import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; import type { ChatSettings } from "@/src/core"; @@ -168,6 +168,10 @@ export function NewSession({ // The zero-keys truth (zero-keys wave): no provider key = the pill // takes precedence over the models CTA (keys come first in the chain). const zeroKeys = useZeroKeys(); + // The strong slot's keyless truth (pre-send key guards, owner interlude + // 2026-09-25): keys may exist generally while the STRONG slot's pinned + // provider has none — the same skip-everything treatment on send. + const strongKeyless = useStrongKeyless(); const [setupOpen, setSetupOpen] = useState(false); const send = useCallback((force = false) => { @@ -178,6 +182,16 @@ export function NewSession({ setSetupOpen(true); return; } + // The pre-send KEY guards (owner interlude, no-orphan-chats): guard + // BEFORE startDraftSession creates the record — a refused first + // send would otherwise leave an orphan error-shell chat in the + // sidebar. Same skip-everything-and-open-setup treatment; `force` + // (setup's onDone replay) bypasses, and the server's 503 gate + // remains the belt for /chat/[id] sessions. + if (!force && (zeroKeys || strongKeyless)) { + setSetupOpen(true); + return; + } setBusy(true); void startDraftSession(text, settings, fetch) .then((target) => { @@ -185,7 +199,7 @@ export function NewSession({ else setBusy(false); }) .catch(() => setBusy(false)); - }, [text, busy, settings, router, configured]); + }, [text, busy, settings, router, configured, zeroKeys, strongKeyless]); return ( <> diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index 246399f..dcf32c0 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -18,6 +18,8 @@ import { useCallback, useEffect, useState } from "react"; import type { AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; +import { useModelCatalog } from "@/components/model-picker"; +import type { ModelCatalogResponse } from "@/src/core"; import { useSettingsSaved } from "@/components/settings-signal"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses @@ -72,4 +74,29 @@ export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) { ); -} \ No newline at end of file +} +/** Pure: the strong slot is pinned to a provider whose key is GONE (owner + * interlude, 2026-09-25 — the original deleted-key scenario). The catalog + * already knows both halves: slots.strong is the resolved pin, providers[] + * lists only PRESENT-key providers (keyless ones are absent, the same + * discipline as the search engines). False when the strong slot is unset + * (the models guard owns that case — this check only runs when slots ARE + * configured) or the catalog hasn't loaded (the server gate remains the + * belt). Exported for pins. */ +export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolean { + const strong = catalog?.slots.strong; + if (!strong) return false; + return !catalog.providers.some((p) => p.id === strong.model.provider && p.keyPresent); +} + +/** The strong-slot keyless truth for the PRE-SEND guard (home / /chat/new): + * the catalog seam with re-reads on BOTH signals that can flip it — "keys" + * (a provider key saved or deleted) and "models" (the strong slot + * re-pinned). Live-updates with the page, never mid-send. */ +export function useStrongKeyless(): boolean { + const { catalog, refresh } = useModelCatalog(); + useSettingsSaved((section) => { + if (section === "keys" || section === "models") refresh(); + }); + return strongKeyless(catalog); +} diff --git a/tests/zero-keys.test.tsx b/tests/zero-keys.test.tsx index c1fbf33..4c2907e 100644 --- a/tests/zero-keys.test.tsx +++ b/tests/zero-keys.test.tsx @@ -10,7 +10,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { hasProviderKey, ZeroKeysPill } from "../components/setup-cta.tsx"; +import { hasProviderKey, strongKeyless, ZeroKeysPill } from "../components/setup-cta.tsx"; +import type { ModelCatalogResponse } from "../src/core/index.ts"; import { SetupKeysBody } from "../components/setup-dialog.tsx"; import { DICTATION_KEYLESS, DEEPGRAM_KEY_ID } from "../components/dictation.tsx"; import type { AuthKeyStatus } from "../src/core/auth-keys.ts"; @@ -115,4 +116,41 @@ describe("the dictation keyless seam (owner dictation trio)", () => { it("the Deepgram row id matches the keys store (KeysSection openRow)", () => { assert.equal(DEEPGRAM_KEY_ID, "DEEPGRAM_API_KEY"); }); -}); \ No newline at end of file +}); +/** A minimal catalog double — only the two halves the strong-keyless guard + * reads: the strong pin and the present-key providers list. */ +const catalog = (strongProvider: string | null, ...keyedProviders: string[]): ModelCatalogResponse => ({ + models: [], + defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null }, + slots: { + strong: strongProvider + ? { model: { provider: strongProvider, id: `${strongProvider}/model` }, thinkingLevel: "low" } + : null, + cheap: null, + vision: null, + }, + providers: keyedProviders.map((id) => ({ id, envVar: `${id.toUpperCase()}_API_KEY`, keyPresent: true })), +}); + +describe("strongKeyless — the pre-send guard's truth (owner interlude, no-orphan-chats)", () => { + it("strong pinned to a provider whose key is gone: the guard FIRES", () => { + // keys exist generally (anthropic), but the strong slot pins openrouter + assert.equal(strongKeyless(catalog("openrouter", "anthropic")), true); + // no keys at all with a strong pin: also keyless (guard case 1 fires too) + assert.equal(strongKeyless(catalog("openrouter")), true); + }); + + it("strong provider's key present: the guard does NOT fire — the normal send path stays open", () => { + assert.equal(strongKeyless(catalog("openrouter", "openrouter", "anthropic")), false); + // belt check: a provider row never counts when keyPresent is false + const lying = catalog("openrouter", "openrouter", "anthropic"); + lying.providers[0]!.keyPresent = false; + assert.equal(strongKeyless(lying), true); + }); + + it("no strong pin (the models guard owns that case) or catalog not loaded: false — the server gate remains the belt", () => { + assert.equal(strongKeyless(catalog(null, "openrouter")), false); + assert.equal(strongKeyless(undefined), false); + }); +});