Agent prompts section: re-read on write, dedupe, owner-ruled layout

The prompts area's five owner-reported symptoms, root causes found
in code and verified live before fixing:

1. STATE ECHO — the toggle's truth (SegmentInfo.enabled) derives
   from the settings record's agent switches, but the segment view
   was read ONCE per section mount: the switch write landed (PUT
   /api/settings/agent) yet the toggle painted its old state until
   a remount re-fetched. Root-cause fix in the settings-saved
   family: the section now re-reads /api/settings/prompts when the
   'lh:settings-saved' signal lands for the agent section — the
   reader the signal law was missing. Toggle flips verify at ~40ms,
   no navigation.

2. DUPLICATION — the server contract returns the shared segments
   in BOTH per-agent lists; the UI rendered both lists back to
   back, so every shared chunk appeared twice (18 rows, 5 dupes).
   sectionedSegments (pure, exported, pinned) dedupes: the agents'
   own chunks plus the shared chunks ONCE.

3. ORDERING — Teacher first, then Prober, then Shared last
   (owner-ruled), each section headed, chunk order within a section
   preserved from assembly order (the server lists ARE assembly
   order: base, shared sorted, own numeric-prefix sorted).

4. GRAYING — the whole-row dim died; only the TOGGLE dims now when
   there is no override file (a wrapper span on the control; the
   file name is always full-contrast).

5. DESCRIPTION LOGIC — the 'No changes yet' line died. No
   description when there's no override (the empty state is clean:
   toggle + name); 'Your version is saved' (PROMPT_SAVED_HINT,
   pinned) appears ONLY when an override exists. The redundant
   'Shared base — appears in both agents' editor note died too —
   the Shared section header carries 'Used by both agents'.

Also: saveSegment/deleteSegment reuse the readPrompts callback
(keeps last-good on a failed re-read; the mount effect owns the
loading state).

Live-verified on :3001: sections in the ruled order with 13 unique
rows, clean-row and overridden-row states per the rulings, toggle
flip instant (view + record agree), save/delete round-trips, and a
shared-segment save + toggle through the single Shared control.

Open finding (NOT fixed here — needs a ruling): an ON switch whose
override file is later deleted orphans the record, and the
agent-section write validation 400s every subsequent write (an
ON switch must have an existing file) while the UI's disabled
toggle cannot reach the OFF escape hatch the validation
anticipates. Repro: Save + switch on, Delete the override, flip
any tool. Server validation + row semantics are Platform's /
owner's call.
This commit is contained in:
amosblomqvist
2026-09-25 19:00:11 +03:00
parent 95bc8d2261
commit 178995c987
2 changed files with 152 additions and 25 deletions
+49
View File
@@ -9,11 +9,13 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { appearanceVars, derivedPalette, resolveAppearance, staleVarNames } from "../components/settings/appearance.tsx";
import {
PROMPT_SAVED_HINT,
composePromptSwitch,
composeSearchEngine,
composeSlotModel,
composeTool,
researchToolUnavailable,
sectionedSegments,
} from "../components/settings/agent-section.tsx";
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
import { classifyKeys, keyStatusLine, type ProviderKeyInfo } from "../components/settings/keys-section.tsx";
@@ -339,3 +341,50 @@ describe("researchToolUnavailable — the row grays on the registry's own signal
}
});
});
describe("sectionedSegments — the prompts overhaul (2026-09-26)", () => {
// The server contract: BOTH per-agent lists include the shared
// segments (same paths) — the view must list them ONCE.
const view = {
prober: [
{ path: "system-prompt.md", sourceText: "b", overrideText: null, enabled: false, shared: true },
{ path: "shared/10-personality.md", sourceText: "s1", overrideText: "custom", enabled: true, shared: true },
{ path: "prober/10-probe-doctrine.md", sourceText: "p1", overrideText: null, enabled: false, shared: false },
],
teacher: [
{ path: "system-prompt.md", sourceText: "b", overrideText: null, enabled: false, shared: true },
{ path: "shared/10-personality.md", sourceText: "s1", overrideText: "custom", enabled: true, shared: true },
{ path: "teacher/05-method.md", sourceText: "t1", overrideText: null, enabled: false, shared: false },
{ path: "teacher/10-planning.md", sourceText: "t2", overrideText: null, enabled: false, shared: false },
],
};
it("the owner's ruled order: Teacher, Prober, Shared — Shared last", () => {
const ids = sectionedSegments(view).map((s) => s.id);
assert.deepEqual(ids, ["teacher", "prober", "shared"]);
});
it("shared chunks appear exactly ONCE across all sections (no duplication)", () => {
const all = sectionedSegments(view).flatMap((s) => s.segments.map((seg) => seg.path));
assert.equal(all.filter((p) => p === "system-prompt.md").length, 1);
assert.equal(all.filter((p) => p === "shared/10-personality.md").length, 1);
// every path unique — no chunk listed twice
assert.equal(new Set(all).size, all.length);
});
it("the Shared section holds the shared chunks in assembly order; the agents' sections hold their own", () => {
const [teacher, prober, shared] = sectionedSegments(view);
assert.ok(teacher && prober && shared, "all three sections present");
assert.deepEqual(teacher.segments.map((s) => s.path), ["teacher/05-method.md", "teacher/10-planning.md"]);
assert.deepEqual(prober.segments.map((s) => s.path), ["prober/10-probe-doctrine.md"]);
// assembly order: the base file first, then the shared fragments
assert.deepEqual(shared.segments.map((s) => s.path), ["system-prompt.md", "shared/10-personality.md"]);
// the Shared header carries the one hint that earns its place
assert.equal(shared.hint, "Used by both agents");
assert.equal(teacher.hint, undefined);
});
it("the indicator copy is pinned (override present = the row earns its line)", () => {
assert.equal(PROMPT_SAVED_HINT, "Your version is saved");
});
});