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:
@@ -35,10 +35,20 @@
|
|||||||
* Absent key = enabled in the schema; the UI writes explicit booleans for
|
* Absent key = enabled in the schema; the UI writes explicit booleans for
|
||||||
* all four (simpler to reason about, sanitize-equivalent).
|
* all four (simpler to reason about, sanitize-equivalent).
|
||||||
*
|
*
|
||||||
* Prompts: GET /api/settings/prompts segments; the switch requires an
|
* Prompts: GET /api/settings/prompts segments — THREE SECTIONS in the
|
||||||
* override FILE (route-enforced), so a file-less row dims whole-row
|
* owner's ruled order (2026-09-26 overhaul): Teacher first, Prober, then
|
||||||
* (graph-view graying) until one is written. Switch state lives in this
|
* Shared LAST. The server returns per-agent lists that each INCLUDE the
|
||||||
* section's PUT; text writes through the segment route.
|
* shared segments; sectionedSegments deduplicates them (shared chunks
|
||||||
|
* once, in prompt-assembly order). The row rulings: only the TOGGLE
|
||||||
|
* grays when there's no override file (the file name is always
|
||||||
|
* full-contrast); NO description line when there's no override (the empty
|
||||||
|
* state is clean — toggle + name); the indicator ("Your version is
|
||||||
|
* saved") appears ONLY when an override exists. The switch requires an
|
||||||
|
* override FILE (route-enforced), so a file-less toggle is disabled.
|
||||||
|
* The segments view re-reads on the 'lh:settings-saved' signal for the
|
||||||
|
* agent section — the view's enabled flags derive from the record's
|
||||||
|
* switches, and the 2026-09-26 state-echo bug was exactly this reader
|
||||||
|
* missing (the toggle painted its old state until a remount re-fetched).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -160,6 +170,45 @@ export function composeSearchEngine(
|
|||||||
return { ...agent, search: { engine } };
|
return { ...agent, search: { engine } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The indicator under a row that HAS an override file (owner ruling
|
||||||
|
* 2026-09-26: the empty state carries NO description line; the
|
||||||
|
* indicator exists only when the user's version is on disk). Exported
|
||||||
|
* for the pin. */
|
||||||
|
export const PROMPT_SAVED_HINT = "Your version is saved";
|
||||||
|
|
||||||
|
/** One section of the prompt view — the owner's ruled order: Teacher,
|
||||||
|
* Prober, then Shared last. */
|
||||||
|
export interface PromptSection {
|
||||||
|
id: "teacher" | "prober" | "shared";
|
||||||
|
label: string;
|
||||||
|
/** The one header that earns a hint: "Shared" alone is not
|
||||||
|
* self-evident. Teacher/Prober are. */
|
||||||
|
hint?: string;
|
||||||
|
segments: SegmentInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split the server's per-agent segment lists into the owner's three
|
||||||
|
* sections. The server contract returns the shared segments in BOTH
|
||||||
|
* lists (same paths, same content) — the shared chunks are listed ONCE
|
||||||
|
* here (taken from the teacher list; identical by construction), in
|
||||||
|
* assembly order: the base file, then the shared fragments sorted. The
|
||||||
|
* agents' own chunks keep their lists' order — which IS the prompt
|
||||||
|
* assembly order (numeric-prefix filename sort). Pure — exported for
|
||||||
|
* tests. */
|
||||||
|
export function sectionedSegments(view: { prober: SegmentInfo[]; teacher: SegmentInfo[] }): PromptSection[] {
|
||||||
|
const own = (who: "prober" | "teacher") => view[who].filter((s) => !s.shared);
|
||||||
|
return [
|
||||||
|
{ id: "teacher", label: "Teacher", segments: own("teacher") },
|
||||||
|
{ id: "prober", label: "Prober", segments: own("prober") },
|
||||||
|
{
|
||||||
|
id: "shared",
|
||||||
|
label: "Shared",
|
||||||
|
hint: "Used by both agents",
|
||||||
|
segments: view.teacher.filter((s) => s.shared),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
|
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
|
||||||
* (absent = off is the schema's resting state; explicit false is the
|
* (absent = off is the schema's resting state; explicit false is the
|
||||||
* same thing but noisier). Pure — tests. */
|
* same thing but noisier). Pure — tests. */
|
||||||
@@ -267,6 +316,20 @@ export function AgentSection({
|
|||||||
.then((body: { available: boolean; engine: string | null; reason: string }) => setSearch(body))
|
.then((body: { available: boolean; engine: string | null; reason: string }) => setSearch(body))
|
||||||
.catch(() => setSearch(null));
|
.catch(() => setSearch(null));
|
||||||
|
|
||||||
|
/** Re-read the prompt-segment view — the signal seam for the
|
||||||
|
* 2026-09-26 state-echo fix: the view's `enabled` flags DERIVE from
|
||||||
|
* the settings record's agent switches (the toggle's truth lives
|
||||||
|
* here, not in the `agent` prop), so an agent-section write must
|
||||||
|
* re-read it or the toggle keeps painting its pre-write state until
|
||||||
|
* a remount. Keeps the last-good view on a failed re-read (the
|
||||||
|
* initial mount fetch below owns the loading/null state). */
|
||||||
|
const readPrompts = useCallback(() => {
|
||||||
|
fetch("/api/settings/prompts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// The catalog (federated: keyed providers only), the resolved slots,
|
// The catalog (federated: keyed providers only), the resolved slots,
|
||||||
// the prompt segments, and the search engines' key presence — one
|
// the prompt segments, and the search engines' key presence — one
|
||||||
// fetch each per section mount. The models read ALSO re-runs on the
|
// fetch each per section mount. The models read ALSO re-runs on the
|
||||||
@@ -284,6 +347,11 @@ export function AgentSection({
|
|||||||
|
|
||||||
useSettingsSaved((section) => {
|
useSettingsSaved((section) => {
|
||||||
if (section === "models") readModels();
|
if (section === "models") readModels();
|
||||||
|
// THE state-echo root cause: the prompt switches write through the
|
||||||
|
// agent section, but this view (the toggles' truth) never re-read —
|
||||||
|
// the settings-saved law (every reader re-reads) was missing this
|
||||||
|
// one reader.
|
||||||
|
if (section === "agent") readPrompts();
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -354,9 +422,7 @@ export function AgentSection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Refetch: file presence gates the switch, so the view must track it.
|
// Refetch: file presence gates the switch, so the view must track it.
|
||||||
fetch("/api/settings/prompts")
|
readPrompts();
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
|
|
||||||
setSegmentStatus(`Saved ${path}`);
|
setSegmentStatus(`Saved ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,9 +433,7 @@ export function AgentSection({
|
|||||||
setSegmentStatus(`Delete failed: ${res.status}`);
|
setSegmentStatus(`Delete failed: ${res.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch("/api/settings/prompts")
|
readPrompts();
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The search-engine menu row — label + the quiet live-key mark. */
|
/** The search-engine menu row — label + the quiet live-key mark. */
|
||||||
@@ -403,16 +467,24 @@ export function AgentSection({
|
|||||||
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
<div key={info.path} data-testid={`prompt-segment-${info.path}`}>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
label={info.path}
|
label={info.path}
|
||||||
hint={fileExists ? undefined : "No changes yet — add yours below"}
|
// Overhaul rulings (2026-09-26): NO description when
|
||||||
|
// there's no override (the empty state is clean —
|
||||||
|
// toggle + name); the indicator appears ONLY when the
|
||||||
|
// user's version is on disk.
|
||||||
|
hint={fileExists ? PROMPT_SAVED_HINT : undefined}
|
||||||
testid={`prompt-row-${info.path}`}
|
testid={`prompt-row-${info.path}`}
|
||||||
dimmed={!fileExists}
|
|
||||||
>
|
>
|
||||||
|
{/* Graying ruling: ONLY the toggle dims without an
|
||||||
|
override file — the file name above stays
|
||||||
|
full-contrast (the whole-row dim is dead). */}
|
||||||
|
<span className={fileExists ? undefined : "opacity-40"}>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={info.enabled && fileExists}
|
checked={info.enabled}
|
||||||
disabled={!fileExists}
|
disabled={!fileExists}
|
||||||
label={`prompt ${info.path}`}
|
label={`prompt ${info.path}`}
|
||||||
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -426,9 +498,6 @@ export function AgentSection({
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-1 flex flex-col gap-2">
|
<div className="mt-1 flex flex-col gap-2">
|
||||||
{info.shared && (
|
|
||||||
<p className="text-xs text-muted-foreground/70">Shared base — appears in both agents.</p>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -684,11 +753,20 @@ export function AgentSection({
|
|||||||
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{(["prober", "teacher"] as const).map((who) => (
|
{/* The owner's ruled order (2026-09-26): Teacher first,
|
||||||
<div key={who}>
|
then Prober, then Shared LAST — shared chunks once
|
||||||
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{who}</p>
|
(deduplicated), each section in prompt-assembly
|
||||||
|
order. */}
|
||||||
|
{sectionedSegments(prompts).map((section) => (
|
||||||
|
<div key={section.id} data-testid={`prompt-section-${section.id}`}>
|
||||||
|
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">
|
||||||
|
{section.label}
|
||||||
|
</p>
|
||||||
|
{section.hint && (
|
||||||
|
<p className="mb-1 text-xs text-muted-foreground/60">{section.hint}</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{prompts[who].map((info) => segmentRow(info))}
|
{section.segments.map((info) => segmentRow(info))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { appearanceVars, derivedPalette, resolveAppearance, staleVarNames } from "../components/settings/appearance.tsx";
|
import { appearanceVars, derivedPalette, resolveAppearance, staleVarNames } from "../components/settings/appearance.tsx";
|
||||||
import {
|
import {
|
||||||
|
PROMPT_SAVED_HINT,
|
||||||
composePromptSwitch,
|
composePromptSwitch,
|
||||||
composeSearchEngine,
|
composeSearchEngine,
|
||||||
composeSlotModel,
|
composeSlotModel,
|
||||||
composeTool,
|
composeTool,
|
||||||
researchToolUnavailable,
|
researchToolUnavailable,
|
||||||
|
sectionedSegments,
|
||||||
} from "../components/settings/agent-section.tsx";
|
} from "../components/settings/agent-section.tsx";
|
||||||
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
|
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
|
||||||
import { classifyKeys, keyStatusLine, type ProviderKeyInfo } from "../components/settings/keys-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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user