Zero-keys wave: provider-key gate renders honestly everywhere

Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:

1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
   (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
   (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
   ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
   CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
   dispatches after save AND delete.

2. Setup stage 1 is self-contained: provider picker (MenuShell +
   ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
   STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
   re-runs on it, and the stage flips to the pickers on its own.
   onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.

3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
   parseModelsRefusal) joins the revert-draft-and-open-setup interception;
   activity-dock reads 'needs a provider key'; dispatch-card shows
   'Add a key' — both deep-link setup, which lands on stage 1 by itself.

4. Dictation trio: the token route's only 503 (missing Deepgram key)
   becomes the client-authored DICTATION_KEYLESS short line; the errored
   idle click clears the error and deep-links Settings -> Keys ->
   DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
   -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).

Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
This commit is contained in:
amosblomqvist
2026-09-24 23:20:59 +03:00
parent 9f0ecccc7a
commit 1f777f770c
15 changed files with 625 additions and 102 deletions
+16 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseModelsRefusal } from "../components/chat.tsx";
import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx";
import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx";
import {
isModelsRefusal,
@@ -66,6 +66,21 @@ describe("providerKeyMissing — the envelope factory (provider-key interlude, 2
});
});
describe("parseProviderKeyRefusal — the send refusal detector's twin (zero-keys wave)", () => {
it("reads the envelope's own code + invariant tail — the wording can never drift client-side", () => {
const REFUSED = JSON.stringify(providerKeyMissing("openrouter", ["strong"]));
assert.equal(parseProviderKeyRefusal(REFUSED), true);
assert.equal(parseProviderKeyRefusal(JSON.stringify(providerKeyMissing("anthropic", ["strong", "vision"]))), true);
});
it("anything else is a real error — false, the honest banner stays", () => {
assert.equal(parseProviderKeyRefusal(REFUSAL), false); // the models refusal is NOT this one
assert.equal(parseProviderKeyRefusal("FAILED: headless crashed"), false);
assert.equal(parseProviderKeyRefusal(undefined), false);
assert.equal(parseProviderKeyRefusal("{not json"), false);
});
});
describe("isProviderKeyRefusal — the job-record matcher (provider-key interlude)", () => {
it("matches the envelope wording regardless of provider label, not real failures", () => {
assert.equal(isProviderKeyRefusal(providerKeyMissing("together", ["cheap"]).error), true);
+11
View File
@@ -66,6 +66,17 @@ describe("activity dock pure layer", () => {
assert.equal(absent.cost, null);
});
it("a key-refused job reads as info — 'needs a provider key' (zero-keys wave, 66dbea9)", () => {
const refused = job({
status: "error",
activity: "failed",
// the runner composes the envelope's error verbatim (invariant tail)
error: "No OpenRouter key is set — add one in Settings → Keys",
});
const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!;
assert.equal(blob.activity, "needs a provider key");
});
it("a models-refused job reads as info — 'needs model setup', not 'failed' (BYOK, 2026-09-22)", () => {
const refused = job({
status: "error",
+11
View File
@@ -101,6 +101,17 @@ describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
assert.ok(!html.includes("text-red-500"));
});
it("a key-refused job is INFO: 'needs a provider key', 'Add a key' (zero-keys wave, 66dbea9)", () => {
const html = renderToStaticMarkup(
inJobs([job({ error: "No OpenRouter key is set — add one in Settings → Keys" })]),
);
assert.ok(html.includes('data-testid="dispatch-refused"'));
assert.ok(html.includes("needs a provider key"));
assert.ok(html.includes("Add a key"));
assert.ok(html.includes('data-testid="dispatch-setup-link"'));
assert.ok(!html.includes("failed"));
});
it("a real failure keeps the honest red failure", () => {
const html = renderToStaticMarkup(inJobs([job({ error: "FAILED: headless crashed" })]));
assert.ok(html.includes('data-testid="dispatch-settled"'));
+118
View File
@@ -0,0 +1,118 @@
/**
* The zero-keys wave's pure seams (owner-locked over Platform's 66dbea9
* key-presence gate, 2026-09-25): the provider-key truth (hasProviderKey
* — tool keys don't count, they unlock no models), the pill's SSR shape,
* and the SetupDialog's self-contained first-key screen (the dialog STAYS
* MOUNTED after a save; these pins hold the screen's structure).
*/
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 { 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";
import type { ProviderKeyInfo } from "../components/settings/keys-section.tsx";
const status = (id: string, present: boolean, source: AuthKeyStatus["source"] = present ? "file" : "none"): AuthKeyStatus => ({
id,
info: null,
present,
masked: present ? "…ab12 (64 chars)" : null,
source,
shadowedByEnv: false,
});
const PROVIDERS: ProviderKeyInfo[] = [
{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" },
{ id: "anthropic", envVar: "ANTHROPIC_API_KEY", label: "Anthropic" },
];
describe("hasProviderKey — the zero-keys pill's truth", () => {
it("a present provider key (env or file) is a key", () => {
assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", true, "env")], PROVIDERS), true);
assert.equal(hasProviderKey([status("ANTHROPIC_API_KEY", true, "file")], PROVIDERS), true);
});
it("a TOOL key alone does not count — Deepgram unlocks no models", () => {
// Deepgram is not in the providers roster: it must never suppress the pill.
assert.equal(hasProviderKey([status("DEEPGRAM_API_KEY", true)], PROVIDERS), false);
});
it("absent provider keys = zero keys (the pill shows)", () => {
assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", false)], PROVIDERS), false);
assert.equal(hasProviderKey([], PROVIDERS), false);
});
});
describe("ZeroKeysPill — the composer-hoisted notice", () => {
it("the pill takes the CTA's chrome with its own testid and copy", () => {
const html = renderToStaticMarkup(createElement(ZeroKeysPill, { onOpen: () => {} }));
assert.ok(html.includes('data-testid="setup-cta-keys"'));
assert.ok(html.includes("Add a provider key"));
// the models CTA's testid must not appear on the keys pill
assert.ok(!html.includes('data-testid="setup-cta"'));
});
});
describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
const base = {
value: "",
saving: false,
error: null,
menuOpen: false,
onToggleMenu: () => {},
onPick: () => {},
onValue: () => {},
onSave: () => {},
menuAnchor: { current: null },
};
it("before a pick: the provider trigger, no key input yet", () => {
const html = renderToStaticMarkup(
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
);
assert.match(html, /First, add a provider key\./);
assert.ok(html.includes("The model pickers list only what your keys unlock"));
assert.ok(html.includes('data-testid="setup-key-provider"'));
assert.ok(html.includes("Pick a provider…"));
assert.ok(!html.includes('data-testid="setup-key-input"'), "no paste field before a provider is picked");
});
it("after a pick: the paste input + Save join", () => {
const html = renderToStaticMarkup(
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: PROVIDERS[0]! }),
);
assert.ok(html.includes('data-testid="setup-key-input"'));
assert.ok(html.includes('data-testid="setup-key-save"'));
assert.ok(html.includes("Save key"));
assert.ok(html.includes("OpenRouter"), "the chosen provider's label rides the trigger");
});
it("a shape-gate 400 keeps its line inline — no Settings round-trip chrome", () => {
const html = renderToStaticMarkup(
createElement(SetupKeysBody, {
...base,
providers: PROVIDERS,
provider: PROVIDERS[0]!!,
error: "Expected 20+ characters",
}),
);
assert.ok(html.includes('data-testid="setup-key-error"'));
assert.ok(html.includes("Expected 20+ characters"));
// the old "Open Keys settings" round-trip died with the self-contained screen
assert.ok(!html.includes("setup-open-keys"));
});
});
describe("the dictation keyless seam (owner dictation trio)", () => {
it("the client-authored short line — not the route's dev-speak", () => {
assert.equal(DICTATION_KEYLESS, "Dictation needs a Deepgram key");
});
it("the Deepgram row id matches the keys store (KeysSection openRow)", () => {
assert.equal(DEEPGRAM_KEY_ID, "DEEPGRAM_API_KEY");
});
});