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 --- backend-handoff.md | 68 ++++++++++++- 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 ++++++++++++++++++++---- 7 files changed, 265 insertions(+), 22 deletions(-) diff --git a/backend-handoff.md b/backend-handoff.md index 7f4ddca..faf429d 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,10 +5,10 @@ > **From:** dispatch-web orchestrator · **To:** `../dispatch-backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. -_Last updated: 2026-06-26 (§2g ADDED — Heartbeat follow-up UI: prompt editor modal (reuses `GET /system-prompt/variables`; -opens 1 backend ask CR-HB-1: confirm/implement `[type:name]` variable resolution in the heartbeat system/task prompts) -+ hours/minutes interval timer (FE-only conversion, no backend change). typecheck 0/0, tests green, biome clean, build OK). -§2f (the initial heartbeat slice) is unchanged._ +_Last updated: 2026-06-26 (§2h ADDED — Heartbeat system-prompt default + reset button: the heartbeat `systemPrompt` is +now an override (empty = inherit the GLOBAL system prompt); FE pre-fills + resets to the default; opens 1 backend ask +CR-HB-2: resolve an empty heartbeat `systemPrompt` to the global system prompt at run time (composes with CR-HB-1). +typecheck 0/0, 849 tests green, biome clean, build OK. §2g/§2f unchanged.)_ **FE is current on `ui-contract@0.2.0` / `transport-contract@0.22.0` / `wire@0.12.0`.** Open asks: **CR-9** (`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence (§2d) is RESOLVED. @@ -587,6 +587,66 @@ literal `[system:os]`) appears in the model's context / run transcript. --- +## 2h. Heartbeat system-prompt default + reset button → **FE BUILT; 1 BACKEND ASK (CR-HB-2)** + +A follow-up to §2f/§2g (branch `feature/heartbeat`). Two UX changes on the heartbeat prompt editor: +1. The system prompt now **defaults to the workspace's regular system prompt** (the global `GET /system-prompt` + template — there is no per-workspace system prompt; `Workspace` has no `systemPrompt` field, and the system prompt + is global, resolved once per conversation). When the heartbeat's `systemPrompt` is empty, the editor pre-fills the + textarea with the global default so the user can see + tweak what will run — but a pre-filled default is NOT an + explicit edit (no `hasChanges` until the user edits away from it). +2. A **"Reset to default" button** reverts the system prompt to the global default (clearing any override → inherit). + +### Semantics: heartbeat `systemPrompt` is an OVERRIDE; empty = inherit the global default + +Following the codebase's established resolution-chain pattern (cwd / reasoning-effort / model / computer — all +"persisted value OR fall back to a default; resolution is SERVER-owned"), the heartbeat's `systemPrompt` is now treated +as an **override**: +- `systemPrompt === ""` (empty) → **inherit** the global system prompt (the workspace's regular prompt). +- non-empty → an explicit override. + +The FE never duplicates the global default into the heartbeat config: when the editor's text matches the default (or is +empty), it persists `systemPrompt: ""` (inherit) — so a later change to the global default still flows through. A +distinct edit persists the override verbatim. Pure helpers in `src/features/heartbeat/logic/view-model.ts`: +`effectiveSystemPrompt(override, default)`, `isInheritingSystemPrompt(override)`, +`persistedSystemPrompt(editable, default)` (+6 tests). + +### CR-HB-2 — Resolve an empty heartbeat `systemPrompt` to the global system prompt at run time → **ASK (backend behavior change)** + +The FE sends `systemPrompt: ""` to inherit. **The backend must resolve an empty heartbeat `systemPrompt` to the GLOBAL +system prompt template (`GET /system-prompt`) at heartbeat-run construction time** — so a heartbeat with no override +actually runs the workspace's regular system prompt (not an empty one). + +- **Resolution:** when building a heartbeat run's turn, if the heartbeat's persisted `systemPrompt` is `""`, substitute + the global system prompt template (the same one `GET /system-prompt` returns / that conversations resolve). If + non-empty, use the override as-is. +- **Composes with CR-HB-1:** after resolving empty → global (CR-HB-2), the resulting prompt's `[type:name]` variable + placeholders must still be resolved (CR-HB-1). So both apply, in order: (1) empty ⇒ global template, (2) resolve + `[type:name]` placeholders in whichever prompt is in effect. (For an override, only step 2 applies.) +- **Timing / cache-safety:** mirror the global template's behavior — resolve once per heartbeat run (or once + persist, + whichever the heartbeat scheduler already does for prompt-cache safety). The FE doesn't care WHEN; only THAT empty + becomes the global prompt. +- **No wire/transport-contract/ui-contract change** — `systemPrompt` is still a `string`; empty = inherit. The FE is + unaffected once the backend resolves it. **No new endpoint needed** — the FE already reads the global default via the + existing `GET /system-prompt` (passed through as `loadDefaultPrompt`). + +### FE summary (this slice) +- `src/features/heartbeat/logic/view-model.ts`: 3 pure inheritance helpers (+6 tests). +- `src/features/heartbeat/ui/PromptEditor.svelte`: `loadDefaultPrompt` port (the global `GET /system-prompt`); loads + the default on open + pre-fills the system textarea when inheriting; `hasChanges` diffs against the EFFECTIVE prompt + (override or default) so a pre-filled default isn't an unsaved change; save persists `""` when the text matches the + default (inherit); new "Reset to default" button + "Inheriting workspace default" badge + status hint. +- `src/features/heartbeat/ui/HeartbeatView.svelte`: passes `loadDefaultPrompt` through; `onSaved` syncs the raw + override (`""` = inherit) into the form. +- `src/app/App.svelte`: passes `loadDefaultPrompt={loadSystemPromptPrompt}` (reuses the existing `store.loadSystemPrompt()` adapter) to `HeartbeatView`. + +**Verification:** typecheck 0/0, 849 tests green, biome clean, build OK. The inheritance RUNTIME behavior (CR-HB-2) can +only be confirmed against a running backend: set the heartbeat system prompt to empty (or click "Reset to default" + +Save), trigger a run, and confirm the run used the GLOBAL system prompt (not an empty one). Until CR-HB-2 ships, an +empty heartbeat `systemPrompt` would run with no system prompt — the FE flags this as the known gap. + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns 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 `