From 1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 21:15:08 +0900 Subject: feat(heartbeat): default system prompt to workspace default + reset button --- src/app/App.svelte | 1 + src/features/heartbeat/index.ts | 3 + src/features/heartbeat/logic/view-model.test.ts | 45 +++++++++ src/features/heartbeat/logic/view-model.ts | 35 +++++++ src/features/heartbeat/ui/HeartbeatView.svelte | 13 ++- src/features/heartbeat/ui/PromptEditor.svelte | 122 ++++++++++++++++++++---- 6 files changed, 201 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/app/App.svelte b/src/app/App.svelte index f9fecc7..7ad8733 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -681,6 +681,7 @@ loadRuns={loadHeartbeatRuns} stopRun={stopHeartbeatRun} loadVariables={loadSystemPromptVariablesPrompt} + loadDefaultPrompt={loadSystemPromptPrompt} onOpenRun={(run) => (heartbeatRun = run)} /> {/if} diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts index a54a448..0789ea6 100644 --- a/src/features/heartbeat/index.ts +++ b/src/features/heartbeat/index.ts @@ -15,16 +15,19 @@ export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-m export { badgeForStatus, DEFAULT_INTERVAL_MINUTES, + effectiveSystemPrompt, effortOptions, emptyForm, formatRunTime, formDiffers, formFromConfig, + isInheritingSystemPrompt, joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, + persistedSystemPrompt, relativeLabel, splitInterval, statusLabelFor, diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index fe97496..68f81c2 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -4,16 +4,19 @@ import type { HeartbeatConfig, HeartbeatRun } from "./types"; import { badgeForStatus, DEFAULT_INTERVAL_MINUTES, + effectiveSystemPrompt, effortOptions, emptyForm, formatRunTime, formDiffers, formFromConfig, + isInheritingSystemPrompt, joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, + persistedSystemPrompt, relativeLabel, splitInterval, statusLabelFor, @@ -250,6 +253,48 @@ describe("config form", () => { }); }); +describe("system-prompt inheritance (override ⇄ global default)", () => { + const DEFAULT = "You are a helpful assistant."; + + it("effectiveSystemPrompt: override wins when non-empty, else the default", () => { + expect(effectiveSystemPrompt("custom", DEFAULT)).toBe("custom"); + expect(effectiveSystemPrompt("", DEFAULT)).toBe(DEFAULT); + }); + + it("isInheritingSystemPrompt: true iff the override is empty", () => { + expect(isInheritingSystemPrompt("")).toBe(true); + expect(isInheritingSystemPrompt("custom")).toBe(false); + }); + + it('persistedSystemPrompt: empty or matching-the-default → inherit ("")', () => { + // matching the default → inherit (never duplicate the default into the config) + expect(persistedSystemPrompt(DEFAULT, DEFAULT)).toBe(""); + // empty edit → inherit + expect(persistedSystemPrompt("", DEFAULT)).toBe(""); + }); + + it("persistedSystemPrompt: a distinct edit → the override verbatim", () => { + expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom"); + expect(persistedSystemPrompt(DEFAULT + "\nmore", DEFAULT)).toBe(DEFAULT + "\nmore"); + }); + + it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => { + // A heartbeat inheriting (override "") displays the default; with no edit, + // persisting yields inherit ("") — so the global default stays the source. + const override = ""; + const displayed = effectiveSystemPrompt(override, DEFAULT); + expect(displayed).toBe(DEFAULT); + expect(persistedSystemPrompt(displayed, DEFAULT)).toBe(""); + }); + + it("round-trip: override → reset to default → persist inherit (clears override)", () => { + // User had an override, clicks Reset (textarea ← default): persisting clears + // the override ("" → inherit) because the text now matches the default. + const afterReset = DEFAULT; + expect(persistedSystemPrompt(afterReset, DEFAULT)).toBe(""); + }); +}); + describe("effortOptions re-export", () => { it("exposes the canonical ladder with the default marked", () => { const opts = effortOptions(); diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index b79fff6..9f25eb9 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -263,6 +263,41 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): ); } +// ── System-prompt inheritance (heartbeat override ⇄ global default) ──────────── +// +// The heartbeat's `systemPrompt` is an OVERRIDE of the global system prompt (the +// one every workspace conversation uses — there is no per-workspace system +// prompt; `GET /system-prompt` is global). An EMPTY override means "inherit the +// global default" (server-owned resolution: the backend resolves empty → global +// at run time; see CR-HB-2). These pure helpers keep the override/inherit +// semantics in ONE place so the editor + form agree. + +/** + * The prompt to DISPLAY: the heartbeat's override if it set one, else the global + * default. The editor pre-fills the textarea with this so the user can see (and + * tweak) what will run — but a pre-filled default is NOT an explicit edit. + */ +export function effectiveSystemPrompt(override: string, defaultPrompt: string): string { + return override !== "" ? override : defaultPrompt; +} + +/** Whether the heartbeat is inheriting the global default (empty override). */ +export function isInheritingSystemPrompt(override: string): boolean { + return override === ""; +} + +/** + * The `systemPrompt` value to PERSIST for the given editable text: if the user's + * text matches the global default (or is empty), persist `""` to INHERIT (so a + * later change to the global default still flows through); otherwise persist the + * text verbatim as an override. This keeps "matching the default = inheriting it" + * — never duplicating the default into the heartbeat config. + */ +export function persistedSystemPrompt(editable: string, defaultPrompt: string): string { + if (editable === "" || editable === defaultPrompt) return ""; + return editable; +} + // The reasoning-effort `